commit 7adc072f5e20c13b90a645de589565b166a94f10 Author: Jens Caers Date: Tue Sep 29 22:03:33 2026 +0200 Publish curated MobilityOps source snapshot diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c7f0cfe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +.git +.gitea +.github +.gitignore +.agents +.codex +.claude +.dyad +.idea +.vscode +.vs +.venv +__pycache__ +.pytest_cache +.ruff_cache +.mypy_cache +node_modules +frontend/node_modules +frontend/dist +dist +artifacts +docs +deploy +n8n/** +!n8n/workflows/ +!n8n/workflows/** +frontend +*.tgz +*.tar.gz +coverage +playwright-report +test-results +.env +.env.* +!.env.example +*.key +*.pem +*.p12 +*.pfx +secrets +credentials +.state +data +backups +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite-shm +*.sqlite-wal +*.log +*.tmp +*.zip +*.tar +*.tar.gz +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..628cc7e --- /dev/null +++ b/.env.example @@ -0,0 +1,123 @@ +COMPOSE_PROJECT_NAME=mobilityops +MOBILITYOPS_ENV=development +MOBILITYOPS_DEMO_MODE=true +MOBILITYOPS_PUBLIC_URL=http://localhost:1228 +# Build-time API origin baked into the web bundle. Leave empty: the SPA calls its own +# origin and nginx proxies /api to the API (required by the CSP connect-src 'self'). +VITE_API_BASE_URL= +DATABASE_URL=postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops +POSTGRES_DB=mobilityops +POSTGRES_USER=mobilityops +POSTGRES_PASSWORD=mobilityops +# Signs session cookies. With MOBILITYOPS_ENV=production the API refuses to start while +# this (or MOBILITYOPS_CALLBACK_TOKEN) still holds its placeholder value. +APP_SECRET=replace-in-production +TZ=Europe/Brussels +# Session cookie Secure flag. Development on localhost may use false; production startup +# requires both an HTTPS public URL and this value set to true. +SESSION_COOKIE_SECURE=false + +# Optional OpenID Connect login. Public demo role buttons remain available when enabled. +OIDC_ENABLED=false +OIDC_PROVIDER_NAME=Organisatieaccount +OIDC_ISSUER_URL= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI= +OIDC_ALLOWED_EMAIL_DOMAINS= +OIDC_AUTO_PROVISION=true +OIDC_DEFAULT_ROLE=rental_employee + +# Observability: JSON logs are always enabled. Set a token only if /metrics is exposed +# outside the private Compose network; Prometheus can send it as a bearer token. +LOG_LEVEL=INFO +# Required when the observability profile is enabled. Keep private and high entropy. +METRICS_BEARER_TOKEN=replace-me-private-metrics-token +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=change-me-before-start +# Alertmanager sends every firing/resolved alert and the continuous watchdog to this +# owner-managed receiver. Production must route it to a channel that is actually watched. +ALERTMANAGER_WEBHOOK_URL=https://n8n.example.test/webhook/mobilityops-alerts + +# Verified scheduled PostgreSQL backups (Unraid override). +BACKUP_INTERVAL_SECONDS=86400 +BACKUP_RETENTION_DAYS=30 +BACKUP_MINIMUM_COPIES=7 +# Restore the newest dump into a disposable database at least weekly. Backup health also +# requires a successful drill within eight days. +BACKUP_RESTORE_DRILL_INTERVAL_SECONDS=604800 +# Set both values to copy every verified backup to an independently mounted path. +BACKUP_SECONDARY_DESTINATION= +MOBILITYOPS_BACKUP_DIR=./backups/postgres +MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite +# Optional real off-site copy through the official rclone OneDrive adapter. OAuth state +# lives only in MOBILITYOPS_RCLONE_CONFIG_DIR and must never be committed. +BACKUP_OFFSITE_INTERVAL_SECONDS=900 +RCLONE_ONEDRIVE_REMOTE=onedrive +RCLONE_ONEDRIVE_PATH=FleetOps/backups +MOBILITYOPS_RCLONE_CONFIG_DIR=./.secrets/rclone +MOBILITYOPS_OFFSITE_VERIFY_DIR=./backups/offsite-verify + +# Privacy governance defaults. +PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS=30 +PRIVACY_AUDIT_RETENTION_DAYS=2555 +PRIVACY_AUDIT_EXPORT_MAX_ROWS=10000 + +# 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 +# Prevent public visitors from repeatedly rebuilding the shared dataset. Concurrent +# resets are always rejected using both process and PostgreSQL advisory locks. +DEMO_RESET_COOLDOWN_SECONDS=60 + +# Operational mode: set MOBILITYOPS_DEMO_MODE=false and provide the first manager. +# Keep these values in a secret store or an untracked production .env file. +INITIAL_ADMIN_EMAIL= +INITIAL_ADMIN_PASSWORD= +INITIAL_ADMIN_DISPLAY_NAME=Operations Manager + +# n8n +N8N_BASE_URL=http://n8n:5678 +N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return +N8N_ENCRYPTION_KEY=replace-me +N8N_BASIC_AUTH_ACTIVE=true +N8N_BASIC_AUTH_USER=admin +N8N_BASIC_AUTH_PASSWORD=change-me +# Recipient and sender used by the importable alert workflow. Configure real, +# monitored addresses in the deployment environment; repository defaults stay synthetic. +MOBILITYOPS_ALERT_RECIPIENT=alerts@example.test +MOBILITYOPS_ALERT_SENDER=n8n@example.test +MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token +# Sent as the X-Fleet-Ops-Trigger-Token header when Fleet Ops calls the n8n return- +# processing webhook, so the webhook trigger can require Header Auth instead of being +# publicly callable by anyone who discovers the URL. Must match the value stored in +# n8n's "Fleet Ops Webhook Trigger Token" Header Auth credential. +MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token + +# RAGcore integration +KNOWLEDGE_PROVIDER=demo +RAGCORE_BASE_URL=http://ragcore-api:8000 +# Optional existing Docker network used by hosted deployments to reach RAGcore through +# its private service alias instead of exposing RAGcore on the LAN. +RAGCORE_DOCKER_NETWORK= +RAGCORE_TENANT=northstar-mobility-demo +RAGCORE_WORKSPACE=mobilityops +RAGCORE_COLLECTION=internal-procedures +RAGCORE_API_TOKEN= +# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3). +RAGCORE_SPACE_ID= +# Skip the slower generation endpoint temporarily after a timeout/non-2xx response and +# use the still-grounded extractive search fallback immediately. +RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS=60 + +# ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own +# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a +# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability +# health check surfaced on the integration status page. +MCP_HUB_REGISTRATION_ENABLED=false +MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000 +MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token +MCP_PROVIDER_ID=fleet-ops diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5f3595b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto eol=lf +*.sh text eol=lf +*.ps1 text eol=crlf +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.zip binary + +# Generated operational evidence is not part of a source release archive. +/artifacts export-ignore +/PROJECT_STATE.md export-ignore +/MASTER_BUILD_PROMPT.md export-ignore +/FILE_INDEX.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ff9578 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Secrets and local configuration +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.pfx +secrets/ +credentials/ + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.venv/ +.coverage +htmlcov/ +*.egg-info/ + +# Frontend and test output +node_modules/ +frontend/node_modules/ +frontend/dist/ +coverage/ +playwright-report/ +test-results/ +*.tsbuildinfo + +# Runtime data and local infrastructure state +.state/ +data/ +backups/local/ +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite-shm +*.sqlite-wal +*.log +*.tmp + +# Generated release/design evidence. Maintained documentation belongs in docs/. +artifacts/**/final-summary.md +artifacts/deployment/ +artifacts/design-validation/current/ +artifacts/**/screenshots/generated/ + +# Local AI/editor state +.codex/ +.claude/ +.agents/ +.dyad/ +.idea/ +.vscode/ +.vs/ +.DS_Store +Thumbs.db + +# Archives and local release bundles +*.zip +*.tar +*.tar.gz +*.tgz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4abab22 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +# Contributing + +MobilityOps contributions must preserve fleet-data privacy, deterministic demo behaviour and the fail-closed integration boundaries documented in `SECURITY.md`. + +- use synthetic vehicles, customers, bookings, returns, telematics events and identity claims in tests and screenshots; +- never commit production databases, exports, operator inventories, private service URLs, tokens, backups or generated browser evidence; +- keep external integrations configurable through environment variables or explicit deployment configuration; +- document new personal-data fields, retention, authorization, audit and deletion/export behaviour; +- add negative tests for authentication, authorization, duplicate handling, webhook validation, path containment and stale/unavailable providers; +- review dependencies, images and browser assets for provenance and redistribution rights. + +Run the relevant backend, frontend, migration, integration, Compose and managed-validation gates before review. Security-sensitive findings belong through the private process in `SECURITY.md`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb0c466 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jens Caers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f46897e --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +.PHONY: up down logs test lint contracts seed reset n8n-setup n8n-setup-scan demo e2e live-smoke + +up: + docker compose up --build -d + +down: + docker compose down + +logs: + docker compose logs -f --tail=200 + +test: + sh scripts/run-isolated-tests.sh + +lint: + docker compose -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests + docker compose -f compose.yaml -f compose.test.yaml run --rm api mypy app + cd frontend && npm run lint + +contracts: + docker compose -f compose.yaml -f compose.test.yaml run --build --rm \ + -v "$(CURDIR):/repo:ro" api python /repo/scripts/check-contracts.py + python scripts/check-source-budgets.py + +seed: + docker compose exec api python -m app.cli seed --reset + +reset: + docker compose down -v + docker compose up --build -d + +# One-time per environment: imports and activates the n8n return-processing workflow. +# The n8n owner account itself cannot be scripted safely and must be created once at +# http://localhost:5678/setup (any email/password, no verification required) before +# this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook +# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in +# the n8n UI before it will actually process a return -- see docs/17-runbook.md. +n8n-setup: + docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json + docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing + docker compose restart n8n + +# One-time per environment: imports and activates the scheduled quality-scan workflow. +# Same owner-account and credential preconditions as n8n-setup above (this workflow only +# needs "Fleet Ops Service Token"). +n8n-setup-scan: + docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json + docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan + docker compose restart n8n + +# Full deterministic demo bootstrap: build, migrate (automatic on api startup), seed. +demo: up + docker compose exec api python -m app.cli seed --reset + +e2e: + cd frontend && npx playwright test + +live-smoke: + cd frontend && npx playwright test --config=playwright.live.config.ts diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..03af803 --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -0,0 +1,3 @@ +# Public source export + +This source snapshot was generated from the private canonical repository at revision `0458403e679d5e76f48b4b49d235476631fecf08`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..6953391 --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,1596 @@ +{ + "schemaVersion": 1, + "sourceRevision": "0458403e679d5e76f48b4b49d235476631fecf08", + "files": [ + { + "path": ".dockerignore", + "sha256": "266752fee928e1e4c48c0334c9907eeab39eb37b8964daff90c7db780801d522", + "bytes": 516 + }, + { + "path": ".env.example", + "sha256": "8205ed8d0354aa38033dedeafaf2a91342abee30ea70faa45c92a8da785d74e0", + "bytes": 5739 + }, + { + "path": ".gitattributes", + "sha256": "9332ad457c2d7a3c2c97aa24d9e3d37a0531846a86d3fe4f4d2ffdb2c252bbaa", + "bytes": 322 + }, + { + "path": ".gitignore", + "sha256": "0a203263c4788da7113930eee9657fd164a182b7ef823b1790d5dcecf1a3b6b7", + "bytes": 871 + }, + { + "path": "CONTRIBUTING.md", + "sha256": "cd1279633316ea689c31e4638133b0cee1b0ce031b44631ad756e0ee8dae2b6a", + "bytes": 1076 + }, + { + "path": "LICENSE", + "sha256": "4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db", + "bytes": 1067 + }, + { + "path": "Makefile", + "sha256": "4bf865f98c9f96f2e8bb955bba597f3b47689a0315b3a9e5c11dd9047b094380", + "bytes": 2234 + }, + { + "path": "README.md", + "sha256": "3e6d0faf54046ae25087825fbe387fafb4aa37170762f94c0acf844daedc6e33", + "bytes": 950 + }, + { + "path": "SECURITY.md", + "sha256": "9691608cdf668aeebf401b95de4a9561f88c88f5e76cb823cf6ffac78b240751", + "bytes": 2100 + }, + { + "path": "backend/Dockerfile", + "sha256": "f2e98da6dc1bbcdf7c83c540208398c24ae1a34badcf2de4340c5a89eab579b9", + "bytes": 1386 + }, + { + "path": "backend/alembic.ini", + "sha256": "6d5747a3393534774ac2ee79629d94836ca9eb5b895714ec98582b9864272f67", + "bytes": 555 + }, + { + "path": "backend/alembic/env.py", + "sha256": "ebf22eed2d7351c45aaa179eff57c75fc9fd588a0245043dfcf9578e5f0c760b", + "bytes": 1244 + }, + { + "path": "backend/alembic/script.py.mako", + "sha256": "304a8bfb6a804e5493f5300e79882c70c9f5bb2e87512f4d16f0e097dddd323f", + "bytes": 635 + }, + { + "path": "backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py", + "sha256": "a98305d9075071062dd20b0dbd4804581f3d84e82f70d6ccf8376effc6e95731", + "bytes": 691 + }, + { + "path": "backend/alembic/versions/4f2b9c8d7e61_unique_open_quality_condition.py", + "sha256": "6874578b421b98a8906313ef98182dec37cc9e8933de856311c5a99a32741928", + "bytes": 618 + }, + { + "path": "backend/alembic/versions/799d8800e241_outbox_last_error_code.py", + "sha256": "b811c69ba92cbf81bb5d8a9f9eb4146d409f3cc668ab083d028ce3e5c376ff01", + "bytes": 639 + }, + { + "path": "backend/alembic/versions/a81d0ce9f662_oidc_identity.py", + "sha256": "d7bb6a54318c3945907468f3774d117641e50c9bc6003fc2dd9d19a2454bec02", + "bytes": 769 + }, + { + "path": "backend/alembic/versions/b7c7b536df85_operational_user_credentials.py", + "sha256": "c548102df0110db99ba44998a8091dd6704c9d0d9c753e188c37d38d56542909", + "bytes": 678 + }, + { + "path": "backend/alembic/versions/b913a72e8c14_customer_privacy_state.py", + "sha256": "5169641473584d20ca22f40e12d9a47bc5fd7033db59623dce810c080b32b4d8", + "bytes": 586 + }, + { + "path": "backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py", + "sha256": "c258874f0ea120770176d12dc2dbd9cb3d875f0aa85449f6803556f0e4372b07", + "bytes": 3694 + }, + { + "path": "backend/alembic/versions/c9498525abb5_initial_schema.py", + "sha256": "e81c434e6698ec270e485bc02381d23c09ec6cb669257a027db6e0e452b1e904", + "bytes": 9813 + }, + { + "path": "backend/alembic/versions/d1f83bc64170_revoked_sessions.py", + "sha256": "c075345c679acf1ff382a3dba2a2952add466677a9a7ca0cecb9523138ae4288", + "bytes": 1347 + }, + { + "path": "backend/alembic/versions/e7b08389f47f_idempotency_records.py", + "sha256": "715583e3445a9a645111880211267f70f2343f7b1b5a09b4449046af05fc8bc5", + "bytes": 1498 + }, + { + "path": "backend/alembic/versions/f43d829ab610_quality_work_queue.py", + "sha256": "3ce64cea53746a0db28c23d955d193a632ad685535473d10d00ea64d041b8cee", + "bytes": 1774 + }, + { + "path": "backend/app/__init__.py", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "bytes": 0 + }, + { + "path": "backend/app/api/__init__.py", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "bytes": 0 + }, + { + "path": "backend/app/api/deps.py", + "sha256": "7478ba0131eef2c78bf81222a7f02b591d9d39f74975ad620a3227958ad50ecd", + "bytes": 3613 + }, + { + "path": "backend/app/api/routers/__init__.py", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "bytes": 0 + }, + { + "path": "backend/app/api/routers/audit.py", + "sha256": "e700389d053046e0d031b7e58f2b65cb4137d7b3fbc0067cfc3671c0fedc8772", + "bytes": 8106 + }, + { + "path": "backend/app/api/routers/auth.py", + "sha256": "d1f6340cc1abfa3c91cb57d40e80eff8ad448f65ff50e46f43d1949086d643bb", + "bytes": 11158 + }, + { + "path": "backend/app/api/routers/bookings.py", + "sha256": "a968f9411058fc0bf143de9440f4da17d8bb0bab8402614e3231bd5ef5aa39d8", + "bytes": 22148 + }, + { + "path": "backend/app/api/routers/customers.py", + "sha256": "6b3753a4ccbafe2d201b12087b4d2fd057aac9074ae8e09d63e89aabc3734250", + "bytes": 1399 + }, + { + "path": "backend/app/api/routers/dashboard.py", + "sha256": "3caa6a77b10ddf40b45c179c1a9c32394e90d87b8ac954ddddaf4d2d5e333d58", + "bytes": 5705 + }, + { + "path": "backend/app/api/routers/data_quality.py", + "sha256": "307d471d119b209ff2e85ca3b57b7fb6550e3f71a6fc3d18b5ff03d70ce7e8ee", + "bytes": 16862 + }, + { + "path": "backend/app/api/routers/demo.py", + "sha256": "5eeef80a9b50d88d716f446990e221079e528b21fde9a59427dc3be0027bc552", + "bytes": 7623 + }, + { + "path": "backend/app/api/routers/integration_status.py", + "sha256": "7bca22cdc07c0175a4ef69189e0d67a07774dd8cd4061510b1add4677fd925ac", + "bytes": 715 + }, + { + "path": "backend/app/api/routers/integrations.py", + "sha256": "64df7fcc9965783a4c1acd4d897dbe8ca37e919ddc920535c57c3ed3f3a99347", + "bytes": 11404 + }, + { + "path": "backend/app/api/routers/knowledge.py", + "sha256": "8da4b167cacdb675405455cd8e2e7e9d342a5371e68c14968675c8c9c9a26710", + "bytes": 5689 + }, + { + "path": "backend/app/api/routers/mcp_integrations.py", + "sha256": "6ddc770d6cc480b85c6570a9fa8793b91fce014658b9cfed8dca0e9d6ddffa52", + "bytes": 7430 + }, + { + "path": "backend/app/api/routers/observability.py", + "sha256": "e1799fc8fd23c1edf29945052384cf915dc97f53563de81996dd95ae3cf97674", + "bytes": 1968 + }, + { + "path": "backend/app/api/routers/privacy.py", + "sha256": "6dbc39b054fdcbb073882e92067f08e7f3857ad7a77ae2480e5efa6c82dd03c0", + "bytes": 5826 + }, + { + "path": "backend/app/api/routers/search.py", + "sha256": "567209da763ead9b1e278feac48e817e88289db1b5a753d8360d48d86b7c8f1d", + "bytes": 5865 + }, + { + "path": "backend/app/api/routers/users.py", + "sha256": "80c15816281ef55d43edb29c61ed55b3cb2ccc66a52a80dc3b037f2596f22202", + "bytes": 3549 + }, + { + "path": "backend/app/api/routers/vehicles.py", + "sha256": "99c956987fad508af0bbd2b074f30d2a682fbe63118e5352271a8b81a2c387c5", + "bytes": 15117 + }, + { + "path": "backend/app/api/routers/workflows.py", + "sha256": "92486bd8dfae907ea88cb477e4ef2b7cc9d29098095528778633782907e66460", + "bytes": 3269 + }, + { + "path": "backend/app/cli.py", + "sha256": "6cfc1ae4cb78b1ef5909f73a7432d2569ad73b204724bfc2f002d5ba2f7fe1eb", + "bytes": 975 + }, + { + "path": "backend/app/core/__init__.py", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "bytes": 0 + }, + { + "path": "backend/app/core/config.py", + "sha256": "f4e06673af982ae921757e66e0765c90f193416e83906c3c3d3ddf192b09caaa", + "bytes": 6061 + }, + { + "path": "backend/app/core/db.py", + "sha256": "6e3cdb0f36907c0a513caae03274c2242db6e600b4f2797ded4f98066b67d4fd", + "bytes": 1909 + }, + { + "path": "backend/app/core/errors.py", + "sha256": "d7839e405b61a99692ba86b9d8e3ab87430a4de54b31bd24d3e3c3f6580d79fc", + "bytes": 762 + }, + { + "path": "backend/app/core/observability.py", + "sha256": "ab9ad4c17f2955e7736b040e270ca415b0f07714c89e49eb8721e5b0a6bc1e0a", + "bytes": 3750 + }, + { + "path": "backend/app/core/ratelimit.py", + "sha256": "d57ebfa7c3f22daeaf7ba17411ef085b8613d8ce07febfd0f96d6831e918c2e8", + "bytes": 2718 + }, + { + "path": "backend/app/core/security.py", + "sha256": "f0a8c31e150563308d1fb22328f13f9bbfcb92a4aa9206fc11445dcc7882d2ab", + "bytes": 2607 + }, + { + "path": "backend/app/main.py", + "sha256": "e636237fbbc7714badd64aa32e2319b9dc99e9bffa23b8da6033ebed56b3e544", + "bytes": 6106 + }, + { + "path": "backend/app/models/__init__.py", + "sha256": "08fcdff135a719466cca56e231bd631e5cde705421dd346469d92f9c52670d50", + "bytes": 757 + }, + { + "path": "backend/app/models/audit.py", + "sha256": "9a5a91dd4ebb5301fa5e782345793f01aa832eca51ba19cefa928436bf5ff2e0", + "bytes": 1476 + }, + { + "path": "backend/app/models/booking.py", + "sha256": "b00f62ee91f1fadba3afb5974d48ef79d9f05ab77ca79a7087aaf0b0906967cc", + "bytes": 1972 + }, + { + "path": "backend/app/models/customer.py", + "sha256": "37a934a34e811d169f714849d427ba1ac977b3dcbd21c90695e0f7d9c8591b34", + "bytes": 1150 + }, + { + "path": "backend/app/models/data_quality.py", + "sha256": "bb5e58766e91ba3030737275ab6345b0f8d0c3e5640c76d851119e2bbfeec7ff", + "bytes": 2839 + }, + { + "path": "backend/app/models/idempotency.py", + "sha256": "2ef30c0b8b3ad1a0bc8481b12947ef134cc9be85bded24eefb82b616e94623bd", + "bytes": 1005 + }, + { + "path": "backend/app/models/inspection.py", + "sha256": "8fd07d866ed872b7981716cfce79567888902b3faa88bc8c0da58af7bfbf11ee", + "bytes": 1471 + }, + { + "path": "backend/app/models/maintenance.py", + "sha256": "3ffa8fdaa5798608c526700bdb18724611efaaf50ec10bbf83a2e15071631d65", + "bytes": 890 + }, + { + "path": "backend/app/models/mixins.py", + "sha256": "30c6c74ff7a8121828238c2f1d6aeaecb465b2626a88b53f23e2f463119262a6", + "bytes": 639 + }, + { + "path": "backend/app/models/outbox.py", + "sha256": "2c536830f725a5eea5bfc2f30ff4559acdb9039123e6326468139d4f3aafe527", + "bytes": 3211 + }, + { + "path": "backend/app/models/revoked_session.py", + "sha256": "ded99b4cbc3e1a6ba3d70c61e0368c7030b7976c33265da83dfd2b4c3aa0212f", + "bytes": 536 + }, + { + "path": "backend/app/models/user.py", + "sha256": "b6f58e1a442376d340f489fa2dd0cc91f1e62432a1fd09bee1cf565ac786204b", + "bytes": 1114 + }, + { + "path": "backend/app/models/vehicle.py", + "sha256": "1798159edcb5af17d3c1942dcf1c27f4ead529a7e64bf159603e27c0f9c85fcb", + "bytes": 1749 + }, + { + "path": "backend/app/schemas.py", + "sha256": "8b7147a9cf348984a15b1fb1e4db761087c740b72227c481c30a5d255020fa95", + "bytes": 16883 + }, + { + "path": "backend/app/seed_loader.py", + "sha256": "9e2cb00941481baba7c3b7b29c30952e6ab5547564cb340b1eac3af19d752a39", + "bytes": 20644 + }, + { + "path": "backend/app/services/__init__.py", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "bytes": 0 + }, + { + "path": "backend/app/services/audit.py", + "sha256": "f25ccfcdb67998b5b618609bee495e843b3628860640c133e51b55c3fc6fd623", + "bytes": 1004 + }, + { + "path": "backend/app/services/data_quality.py", + "sha256": "80272d0589e98e45d543e3ecc71ad7b11746a8ecd2e9a624037fda33b38ed43c", + "bytes": 30393 + }, + { + "path": "backend/app/services/data_quality_common.py", + "sha256": "cdea87cb7b94ebf3596c63c5f323dd8983c3af232f413c4a59d5b592824a5ce3", + "bytes": 1849 + }, + { + "path": "backend/app/services/data_quality_duplicate_scan.py", + "sha256": "53df090e6a22a351263dd89e55d1c209b565c9d6c8afbf54c28c8ee48e3b08fe", + "bytes": 3809 + }, + { + "path": "backend/app/services/data_quality_odometer.py", + "sha256": "19f422250832d40020e00f7f33ac260bb4ecf42e00586cde03619c76abb0c1bd", + "bytes": 20312 + }, + { + "path": "backend/app/services/demo_manifest.py", + "sha256": "55142b6e90f74589954f7b6ca4afd3062a49544de194cccf7c371ab570cbdf29", + "bytes": 8716 + }, + { + "path": "backend/app/services/dispatcher.py", + "sha256": "7184103d57fffc8ed9e0942ca36ff599806c18fcb93983bb3ef21137f57f84ee", + "bytes": 10486 + }, + { + "path": "backend/app/services/integration_status.py", + "sha256": "80918ccb4ebe31103544dece6e0a4e8ddca652d1cb0e44b5bdf13f31d61ec724", + "bytes": 14522 + }, + { + "path": "backend/app/services/knowledge/__init__.py", + "sha256": "661af86945543963dae226b6b987ef05745327d203b0835fc9a86fa37c04fe67", + "bytes": 1743 + }, + { + "path": "backend/app/services/knowledge/demo.py", + "sha256": "c5d0b664149b26fd80a3b0e8c46d06f1b8c190c93683f1dc84f56e93ab85f676", + "bytes": 13002 + }, + { + "path": "backend/app/services/knowledge/procedures.py", + "sha256": "e1a60549b723fc98501eaa06cabe8d4f28d94f068aecade6b1942ee74f9c6c7d", + "bytes": 2600 + }, + { + "path": "backend/app/services/knowledge/ragcore.py", + "sha256": "52de8fe616bd5eafe4e2b33baa608290b6ff7c5af69fce4edcb3842a5afebec4", + "bytes": 25793 + }, + { + "path": "backend/app/services/operations.py", + "sha256": "f06d7e9721e83bbc23f29f87ad587a896caebfe5bea9a1fe6ceed240a09fc8fc", + "bytes": 2885 + }, + { + "path": "backend/app/services/returns.py", + "sha256": "68dd06b1b0f76b567333846fee75e0002465eb1fec1c8caf2fd33852570dafd9", + "bytes": 13358 + }, + { + "path": "backend/app/services/sessions.py", + "sha256": "fc2f9d24bc5f81545a116cb21dbcef82ba2c2246c52d8fe5dcb9476582a982ad", + "bytes": 1147 + }, + { + "path": "backend/app/services/vehicle_status.py", + "sha256": "a4f4be5ce9300537580b2dd66eaa8ea936849a5f1c556fffe9efd8d46e6c8d36", + "bytes": 9762 + }, + { + "path": "backend/entrypoint.sh", + "sha256": "52a8f9ef312e451fea438027198c1dcc0a9267b8c2e40322df258df22abb92e1", + "bytes": 145 + }, + { + "path": "backend/pyproject.toml", + "sha256": "2a502c91f405d4663ffe95d669b189dc114f6913d248690bb57cd2defb2aba62", + "bytes": 931 + }, + { + "path": "backend/requirements-prod.lock", + "sha256": "66f892765eaeeb771b872263c35dd931b025503af25b97337dcbe99527f5619d", + "bytes": 3510 + }, + { + "path": "backend/requirements.lock", + "sha256": "8eba1a9c9ecccadf3dba38681a220a49cc38946493202474e93744cf4ce7c6c3", + "bytes": 2827 + }, + { + "path": "backend/scripts/generate_openapi.py", + "sha256": "ed7cf69087c9b4dd5f9abdf78e8c5d76c2bd9662568323bd6ce8fd5d8e8005c9", + "bytes": 824 + }, + { + "path": "backend/tests/conftest.py", + "sha256": "0b91629d257c3fa8d6003ab4639aaaf497f6f502b1d6042b610873ff44c24147", + "bytes": 878 + }, + { + "path": "backend/tests/test_audit.py", + "sha256": "49c9ee3c705d70d6d2b1b0c5c2ef72fb6eb8a609afa9ba1cf5a45c31d2797573", + "bytes": 3103 + }, + { + "path": "backend/tests/test_auth.py", + "sha256": "b1572d2d77a90adc81c5757d8b078acbad365f8308c6d403d0d7af18597710fe", + "bytes": 11739 + }, + { + "path": "backend/tests/test_bookings.py", + "sha256": "1ab55c7a1227f9783a17356a0ea9a96a48646811c98027850fd8d8445be0524c", + "bytes": 14667 + }, + { + "path": "backend/tests/test_dashboard.py", + "sha256": "5121332220d2242ba09ebb3b76329037375a1c46132128051a5ee446f63a75cd", + "bytes": 3229 + }, + { + "path": "backend/tests/test_data_quality.py", + "sha256": "54ac9c3d1782de512a48d043f4406de490689faac740f9a387a1e9626f337a7c", + "bytes": 57076 + }, + { + "path": "backend/tests/test_database_constraints.py", + "sha256": "ed3d08dc4b7b0bf67f260262ce55c77ebfc597e4727c29d3df4528ad36514924", + "bytes": 1057 + }, + { + "path": "backend/tests/test_demo_manifest.py", + "sha256": "fac99fc9f1b6126c3b67863601b535d628133b3aefb2d713b0170428a6852b22", + "bytes": 5807 + }, + { + "path": "backend/tests/test_dispatcher.py", + "sha256": "abf32fb36311d92082d719789769359d4916d9e1c77897f72d1c89c27c286811", + "bytes": 13943 + }, + { + "path": "backend/tests/test_hardening.py", + "sha256": "527d2e3ebab70733835650968df1ea09a49f19df01430a1ca695c691cb52935d", + "bytes": 12121 + }, + { + "path": "backend/tests/test_health.py", + "sha256": "42c6a26770ae39d88f61bda375bec500a762f8e9e8eeb73dcda918ff25ca09fb", + "bytes": 1223 + }, + { + "path": "backend/tests/test_integration_status.py", + "sha256": "09063f004afb94994f185225d7b81fe6af2c4c665a2c1760c5577d31089d38cf", + "bytes": 10888 + }, + { + "path": "backend/tests/test_integrations.py", + "sha256": "100604e1e5f9db3e584f75a0ee71f85013697d8ef942e67c90d208eaa7c48153", + "bytes": 16756 + }, + { + "path": "backend/tests/test_knowledge.py", + "sha256": "3955cfb5b342ed97a2b100e9438a4f80c93e50b07492611e7e48154bd6f8617b", + "bytes": 41366 + }, + { + "path": "backend/tests/test_mcp_integrations.py", + "sha256": "19eb13af94b49f9ab4787c8c2b360c985f1f8351ee0f27d84b3088e71bca0708", + "bytes": 7236 + }, + { + "path": "backend/tests/test_migrations.py", + "sha256": "1f609643eab2e4caa06544c556d5b9bc4376b5647708cba538abb2effc8f2874", + "bytes": 3176 + }, + { + "path": "backend/tests/test_observability.py", + "sha256": "ab585d1f33f4023c9008d13c5cd76ccd5ede87a02ebf9279b3931a99ade5093c", + "bytes": 2043 + }, + { + "path": "backend/tests/test_operational_auth.py", + "sha256": "69af1a74c67e461ddd0afc283a20398508f7fb55a4393f544368770a9b2f185f", + "bytes": 1357 + }, + { + "path": "backend/tests/test_privacy.py", + "sha256": "5568df1da92d1a88460e9542a6b545bb3e71f9e35765cd6fbc4df52419a9bd12", + "bytes": 4700 + }, + { + "path": "backend/tests/test_return.py", + "sha256": "e200d9e9bd43198e398f945f20ba7fa931954d2189552ba8694488603457be04", + "bytes": 12041 + }, + { + "path": "backend/tests/test_search.py", + "sha256": "b7e19f8b3e301b034c1671cc8dd5c491d18acb6375d4558a401aec8c4c36776b", + "bytes": 3582 + }, + { + "path": "backend/tests/test_seed.py", + "sha256": "4c9064ae17ec0ab1dbc830663d93992c210182a8dd14cf88f6c942060add8b3e", + "bytes": 12514 + }, + { + "path": "backend/tests/test_seed_generation.py", + "sha256": "d2d56957483b9013294644c5107398c3336b4680632192a7393b8a0f6ad82462", + "bytes": 2687 + }, + { + "path": "backend/tests/test_users.py", + "sha256": "c93b0e9f83909c34e0772d002b5c88038b6872aa8e8c0f2771c00cdbb55c6860", + "bytes": 1512 + }, + { + "path": "backend/tests/test_vehicle_status.py", + "sha256": "3334e977caf978a8009ec7bee241ef6bb2b7c9496a8f2419a19f1810cbcae842", + "bytes": 6348 + }, + { + "path": "backend/tests/test_vehicles.py", + "sha256": "f2ba2a702e6310dce4aadb6578244ee026cda8cab12052017157f816aeeb9c10", + "bytes": 5901 + }, + { + "path": "backend/tests/test_workflows.py", + "sha256": "e20a42eef02a831a3317e3161b61bf57e4b889afcd2634f9400ff79b1e72574e", + "bytes": 3814 + }, + { + "path": "compose.test.yaml", + "sha256": "0fa974c95d4a56a97603a43208a4e91a08a8d6e951b6c0480fbc5b0b6b3d16ce", + "bytes": 701 + }, + { + "path": "compose.yaml", + "sha256": "24176195085571aaa242c2ac3c83c7d54a08b1103001e73c5a8b3f466a23354d", + "bytes": 5306 + }, + { + "path": "contracts/events.schema.json", + "sha256": "eecec456675edf2775a9fe388cc05b42a64152e6b9b87f4aece91c4dcce57573", + "bytes": 1315 + }, + { + "path": "contracts/mcp-tools.json", + "sha256": "38be51e70745f6a1efa6841c228e1cc7c22ff6673a1cd7b5641a9d6065240ca9", + "bytes": 3771 + }, + { + "path": "contracts/openapi.yaml", + "sha256": "77e4b232454967cefa8c17eb94cd33f7d4e6c73a91509bd35ecb525d45254ba3", + "bytes": 123070 + }, + { + "path": "contracts/ragcore-contract-assumptions.md", + "sha256": "97bf7d2b12d3734f857c25e4cea80f0d8bda99a624e06f0523d79d34b705a5e1", + "bytes": 653 + }, + { + "path": "frontend/.dockerignore", + "sha256": "c08137865a174c9bee3a93e0aa1cf8098e616cc447eab27ba23f106f0fd61e8d", + "bytes": 80 + }, + { + "path": "frontend/Dockerfile", + "sha256": "313f19ae47cb9771daa693224d6232b539657d90f43fc522ba358ade8c98d5c9", + "bytes": 1157 + }, + { + "path": "frontend/e2e-live/smoke.spec.ts", + "sha256": "41b7996e25b5092eb0ee3425d6d6f5e9fbfb076fea19449996e56a8a8e4e75f8", + "bytes": 2478 + }, + { + "path": "frontend/e2e/__screenshots__/highlights-main.png", + "sha256": "27f34abeab947064154555ebcda4817c471ffe323cf9bd0adc2e9826fd9dfd28", + "bytes": 83029 + }, + { + "path": "frontend/e2e/__screenshots__/login-access.png", + "sha256": "893ca38ec8caf8fbd33e46c635b6f6d51e387f836b575ed0d124d7d761835df6", + "bytes": 29827 + }, + { + "path": "frontend/e2e/_capture-demo-screenshots.spec.ts", + "sha256": "6d0e9d4d0f5feaca651d133c51c266ab4991967d5c5e07790823dead72a144d9", + "bytes": 5213 + }, + { + "path": "frontend/e2e/_capture-recruiter-screenshots.spec.ts", + "sha256": "492045e0877413424b0f8b8d2767c8219f6417cc6210e9b5a7ac5e39b6549081", + "bytes": 1959 + }, + { + "path": "frontend/e2e/_capture-screenshots.spec.ts", + "sha256": "e5d62ef4c8db11e818d8ef611ccfc99aec9b1be2173d3a0ae2f484b2cb0bcb95", + "bytes": 2366 + }, + { + "path": "frontend/e2e/accessibility.spec.ts", + "sha256": "a1967ffa32eff287d926c1913478494c585af4cba4db5c07d0cec04203f6cd05", + "bytes": 1803 + }, + { + "path": "frontend/e2e/clickable-rows.spec.ts", + "sha256": "f45228c88c67a6db813c57d4e984f226a29f779ac4393113f9d79f6cbe0859ae", + "bytes": 5847 + }, + { + "path": "frontend/e2e/context-resilience.spec.ts", + "sha256": "7120b9c007de22351f6c1aaaef15ad4920b06313db994a940f148b13269c9551", + "bytes": 8900 + }, + { + "path": "frontend/e2e/demo-accessibility.spec.ts", + "sha256": "cb64af2da3c3501a730a42acef4505fdb9234e4c1ff5f5243f2c82c11caf193c", + "bytes": 7029 + }, + { + "path": "frontend/e2e/demo-entry.spec.ts", + "sha256": "163b7b419668561e0cff500f37b9ced64522960de4439ec998e95836b22817a4", + "bytes": 3190 + }, + { + "path": "frontend/e2e/demo-guide.spec.ts", + "sha256": "88dd0c10d947fbcedc5e8305b5ff4c96c34e1839cb4b7a4b6ab34312939ccf9e", + "bytes": 12092 + }, + { + "path": "frontend/e2e/demo-legibility.spec.ts", + "sha256": "567248582cb9bfa536a0c303b5c8bcdeda925c805856f3616a65fb8e28f91017", + "bytes": 4467 + }, + { + "path": "frontend/e2e/demo.spec.ts", + "sha256": "a1aa730604746664bd7e86b0b586d4e2a6b583febd546df7df3fe8c7471828d9", + "bytes": 7604 + }, + { + "path": "frontend/e2e/error-messages.spec.ts", + "sha256": "25f9df6253b60de371f7ad65b75b08549299dfd1b07abfb1f53cb4ffcf858902", + "bytes": 10234 + }, + { + "path": "frontend/e2e/fleet-ops-correction.spec.ts", + "sha256": "4433d57c93bad58ecf4b6f23e49a00de272b7c905067024b97c29de0cdc92350", + "bytes": 18718 + }, + { + "path": "frontend/e2e/greeting-live.spec.ts", + "sha256": "4b144d81089bab86489fded5ea91b7fe84fa4daf5a5981bdd25a67825645461a", + "bytes": 5073 + }, + { + "path": "frontend/e2e/greeting.spec.ts", + "sha256": "aa4e89916ad30fd99f2d6c577bf4d858e4576ccd830284149c179f27dcdf92a6", + "bytes": 4373 + }, + { + "path": "frontend/e2e/guided-demo-full.spec.ts", + "sha256": "02f80100872192b075d829d02fac8b6772d8355e953c2799bd8fb10825c942d0", + "bytes": 6724 + }, + { + "path": "frontend/e2e/i18n-coverage.spec.ts", + "sha256": "794e2490b528fc85b3ada0428920b33f7a21111f523741334da6560e5c61bb55", + "bytes": 14880 + }, + { + "path": "frontend/e2e/interactive-elements.spec.ts", + "sha256": "a834dd64d7b4dbe82e78677031736b2eac584d8f8bd0519714096c8ab980cd0f", + "bytes": 20732 + }, + { + "path": "frontend/e2e/operational-workflows.spec.ts", + "sha256": "3c1c71ee96b7b8fc9d9f31aa21ae77c7524b63a5094005b0db6adc94b8e03b1c", + "bytes": 6156 + }, + { + "path": "frontend/e2e/privacy.spec.ts", + "sha256": "1cd5fba11b9cd768fa083196eb22a165b37a4dd4cf556623efbb5e6395912b24", + "bytes": 1607 + }, + { + "path": "frontend/e2e/recruiter-polish.spec.ts", + "sha256": "1e61d062389ec6cc4dfc024962ff81092177228c361d09acdb0bcdf68f6de22f", + "bytes": 6572 + }, + { + "path": "frontend/e2e/responsive-i18n.spec.ts", + "sha256": "c9df06a5bbac23bd9f58dba4b610cfa933b38ef9ff47c2e872fa11d7a7b54b2a", + "bytes": 2270 + }, + { + "path": "frontend/e2e/roadmap-regression.spec.ts", + "sha256": "9032d3be29037c22c1284fa6c5aa5040f56f41ed97fa02000399cf8221eaae73", + "bytes": 2058 + }, + { + "path": "frontend/e2e/ui-redesign.spec.ts", + "sha256": "f34a781da735073c2481c79b5f539deb9a2a7e33885a118884894ee31dcc7530", + "bytes": 5273 + }, + { + "path": "frontend/e2e/visual-regression.spec.ts", + "sha256": "3f00bddfa1b00c05b6b6eea4dbbb8c1c36449d9f28c7738c6b88421ed6c9ca67", + "bytes": 508 + }, + { + "path": "frontend/eslint.config.js", + "sha256": "59b84800b2e22eca4cb0a2d14e807a22c22e6bccb7039a8d3ecf1fcbbb060215", + "bytes": 1420 + }, + { + "path": "frontend/index.html", + "sha256": "a55110a6bf6f057a693fd7a283df8509884cbd9e26168883e4f8ef3eb8ecd372", + "bytes": 1305 + }, + { + "path": "frontend/nginx.conf", + "sha256": "4a5b802522c2f283801eb2872c3c3ecab973e6dd473d4d6dc2816378baf026d5", + "bytes": 2010 + }, + { + "path": "frontend/package-lock.json", + "sha256": "0b8dd7fc6285dd3539827fbf6c9aee4102eb1150cd7fa5ac6f9a8f013dede7e5", + "bytes": 169491 + }, + { + "path": "frontend/package.json", + "sha256": "0a1a953f579c9d3b2c332f25c26f4308885a5d95e28eb96463b0debef04d0330", + "bytes": 1002 + }, + { + "path": "frontend/playwright.config.ts", + "sha256": "131590241ba0a5b8acce81a0e050f149bece7d18dfe75caec96564778d8a1d4f", + "bytes": 1336 + }, + { + "path": "frontend/playwright.live.config.ts", + "sha256": "6f1b76ead108561ce8ea785bda5b02b3d2249e9122ab6960a062da907fb5099f", + "bytes": 574 + }, + { + "path": "frontend/public/favicon.svg", + "sha256": "3d92feb153ec1cd1b6a523cbcb9b20a1c010eae6da4cd0c4e5cc9a47d4b8b8cd", + "bytes": 344 + }, + { + "path": "frontend/public/og-fleet-ops.svg", + "sha256": "3aabe2bcea21428e97320333d279f00122258ac351e97fe6d54de0ac9eca00fd", + "bytes": 2205 + }, + { + "path": "frontend/src/App.tsx", + "sha256": "66a794fca8e3d9215142349e3db394d95392dc1c4ff89ca07e08275fe2d2c74e", + "bytes": 4502 + }, + { + "path": "frontend/src/api/apiError.ts", + "sha256": "7440b00f733296ae1a34802f0aaf4e53e09abc7dafe9fb98d18d8b8526f748ea", + "bytes": 298 + }, + { + "path": "frontend/src/api/client.ts", + "sha256": "2619064a9058b0416ce943335c2be0e4e5a6bee00226823abdd221de67008502", + "bytes": 3017 + }, + { + "path": "frontend/src/api/errorMessages.ts", + "sha256": "576dc5d21c9260154dc0a5a34418ff7645548489157c234a18566f859fcd5dcd", + "bytes": 3576 + }, + { + "path": "frontend/src/api/types.ts", + "sha256": "5c1c03f6192a3b7d2ac020dc4d1c446a7ab393751b5789726110ee5e0d92fc76", + "bytes": 10847 + }, + { + "path": "frontend/src/components/ArchitectureFlow.tsx", + "sha256": "9af949bae12099bc057ac43910ee3a671eef5c51cb5fa0d4ea8df360363be8db", + "bytes": 4627 + }, + { + "path": "frontend/src/components/Badge.tsx", + "sha256": "18c31225a5f7fef3202edc0bc4c1b2172e8dda18a5ff90b673eeae0d334c3921", + "bytes": 460 + }, + { + "path": "frontend/src/components/CheckoutForm.tsx", + "sha256": "dc16434fc37bf7d81fcef34ae7160486ac489503b89fedcc34ce75e75a2deabb", + "bytes": 3540 + }, + { + "path": "frontend/src/components/DemoBadge.tsx", + "sha256": "ec90eeda9bffea077d45d41fe7ed086d2b3b2145ee920158fdadae39257c1b6c", + "bytes": 2627 + }, + { + "path": "frontend/src/components/DemoGuide.tsx", + "sha256": "b4d7412643226dc59be64e78c9a3d4fab831e9187a8c16dde4f1caaf85e32825", + "bytes": 10359 + }, + { + "path": "frontend/src/components/Icons.tsx", + "sha256": "9a87f37fc5c14e8b5365fdff0763fb2c38476729ef953aa63d741b32f65e11a4", + "bytes": 3139 + }, + { + "path": "frontend/src/components/LanguageSwitcher.tsx", + "sha256": "d6a8490602e9a373e6062f017e7fde2da8f3b2fb61a6411abb91b8d2c0e74e81", + "bytes": 998 + }, + { + "path": "frontend/src/components/Layout.tsx", + "sha256": "fa66b39a2d8da7c2db7d48825e1cd06f9d643e0da6781f1f03580737268ea4ab", + "bytes": 15698 + }, + { + "path": "frontend/src/components/PageChrome.tsx", + "sha256": "ebfdb1d411503573389c7cff2902688d4f52a573dfc3b0c79a9d7f5aa9752958", + "bytes": 3284 + }, + { + "path": "frontend/src/components/Pagination.tsx", + "sha256": "c89218ad652bab9f61c44cb7f41b070b9695351725cb785881d184aa16b9681e", + "bytes": 757 + }, + { + "path": "frontend/src/components/RequireAuth.tsx", + "sha256": "e971ee045bacf679708a1aeb6428cb334f61ee92a85a553ec4260353cf3e1b24", + "bytes": 489 + }, + { + "path": "frontend/src/components/ReturnForm.tsx", + "sha256": "d24b2ecb92c4595138b8c169e7a141b197ec9a804c8cb4bf871aa5a5f036beb6", + "bytes": 12351 + }, + { + "path": "frontend/src/components/VehicleMaintenanceActions.tsx", + "sha256": "e3e89dbe22b4485652c06ea76d0652103b2cbad7746da1cbe3b084d539b0af9d", + "bytes": 4435 + }, + { + "path": "frontend/src/context/AuthContext.tsx", + "sha256": "c8cd1d2499fbdb38c370aa29f8868f2ef1d851d762e29463f2b53a364a8b842a", + "bytes": 5473 + }, + { + "path": "frontend/src/context/DemoGuideContext.tsx", + "sha256": "53f20314cc72aeadeeb89d4ab23f5bf86d9e49011fb62590a7cf8cde32f0cd85", + "bytes": 3950 + }, + { + "path": "frontend/src/context/DemoManifestContext.tsx", + "sha256": "f61b34a29f3771523d5ae0cd604c27d8407fa5775e7098703a19b3383cfb4352", + "bytes": 1784 + }, + { + "path": "frontend/src/data/demoGuideSteps.ts", + "sha256": "7a4ed66d6a7e00de8c40f159486b8964dc7361c34997a798c8cf1c07fab0db1a", + "bytes": 1826 + }, + { + "path": "frontend/src/data/evidenceSignals.ts", + "sha256": "bea79d31753a53b27018b5f3365f1f3797e6a1e30ec4586aeecaa9e789a50bf1", + "bytes": 1942 + }, + { + "path": "frontend/src/data/integrationLabels.ts", + "sha256": "a9960fc0ba8d22680701b5727c026b4fe41eb11c66048687abcd927bb58532e6", + "bytes": 1864 + }, + { + "path": "frontend/src/hooks/useViewportTier.ts", + "sha256": "e8befad382dcea860848516cf30a3e8eb21ac518c101a0ad7f8892bbc10b9ca9", + "bytes": 1305 + }, + { + "path": "frontend/src/i18n/brusselsDateTime.ts", + "sha256": "6fa15a54c7f26ea052d386d7d4b6f6bb61d085716fd5c815a552027ff644a306", + "bytes": 2871 + }, + { + "path": "frontend/src/i18n/config.ts", + "sha256": "53aca7e0f9b47eed462b30b9fc593c8693f23923a94fe1e917af18920f0a2b58", + "bytes": 6066 + }, + { + "path": "frontend/src/i18n/format.ts", + "sha256": "729a6c1243ccae53e1204844bcb35bbd718fab480bc85a7fa5445eff21987e6e", + "bytes": 1158 + }, + { + "path": "frontend/src/i18n/greeting.ts", + "sha256": "459f3cfa4afd9520a9ead7527a43b9edae054de78629e1aef8dbf9f95159ab86", + "bytes": 1308 + }, + { + "path": "frontend/src/i18n/locales/en-GB/accessibility.json", + "sha256": "6f6820d862579055adf5dba937c45fa0a403b10e8f4896436212602c4ed38b3a", + "bytes": 190 + }, + { + "path": "frontend/src/i18n/locales/en-GB/audit.json", + "sha256": "7232b0863fbf64df1f88763bb18d6c2202d2ae6c799103678bc786b13e20865e", + "bytes": 4441 + }, + { + "path": "frontend/src/i18n/locales/en-GB/auth.json", + "sha256": "d9759a0ced146a29a375bc810fc344f372923c33c7c38eebd07f02b00cfdcf03", + "bytes": 1915 + }, + { + "path": "frontend/src/i18n/locales/en-GB/bookings.json", + "sha256": "f72c06b56689bf155c13341f575058a9048965ec9f18e9638deb2beca6544f90", + "bytes": 5575 + }, + { + "path": "frontend/src/i18n/locales/en-GB/common.json", + "sha256": "ddac31f07a59e18a0185c74f4fc3a2644f56f7d2deb8e1ce24bccb5eaf5eb828", + "bytes": 1143 + }, + { + "path": "frontend/src/i18n/locales/en-GB/dashboard.json", + "sha256": "d9a8717f531d3ceb6e6e9a9430f93520d757acfc00db4d69ccb7743559554190", + "bytes": 3115 + }, + { + "path": "frontend/src/i18n/locales/en-GB/demo.json", + "sha256": "71b458f10d31a631cc5f01d54c281f7595978bb29df22553181adaddec84ef88", + "bytes": 19195 + }, + { + "path": "frontend/src/i18n/locales/en-GB/errors.json", + "sha256": "c23983dbf57d44af7d9201cbc7e3b5565af36ec1f1d94c9f14fcbfdd83ca6d69", + "bytes": 8264 + }, + { + "path": "frontend/src/i18n/locales/en-GB/fleet.json", + "sha256": "949f54816351312b0a59e9c68c5a923d5e55de32ce6e0a02a6de80decd1d9d60", + "bytes": 3461 + }, + { + "path": "frontend/src/i18n/locales/en-GB/integrations.json", + "sha256": "6900cd02e02e08643139e8a7665bb48477eb3978382074a4d6f6882bcac61036", + "bytes": 6072 + }, + { + "path": "frontend/src/i18n/locales/en-GB/knowledge.json", + "sha256": "89e8183cf7cb34d2df44e9f8cf0b1246a07f303ead015db719853c8969c7de46", + "bytes": 3867 + }, + { + "path": "frontend/src/i18n/locales/en-GB/navigation.json", + "sha256": "1e4df6359e6be6db74756f276763fe17f27d14ca7f8e700a428e6d529c1c4559", + "bytes": 1850 + }, + { + "path": "frontend/src/i18n/locales/en-GB/operations.json", + "sha256": "0c5b6eaf9f254e42182359465045769907f39f2d71ecad3d42570e5e7555f554", + "bytes": 936 + }, + { + "path": "frontend/src/i18n/locales/en-GB/privacy.json", + "sha256": "c0a33be8c2b83b38f103a001aad42a33c80790b74c1c8d6c8d32010cf16623c5", + "bytes": 1331 + }, + { + "path": "frontend/src/i18n/locales/en-GB/quality.json", + "sha256": "15bc996fc63558cb778e8e9cff9a77fa057fbad13f0ddbdf932c8b59c17c7e32", + "bytes": 13248 + }, + { + "path": "frontend/src/i18n/locales/en-GB/returns.json", + "sha256": "117658154f79f1935344bb978f36ec385e8cbe70a3b0f29d8e9d599e1c7bb179", + "bytes": 4102 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/accessibility.json", + "sha256": "1ad431b41ce6f0f7ee316f6c1bb11882f1f0aa9958fd1cc6c5ef1f85348c4762", + "bytes": 218 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/audit.json", + "sha256": "a555f3d0daab9893c7002f5216d4c97650521eef801bd9f7598025ea3e3aaaa4", + "bytes": 4994 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/auth.json", + "sha256": "20ba5895fe45b681744846eabefc6e45be9783ceff1362d3ce67f20146e711cb", + "bytes": 2273 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/bookings.json", + "sha256": "93cf48944f37118798599a36004f1fb8744c808ac9f1fe04b6a8d9c415c9d77b", + "bytes": 6235 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/common.json", + "sha256": "a74f9a413e254324639cbd343ba56102142bcb0e91c37386aed8666cc3825524", + "bytes": 1276 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/dashboard.json", + "sha256": "a5e066f2641419660e4cb159835af79058091668981978923d274894d0feaf2e", + "bytes": 3459 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/demo.json", + "sha256": "91368bb1a1e97850c21ed6e0b6ae45952ec1eb5d08f3324437f639be926c6f19", + "bytes": 22310 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/errors.json", + "sha256": "63dca7903610d850dc6112105437b759192f15c35505770bea89fb988c5c8cf0", + "bytes": 9170 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/fleet.json", + "sha256": "8d08831e8d9cdc1dae05fcbb6b8ae64637ce9360b22a68a79eac01dd3f211073", + "bytes": 3866 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/integrations.json", + "sha256": "11a503a2f2bcd36df0ea95abd3a31c0740c72af15a932fedce22d35ea23f1e9a", + "bytes": 7054 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/knowledge.json", + "sha256": "d3b7ce22807569ca6639c21bd27340c1037f19c27ab4f4ee4cc410ec7d7e31ea", + "bytes": 4428 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/navigation.json", + "sha256": "4a2acd6e125ccfb362399013fbcabb93ec990abcd21cc45ff50de5c05f312f16", + "bytes": 2147 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/operations.json", + "sha256": "e2cfecf292271ce8eb82a1117e409f89b13338e849c4f38594fabad055497826", + "bytes": 1109 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/privacy.json", + "sha256": "cedb3842e105d0ed80844df881e171a7dbf534020b41660000c072f222e48f99", + "bytes": 1486 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/quality.json", + "sha256": "3ec11564a0b9ce44c729643281524ff5bffa2cffeb752b380e781a12a85c638b", + "bytes": 15044 + }, + { + "path": "frontend/src/i18n/locales/fr-BE/returns.json", + "sha256": "8fbbd6fa24363a6b10965b21993d01bd6ef9fd5d0479baf261f9ea0744809877", + "bytes": 4647 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/accessibility.json", + "sha256": "91887c4eca06d4d0f3713f6503ee0d9e3e714d89ae9e10ae9e17fddb67b15e46", + "bytes": 208 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/audit.json", + "sha256": "088c26af5a6eb3e5af79d70a1fc072d13c40cff3ece573d2fc24fdf48de0c41e", + "bytes": 4852 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/auth.json", + "sha256": "e75c5eb4e3e2a5f7df4d0fa9b7e1a47b5efc0d2a9f53a5dab5ba8ba64a735ef5", + "bytes": 2038 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/bookings.json", + "sha256": "ffa9c25fc9a27ee9071593f3d29c6f690286026ea03d1a7d633daa6e0628c343", + "bytes": 5793 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/common.json", + "sha256": "367a1317ce6cc3a64a2a75c03cdef9f0ec8bb7f9e643c35008d4630328b67e29", + "bytes": 1202 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/dashboard.json", + "sha256": "be4c56b0c310dd810bae79810c6fb0b8db0fa9c2a1123806e1b649a1b037cf4f", + "bytes": 3331 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/demo.json", + "sha256": "7b727f0ae3a517a3a65e998f254c1f170007a9ddfea6c234e2fd57bfc8cd7322", + "bytes": 20345 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/errors.json", + "sha256": "ffb8eb486c37649c32abd3f772fc87876bbdf36dde769f0347973d39dfb3b7db", + "bytes": 8892 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/fleet.json", + "sha256": "f1da01728da46fe8c6c91b459bcca2054d2909802fb9d591fe19681c96b94bdf", + "bytes": 3664 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/integrations.json", + "sha256": "402560aef58d378be829282599f6c6cb00ee2c93efcafcb1143f392d9fcf750b", + "bytes": 6507 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/knowledge.json", + "sha256": "9b82f401d24175bc3e437c08b11ac132189e339f1f31e281f610ab958c545bd3", + "bytes": 3986 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/navigation.json", + "sha256": "193a9b87fc8de3327112deb43b70bf615813add637564451e541408136f2f763", + "bytes": 1957 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/operations.json", + "sha256": "e8b8e9a383dc3601fb6e5aeb1c1719f662fabbe55823239ffdfb1476480a88aa", + "bytes": 1031 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/privacy.json", + "sha256": "e53545457e0a11b5cdf0c554afa66f0ce6529d10ee1345e06b0d413e94fa84aa", + "bytes": 1370 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/quality.json", + "sha256": "c6b9bf426ec1b8d82f374e0c6067c3f1805b7171614b00db08a95e02a13966d4", + "bytes": 14215 + }, + { + "path": "frontend/src/i18n/locales/nl-BE/returns.json", + "sha256": "d65176423afe7ca8c859c517d192de6bed0342b452b7a3258da638bf3b7c5696", + "bytes": 4409 + }, + { + "path": "frontend/src/i18n/useGreetingPeriod.ts", + "sha256": "6289bfe5cff5e1377d2d4535d1cd05cd54c671c0087c8098e2975551a0d6f236", + "bytes": 791 + }, + { + "path": "frontend/src/main.tsx", + "sha256": "79ca00f75cf342100c00d60f358c061d46d3db5ae3a51366a939d6562968fd75", + "bytes": 359 + }, + { + "path": "frontend/src/pages/AboutDemo.tsx", + "sha256": "5fa717c720acdd61b9c39b366beac700c8b686e8978250fce9fc2da6cfbb9ad5", + "bytes": 5654 + }, + { + "path": "frontend/src/pages/Audit.tsx", + "sha256": "bd538ca1db8841cdd5defb23bc3b3660379c094ad602d95827379829a72f669b", + "bytes": 10819 + }, + { + "path": "frontend/src/pages/Automation.tsx", + "sha256": "1fa6aacb132830b6543640e4f1cc02448b892b9347d8c05a5d9a2e9462d1d78c", + "bytes": 20179 + }, + { + "path": "frontend/src/pages/BookingCreate.tsx", + "sha256": "0e36522bad8eb22c38a77383371c3f10c06c028835fdded00356d68b9bfc2fee", + "bytes": 7225 + }, + { + "path": "frontend/src/pages/BookingDetail.tsx", + "sha256": "bf1419078c125cdf3c773306fed2ffd6d008514064c27d0e360cbdd46545d5bb", + "bytes": 13100 + }, + { + "path": "frontend/src/pages/Bookings.tsx", + "sha256": "61320c0ca187be45ebf2c04d552adc31eb9ae179b64078ee18ad5f6bf86081e6", + "bytes": 7297 + }, + { + "path": "frontend/src/pages/Dashboard.tsx", + "sha256": "0ce3ab829fa5f411e667558b436bf04c852eeef4a650f4703c3c1d04e710b275", + "bytes": 18314 + }, + { + "path": "frontend/src/pages/DataQuality.tsx", + "sha256": "ca6f815a508aa75d1c49ef8db0f00ed9c4db626fc3167b31000f474aec240aad", + "bytes": 17502 + }, + { + "path": "frontend/src/pages/DataQualityIssueDetail.tsx", + "sha256": "66c8fcef1bf446f20e2e0a6ddc1197f40e52d05479be96c59c4190f91c9721c6", + "bytes": 29407 + }, + { + "path": "frontend/src/pages/Highlights.tsx", + "sha256": "4a65be004f3b94dcc31ebff1c73cb3520ae6b45458b07e2b8f9781a1917372a6", + "bytes": 2580 + }, + { + "path": "frontend/src/pages/Knowledge.tsx", + "sha256": "0e772dbc5125b53e51f947ec35811a71f66d3bec0b3adfea071c4d5446251700", + "bytes": 12368 + }, + { + "path": "frontend/src/pages/Login.tsx", + "sha256": "ec2b49607c666ae223beb76692a9b09fe750f234f008f1251eee6ea8a035f0d5", + "bytes": 6692 + }, + { + "path": "frontend/src/pages/Privacy.tsx", + "sha256": "5ec9c955c5a4033929ad42b19ae821ab01c2e15e0fecee67225d9bb96459955e", + "bytes": 4580 + }, + { + "path": "frontend/src/pages/Scenarios.tsx", + "sha256": "45cf57103c99aeadc1f02308113d7dd7742912daf2fb699edfca780988932d0f", + "bytes": 3538 + }, + { + "path": "frontend/src/pages/Users.tsx", + "sha256": "cecb75bc9ef5f513e8594347831349b754ef976e52cf4e00f0cf3a099e86a309", + "bytes": 7378 + }, + { + "path": "frontend/src/pages/VehicleDetail.tsx", + "sha256": "e00c236ab050757d37a23feb34db3311a45ec94f954e68be3073ab00800ac660", + "bytes": 6519 + }, + { + "path": "frontend/src/pages/Vehicles.tsx", + "sha256": "5141cdc85451a2cb6f4289a85032efc73740be5f5cacd4053bea5d12e1d5818b", + "bytes": 6849 + }, + { + "path": "frontend/src/pages/data-quality/DuplicateCustomerPanel.tsx", + "sha256": "c844efdf6aeed075cc9d24ccb95f2d3cfd964a241960c143de1f308ac10ee9d6", + "bytes": 8439 + }, + { + "path": "frontend/src/product.ts", + "sha256": "0ce805fdc6463f9b393a5e8f9c25d12ac9065e8a70410733c4179d1798e1d065", + "bytes": 369 + }, + { + "path": "frontend/src/styles-architecture-flow.css", + "sha256": "4e6b01de583c9d3f1ace85320f36ea604865b56e7e55365fa76c8c3a7b80cd5e", + "bytes": 8057 + }, + { + "path": "frontend/src/styles-data-quality.css", + "sha256": "b6542d06a968eecd3aa5f21eb152d837adf1de96c003b031cc7f8bada4016c8e", + "bytes": 6525 + }, + { + "path": "frontend/src/styles.css", + "sha256": "5c58bad27d1c5f3d4d09724c9c8140c6f7529eb4698520a4d390aacb05c6f57d", + "bytes": 76342 + }, + { + "path": "frontend/src/vite-env.d.ts", + "sha256": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5", + "bytes": 38 + }, + { + "path": "frontend/tsconfig.json", + "sha256": "83efc1798334967d5a907f26b8cba31fa213f16b5ac0ed5c02fd38e7cf0ceb66", + "bytes": 527 + }, + { + "path": "frontend/vite.config.ts", + "sha256": "eca485c281977125366d4800553256e419f364984e615f75a2d2bae54f7857fd", + "bytes": 136 + }, + { + "path": "knowledge/manifest.json", + "sha256": "ae268c150a4707ca8277f0dbab542316bf0b59843571f5389b8cee1f3942ee10", + "bytes": 3885 + }, + { + "path": "knowledge/procedures/en-GB/01-vehicle-checkout.md", + "sha256": "31f9dd030e06f80b4320030df1749d170e6196aed38cb3021a87ca6fb8be3318", + "bytes": 835 + }, + { + "path": "knowledge/procedures/en-GB/02-vehicle-return.md", + "sha256": "47aec42a4093c481cf8318848f332f4a94f8988a7103c87b7828b3a82d4d3f59", + "bytes": 922 + }, + { + "path": "knowledge/procedures/en-GB/03-damage-handling.md", + "sha256": "ececef84539fa024a8032db4f42995a9fbdb825802f85e4d20db948dfbc4248b", + "bytes": 820 + }, + { + "path": "knowledge/procedures/en-GB/04-odometer-anomalies.md", + "sha256": "93554311119b9ce16f4a16d39ccb97807032c1f34c778117e5644a8c4be52279", + "bytes": 777 + }, + { + "path": "knowledge/procedures/en-GB/05-cleaning-checklist.md", + "sha256": "1aabf396f54ac60ca62b05a9bdba94ecdb1a8e21d3dac73e05eada8d2fdb2dfb", + "bytes": 644 + }, + { + "path": "knowledge/procedures/en-GB/06-maintenance-escalation.md", + "sha256": "f2bdaf59c2c5f2a3fa82b1c038cadf116ad89af2b1fd27b0d48f41b92eecf0fe", + "bytes": 662 + }, + { + "path": "knowledge/procedures/en-GB/07-customer-documents.md", + "sha256": "569829b50ebd462d3978ff22ee2283fde8432d5cd08b39d64e2fa94e2ea910af", + "bytes": 682 + }, + { + "path": "knowledge/procedures/en-GB/08-privacy.md", + "sha256": "39981d9871f0bf6c4d495809ca0026ad16d646e5b68bcbf7e6c08315a04fd1e6", + "bytes": 684 + }, + { + "path": "knowledge/procedures/en-GB/09-booking-conflicts.md", + "sha256": "725cd8e50bb4071153aaad5e2fb4bbdbce4745aa2fdf39f8271eb2375cc2ee80", + "bytes": 643 + }, + { + "path": "knowledge/procedures/en-GB/10-roles-and-escalation.md", + "sha256": "9f62e555e89737d0096b972ca1ee15fc32e53368f50328a3dee5f64671015c4f", + "bytes": 690 + }, + { + "path": "knowledge/procedures/en-GB/11-vehicle-availability.md", + "sha256": "82593d36ecb03ab2e572ea8c70bd270e650738d364fad2bd9c81a410dd2c7623", + "bytes": 876 + }, + { + "path": "knowledge/procedures/fr-BE/01-vehicle-checkout.md", + "sha256": "a10414a68281da1752cd373fc6a8378c50a5b360959a031307cbe0a8a10ac711", + "bytes": 1005 + }, + { + "path": "knowledge/procedures/fr-BE/02-vehicle-return.md", + "sha256": "6ed004c6b98286d1a8c0b031dfae75daa1c19f23dc6f847b558b73bead6833ce", + "bytes": 1066 + }, + { + "path": "knowledge/procedures/fr-BE/03-damage-handling.md", + "sha256": "99183d0e30e4b54cd20becb10b629d1dbe6736ef4e4632ab31b237c95e1f9711", + "bytes": 985 + }, + { + "path": "knowledge/procedures/fr-BE/04-odometer-anomalies.md", + "sha256": "9cf5feb2d5f1473de05bbb01e091c2d8e00375c40bd3eb681be5050e92a37e45", + "bytes": 910 + }, + { + "path": "knowledge/procedures/fr-BE/05-cleaning-checklist.md", + "sha256": "1105e81cd9a7ae295740320a735852e8f6fea6708912aa49f301a856cda7741a", + "bytes": 775 + }, + { + "path": "knowledge/procedures/fr-BE/06-maintenance-escalation.md", + "sha256": "4446d985e849061ad5fc2f85b9168b0dad7d5c88ad88c15430998ac9b1f1aeeb", + "bytes": 737 + }, + { + "path": "knowledge/procedures/fr-BE/07-customer-documents.md", + "sha256": "24d0d63e050ee474626a23f814c011a4a44d3efd054a5eebc9b8d889076bfa0f", + "bytes": 778 + }, + { + "path": "knowledge/procedures/fr-BE/08-privacy.md", + "sha256": "6b699e31743d66cabb11008c6118daf1843f59c3e66cd0151d4f6a55acae564f", + "bytes": 866 + }, + { + "path": "knowledge/procedures/fr-BE/09-booking-conflicts.md", + "sha256": "29f813e58a8577be50b5c2bedcfadb542184d3a0ad44ab4e49a690c448ca0dca", + "bytes": 809 + }, + { + "path": "knowledge/procedures/fr-BE/10-roles-and-escalation.md", + "sha256": "ea801d280f86fc22ea817b4fa70e1682ee0adf21bc58e15122093f3684c246ee", + "bytes": 888 + }, + { + "path": "knowledge/procedures/fr-BE/11-vehicle-availability.md", + "sha256": "c0f37b3181102f052cb9e2918dfc869b6c45727dedc267edcedd5536ae995c4e", + "bytes": 1031 + }, + { + "path": "knowledge/procedures/nl-BE/01-vehicle-checkout.md", + "sha256": "e8fe248d7ceb6bf645ae3b8297e3ab23e8049fba35ac4c5eabf199b14571eb2e", + "bytes": 939 + }, + { + "path": "knowledge/procedures/nl-BE/02-vehicle-return.md", + "sha256": "65b879a992f32acbe3a62c44d1d4655bfbb70c7d0394151d25c4a385afdc19f9", + "bytes": 1023 + }, + { + "path": "knowledge/procedures/nl-BE/03-damage-handling.md", + "sha256": "88046efc4f630b687c93be3b1df6e5da26b076fa0bea766fd8f6326e021b40d7", + "bytes": 889 + }, + { + "path": "knowledge/procedures/nl-BE/04-odometer-anomalies.md", + "sha256": "68157f3f1cc1436d09fa24eef8040cd83707d0eca6068297149071d3199fe8a6", + "bytes": 849 + }, + { + "path": "knowledge/procedures/nl-BE/05-cleaning-checklist.md", + "sha256": "63d69019a08f80daa43ca2505aa035af0d3318734270277e4ff321e5533ce4dd", + "bytes": 711 + }, + { + "path": "knowledge/procedures/nl-BE/06-maintenance-escalation.md", + "sha256": "5a1b5caeec8496977dbbaaec2431bddbfcd692b9fc2c33d9cfba1b5ebf529aa1", + "bytes": 726 + }, + { + "path": "knowledge/procedures/nl-BE/07-customer-documents.md", + "sha256": "eda9e4a0d8161c0f4cbff53cc64503ecfe745e9112a7413fb874296f13d061d9", + "bytes": 727 + }, + { + "path": "knowledge/procedures/nl-BE/08-privacy.md", + "sha256": "55e9dbb3da3028022f4f076f852fb044f0204019f93bd7dedc5074f17bad3bbf", + "bytes": 716 + }, + { + "path": "knowledge/procedures/nl-BE/09-booking-conflicts.md", + "sha256": "7682a6b8b24fd40aad5724c30f7bc70d9b4e3ea90aacdc15dd269fecec1795aa", + "bytes": 739 + }, + { + "path": "knowledge/procedures/nl-BE/10-roles-and-escalation.md", + "sha256": "187c51aa8b1dae9f821d11ae6ca0673dc6c492735b6d12a43142d1f348ca20c1", + "bytes": 760 + }, + { + "path": "knowledge/procedures/nl-BE/11-vehicle-availability.md", + "sha256": "36b787b1bc53c810232f11fd76a3f25b972401fe02019790783f76229aa1da41", + "bytes": 1002 + }, + { + "path": "scripts/check-contracts.py", + "sha256": "a15235c795bdf4606092672f00c6f95fc5b0b371cbdf8d2c8aa88b135a7067be", + "bytes": 8385 + }, + { + "path": "scripts/check-frontend-budget.mjs", + "sha256": "0c74275ca98d0b84db5f56fc5058aafba3090527110f902d862d7be4b1100c62", + "bytes": 710 + }, + { + "path": "scripts/check-source-budgets.py", + "sha256": "90fe88f23d7f6f6714f971331716f5f477095dc67694ca6349dd5b0bd263eac7", + "bytes": 1159 + }, + { + "path": "scripts/generate-openapi.py", + "sha256": "892d53451649a491ed63a6c74b8b25dfecd332de01e37462d036c0372b64f3ee", + "bytes": 362 + }, + { + "path": "scripts/generate-release-provenance.py", + "sha256": "cb57382d99f8c0274e5b08f6a37146f1a24bab8c48e35dfae45181658b1ed617", + "bytes": 2007 + }, + { + "path": "scripts/run-isolated-tests.sh", + "sha256": "de75f008104cccca75ca84f6a7710a9543b40d34280ae4ea73de15d79fbb4217", + "bytes": 298 + }, + { + "path": "scripts/run-readonly-load-smoke.py", + "sha256": "53fa2f1cbd66a20a25698ad6b3adea8ad70615286567eafe5969682f756089d5", + "bytes": 3090 + }, + { + "path": "scripts/scan-ci-image.sh", + "sha256": "644a4c8a8552c90a36e7f26a2afc6dda73319fb4ae44d1e1066940fa792178f8", + "bytes": 997 + }, + { + "path": "seed/README.md", + "sha256": "4fe82faf825c6655baff3380d9ff45b142de36bfb7ba4fc41ee44a49606890f1", + "bytes": 459 + }, + { + "path": "seed/bookings.csv", + "sha256": "a05a7a9ece4e6a95f347b2d278f67d3f5f9dd955aa84f9218318ce21ca8b867f", + "bytes": 23692 + }, + { + "path": "seed/customers.csv", + "sha256": "81e1268fc05d72c8ba9de8855586c12dc5fbd1651f98e6570af1da22b83d5e6e", + "bytes": 14784 + }, + { + "path": "seed/data_quality_issues.csv", + "sha256": "b39798b31bd3a8154433cdaec4de53a89f471e8e7d7328411e9e40c6364a884d", + "bytes": 2138 + }, + { + "path": "seed/generate_seed.py", + "sha256": "edc2b1018df22d8b724876a4268ed0d4d9ad2ea2a4ff1cb47829daa2271b7f8a", + "bytes": 22854 + }, + { + "path": "seed/inspections.csv", + "sha256": "3678580117ece94943b544f15f6bdfd510cce71a48e15d65451b73144d987db2", + "bytes": 6208 + }, + { + "path": "seed/maintenance.csv", + "sha256": "438140ffd3d6a92bf9e3a33ee3b12130c7cc731a381623b85f8b4854ffda5db1", + "bytes": 3669 + }, + { + "path": "seed/vehicles.csv", + "sha256": "4d289c4a2d22ebcb62f6659dc58a58fe7ca3e5af7db751928a3f41c3e87383b2", + "bytes": 3469 + }, + { + "path": "seed/workflow_runs.csv", + "sha256": "d074b08265dde24181f6802a3226fcc48b43f77310ef3737600ef4a6e3d09eb4", + "bytes": 2125 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b61fb56 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# MobilityOps · public source + +MobilityOps (Fleet Ops) is a synthetic-data operations demo for vehicle rental and service teams. This repository is the reviewed public source snapshot. Development and deployment happen in a private canonical repository; each public update is generated from a committed revision with a fresh content review. + +## Run locally + +Copy `.env.example` to `.env`, then run `make demo`. Open . The demo uses fictional records and a local knowledge provider. + +## Contents + +- `backend/` — FastAPI domain and API +- `frontend/` — React interface and browser tests +- `contracts/` — API and event contracts +- `knowledge/` and `seed/` — synthetic procedures and records + +## Security + +Please report vulnerabilities through the contact in [SECURITY.md](SECURITY.md). No operational deployment state or private Git history is included in this repository. + +Licensed under the terms in [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0124648 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,56 @@ +# Security Policy + +## Supported versions + +| Version | Security support | +|---|---| +| Latest tagged PoC release and current `master` | Supported | +| Older commits, branches and untagged deployments | Not supported | + +MobilityOps is a synthetic-data proof of concept, not a production identity, +payments or public reservation platform. Security fixes target the current +release line only. + +## Reporting a vulnerability + +Do not disclose suspected vulnerabilities through a public issue. + +Report them privately to `security@itworx.tech` with: + +- the affected revision, endpoint or component; +- reproduction steps and prerequisites; +- the observed and expected behaviour; +- the security impact; +- a minimal proof of concept, without unnecessary personal or secret data. + +Receipt should be acknowledged within three business days. An initial +assessment or request for additional evidence should follow within ten +business days. Remediation timing depends on severity and reproducibility. + +## Scope + +In scope: + +- MobilityOps backend, frontend, container and deployment code; +- authentication, authorization, tenant boundaries and audit integrity; +- database, outbox, backup and restore behaviour; +- MobilityOps-owned n8n workflow definitions; +- RAGcore and MCP Hub integration boundaries implemented in this repository. + +Out of scope: + +- denial-of-service or destructive testing against the hosted demo; +- social engineering, credential stuffing or physical attacks; +- synthetic demo-data exposure without a security-boundary failure; +- vulnerabilities solely inside RAGcore, ITWorx MCP Hub, n8n or another + third-party service. Report those to their respective owners. + +Do not access data beyond what is required to demonstrate the issue, modify +shared infrastructure, interrupt other services or retain obtained secrets. + +## Coordinated disclosure + +Good-faith research that respects this policy and applicable law will be +handled constructively. Allow a reasonable remediation period before public +disclosure. Submitted reports and evidence are used only for investigation, +remediation and verification. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8bbd9e0 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base +ARG VCS_REF=development +ARG BUILD_DATE=unknown +LABEL org.opencontainers.image.title="Fleet Ops API" \ + org.opencontainers.image.revision="$VCS_REF" \ + org.opencontainers.image.created="$BUILD_DATE" \ + org.opencontainers.image.source="https://fleetops.example.test" +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY backend/requirements-prod.lock ./ +RUN pip install --no-cache-dir -r requirements-prod.lock +COPY backend/pyproject.toml ./ +COPY backend/app ./app +COPY backend/alembic ./alembic +COPY backend/alembic.ini ./ +COPY seed ./seed +COPY knowledge ./knowledge +COPY backend/entrypoint.sh ./entrypoint.sh +RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh \ + && addgroup --system app && adduser --system --ingroup app --home /app app \ + && chown -R app:app /app + +FROM runtime-base AS test +COPY backend/requirements.lock ./requirements.lock +RUN pip install --no-cache-dir -r requirements.lock +COPY backend/tests ./tests +COPY contracts ./contracts +COPY scripts/check-contracts.py ./scripts/check-contracts.py +COPY n8n/workflows ./n8n/workflows +USER app + +FROM runtime-base AS runtime +# Run migrations and the API as an unprivileged user; nothing here needs root. +USER app +EXPOSE 8000 +CMD ["./entrypoint.sh"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..5aabe3a --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..367c622 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,46 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context +from app.core.config import get_settings +from app.models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py b/backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py new file mode 100644 index 0000000..69e225a --- /dev/null +++ b/backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py @@ -0,0 +1,28 @@ +"""idempotency request fingerprint + +Revision ID: 0a4c1d2e3f5b +Revises: c24f6a9d013e +Create Date: 2026-08-16 22:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0a4c1d2e3f5b" +down_revision: Union[str, None] = "c24f6a9d013e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "idempotency_records", + sa.Column("request_fingerprint", sa.String(length=64), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("idempotency_records", "request_fingerprint") diff --git a/backend/alembic/versions/4f2b9c8d7e61_unique_open_quality_condition.py b/backend/alembic/versions/4f2b9c8d7e61_unique_open_quality_condition.py new file mode 100644 index 0000000..2aea5b3 --- /dev/null +++ b/backend/alembic/versions/4f2b9c8d7e61_unique_open_quality_condition.py @@ -0,0 +1,27 @@ +"""enforce one open issue per detected condition + +Revision ID: 4f2b9c8d7e61 +Revises: 0a4c1d2e3f5b +""" + +from alembic import op +import sqlalchemy as sa + +revision = "4f2b9c8d7e61" +down_revision = "0a4c1d2e3f5b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "uq_data_quality_one_open_condition", + "data_quality_issues", + ["rule_type", "entity_type", "entity_id"], + unique=True, + postgresql_where=sa.text("status = 'open'"), + ) + + +def downgrade() -> None: + op.drop_index("uq_data_quality_one_open_condition", table_name="data_quality_issues") diff --git a/backend/alembic/versions/799d8800e241_outbox_last_error_code.py b/backend/alembic/versions/799d8800e241_outbox_last_error_code.py new file mode 100644 index 0000000..755e05b --- /dev/null +++ b/backend/alembic/versions/799d8800e241_outbox_last_error_code.py @@ -0,0 +1,25 @@ +"""outbox last_error_code + +Revision ID: 799d8800e241 +Revises: e7b08389f47f +Create Date: 2026-08-03 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '799d8800e241' +down_revision: Union[str, None] = 'e7b08389f47f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('outbox_events', sa.Column('last_error_code', sa.String(length=60), nullable=True)) + + +def downgrade() -> None: + op.drop_column('outbox_events', 'last_error_code') diff --git a/backend/alembic/versions/a81d0ce9f662_oidc_identity.py b/backend/alembic/versions/a81d0ce9f662_oidc_identity.py new file mode 100644 index 0000000..441cff1 --- /dev/null +++ b/backend/alembic/versions/a81d0ce9f662_oidc_identity.py @@ -0,0 +1,28 @@ +"""add optional external OIDC identity + +Revision ID: a81d0ce9f662 +Revises: f43d829ab610 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "a81d0ce9f662" +down_revision = "f43d829ab610" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("identity_provider", sa.String(80), nullable=True)) + op.add_column("users", sa.Column("external_subject", sa.String(255), nullable=True)) + op.create_unique_constraint( + "uq_user_external_identity", "users", ["identity_provider", "external_subject"] + ) + + +def downgrade() -> None: + op.drop_constraint("uq_user_external_identity", "users", type_="unique") + op.drop_column("users", "external_subject") + op.drop_column("users", "identity_provider") diff --git a/backend/alembic/versions/b7c7b536df85_operational_user_credentials.py b/backend/alembic/versions/b7c7b536df85_operational_user_credentials.py new file mode 100644 index 0000000..594c205 --- /dev/null +++ b/backend/alembic/versions/b7c7b536df85_operational_user_credentials.py @@ -0,0 +1,25 @@ +"""operational user credentials + +Revision ID: b7c7b536df85 +Revises: 799d8800e241 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "b7c7b536df85" +down_revision = "799d8800e241" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("email", sa.String(length=320), nullable=True)) + op.add_column("users", sa.Column("password_hash", sa.String(length=512), nullable=True)) + op.create_unique_constraint("uq_users_email", "users", ["email"]) + + +def downgrade() -> None: + op.drop_constraint("uq_users_email", "users", type_="unique") + op.drop_column("users", "password_hash") + op.drop_column("users", "email") diff --git a/backend/alembic/versions/b913a72e8c14_customer_privacy_state.py b/backend/alembic/versions/b913a72e8c14_customer_privacy_state.py new file mode 100644 index 0000000..3e7bf03 --- /dev/null +++ b/backend/alembic/versions/b913a72e8c14_customer_privacy_state.py @@ -0,0 +1,24 @@ +"""add explicit customer anonymisation state + +Revision ID: b913a72e8c14 +Revises: a81d0ce9f662 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "b913a72e8c14" +down_revision = "a81d0ce9f662" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("customers", sa.Column("anonymized_at", sa.DateTime(timezone=True))) + op.create_index("ix_customers_anonymized_at", "customers", ["anonymized_at"]) + + +def downgrade() -> None: + op.drop_index("ix_customers_anonymized_at", table_name="customers") + op.drop_column("customers", "anonymized_at") diff --git a/backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py b/backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py new file mode 100644 index 0000000..29db9d3 --- /dev/null +++ b/backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py @@ -0,0 +1,68 @@ +"""add domain constraints and operational indexes + +Revision ID: c24f6a9d013e +Revises: b913a72e8c14 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "c24f6a9d013e" +down_revision = "b913a72e8c14" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + checks = ( + ("bookings", "ck_bookings_status", "status IN ('reserved','active','returned','cancelled','blocked')"), + ("bookings", "ck_bookings_time_window", "ends_at > starts_at"), + ("bookings", "ck_bookings_start_odometer", "start_odometer_km IS NULL OR start_odometer_km >= 0"), + ("bookings", "ck_bookings_end_odometer", "end_odometer_km IS NULL OR end_odometer_km >= 0"), + ("vehicles", "ck_vehicles_operational_status", "operational_status IN ('available','rented','cleaning','maintenance','blocked')"), + ("vehicles", "ck_vehicles_model_year", "model_year BETWEEN 1900 AND 2100"), + ("vehicles", "ck_vehicles_odometer", "odometer_km >= 0"), + ("vehicles", "ck_vehicles_next_service", "next_service_km >= 0"), + ("vehicles", "ck_vehicles_version", "version >= 1"), + ("data_quality_issues", "ck_data_quality_rule_type", "rule_type IN ('possible_duplicate_customer','missing_required_field','odometer_regression','booking_overlap','vehicle_status_conflict')"), + ("data_quality_issues", "ck_data_quality_severity", "severity IN ('low','medium','high')"), + ("data_quality_issues", "ck_data_quality_status", "status IN ('open','deferred','resolved','rejected')"), + ("outbox_events", "ck_outbox_delivery_status", "delivery_status IN ('pending','delivering','succeeded','failed')"), + ("outbox_events", "ck_outbox_attempts", "attempts >= 0"), + ("audit_events", "ck_audit_actor_type", "actor_type IN ('user','service','system')"), + ) + for table, name, condition in checks: + op.create_check_constraint(name, table, condition) + + op.create_index("ix_bookings_vehicle_status_window", "bookings", ["vehicle_id", "status", "starts_at", "ends_at"]) + op.create_index("ix_data_quality_work_queue", "data_quality_issues", ["status", "due_at", "severity"]) + op.create_index("ix_outbox_delivery_next_attempt", "outbox_events", ["delivery_status", "next_attempt_at"]) + op.create_index("ix_audit_action_occurred", "audit_events", ["action", "occurred_at"]) + op.create_index("ix_audit_entity", "audit_events", ["entity_type", "entity_id"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_entity", table_name="audit_events") + op.drop_index("ix_audit_action_occurred", table_name="audit_events") + op.drop_index("ix_outbox_delivery_next_attempt", table_name="outbox_events") + op.drop_index("ix_data_quality_work_queue", table_name="data_quality_issues") + op.drop_index("ix_bookings_vehicle_status_window", table_name="bookings") + for table, name in ( + ("audit_events", "ck_audit_actor_type"), + ("outbox_events", "ck_outbox_attempts"), + ("outbox_events", "ck_outbox_delivery_status"), + ("data_quality_issues", "ck_data_quality_status"), + ("data_quality_issues", "ck_data_quality_severity"), + ("data_quality_issues", "ck_data_quality_rule_type"), + ("vehicles", "ck_vehicles_version"), + ("vehicles", "ck_vehicles_next_service"), + ("vehicles", "ck_vehicles_odometer"), + ("vehicles", "ck_vehicles_model_year"), + ("vehicles", "ck_vehicles_operational_status"), + ("bookings", "ck_bookings_end_odometer"), + ("bookings", "ck_bookings_start_odometer"), + ("bookings", "ck_bookings_time_window"), + ("bookings", "ck_bookings_status"), + ): + op.drop_constraint(name, table, type_="check") diff --git a/backend/alembic/versions/c9498525abb5_initial_schema.py b/backend/alembic/versions/c9498525abb5_initial_schema.py new file mode 100644 index 0000000..ed81339 --- /dev/null +++ b/backend/alembic/versions/c9498525abb5_initial_schema.py @@ -0,0 +1,184 @@ +"""initial schema + +Revision ID: c9498525abb5 +Revises: +Create Date: 2026-08-01 20:50:05.997864 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'c9498525abb5' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_events', + sa.Column('actor_type', sa.String(length=20), nullable=False), + sa.Column('actor_id', sa.UUID(), nullable=True), + sa.Column('actor_label', sa.String(length=120), nullable=False), + sa.Column('action', sa.String(length=80), nullable=False), + sa.Column('entity_type', sa.String(length=30), nullable=False), + sa.Column('entity_id', sa.UUID(), nullable=True), + sa.Column('correlation_id', sa.UUID(), nullable=False), + sa.Column('before_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('after_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('metadata_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('customers', + sa.Column('public_ref', sa.String(length=20), nullable=False), + sa.Column('first_name', sa.String(length=80), nullable=False), + sa.Column('last_name', sa.String(length=80), nullable=False), + sa.Column('email', sa.String(length=200), nullable=True), + sa.Column('phone', sa.String(length=40), nullable=True), + sa.Column('postal_code', sa.String(length=20), nullable=True), + sa.Column('city', sa.String(length=120), nullable=True), + sa.Column('date_of_birth', sa.Date(), nullable=True), + sa.Column('merged_into_customer_id', sa.UUID(), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['merged_into_customer_id'], ['customers.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_ref') + ) + op.create_table('data_quality_issues', + sa.Column('public_ref', sa.String(length=20), nullable=False), + sa.Column('rule_type', sa.String(length=40), nullable=False), + sa.Column('entity_type', sa.String(length=30), nullable=False), + sa.Column('entity_id', sa.UUID(), nullable=False), + sa.Column('severity', sa.String(length=10), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('evidence_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('proposed_action_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('detected_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('resolved_by', sa.String(length=120), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_ref') + ) + op.create_table('outbox_events', + sa.Column('event_id', sa.UUID(), nullable=False), + sa.Column('event_type', sa.String(length=60), nullable=False), + sa.Column('aggregate_type', sa.String(length=30), nullable=False), + sa.Column('aggregate_id', sa.UUID(), nullable=False), + sa.Column('payload_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('delivery_status', sa.String(length=20), nullable=False), + sa.Column('attempts', sa.Integer(), nullable=False), + sa.Column('next_attempt_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_error', sa.Text(), nullable=True), + sa.Column('external_run_id', sa.String(length=120), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('event_id') + ) + op.create_table('users', + sa.Column('public_ref', sa.String(length=20), nullable=False), + sa.Column('display_name', sa.String(length=120), nullable=False), + sa.Column('role', sa.String(length=30), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_ref') + ) + op.create_table('vehicles', + sa.Column('public_ref', sa.String(length=20), nullable=False), + sa.Column('make', sa.String(length=80), nullable=False), + sa.Column('model', sa.String(length=80), nullable=False), + sa.Column('model_year', sa.Integer(), nullable=False), + sa.Column('registration_number', sa.String(length=40), nullable=False), + sa.Column('location', sa.String(length=120), nullable=False), + sa.Column('operational_status', sa.String(length=20), nullable=False), + sa.Column('odometer_km', sa.Integer(), nullable=False), + sa.Column('next_service_km', sa.Integer(), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_ref'), + sa.UniqueConstraint('registration_number') + ) + op.create_table('bookings', + sa.Column('public_ref', sa.String(length=20), nullable=False), + sa.Column('customer_id', sa.UUID(), nullable=False), + sa.Column('vehicle_id', sa.UUID(), nullable=False), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('ends_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('start_odometer_km', sa.Integer(), nullable=True), + sa.Column('end_odometer_km', sa.Integer(), nullable=True), + sa.Column('requirements_complete', sa.Boolean(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_ref') + ) + op.create_table('maintenance_records', + sa.Column('public_ref', sa.String(length=20), nullable=False), + sa.Column('vehicle_id', sa.UUID(), nullable=False), + sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('odometer_km', sa.Integer(), nullable=False), + sa.Column('category', sa.String(length=60), nullable=False), + sa.Column('summary', sa.Text(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_ref') + ) + op.create_table('inspections', + sa.Column('public_ref', sa.String(length=20), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('vehicle_id', sa.UUID(), nullable=False), + sa.Column('type', sa.String(length=20), nullable=False), + sa.Column('fuel_level_percent', sa.Integer(), nullable=False), + sa.Column('cleanliness_ok', sa.Boolean(), nullable=False), + sa.Column('damage_reported', sa.Boolean(), nullable=False), + sa.Column('technical_warning', sa.Boolean(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('odometer_km', sa.Integer(), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('completed_by', sa.String(length=120), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_ref') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('inspections') + op.drop_table('maintenance_records') + op.drop_table('bookings') + op.drop_table('vehicles') + op.drop_table('users') + op.drop_table('outbox_events') + op.drop_table('data_quality_issues') + op.drop_table('customers') + op.drop_table('audit_events') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/d1f83bc64170_revoked_sessions.py b/backend/alembic/versions/d1f83bc64170_revoked_sessions.py new file mode 100644 index 0000000..9026b6c --- /dev/null +++ b/backend/alembic/versions/d1f83bc64170_revoked_sessions.py @@ -0,0 +1,49 @@ +"""persist revoked sessions + +Revision ID: d1f83bc64170 +Revises: b7c7b536df85 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "d1f83bc64170" +down_revision = "b7c7b536df85" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "revoked_sessions", + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_revoked_sessions_expires_at", "revoked_sessions", ["expires_at"]) + op.create_index( + "ix_revoked_sessions_token_hash", + "revoked_sessions", + ["token_hash"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_revoked_sessions_token_hash", table_name="revoked_sessions") + op.drop_index("ix_revoked_sessions_expires_at", table_name="revoked_sessions") + op.drop_table("revoked_sessions") diff --git a/backend/alembic/versions/e7b08389f47f_idempotency_records.py b/backend/alembic/versions/e7b08389f47f_idempotency_records.py new file mode 100644 index 0000000..b9b7977 --- /dev/null +++ b/backend/alembic/versions/e7b08389f47f_idempotency_records.py @@ -0,0 +1,41 @@ +"""idempotency records + +Revision ID: e7b08389f47f +Revises: c9498525abb5 +Create Date: 2026-08-01 21:24:35.472070 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'e7b08389f47f' +down_revision: Union[str, None] = 'c9498525abb5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('idempotency_records', + sa.Column('idempotency_key', sa.String(length=128), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('response_status', sa.Integer(), nullable=False), + sa.Column('response_body', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('idempotency_key') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('idempotency_records') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/f43d829ab610_quality_work_queue.py b/backend/alembic/versions/f43d829ab610_quality_work_queue.py new file mode 100644 index 0000000..0f2e4b1 --- /dev/null +++ b/backend/alembic/versions/f43d829ab610_quality_work_queue.py @@ -0,0 +1,65 @@ +"""add ownership and SLA deadlines to data quality issues + +Revision ID: f43d829ab610 +Revises: d1f83bc64170 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "f43d829ab610" +down_revision = "d1f83bc64170" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "data_quality_issues", + sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "data_quality_issues", + sa.Column("assigned_to_user_id", sa.Uuid(), nullable=True), + ) + op.create_foreign_key( + "fk_data_quality_issues_assigned_user", + "data_quality_issues", + "users", + ["assigned_to_user_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index("ix_data_quality_issues_due_at", "data_quality_issues", ["due_at"]) + op.create_index( + "ix_data_quality_issues_assigned_to_user_id", + "data_quality_issues", + ["assigned_to_user_id"], + ) + op.execute( + """ + UPDATE data_quality_issues + SET due_at = detected_at + CASE severity + WHEN 'high' THEN interval '4 hours' + WHEN 'low' THEN interval '3 days' + ELSE interval '1 day' + END + WHERE status = 'open' AND due_at IS NULL + """ + ) + + +def downgrade() -> None: + op.drop_index( + "ix_data_quality_issues_assigned_to_user_id", + table_name="data_quality_issues", + ) + op.drop_index("ix_data_quality_issues_due_at", table_name="data_quality_issues") + op.drop_constraint( + "fk_data_quality_issues_assigned_user", + "data_quality_issues", + type_="foreignkey", + ) + op.drop_column("data_quality_issues", "assigned_to_user_id") + op.drop_column("data_quality_issues", "due_at") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..ba6d513 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import hmac +import re +from collections.abc import Generator +from dataclasses import dataclass + +from fastapi import Depends, Header, HTTPException, Request, status +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.db import SessionLocal +from app.core.security import SessionPayload, read_session_token +from app.models.user import User +from app.schemas import CurrentUser, Role +from app.services.sessions import is_session_revoked + +settings = get_settings() +_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined] + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser: + token = request.cookies.get(settings.session_cookie_name) + payload: SessionPayload | None = read_session_token(token) if token else None + revoked = token is not None and is_session_revoked(db, token) + if payload is None or payload.role not in _VALID_ROLES or revoked: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + # Demo reset deliberately rebuilds the deterministic users table. Retaining the + # signed demo session until the reset endpoint clears its cookie keeps existing demo + # workflows stable; operational sessions are always checked against the live record. + if settings.mobilityops_demo_mode: + demo_role: Role = payload.role # type: ignore[assignment] + return CurrentUser( + public_ref=payload.public_ref, + display_name=payload.display_name, + role=demo_role, + ) + user = db.get(User, payload.user_id) + if ( + user is None + or not user.active + or user.public_ref != payload.public_ref + or user.role not in _VALID_ROLES + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + role: Role = user.role # type: ignore[assignment] + return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=role) + + +def require_operations_manager( + user: CurrentUser = Depends(get_current_user), +) -> CurrentUser: + if user.role != "operations_manager": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Operations Manager role required" + ) + return user + + +@dataclass(frozen=True) +class McpClientContext: + reported_client_id: str + tenant: str + + +_MCP_CLIENT_ID = re.compile( + r"^itworx-mcp-hub:(?:readiness|mobilityops:[A-Za-z0-9][A-Za-z0-9._:-]{0,127})$" +) + + +def require_mcp_service_token( + x_service_token: str = Header(..., alias="X-Service-Token"), + x_client_id: str = Header(..., alias="X-Client-Id", min_length=1, max_length=180), + x_tenant_id: str | None = Header(default=None, alias="X-Tenant-Id", max_length=120), +) -> McpClientContext: + if not hmac.compare_digest(x_service_token, settings.mcp_hub_service_token): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token" + ) + if not _MCP_CLIENT_ID.fullmatch(x_client_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Untrusted MCP client identity" + ) + if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch") + return McpClientContext(reported_client_id=x_client_id, tenant=settings.ragcore_tenant) diff --git a/backend/app/api/routers/__init__.py b/backend/app/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routers/audit.py b/backend/app/api/routers/audit.py new file mode 100644 index 0000000..2fcf7b2 --- /dev/null +++ b/backend/app/api/routers/audit.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import csv +import io +import json +import uuid +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_operations_manager +from app.core.config import get_settings +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, AuditEventPageOut, CurrentUser +from app.services.audit import record_audit_event + +router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) +settings = get_settings() + +# 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 _as_utc(value: datetime | None) -> datetime | None: + """Treat naive query datetimes as UTC so they compare safely with aware values.""" + if value is None: + return None + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +@router.get("/export.csv") +def export_audit_csv( + occurred_from: datetime | None = Query(default=None), + occurred_to: datetime | None = Query(default=None), + db: Session = Depends(get_db), + actor: CurrentUser = Depends(require_operations_manager), +) -> Response: + end = _as_utc(occurred_to) or datetime.now(UTC) + start = _as_utc(occurred_from) or end - timedelta(days=30) + if end <= start or end - start > timedelta(days=90): + raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days") + events = db.scalars( + select(AuditEvent) + .where(AuditEvent.occurred_at >= start, AuditEvent.occurred_at <= end) + .order_by(AuditEvent.occurred_at) + .limit(settings.privacy_audit_export_max_rows + 1) + ).all() + if len(events) > settings.privacy_audit_export_max_rows: + raise HTTPException(status_code=413, detail="Audit export exceeds configured row limit") + output = io.StringIO(newline="") + writer = csv.writer(output) + writer.writerow( + ( + "id", + "occurred_at", + "actor_type", + "actor_label", + "action", + "entity_type", + "entity_id", + "correlation_id", + "before", + "after", + "metadata", + ) + ) + for event in events: + writer.writerow( + ( + event.id, + event.occurred_at.isoformat(), + event.actor_type, + event.actor_label, + event.action, + event.entity_type, + event.entity_id or "", + event.correlation_id, + json.dumps(event.before_json, separators=(",", ":"), default=str), + json.dumps(event.after_json, separators=(",", ":"), default=str), + json.dumps(event.metadata_json, separators=(",", ":"), default=str), + ) + ) + record_audit_event( + db, + actor_type="user", + actor_label=actor.display_name, + action="audit_exported", + entity_type="audit", + metadata={"from": start.isoformat(), "to": end.isoformat(), "rows": len(events)}, + ) + db.commit() + return Response( + output.getvalue(), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="mobilityops-audit.csv"'}, + ) + + +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] | AuditEventPageOut) +def list_audit_events( + actor_label: str | None = Query(default=None), + action: str | None = Query(default=None), + entity_type: str | None = Query(default=None), + entity_ref: str | None = Query(default=None, min_length=1, max_length=100), + correlation_id: uuid.UUID | None = Query(default=None), + occurred_from: datetime | None = Query(default=None), + occurred_to: datetime | None = Query(default=None), + page: int | None = Query(default=None, ge=1), + page_size: int = Query(default=25, ge=1, le=25), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(require_operations_manager), +) -> list[AuditEventOut] | AuditEventPageOut: + stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()) + if actor_label: + stmt = stmt.where(AuditEvent.actor_label == actor_label) + if action: + stmt = stmt.where(AuditEvent.action == action) + if entity_type: + stmt = stmt.where(AuditEvent.entity_type == entity_type) + if entity_ref: + matched_ids: set[uuid.UUID] = set() + for model in _ENTITY_MODELS.values(): + matched_ids.update( + db.scalars( + select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%")) + ).all() + ) + if not matched_ids: + if page is None: + return [] + return AuditEventPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1) + stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids)) + if correlation_id: + stmt = stmt.where(AuditEvent.correlation_id == correlation_id) + if occurred_from: + stmt = stmt.where(AuditEvent.occurred_at >= occurred_from) + if occurred_to: + stmt = stmt.where(AuditEvent.occurred_at <= occurred_to) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + page_number = page or 1 + events = db.scalars( + stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) + ).all() + entity_refs = _resolve_entity_refs(db, events) + + out = [] + for e in events: + ref = entity_refs.get(e.entity_id) if e.entity_id else None + route = _ROUTE_TEMPLATES.get(e.entity_type) + out.append( + AuditEventOut( + id=str(e.id), + actor_type=e.actor_type, + actor_label=e.actor_label, + action=e.action, + entity_type=e.entity_type, + entity_id=str(e.entity_id) if e.entity_id else None, + entity_ref=ref, + entity_link=route.format(ref=ref) if route and ref else None, + correlation_id=str(e.correlation_id), + occurred_at=e.occurred_at, + before=e.before_json, + after=e.after_json, + metadata=e.metadata_json, + ) + ) + if page is None: + return out + total_pages = max(1, (total + page_size - 1) // page_size) + return AuditEventPageOut( + items=out, + page=min(page_number, total_pages), + page_size=page_size, + total=total, + total_pages=total_pages, + ) diff --git a/backend/app/api/routers/auth.py b/backend/app/api/routers/auth.py new file mode 100644 index 0000000..1b0ee81 --- /dev/null +++ b/backend/app/api/routers/auth.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import time +import uuid + +from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped] +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.core.config import get_settings +from app.core.ratelimit import FailedAttemptLimiter +from app.core.security import ( + SessionPayload, + create_session_token, + hash_password, + read_session_token, + verify_password, +) +from app.models.user import User +from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest +from app.services.audit import record_audit_event +from app.services.sessions import revoke_session + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) +settings = get_settings() +_login_limiter = ( + FailedAttemptLimiter( + max_failures=settings.login_max_failures, + window_seconds=settings.login_failure_window_seconds, + ) + if settings.login_max_failures > 0 + else None +) + + +def _client_key(request: Request) -> str: + # The API sits behind the web container's reverse proxy in every documented + # deployment. The proxy appends/overwrites the socket peer as the final hop, so an + # attacker-controlled leading value must never select a fresh limiter bucket. + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + return forwarded.split(",")[-1].strip() + return request.client.host if request.client else "unknown" + + +oauth = OAuth() +if settings.oidc_enabled and settings.oidc_issuer_url: + oauth.register( + name="oidc", + client_id=settings.oidc_client_id, + client_secret=settings.oidc_client_secret, + server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, + ) + + +def _current_user_out(user: User) -> CurrentUser: + return CurrentUser( + public_ref=user.public_ref, + display_name=user.display_name, + role=user.role, # type: ignore[arg-type] + ) + + +def _set_session(response: Response, user: User) -> None: + token = create_session_token( + SessionPayload( + user_id=str(user.id), + public_ref=user.public_ref, + role=user.role, + display_name=user.display_name, + issued_at=int(time.time()), + session_id=str(uuid.uuid4()), + ) + ) + response.set_cookie( + settings.session_cookie_name, + token, + httponly=True, + samesite="lax", + secure=settings.session_cookie_secure, + max_age=settings.session_ttl_seconds, + ) + + +def bootstrap_initial_admin(db: Session) -> None: + """Create or rotate the explicitly configured first manager in operational mode.""" + if ( + settings.mobilityops_demo_mode + or not settings.initial_admin_email + or not settings.initial_admin_password + ): + return + email = settings.initial_admin_email.strip().lower() + user = db.scalar(select(User).where(User.email == email)) + if user is None: + user = User( + public_ref="USR-ADMIN", + email=email, + password_hash=hash_password(settings.initial_admin_password), + display_name=settings.initial_admin_display_name, + role="operations_manager", + active=True, + ) + db.add(user) + record_audit_event( + db, + actor_type="system", + actor_label="bootstrap", + action="operational_admin_created", + entity_type="user", + entity_id=user.id, + ) + db.commit() + + +def _oidc_configured() -> bool: + return bool( + settings.oidc_enabled + and settings.oidc_issuer_url + and settings.oidc_client_id + and settings.oidc_client_secret + ) + + +def _oidc_redirect_uri() -> str: + return settings.oidc_redirect_uri or ( + f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback" + ) + + +def _allowed_oidc_email(email: str) -> bool: + domains = { + value.strip().casefold() + for value in settings.oidc_allowed_email_domains.split(",") + if value.strip() + } + return not domains or email.rsplit("@", 1)[-1].casefold() in domains + + +def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User: + subject = str(claims.get("sub") or "").strip() + email = str(claims.get("email") or "").strip().lower() + if not subject or not email or claims.get("email_verified") is not True: + raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required") + if not _allowed_oidc_email(email): + raise HTTPException(status_code=403, detail="Email domain is not allowed") + + provider = settings.oidc_issuer_url.rstrip("/") + user = db.scalar( + select(User).where( + User.identity_provider == provider, + User.external_subject == subject, + ) + ) + if user is None: + user = db.scalar(select(User).where(User.email == email)) + if user is not None and user.external_subject not in (None, subject): + raise HTTPException(status_code=409, detail="Email is linked to another identity") + if user is not None and claims.get("email_verified") is not True: + # Linking an existing local account (possibly the bootstrap admin) purely on an + # email match requires the IdP to explicitly assert the address is verified; + # an absent claim is treated as unverified. + raise HTTPException(status_code=401, detail="Verified OIDC email is required") + created = user is None + if created: + if not settings.oidc_auto_provision: + raise HTTPException(status_code=403, detail="OIDC user is not provisioned") + role = settings.oidc_default_role + if role not in {"operations_manager", "rental_employee"}: + role = "rental_employee" + user = User( + public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}", + email=email, + password_hash=None, + display_name=str(claims.get("name") or email), + role=role, + active=True, + ) + db.add(user) + db.flush() + assert user is not None + if not user.active: + raise HTTPException(status_code=403, detail="User is inactive") + user.identity_provider = provider + user.external_subject = subject + record_audit_event( + db, + actor_type="system" if created else "user", + actor_id=None if created else user.id, + actor_label=settings.oidc_provider_name, + action="oidc_user_provisioned" if created else "oidc_identity_linked", + entity_type="user", + entity_id=user.id, + metadata={"provider": provider}, + ) + return user + + +@router.get("/oidc/status", response_model=OidcStatusOut) +def oidc_status() -> OidcStatusOut: + return OidcStatusOut( + enabled=_oidc_configured(), + provider_name=settings.oidc_provider_name if _oidc_configured() else None, + ) + + +@router.get("/oidc/login") +async def oidc_login(request: Request) -> Response: + if not _oidc_configured(): + raise HTTPException(status_code=404, detail="OIDC login is not configured") + client = oauth.create_client("oidc") + if client is None: + raise HTTPException(status_code=503, detail="OIDC client is unavailable") + return await client.authorize_redirect(request, _oidc_redirect_uri()) + + +@router.get("/oidc/callback") +async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response: + if not _oidc_configured(): + raise HTTPException(status_code=404, detail="OIDC login is not configured") + client = oauth.create_client("oidc") + if client is None: + raise HTTPException(status_code=503, detail="OIDC client is unavailable") + try: + token = await client.authorize_access_token(request) + except OAuthError as exc: + raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc + user = _resolve_oidc_user(db, dict(token.get("userinfo") or {})) + response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard") + _set_session(response, user) + record_audit_event( + db, + actor_type="user", + actor_id=user.id, + actor_label=user.display_name, + action="oidc_login", + entity_type="user", + entity_id=user.id, + metadata={"provider": settings.oidc_issuer_url.rstrip("/")}, + ) + db.commit() + return response + + +@router.post("/login", response_model=CurrentUser) +def password_login( + body: PasswordLoginRequest, + request: Request, + response: Response, + db: Session = Depends(get_db), +) -> CurrentUser: + if settings.mobilityops_demo_mode: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Password login is unavailable in demo mode", + ) + limiter_key = _client_key(request) + retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0 + if retry_after: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many failed login attempts. Try again later.", + headers={"Retry-After": str(retry_after)}, + ) + user = db.scalar(select(User).where(User.email == body.email.strip().lower())) + if user is None or not user.active or not verify_password(body.password, user.password_hash): + if _login_limiter: + _login_limiter.record_failure(limiter_key) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + if _login_limiter: + _login_limiter.reset(limiter_key) + _set_session(response, user) + record_audit_event( + db, + actor_type="user", + actor_id=user.id, + actor_label=user.display_name, + action="password_login", + entity_type="user", + entity_id=user.id, + ) + db.commit() + return _current_user_out(user) + + +@router.get("/session", response_model=CurrentUser) +def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + response.headers["Cache-Control"] = "no-store" + return user + + +@router.post("/logout") +def 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 and token is not None: + revoke_session(db, token, payload) + record_audit_event( + db, + actor_type="user", + actor_id=uuid.UUID(payload.user_id), + actor_label=payload.display_name, + action="logout", + entity_type="user", + ) + db.commit() + response.delete_cookie(settings.session_cookie_name) + return {"status": "logged_out"} diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py new file mode 100644 index 0000000..333f215 --- /dev/null +++ b/backend/app/api/routers/bookings.py @@ -0,0 +1,565 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Literal + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response +from sqlalchemy import case, func, 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.customer import Customer +from app.models.inspection import Inspection +from app.models.vehicle import Vehicle +from app.schemas import ( + AvailableVehicleOut, + BookingOut, + BookingPageOut, + CancelBookingRequest, + CheckoutBookingRequest, + CheckoutBookingResult, + CompleteBookingRequirementsRequest, + CreateBookingRequest, + CurrentUser, + NextBookingRisk, + RegisterReturnRequest, + RegisterReturnResult, + RescheduleBookingRequest, + ReturnPreviewResult, +) +from app.services.audit import record_audit_event +from app.services.data_quality import open_odometer_regression_issue +from app.services.returns import preview_vehicle_return, register_vehicle_return + +router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"]) + + +def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOut: + return BookingOut( + public_ref=booking.public_ref, + customer_ref=customer.public_ref, + vehicle_ref=vehicle.public_ref, + starts_at=booking.starts_at, + ends_at=booking.ends_at, + status=booking.status, + start_odometer_km=booking.start_odometer_km, + end_odometer_km=booking.end_odometer_km, + requirements_complete=booking.requirements_complete, + customer_name=f"{customer.first_name} {customer.last_name}", + ) + + +@router.get("", response_model=list[BookingOut] | BookingPageOut) +def list_bookings( + status: str | None = Query(default=None), + vehicle_ref: str | None = Query(default=None), + query: str | None = Query(default=None, min_length=1, max_length=100), + starts_from: datetime | None = Query(default=None), + starts_to: datetime | None = Query(default=None), + location: str | None = Query(default=None, min_length=1, max_length=120), + sort: Literal["operational", "starts_asc", "starts_desc"] = Query(default="operational"), + page: int | None = Query(default=None, ge=1), + page_size: int = Query(default=25, ge=1, le=25), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[BookingOut] | BookingPageOut: + stmt = select(Booking) + if status: + stmt = stmt.where(Booking.status == status) + if vehicle_ref: + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) + if vehicle is None: + if page is None: + return [] + return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1) + stmt = stmt.where(Booking.vehicle_id == vehicle.id) + if starts_from: + stmt = stmt.where(Booking.ends_at >= starts_from) + if starts_to: + stmt = stmt.where(Booking.starts_at < starts_to) + if query or location: + stmt = stmt.join(Customer, Booking.customer_id == Customer.id).join( + Vehicle, Booking.vehicle_id == Vehicle.id + ) + if location: + stmt = stmt.where(Vehicle.location.ilike(location.strip())) + if query: + term = f"%{query.strip()}%" + stmt = stmt.where( + or_( + Booking.public_ref.ilike(term), + Customer.first_name.ilike(term), + Customer.last_name.ilike(term), + Vehicle.public_ref.ilike(term), + ) + ) + if sort == "starts_asc": + stmt = stmt.order_by(Booking.starts_at.asc()) + elif sort == "starts_desc": + stmt = stmt.order_by(Booking.starts_at.desc()) + else: + now = datetime.now(UTC) + operational_bucket = case( + (Booking.status == "active", 0), + (Booking.starts_at >= now, 1), + else_=2, + ) + stmt = stmt.order_by( + operational_bucket, + case((Booking.starts_at >= now, Booking.starts_at)).asc().nulls_last(), + Booking.starts_at.desc(), + ) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + page_number = page or 1 + bookings = db.scalars( + stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) + ).all() + customer_ids = {booking.customer_id for booking in bookings} + vehicle_ids = {booking.vehicle_id for booking in bookings} + customers = { + customer.id: customer + for customer in db.scalars(select(Customer).where(Customer.id.in_(customer_ids))).all() + } + vehicles = { + vehicle.id: vehicle + for vehicle in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all() + } + items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings] + if page is None: + return items + total_pages = max(1, (total + page_size - 1) // page_size) + return BookingPageOut( + items=items, + page=min(page_number, total_pages), + page_size=page_size, + total=total, + total_pages=total_pages, + ) + + +@router.post("", response_model=BookingOut, status_code=201) +def create_booking( + body: CreateBookingRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> BookingOut: + if body.ends_at <= body.starts_at: + raise HTTPException(status_code=422, detail="Booking end must be after its start") + customer = db.scalar( + select(Customer).where(Customer.public_ref == body.customer_ref).with_for_update() + ) + if ( + customer is None + or customer.merged_into_customer_id is not None + or customer.anonymized_at is not None + ): + raise HTTPException(status_code=422, detail="Customer is unavailable for booking") + # Serialise booking creation per vehicle. The overlap check must run after + # acquiring this lock, otherwise two concurrent requests can both pass it. + vehicle = db.scalar( + select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref).with_for_update() + ) + if ( + vehicle is None + or not vehicle.active + or vehicle.operational_status in {"maintenance", "blocked"} + ): + raise HTTPException(status_code=422, detail="Vehicle is unavailable for booking") + overlap = db.scalar( + select(Booking.id).where( + Booking.vehicle_id == vehicle.id, + Booking.status.in_(("reserved", "active")), + Booking.starts_at < body.ends_at, + Booking.ends_at > body.starts_at, + ) + ) + if overlap is not None: + raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking") + booking = Booking( + public_ref=f"BK-{uuid.uuid4().hex[:10].upper()}", + customer_id=customer.id, + vehicle_id=vehicle.id, + starts_at=body.starts_at, + ends_at=body.ends_at, + status="reserved", + start_odometer_km=None, + end_odometer_km=None, + # Requirements are intentionally confirmed in a separate, audited action. + # Never allow booking creation to bypass that operational checkpoint. + requirements_complete=False, + ) + db.add(booking) + db.flush() + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="booking_created", + entity_type="booking", + entity_id=booking.id, + after={"public_ref": booking.public_ref, "vehicle_ref": vehicle.public_ref}, + ) + db.commit() + return _to_out(booking, customer, vehicle) + + +@router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult) +def checkout_booking( + public_ref: str, + body: CheckoutBookingRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> CheckoutBookingResult: + booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) + if booking is None: + raise HTTPException(status_code=404, detail="Booking not found") + if booking.status != "reserved": + raise HTTPException(status_code=409, detail="Only a reserved booking can be checked out") + if not booking.requirements_complete: + raise HTTPException(status_code=409, detail="Booking requirements are incomplete") + vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update()) + if vehicle is None: + raise HTTPException(status_code=500, detail="Booking references a missing vehicle") + if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}: + raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout") + active_conflict = db.scalar( + select(Booking.id).where( + Booking.vehicle_id == vehicle.id, + Booking.status == "active", + Booking.id != booking.id, + ) + ) + if active_conflict is not None: + raise HTTPException(status_code=409, detail="Vehicle already has an active booking") + + correlation_id = uuid.uuid4() + before_booking = { + "status": booking.status, + "start_odometer_km": booking.start_odometer_km, + } + before_vehicle = { + "operational_status": vehicle.operational_status, + "odometer_km": vehicle.odometer_km, + } + attention_reasons: list[str] = [] + if body.start_odometer_km < vehicle.odometer_km: + attention_reasons.append("odometer_regression") + if not body.cleanliness_ok: + attention_reasons.append("cleanliness") + if body.damage_reported: + attention_reasons.append("damage") + if body.technical_warning: + attention_reasons.append("technical_warning") + + inspection = Inspection( + public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}", + booking_id=booking.id, + vehicle_id=vehicle.id, + type="checkout", + fuel_level_percent=body.fuel_level_percent, + cleanliness_ok=body.cleanliness_ok, + damage_reported=body.damage_reported, + technical_warning=body.technical_warning, + notes=body.notes, + odometer_km=body.start_odometer_km, + completed_at=datetime.now(UTC), + completed_by=user.display_name, + ) + db.add(inspection) + if "odometer_regression" in attention_reasons: + open_odometer_regression_issue( + db, + vehicle=vehicle, + reading_ref=inspection.public_ref, + reading_km=body.start_odometer_km, + canonical_km=vehicle.odometer_km, + source_type="checkout", + related_refs=[booking.public_ref, inspection.public_ref], + actor_label=user.display_name, + correlation_id=correlation_id, + ) + if attention_reasons: + booking.status = "blocked" + vehicle.operational_status = ( + "maintenance" if body.damage_reported or body.technical_warning else "cleaning" + ) + else: + booking.status = "active" + booking.start_odometer_km = body.start_odometer_km + vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km) + vehicle.operational_status = "rented" + vehicle.version += 1 + db.flush() + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="booking_checkout_recorded", + entity_type="booking", + entity_id=booking.id, + correlation_id=correlation_id, + before=before_booking, + after={ + "inspection_ref": inspection.public_ref, + "booking_status": booking.status, + "start_odometer_km": booking.start_odometer_km, + "vehicle_status": vehicle.operational_status, + "attention_reasons": attention_reasons, + }, + ) + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="vehicle_status_changed", + entity_type="vehicle", + entity_id=vehicle.id, + correlation_id=correlation_id, + before=before_vehicle, + after={ + "operational_status": vehicle.operational_status, + "odometer_km": vehicle.odometer_km, + }, + metadata={"booking_ref": booking.public_ref, "inspection_ref": inspection.public_ref}, + ) + db.commit() + return CheckoutBookingResult( + booking_ref=booking.public_ref, + vehicle_ref=vehicle.public_ref, + inspection_ref=inspection.public_ref, + booking_status=booking.status, + resulting_vehicle_status=vehicle.operational_status, + activated=booking.status == "active", + attention_reasons=attention_reasons, + ) + + +@router.get("/availability", response_model=list[AvailableVehicleOut]) +def list_available_vehicles( + starts_at: datetime, + ends_at: datetime, + query: str | None = Query(default=None, max_length=100), + limit: int = Query(default=25, ge=1, le=50), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[AvailableVehicleOut]: + if ends_at <= starts_at: + raise HTTPException(status_code=422, detail="Booking end must be after its start") + overlapping_vehicle_ids = select(Booking.vehicle_id).where( + Booking.status.in_(("reserved", "active")), + Booking.starts_at < ends_at, + Booking.ends_at > starts_at, + ) + stmt = ( + select(Vehicle) + .where( + Vehicle.active.is_(True), + Vehicle.operational_status.not_in(("maintenance", "blocked")), + Vehicle.id.not_in(overlapping_vehicle_ids), + ) + .order_by(Vehicle.location, Vehicle.public_ref) + .limit(limit) + ) + if query and query.strip(): + term = f"%{query.strip()}%" + stmt = stmt.where( + or_( + Vehicle.public_ref.ilike(term), + Vehicle.make.ilike(term), + Vehicle.model.ilike(term), + Vehicle.registration_number.ilike(term), + Vehicle.location.ilike(term), + ) + ) + return [ + AvailableVehicleOut( + public_ref=vehicle.public_ref, + make=vehicle.make, + model=vehicle.model, + registration_number=vehicle.registration_number, + location=vehicle.location, + operational_status=vehicle.operational_status, + ) + for vehicle in db.scalars(stmt).all() + ] + + +@router.get("/{public_ref}", response_model=BookingOut) +def get_booking( + public_ref: str, + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> BookingOut: + booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref)) + if booking is None: + raise HTTPException(status_code=404, detail="Booking not found") + customer = db.get(Customer, booking.customer_id) + vehicle = db.get(Vehicle, booking.vehicle_id) + if customer is None or vehicle is None: + raise HTTPException(status_code=500, detail="Booking references a missing record") + return _to_out(booking, customer, vehicle) + + +@router.post("/{public_ref}/complete-requirements", response_model=BookingOut) +def complete_booking_requirements( + public_ref: str, + body: CompleteBookingRequirementsRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> BookingOut: + booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) + if booking is None: + raise HTTPException(status_code=404, detail="Booking not found") + if booking.status != "reserved": + raise HTTPException( + status_code=409, + detail="Requirements can only be confirmed for a reserved booking", + ) + customer = db.get(Customer, booking.customer_id) + vehicle = db.get(Vehicle, booking.vehicle_id) + if customer is None or vehicle is None: + raise HTTPException(status_code=500, detail="Booking references a missing record") + if not booking.requirements_complete: + booking.requirements_complete = True + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="booking_requirements_completed", + entity_type="booking", + entity_id=booking.id, + before={"requirements_complete": False}, + after={"requirements_complete": True}, + metadata={"confirmation": body.confirmation.strip()}, + ) + db.commit() + return _to_out(booking, customer, vehicle) + + +@router.patch("/{public_ref}/schedule", response_model=BookingOut) +def reschedule_booking( + public_ref: str, + body: RescheduleBookingRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> BookingOut: + if body.ends_at <= body.starts_at: + raise HTTPException(status_code=422, detail="Booking end must be after its start") + booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) + if booking is None: + raise HTTPException(status_code=404, detail="Booking not found") + if booking.status != "reserved": + raise HTTPException(status_code=409, detail="Only a reserved booking can be rescheduled") + vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update()) + customer = db.get(Customer, booking.customer_id) + if customer is None or vehicle is None: + raise HTTPException(status_code=500, detail="Booking references a missing record") + overlap = db.scalar( + select(Booking.id).where( + Booking.vehicle_id == booking.vehicle_id, + Booking.id != booking.id, + Booking.status.in_(("reserved", "active")), + Booking.starts_at < body.ends_at, + Booking.ends_at > body.starts_at, + ) + ) + if overlap is not None: + raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking") + before = {"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()} + booking.starts_at = body.starts_at + booking.ends_at = body.ends_at + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="booking_rescheduled", + entity_type="booking", + entity_id=booking.id, + before=before, + after={"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()}, + metadata={"reason": body.reason.strip()}, + ) + db.commit() + return _to_out(booking, customer, vehicle) + + +@router.post("/{public_ref}/cancel", response_model=BookingOut) +def cancel_booking( + public_ref: str, + body: CancelBookingRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> BookingOut: + booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) + if booking is None: + raise HTTPException(status_code=404, detail="Booking not found") + if booking.status not in ("reserved", "blocked"): + # A booking blocked at checkout (damage, technical warning, ...) has no other exit: + # it never became active, so it can neither be returned nor completed. Cancelling + # it (audited, with a reason) is the only way to close the file. + raise HTTPException( + status_code=409, detail="Only a reserved or blocked booking can be cancelled" + ) + customer = db.get(Customer, booking.customer_id) + vehicle = db.get(Vehicle, booking.vehicle_id) + if customer is None or vehicle is None: + raise HTTPException(status_code=500, detail="Booking references a missing record") + before = {"status": booking.status} + booking.status = "cancelled" + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="booking_cancelled", + entity_type="booking", + entity_id=booking.id, + before=before, + after={"status": booking.status, "reason": body.reason.strip()}, + ) + db.commit() + 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, + status_reason_code=evaluation.status_reason_code, + status_reason_params=evaluation.status_reason_params, + 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", response_model=RegisterReturnResult) +def register_return( + public_ref: str, + body: RegisterReturnRequest, + response: Response, + idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128), + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> RegisterReturnResult: + status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user) + response.status_code = status_code + return RegisterReturnResult(**result) diff --git a/backend/app/api/routers/customers.py b/backend/app/api/routers/customers.py new file mode 100644 index 0000000..f475a5a --- /dev/null +++ b/backend/app/api/routers/customers.py @@ -0,0 +1,42 @@ +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.customer import Customer +from app.schemas import CurrentUser, CustomerOptionOut + +router = APIRouter(prefix="/api/v1/customers", tags=["customers"]) + + +@router.get("", response_model=list[CustomerOptionOut]) +def search_customers( + query: str = Query(min_length=2, max_length=100), + limit: int = Query(default=20, ge=1, le=50), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[CustomerOptionOut]: + term = f"%{query.strip()}%" + customers = db.scalars( + select(Customer) + .where( + Customer.merged_into_customer_id.is_(None), + Customer.anonymized_at.is_(None), + or_( + Customer.public_ref.ilike(term), + Customer.first_name.ilike(term), + Customer.last_name.ilike(term), + Customer.email.ilike(term), + ), + ) + .order_by(Customer.last_name, Customer.first_name) + .limit(limit) + ).all() + return [ + CustomerOptionOut( + public_ref=customer.public_ref, + display_name=f"{customer.first_name} {customer.last_name}", + email=customer.email, + ) + for customer in customers + ] diff --git a/backend/app/api/routers/dashboard.py b/backend/app/api/routers/dashboard.py new file mode 100644 index 0000000..afa04bb --- /dev/null +++ b/backend/app/api/routers/dashboard.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal +from zoneinfo import ZoneInfo + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.core.config import get_settings +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.outbox import OutboxEvent +from app.models.vehicle import Vehicle +from app.schemas import ( + AttentionItem, + AutomationRunOut, + CurrentUser, + DashboardOut, + EvidenceSignalOut, + TodayItem, +) +from app.services.operations import compute_metrics + +router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"]) +settings = get_settings() + +_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} + + +def _local_tz() -> ZoneInfo: + return ZoneInfo(settings.demo_timezone) + + +def _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. + # Timestamps are stored in UTC but the operational day is the local (Europe/Brussels) + # calendar day, so a 23:30Z departure belongs to tomorrow's schedule in summer. + return datetime.now(_local_tz()).date() + + +def _local_date(value: datetime) -> date: + return value.astimezone(_local_tz()).date() + + +@router.get("", response_model=DashboardOut) +def get_dashboard( + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> DashboardOut: + metrics = compute_metrics(db) + + vehicles_by_id = {v.id: v for v in db.scalars(select(Vehicle)).all()} + customers_by_id = {c.id: c for c in db.scalars(select(Customer)).all()} + + issues = db.scalars( + select(DataQualityIssue) + .where(DataQualityIssue.status == "open") + .order_by(DataQualityIssue.detected_at.asc()) + ).all() + attention_items = [] + for issue in issues: + entity: Vehicle | Customer | None + link_type: Literal["vehicle", "customer"] + if issue.entity_type == "vehicle": + entity = vehicles_by_id.get(issue.entity_id) + link_type = "vehicle" + else: + entity = customers_by_id.get(issue.entity_id) + link_type = "customer" + link_ref = entity.public_ref if entity else "" + # The backend never emits prose for the attention queue -- only stable signal + # codes + raw data params, exactly like the issue detail page's evidence list + # (see app/services/data_quality.py::_open_issue). The frontend is the one place + # that turns these into the operator's selected language; `evidence_json["summary"]` + # is a technical fallback only, never rendered here. + signals = [ + EvidenceSignalOut(code=s["code"], params=s.get("params", {})) + for s in issue.evidence_json.get("signals", []) + ] + attention_items.append( + AttentionItem( + kind="quality_issue", + severity=issue.severity, + rule_type=issue.rule_type, + evidence_signals=signals, + link_type=link_type, + link_ref=link_ref, + issue_ref=issue.public_ref, + ) + ) + # Curate a credible severity mix instead of letting `high` dominate every slot: + # each item's real severity is unchanged, only the display selection is capped per + # tier (a handful of "now", then "today", then "later") so a heavy day of high-severity + # issues doesn't crowd out medium/low ones the operator should still see. + high_items = [i for i in attention_items if i.severity == "high"] + medium_items = [i for i in attention_items if i.severity == "medium"] + low_items = [i for i in attention_items if i.severity == "low"] + attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8] + + today = _today() + bookings = db.scalars(select(Booking)).all() + today_items: list[TodayItem] = [] + for b in bookings: + vehicle = vehicles_by_id.get(b.vehicle_id) + vehicle_ref = vehicle.public_ref if vehicle else "" + if _local_date(b.starts_at) == today and b.status in ("reserved", "active"): + today_items.append( + TodayItem( + kind="departure", + booking_ref=b.public_ref, + vehicle_ref=vehicle_ref, + scheduled_at=b.starts_at, + ) + ) + if _local_date(b.ends_at) == today and b.status in ("active", "returned"): + today_items.append( + TodayItem( + kind="return", + booking_ref=b.public_ref, + vehicle_ref=vehicle_ref, + scheduled_at=b.ends_at, + ) + ) + today_items.sort(key=lambda item: item.scheduled_at) + + recent = db.scalars(select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)).all() + recent_automation = [ + AutomationRunOut( + event_id=str(r.event_id), + event_type=r.event_type, + aggregate_ref=r.payload_json.get("aggregate_ref", ""), + status=r.delivery_status, + attempts=r.attempts, + last_error=r.last_error, + last_error_code=r.last_error_code, + occurred_at=r.occurred_at, + ) + for r in recent + ] + + return DashboardOut( + metrics=metrics, + attention_items=attention_items, + today=today_items, + recent_automation=recent_automation, + ) diff --git a/backend/app/api/routers/data_quality.py b/backend/app/api/routers/data_quality.py new file mode 100644 index 0000000..121095b --- /dev/null +++ b/backend/app/api/routers/data_quality.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import case, func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_operations_manager +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.inspection import Inspection +from app.models.maintenance import MaintenanceRecord +from app.models.user import User +from app.models.vehicle import Vehicle +from app.schemas import ( + ApplyRecommendedStatusRequest, + ApplyRecommendedStatusResult, + BulkDataQualityWorkRequest, + BulkDataQualityWorkResult, + CurrentUser, + DataQualityIssueDetailOut, + DataQualityIssueOut, + DataQualityIssuePageOut, + MergeCustomersRequest, + MergeCustomersResult, + ProvideFieldsRequest, + ResolveOdometerRegressionRequest, + ResolveOverlapRequest, + ScanResultOut, + StatusRecommendationOut, + VehicleStatusFactsOut, +) +from app.services.audit import record_audit_event +from app.services.data_quality import ( + apply_recommended_status, + defer_issue, + merge_customers, + preview_vehicle_status_recommendation, + provide_missing_fields, + reject_issue, + resolve_booking_overlap, + resolve_odometer_regression, + run_scan, +) + +router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"]) + + +def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut: + assignee = issue.assigned_to_user + return DataQualityIssueOut( + public_ref=issue.public_ref, + rule_type=issue.rule_type, + entity_type=issue.entity_type, + entity_ref=issue.evidence_json.get("entity_ref", ""), + severity=issue.severity, + status=issue.status, + evidence=issue.evidence_json, + detected_at=issue.detected_at, + due_at=issue.due_at, + assigned_to_ref=assignee.public_ref if assignee else None, + assigned_to_name=assignee.display_name if assignee else None, + overdue=( + issue.status == "open" and issue.due_at is not None and issue.due_at < datetime.now(UTC) + ), + resolved_at=issue.resolved_at, + ) + + +@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut) +def list_issues( + status: str | None = Query(default=None), + rule_type: str | None = Query(default=None), + severity: str | None = Query(default=None), + assigned_to_ref: str | None = Query(default=None), + overdue: bool | None = Query(default=None), + demo_only: bool | None = Query(default=None), + page: int | None = Query(default=None, ge=1), + page_size: int = Query(default=25, ge=1, le=25), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(require_operations_manager), +) -> list[DataQualityIssueOut] | DataQualityIssuePageOut: + severity_order = case( + (DataQualityIssue.severity == "high", 0), + (DataQualityIssue.severity == "medium", 1), + else_=2, + ) + stmt = select(DataQualityIssue).order_by( + DataQualityIssue.due_at.asc().nulls_last(), + severity_order, + DataQualityIssue.detected_at.desc(), + ) + if status: + stmt = stmt.where(DataQualityIssue.status == status) + if rule_type: + stmt = stmt.where(DataQualityIssue.rule_type == rule_type) + if severity: + stmt = stmt.where(DataQualityIssue.severity == severity) + if assigned_to_ref == "unassigned": + stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None)) + elif assigned_to_ref: + stmt = stmt.join(DataQualityIssue.assigned_to_user).where( + User.public_ref == assigned_to_ref + ) + if overdue is True: + stmt = stmt.where( + DataQualityIssue.status == "open", + DataQualityIssue.due_at < datetime.now(UTC), + ) + if demo_only is True: + # Server-side so the guided demo scenarios are found on any page, not only the + # 25 rows currently loaded in the browser. + stmt = stmt.where(DataQualityIssue.public_ref.like("DQ-DEMO-%")) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + page_number = page or 1 + issues = db.scalars( + stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) + ).all() + items = [_to_out(i) for i in issues] + if page is None: + return items + total_pages = max(1, (total + page_size - 1) // page_size) + return DataQualityIssuePageOut( + items=items, + page=min(page_number, total_pages), + page_size=page_size, + total=total, + total_pages=total_pages, + ) + + +@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult) +def update_issue_work_queue( + body: BulkDataQualityWorkRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> BulkDataQualityWorkResult: + refs = list(dict.fromkeys(body.issue_refs)) + if ( + body.assigned_to_ref is None + and not body.clear_assignment + and body.due_at is None + and not body.clear_due_at + ): + raise HTTPException(status_code=422, detail="No work queue change was requested") + if body.assigned_to_ref is not None and body.clear_assignment: + raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment") + if body.due_at is not None and body.clear_due_at: + raise HTTPException(status_code=422, detail="Choose a due date or clear the due date") + if body.due_at is not None and body.due_at.tzinfo is None: + raise HTTPException(status_code=422, detail="Due date must include a timezone") + + assignee = None + if body.assigned_to_ref is not None: + assignee = db.scalar( + select(User).where( + User.public_ref == body.assigned_to_ref, + User.active.is_(True), + ) + ) + if assignee is None: + raise HTTPException(status_code=422, detail="Active assignee not found") + + issues = list( + db.scalars( + select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update() + ).all() + ) + if len(issues) != len(refs): + found = {issue.public_ref for issue in issues} + missing = next(ref for ref in refs if ref not in found) + raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found") + + for issue in issues: + if issue.status != "open": + raise HTTPException( + status_code=409, + detail=f"Data quality issue {issue.public_ref} is not open", + ) + before = { + "assigned_to_ref": issue.assigned_to_user.public_ref + if issue.assigned_to_user + else None, + "due_at": issue.due_at.isoformat() if issue.due_at else None, + } + if body.assigned_to_ref is not None: + issue.assigned_to_user = assignee + elif body.clear_assignment: + issue.assigned_to_user = None + if body.due_at is not None: + issue.due_at = body.due_at + elif body.clear_due_at: + issue.due_at = None + after = { + "assigned_to_ref": assignee.public_ref + if body.assigned_to_ref is not None and assignee + else (None if body.clear_assignment else before["assigned_to_ref"]), + "due_at": issue.due_at.isoformat() if issue.due_at else None, + } + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="data_quality_work_updated", + entity_type="data_quality_issue", + entity_id=issue.id, + before=before, + after=after, + ) + db.commit() + for issue in issues: + db.refresh(issue) + return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue 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", + "MAINT-": "maintenance", + "MNT-": "maintenance", +} + + +def _entity_type_for_ref(ref: str) -> str | None: + for prefix, entity_type in _PREFIX_TO_TYPE.items(): + if ref.startswith(prefix): + return entity_type + return None + + +def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None: + if entity_type == "customer": + customer = db.scalar(select(Customer).where(Customer.public_ref == ref)) + if customer is None: + return None + return { + "entity_type": "customer", + "public_ref": customer.public_ref, + "first_name": customer.first_name, + "last_name": customer.last_name, + "email": customer.email, + "phone": customer.phone, + "postal_code": customer.postal_code, + "city": customer.city, + } + if entity_type == "vehicle": + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref)) + if vehicle is None: + return None + return { + "entity_type": "vehicle", + "public_ref": vehicle.public_ref, + "registration_number": vehicle.registration_number, + "make": vehicle.make, + "model": vehicle.model, + "location": vehicle.location, + "operational_status": vehicle.operational_status, + "odometer_km": vehicle.odometer_km, + } + if entity_type == "booking": + booking = db.scalar(select(Booking).where(Booking.public_ref == ref)) + if booking is None: + return None + vehicle = db.get(Vehicle, booking.vehicle_id) + customer = db.get(Customer, booking.customer_id) + return { + "entity_type": "booking", + "public_ref": booking.public_ref, + "status": booking.status, + "starts_at": booking.starts_at.isoformat(), + "ends_at": booking.ends_at.isoformat(), + "vehicle_ref": vehicle.public_ref if vehicle else None, + "customer_ref": customer.public_ref if customer else None, + "end_odometer_km": booking.end_odometer_km, + } + if entity_type == "inspection": + inspection = db.scalar(select(Inspection).where(Inspection.public_ref == ref)) + if inspection is None: + return None + booking = db.get(Booking, inspection.booking_id) + return { + "entity_type": "inspection", + "public_ref": inspection.public_ref, + "type": inspection.type, + "odometer_km": inspection.odometer_km, + "completed_at": inspection.completed_at.isoformat(), + "booking_ref": booking.public_ref if booking else None, + } + if entity_type == "maintenance": + record = db.scalar(select(MaintenanceRecord).where(MaintenanceRecord.public_ref == ref)) + if record is None: + return None + return { + "entity_type": "maintenance", + "public_ref": record.public_ref, + "odometer_km": record.odometer_km, + "occurred_at": record.occurred_at.isoformat(), + "category": record.category, + } + return None + + +@router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut) +def get_issue( + public_ref: str, + db: Session = Depends(get_db), + _user: CurrentUser = Depends(require_operations_manager), +) -> DataQualityIssueDetailOut: + issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)) + if issue is None: + raise HTTPException(status_code=404, detail="Data quality issue not found") + base = _to_out(issue) + related_refs = issue.evidence_json.get("related_refs", []) + related_snapshots = [] + for ref in related_refs: + entity_type = _entity_type_for_ref(ref) + if entity_type is None: + continue + snap = _snapshot(entity_type, ref, db) + if snap is not None: + related_snapshots.append(snap) + return DataQualityIssueDetailOut( + **base.model_dump(), + entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db), + related_snapshots=related_snapshots, + ) + + +@router.post("/issues/{public_ref}/defer", response_model=DataQualityIssueOut) +def defer( + public_ref: str, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> DataQualityIssueOut: + issue = defer_issue(db, public_ref, user) + return _to_out(issue) + + +@router.post("/issues/{public_ref}/reject", response_model=DataQualityIssueOut) +def reject( + public_ref: str, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> DataQualityIssueOut: + issue = reject_issue(db, public_ref, user) + return _to_out(issue) + + +@router.post("/issues/{public_ref}/merge-customers", response_model=MergeCustomersResult) +def merge( + public_ref: str, + body: MergeCustomersRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> MergeCustomersResult: + result = merge_customers(db, public_ref, body.survivor_ref, body.field_overrides, user) + 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}/status-recommendation", response_model=StatusRecommendationOut) +def status_recommendation( + public_ref: str, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> StatusRecommendationOut: + """Non-mutating preview: computes the recommendation without changing anything, + resolving no issue and writing no audit event. Safe to call repeatedly.""" + _issue, _vehicle, recommendation, token = preview_vehicle_status_recommendation(db, public_ref) + return StatusRecommendationOut( + current_status=recommendation.current_status, + recommended_status=recommendation.recommended_status, + recommendation_code=recommendation.recommendation_code, + safe_to_apply=recommendation.safe_to_apply, + manual_review_required=recommendation.manual_review_required, + facts=VehicleStatusFactsOut(**recommendation.facts.as_dict()), + blocking_reasons=recommendation.blocking_reasons, + recommendation_token=token, + ) + + +@router.post( + "/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult +) +def apply_status( + public_ref: str, + body: ApplyRecommendedStatusRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> ApplyRecommendedStatusResult: + issue, applied_status, reason_code = apply_recommended_status( + db, public_ref, user, body.recommendation_token + ) + return ApplyRecommendedStatusResult( + issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code + ) + + +@router.post("/scan", response_model=ScanResultOut) +def scan( + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> ScanResultOut: + result = run_scan(db, actor_label=user.display_name, actor_type="user") + return ScanResultOut(created=result.created) diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py new file mode 100644 index 0000000..e64aae1 --- /dev/null +++ b/backend/app/api/routers/demo.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import threading +import time +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db, require_operations_manager +from app.core.config import get_settings +from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset, engine +from app.core.security import SessionPayload, create_session_token, read_session_token +from app.models.audit import AuditEvent +from app.models.user import User +from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut +from app.seed_loader import reset_and_seed +from app.services.audit import record_audit_event +from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report +from app.services.sessions import revoke_session + +router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) +settings = get_settings() +_reset_guard = threading.Lock() +_RESET_ADVISORY_LOCK_ID = 706_533_149 + + +@router.get("/manifest", response_model=DemoManifestOut) +def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut: + if not settings.mobilityops_demo_mode: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled") + # Deliberately unauthenticated: the demo-entry screen and the permanent demo badge + # both need this before any session exists. Nothing here is sensitive — it's the same + # honest "what is this demo" summary a logged-in user would see. + return build_demo_manifest(db) + + +@router.post("/login", response_model=CurrentUser) +def demo_login( + body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) +) -> CurrentUser: + if not settings.mobilityops_demo_mode: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled") + public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP" + user = db.scalar(select(User).where(User.public_ref == public_ref)) + if user is None: + raise LookupError("Demo users are missing; run the seed loader first.") + + token = create_session_token( + SessionPayload( + user_id=str(user.id), + public_ref=user.public_ref, + role=user.role, + display_name=user.display_name, + issued_at=int(time.time()), + session_id=str(uuid.uuid4()), + ) + ) + response.set_cookie( + settings.session_cookie_name, + token, + httponly=True, + samesite="lax", + secure=settings.session_cookie_secure, + max_age=settings.session_ttl_seconds, + ) + record_audit_event( + db, + actor_type="user", + actor_id=user.id, + actor_label=user.display_name, + action="demo_login", + entity_type="user", + entity_id=user.id, + ) + db.commit() + 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 and token is not None: + revoke_session(db, token, payload) + record_audit_event( + db, + actor_type="user", + actor_id=uuid.UUID(payload.user_id), + actor_label=payload.display_name, + action="demo_logout", + entity_type="user", + ) + db.commit() + response.delete_cookie(settings.session_cookie_name) + return {"status": "logged_out"} + + +@router.post("/reset") +def demo_reset( + response: Response, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> dict: + if not settings.mobilityops_demo_mode: + # Outside demo mode the reset endpoint must not exist at all: it wipes + # operational data and replaces it with synthetic records. + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled") + if not settings.demo_allow_reset: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Demo reset is disabled on this deployment.", + ) + if not _reset_guard.acquire(blocking=False): + raise HTTPException(status_code=409, detail="A demo reset is already running.") + replica_lock_connection = None + try: + # A session-level lock on its own connection rejects another replica immediately; + # the main DB session can then safely end its auth read transaction and wait on + # the normal shared/exclusive data barrier without releasing this replica guard. + replica_lock_connection = engine.connect() + locked = replica_lock_connection.scalar( + select(func.pg_try_advisory_lock(_RESET_ADVISORY_LOCK_ID)) + ) + if not locked: + raise HTTPException(status_code=409, detail="A demo reset is already running.") + begin_exclusive_demo_reset(db) + # The audit timestamp is shared by every replica. A process-local monotonic + # timestamp cannot protect a multi-replica deployment. + last_reset_at = db.scalar( + select(AuditEvent.occurred_at) + .where(AuditEvent.action == "demo_reset") + .order_by(AuditEvent.occurred_at.desc()) + .limit(1) + ) + elapsed = (datetime.now(UTC) - last_reset_at).total_seconds() if last_reset_at else None + if elapsed is not None and elapsed < settings.demo_reset_cooldown_seconds: + retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999)) + raise HTTPException( + status_code=429, + detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.", + headers={"Retry-After": str(retry_after)}, + ) + result = reset_and_seed(db, preserve_integration_telemetry=True, commit=False) + integrity = scenario_integrity_report(db) + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="demo_reset", + entity_type="system", + metadata={ + "counts": result.counts, + "anchor_date": result.anchor_date.isoformat(), + "scenario_integrity": integrity, + }, + ) + db.commit() + end_exclusive_demo_reset(db) + finally: + if replica_lock_connection is not None: + try: + replica_lock_connection.scalar( + select(func.pg_advisory_unlock(_RESET_ADVISORY_LOCK_ID)) + ) + finally: + replica_lock_connection.close() + _reset_guard.release() + response.delete_cookie(settings.session_cookie_name) + return { + "status": "reset", + "counts": result.counts, + "anchor_date": result.anchor_date.isoformat(), + "seeded_at": result.seeded_at.isoformat(), + "scenario_integrity": integrity, + } diff --git a/backend/app/api/routers/integration_status.py b/backend/app/api/routers/integration_status.py new file mode 100644 index 0000000..dac7aca --- /dev/null +++ b/backend/app/api/routers/integration_status.py @@ -0,0 +1,21 @@ +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.schemas import CurrentUser, IntegrationStatusOut +from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status + +router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) + + +@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=derive_mcp_hub_status(db), + ) diff --git a/backend/app/api/routers/integrations.py b/backend/app/api/routers/integrations.py new file mode 100644 index 0000000..02fc0b5 --- /dev/null +++ b/backend/app/api/routers/integrations.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import hashlib +import hmac +import uuid +from datetime import UTC, datetime +from pathlib import Path + +from fastapi import APIRouter, Depends, Header +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.core.config import get_settings +from app.core.errors import AppError +from app.models.audit import AuditEvent +from app.models.outbox import OutboxEvent +from app.schemas import ( + N8nHeartbeatIn, + N8nHeartbeatResult, + ProcedureDocumentOut, + ProcedureListOut, + ProcedureSyncResultIn, + ProcedureSyncResultResult, + ReturnCallbackIn, + ScanResultOut, + WorkflowErrorReportIn, + WorkflowErrorReportResult, +) +from app.services.audit import record_audit_event +from app.services.data_quality import run_scan +from app.services.knowledge.procedures import iter_procedure_documents + +router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"]) +settings = get_settings() + +_CANONICAL_WORKFLOW_NAMES = frozenset( + { + "Fleet Ops — Vehicle Return Orchestration", + "Fleet Ops — Scheduled Data Quality Scan", + "Fleet Ops — RAGcore Procedure Sync", + "Fleet Ops — Workflow Error Handler", + } +) + + +def _require_service_token(service_token: str) -> None: + # Constant-time comparison: a plain ``!=`` leaks how many leading bytes matched. + if not hmac.compare_digest( + service_token.encode("utf-8"), settings.n8n_callback_token.encode("utf-8") + ): + raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401) + + +def _lock_idempotency_key(db: Session, namespace: str, key: str) -> None: + """Serialize callback check+insert by a stable, transaction-scoped key.""" + digest = hashlib.sha256(f"{namespace}:{key}".encode()).digest() + lock_id = int.from_bytes(digest[:8], byteorder="big", signed=True) + db.scalar(select(func.pg_advisory_xact_lock(lock_id))) + + +@router.post("/heartbeat", response_model=N8nHeartbeatResult) +def workflow_heartbeat( + body: N8nHeartbeatIn, + service_token: str = Header(..., alias="X-Service-Token"), + db: Session = Depends(get_db), +) -> N8nHeartbeatResult: + """Authenticated, idempotent execution evidence from a canonical n8n workflow.""" + _require_service_token(service_token) + if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES: + raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422) + _lock_idempotency_key(db, "n8n_workflow_heartbeat", f"{body.execution_id}:{body.status}") + already_recorded = ( + db.scalar( + select(AuditEvent.id).where( + AuditEvent.action == "n8n_workflow_heartbeat", + AuditEvent.metadata_json["execution_id"].astext == body.execution_id, + AuditEvent.after_json["status"].astext == body.status, + ) + ) + is not None + ) + if not already_recorded: + record_audit_event( + db, + actor_type="service", + actor_label="n8n workflow heartbeat", + action="n8n_workflow_heartbeat", + entity_type="automation", + after={ + "workflow_id": body.workflow_id, + "workflow_name": body.workflow_name, + "status": body.status, + }, + metadata={"execution_id": body.execution_id}, + ) + db.commit() + return N8nHeartbeatResult( + status="already_registered" if already_recorded else "registered", + execution_id=body.execution_id, + occurred_at=datetime.now(UTC), + ) + + +@router.post("/return-callback") +def return_callback( + body: ReturnCallbackIn, + idempotency_key: str = Header(..., alias="Idempotency-Key"), + service_token: str = Header(..., alias="X-Service-Token"), + db: Session = Depends(get_db), +) -> dict: + _require_service_token(service_token) + + try: + event_id = uuid.UUID(idempotency_key) + except ValueError as exc: + raise AppError( + "INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be the event's UUID.", status_code=422 + ) from exc + + event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update()) + if event is None: + raise AppError("EVENT_NOT_FOUND", "No outbox event matches this event ID.", status_code=404) + if body.event_id != event_id: + raise AppError( + "CALLBACK_EVENT_MISMATCH", + "Callback event_id does not match Idempotency-Key.", + status_code=409, + ) + try: + expected_correlation_id = uuid.UUID(str(event.payload_json["correlation_id"])) + except (KeyError, TypeError, ValueError) as exc: + raise AppError( + "INVALID_EVENT_CORRELATION", + "The stored outbox event has no valid correlation ID.", + status_code=409, + ) from exc + if body.correlation_id != expected_correlation_id: + raise AppError( + "CALLBACK_CORRELATION_MISMATCH", + "Callback correlation_id does not match the outbox event.", + status_code=409, + ) + + # Idempotent by event ID: n8n or our own dispatcher may redeliver the same event + # (e.g. a lost response after a timeout), so this callback must not double-record. + already_recorded = ( + db.scalar( + select(AuditEvent.id).where( + AuditEvent.action == "n8n_return_followup_recorded", + AuditEvent.metadata_json["event_id"].astext == str(event_id), + ) + ) + is not None + ) + if not already_recorded: + record_audit_event( + db, + actor_type="service", + actor_label="n8n", + action="n8n_return_followup_recorded", + entity_type="booking", + correlation_id=expected_correlation_id, + after={"follow_up": body.follow_up, "summary": body.summary}, + metadata={"event_id": str(event_id)}, + ) + db.commit() + + return { + "status": "recorded", + "event_id": str(event_id), + "occurred_at": datetime.now(UTC).isoformat(), + } + + +@router.post("/scheduled-scan", response_model=ScanResultOut) +def scheduled_scan( + service_token: str = Header(..., alias="X-Service-Token"), + db: Session = Depends(get_db), +) -> ScanResultOut: + """Triggered by the scheduled n8n quality-scan workflow. Narrow, read-mostly, and + safe to call repeatedly: run_scan() only ever creates an issue for a condition that + doesn't already have one open, so a duplicate or overlapping trigger does no + duplicate domain work -- it just reports zero new issues for anything already known.""" + _require_service_token(service_token) + + result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service") + return ScanResultOut(created=result.created) + + +@router.post("/workflow-error", response_model=WorkflowErrorReportResult) +def workflow_error( + body: WorkflowErrorReportIn, + service_token: str = Header(..., alias="X-Service-Token"), + db: Session = Depends(get_db), +) -> WorkflowErrorReportResult: + """Receives a bounded, secret-free failure report from the central n8n "Fleet Ops -- + Workflow Error Handler" workflow, which is attached as the Error Workflow on every + other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same + error report (e.g. after a timed-out response), so this must not double-record.""" + _require_service_token(service_token) + _lock_idempotency_key(db, "n8n_workflow_failure", body.execution_id) + + already_recorded = ( + db.scalar( + select(AuditEvent.id).where( + AuditEvent.action == "n8n_workflow_failure_registered", + AuditEvent.metadata_json["execution_id"].astext == body.execution_id, + ) + ) + is not None + ) + if not already_recorded: + record_audit_event( + db, + actor_type="service", + actor_label="n8n error handler", + action="n8n_workflow_failure_registered", + entity_type="automation", + correlation_id=body.correlation_id, + after={ + "workflow_id": body.workflow_id, + "workflow_name": body.workflow_name, + "error_category": body.error_category, + "error_summary": body.error_summary, + "trigger_context": body.trigger_context, + "attempt": body.attempt, + "retry_action": body.retry_action, + "failed_at": body.failed_at.isoformat(), + }, + metadata={"execution_id": body.execution_id}, + ) + db.commit() + + return WorkflowErrorReportResult( + status="already_registered" if already_recorded else "registered", + execution_id=body.execution_id, + occurred_at=datetime.now(UTC), + ) + + +@router.get("/procedures", response_model=ProcedureListOut) +def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut: + """Read-only source list for the RAGcore Procedure Sync workflow: every procedure + Markdown file Fleet Ops ships, across every supported language, with a stable + per-document id (source_id) and a content hash so the caller can detect changes + without re-fetching content it already has.""" + _require_service_token(service_token) + + documents = [ + ProcedureDocumentOut( + id=doc.source_id, + language=doc.language, + document_id=doc.document_id, + title=doc.title, + version=doc.version, + content=doc.content, + content_hash=doc.content_hash, + ) + for doc in iter_procedure_documents(Path(settings.knowledge_dir)) + ] + return ProcedureListOut(documents=documents) + + +@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult) +def procedures_sync_result( + body: ProcedureSyncResultIn, + service_token: str = Header(..., alias="X-Service-Token"), + db: Session = Depends(get_db), +) -> ProcedureSyncResultResult: + """Receives a summary (counts only, no document content) from the n8n "Fleet Ops -- + RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore. + Idempotent on execution_id, matching the workflow-error and return-callback pattern.""" + _require_service_token(service_token) + _lock_idempotency_key(db, "n8n_procedure_sync", body.execution_id) + + already_recorded = ( + db.scalar( + select(AuditEvent.id).where( + AuditEvent.action == "n8n_procedures_synced", + AuditEvent.metadata_json["execution_id"].astext == body.execution_id, + ) + ) + is not None + ) + if not already_recorded: + record_audit_event( + db, + actor_type="service", + actor_label="n8n procedure sync", + action="n8n_procedures_synced", + entity_type="automation", + after={"synced": body.synced, "failed": body.failed}, + metadata={"execution_id": body.execution_id}, + ) + db.commit() + + return ProcedureSyncResultResult( + status="already_registered" if already_recorded else "registered", + execution_id=body.execution_id, + occurred_at=datetime.now(UTC), + ) diff --git a/backend/app/api/routers/knowledge.py b/backend/app/api/routers/knowledge.py new file mode 100644 index 0000000..3883984 --- /dev/null +++ b/backend/app/api/routers/knowledge.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import hashlib +import uuid +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.core.config import get_settings +from app.core.ratelimit import SlidingWindowLimiter +from app.models.audit import AuditEvent +from app.schemas import CurrentUser +from app.services.audit import record_audit_event +from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider + +router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) +settings = get_settings() +_question_limiter = ( + SlidingWindowLimiter( + max_requests=settings.knowledge_max_requests, + window_seconds=settings.knowledge_rate_limit_window_seconds, + ) + if settings.knowledge_max_requests > 0 + else None +) + +SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"] + + +class AskQuestionRequest(BaseModel): + question: str = Field(min_length=3, max_length=1000) + language: SupportedLanguage = "en-GB" + + +class KnowledgeFeedbackRequest(BaseModel): + correlation_id: uuid.UUID + helpful: bool + + +@router.post("/questions", response_model=GroundedAnswer) +def ask_question( + body: AskQuestionRequest, + request: Request, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> GroundedAnswer: + if _question_limiter is not None: + forwarded = request.headers.get("x-forwarded-for", "") + client_ip = ( + forwarded.split(",")[-1].strip() + if forwarded + else request.client.host + if request.client + else "unknown" + ) + token = request.cookies.get(settings.session_cookie_name, "") + session_key = hashlib.sha256(token.encode("utf-8")).hexdigest() + retry_after = max( + _question_limiter.consume(f"ip:{client_ip}"), + _question_limiter.consume(f"session:{session_key}"), + ) + if retry_after: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many knowledge questions. Try again later.", + headers={"Retry-After": str(retry_after)}, + ) + correlation_id = str(uuid.uuid4()) + provider = get_knowledge_provider() + answer = provider.ask(body.question, correlation_id, body.language) + + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="knowledge_question_asked", + entity_type="knowledge", + correlation_id=uuid.UUID(correlation_id), + metadata={ + "evidence_state": answer.evidence_state, + "provider": answer.provider, + "source_ids": [s.document_id for s in answer.sources], + "question_length": len(body.question), + "language": body.language, + }, + ) + db.commit() + return answer + + +@router.post("/feedback") +def record_feedback( + body: KnowledgeFeedbackRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> dict[str, str]: + question_event = db.scalar( + select(AuditEvent.id).where( + AuditEvent.action == "knowledge_question_asked", + AuditEvent.correlation_id == body.correlation_id, + AuditEvent.actor_label == user.display_name, + ) + ) + if question_event is None: + raise HTTPException(status_code=404, detail="Knowledge exchange not found") + + existing = db.scalar( + select(AuditEvent).where( + AuditEvent.action == "knowledge_feedback_recorded", + AuditEvent.correlation_id == body.correlation_id, + AuditEvent.actor_label == user.display_name, + ) + ) + if existing is not None: + existing.metadata_json = {"helpful": body.helpful} + else: + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="knowledge_feedback_recorded", + entity_type="knowledge", + correlation_id=body.correlation_id, + metadata={"helpful": body.helpful}, + ) + db.commit() + return {"status": "recorded"} + + +@router.get("/status", response_model=KnowledgeHealth) +def knowledge_status( + language: SupportedLanguage = "en-GB", + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> KnowledgeHealth: + health = get_knowledge_provider().health(language) + if health.provider != "ragcore": + return health + + latest_sync = db.scalar( + select(AuditEvent) + .where(AuditEvent.action == "n8n_procedures_synced") + .order_by(AuditEvent.occurred_at.desc()) + .limit(1) + ) + if latest_sync is None: + return health + + reported = latest_sync.after_json or {} + synced = reported.get("synced") + failed = reported.get("failed") + return health.model_copy( + update={ + "reported_synced_document_count": synced if isinstance(synced, int) else None, + "reported_failed_document_count": failed if isinstance(failed, int) else None, + "last_sync_at": latest_sync.occurred_at, + # A persisted sync callback is useful additional provenance, but must not + # downgrade stronger provider-side verification to merely "reported". + "statistics_state": ( + health.statistics_state + if health.statistics_state == "verified" + else "sync_reported" + ), + } + ) diff --git a/backend/app/api/routers/mcp_integrations.py b/backend/app/api/routers/mcp_integrations.py new file mode 100644 index 0000000..c180c87 --- /dev/null +++ b/backend/app/api/routers/mcp_integrations.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import uuid +from datetime import date + +from fastapi import APIRouter, Depends, Header, Query, Response +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import McpClientContext, get_db, require_mcp_service_token +from app.core.config import get_settings +from app.core.errors import AppError +from app.models.booking import Booking +from app.models.data_quality import DataQualityIssue +from app.models.vehicle import Vehicle +from app.schemas import ( + AttentionVehicleOut, + McpKnowledgeSearchRequest, + McpVehicleDetailOut, + OperationsSummaryOut, +) +from app.services.audit import record_audit_event +from app.services.knowledge import GroundedAnswer, get_knowledge_provider +from app.services.operations import compute_metrics, list_attention_vehicles + +router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"]) +settings = get_settings() + + +def get_correlation_id( + x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"), +) -> str: + """Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet + Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or + it isn't a valid UUID (per the task's own correlation-propagation contract).""" + if x_correlation_id: + try: + return str(uuid.UUID(x_correlation_id)) + except ValueError: + pass + return str(uuid.uuid4()) + + +def _audit_service_request( + db: Session, + *, + reported_client_id: str, + tool: str, + status_label: str, + correlation_id: str, + metadata: dict[str, object] | None = None, +) -> None: + record_audit_event( + db, + actor_type="service", + # The shared service token authenticates the Hub, not the caller identity that + # the Hub reports in a header. Keep attribution authoritative and retain the + # reported value only as explicitly non-authenticated diagnostic metadata. + actor_label="itworx-mcp-hub", + action="mcp_tool_request", + entity_type="mcp_tool", + correlation_id=uuid.UUID(correlation_id), + metadata={ + "tool": tool, + "status": status_label, + "reported_client_id": reported_client_id, + **(metadata or {}), + }, + ) + db.commit() + + +def _set_trace_headers(response: Response, correlation_id: str, tenant: str) -> None: + response.headers["X-Correlation-Id"] = correlation_id + response.headers["X-Tenant-Id"] = tenant + response.headers["Cache-Control"] = "no-store" + + +@router.get("/operations-summary", response_model=OperationsSummaryOut) +def operations_summary( + response: Response, + db: Session = Depends(get_db), + client: McpClientContext = Depends(require_mcp_service_token), + correlation_id: str = Depends(get_correlation_id), +) -> OperationsSummaryOut: + metrics = compute_metrics(db) + _set_trace_headers(response, correlation_id, client.tenant) + _audit_service_request( + db, + reported_client_id=client.reported_client_id, + tool="fleet_ops_get_operations_summary", + status_label="ok", + correlation_id=correlation_id, + ) + return OperationsSummaryOut(tenant=client.tenant, metrics=metrics) + + +@router.get("/attention-vehicles", response_model=list[AttentionVehicleOut]) +def attention_vehicles( + response: Response, + minimum_severity: str = Query(default="medium", pattern="^(low|medium|high)$"), + date_filter: date | None = Query(default=None, alias="date"), + limit: int = Query(default=20, ge=1, le=50), + db: Session = Depends(get_db), + client: McpClientContext = Depends(require_mcp_service_token), + correlation_id: str = Depends(get_correlation_id), +) -> list[AttentionVehicleOut]: + results = list_attention_vehicles( + db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit + ) + _set_trace_headers(response, correlation_id, client.tenant) + _audit_service_request( + db, + reported_client_id=client.reported_client_id, + tool="fleet_ops_list_attention_vehicles", + status_label="ok", + correlation_id=correlation_id, + ) + return [AttentionVehicleOut(**r) for r in results] + + +@router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut) +def vehicle_details( + vehicle_ref: str, + response: Response, + db: Session = Depends(get_db), + client: McpClientContext = Depends(require_mcp_service_token), + correlation_id: str = Depends(get_correlation_id), +) -> McpVehicleDetailOut: + _set_trace_headers(response, correlation_id, client.tenant) + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) + if vehicle is None: + _audit_service_request( + db, + reported_client_id=client.reported_client_id, + tool="fleet_ops_get_vehicle_details", + status_label="not_found", + correlation_id=correlation_id, + ) + raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404) + + open_issue_count = len( + db.scalars( + select(DataQualityIssue.id).where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + DataQualityIssue.status == "open", + ) + ).all() + ) + current_booking = db.scalar( + select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "active") + ) + + _audit_service_request( + db, + reported_client_id=client.reported_client_id, + tool="fleet_ops_get_vehicle_details", + status_label="ok", + correlation_id=correlation_id, + ) + return McpVehicleDetailOut( + public_ref=vehicle.public_ref, + make=vehicle.make, + model=vehicle.model, + model_year=vehicle.model_year, + location=vehicle.location, + operational_status=vehicle.operational_status, + odometer_km=vehicle.odometer_km, + next_service_km=vehicle.next_service_km, + open_quality_issue_count=open_issue_count, + current_booking_ref=current_booking.public_ref if current_booking else None, + ) + + +@router.post("/search-knowledge", response_model=GroundedAnswer) +def search_knowledge( + body: McpKnowledgeSearchRequest, + response: Response, + db: Session = Depends(get_db), + client: McpClientContext = Depends(require_mcp_service_token), + correlation_id: str = Depends(get_correlation_id), +) -> GroundedAnswer: + provider = get_knowledge_provider() + answer = provider.ask(body.question, correlation_id, language=body.locale) + source_count_available = len(answer.sources) + answer.sources = answer.sources[: body.max_sources] + _set_trace_headers(response, correlation_id, client.tenant) + response.headers["X-Sources-Available"] = str(source_count_available) + response.headers["X-Sources-Returned"] = str(len(answer.sources)) + _audit_service_request( + db, + reported_client_id=client.reported_client_id, + tool="fleet_ops_search_knowledge", + status_label=answer.evidence_state, + correlation_id=correlation_id, + metadata={ + "tenant": client.tenant, + "locale": body.locale, + "sources_available": source_count_available, + "sources_returned": len(answer.sources), + }, + ) + return answer diff --git a/backend/app/api/routers/observability.py b/backend/app/api/routers/observability.py new file mode 100644 index 0000000..35be438 --- /dev/null +++ b/backend/app/api/routers/observability.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import hmac + +from fastapi import APIRouter, Header, HTTPException +from fastapi.responses import Response +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from sqlalchemy import func, select + +from app.core.config import get_settings +from app.core.db import SessionLocal +from app.core.observability import OUTBOX_EVENTS +from app.models.outbox import DELIVERY_STATUSES, DEMO_SCENARIO_ERROR_CODE, OutboxEvent + +router = APIRouter(tags=["observability"]) +settings = get_settings() + + +def _refresh_database_metrics() -> None: + with SessionLocal() as db: + rows = db.execute( + select( + OutboxEvent.delivery_status, + (OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE).label("demo"), + func.count(), + ).group_by(OutboxEvent.delivery_status, "demo") + ).all() + OUTBOX_EVENTS.clear() + # Keep every time series present even when a state currently contains no rows. + # Stable zero-valued series make dashboards and alerts deterministic after resets, + # restores and fresh installations instead of turning "zero" into "no data". + for scenario in ("synthetic", "operational"): + for status in DELIVERY_STATUSES: + OUTBOX_EVENTS.labels(scenario, status).set(0) + for status, is_demo, count in rows: + OUTBOX_EVENTS.labels("synthetic" if is_demo else "operational", str(status)).set(count) + + +@router.get("/metrics", include_in_schema=False) +def metrics(authorization: str | None = Header(default=None)) -> Response: + if settings.metrics_bearer_token: + supplied = authorization.removeprefix("Bearer ") if authorization else "" + if not hmac.compare_digest(supplied, settings.metrics_bearer_token): + raise HTTPException(status_code=401, detail="Metrics token required") + _refresh_database_metrics() + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/backend/app/api/routers/privacy.py b/backend/app/api/routers/privacy.py new file mode 100644 index 0000000..f8ff6bd --- /dev/null +++ b/backend/app/api/routers/privacy.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_operations_manager +from app.core.config import get_settings +from app.models.booking import Booking +from app.models.customer import Customer +from app.schemas import ( + CurrentUser, + CustomerAnonymizeRequest, + CustomerAnonymizeResult, + PrivacyRetentionOut, +) +from app.services.audit import record_audit_event + +router = APIRouter(prefix="/api/v1/privacy", tags=["privacy"]) +settings = get_settings() + + +def _retention_cutoff() -> datetime: + return datetime.now(UTC) - timedelta(days=settings.privacy_minimum_booking_retention_days) + + +def _customer_is_eligible(db: Session, customer_id) -> bool: + blocking = db.scalar( + select(func.count()) + .select_from(Booking) + .where( + Booking.customer_id == customer_id, + or_( + Booking.status.in_(("reserved", "active")), + Booking.ends_at > _retention_cutoff(), + ), + ) + ) + return not blocking + + +@router.get("/retention", response_model=PrivacyRetentionOut) +def retention_status( + db: Session = Depends(get_db), + _user: CurrentUser = Depends(require_operations_manager), +) -> PrivacyRetentionOut: + customers = db.scalars(select(Customer)).all() + return PrivacyRetentionOut( + minimum_booking_retention_days=settings.privacy_minimum_booking_retention_days, + audit_retention_days=settings.privacy_audit_retention_days, + customers_total=len(customers), + customers_anonymized=sum(customer.anonymized_at is not None for customer in customers), + customers_eligible=sum( + customer.anonymized_at is None and _customer_is_eligible(db, customer.id) + for customer in customers + ), + ) + + +@router.get("/customers/{public_ref}/export") +def export_customer_data( + public_ref: str, + db: Session = Depends(get_db), + actor: CurrentUser = Depends(require_operations_manager), +) -> JSONResponse: + customer = db.scalar(select(Customer).where(Customer.public_ref == public_ref)) + if customer is None: + raise HTTPException(status_code=404, detail="Customer not found") + bookings = db.scalars( + select(Booking).where(Booking.customer_id == customer.id).order_by(Booking.starts_at) + ).all() + payload = { + "generated_at": datetime.now(UTC).isoformat(), + "customer": { + "public_ref": customer.public_ref, + "first_name": customer.first_name, + "last_name": customer.last_name, + "email": customer.email, + "phone": customer.phone, + "postal_code": customer.postal_code, + "city": customer.city, + "date_of_birth": customer.date_of_birth.isoformat() if customer.date_of_birth else None, + "anonymized_at": customer.anonymized_at.isoformat() if customer.anonymized_at else None, + }, + "bookings": [ + { + "public_ref": booking.public_ref, + "starts_at": booking.starts_at.isoformat(), + "ends_at": booking.ends_at.isoformat(), + "status": booking.status, + } + for booking in bookings + ], + } + record_audit_event( + db, + actor_type="user", + actor_label=actor.display_name, + action="privacy_customer_exported", + entity_type="customer", + entity_id=customer.id, + metadata={"customer_ref": customer.public_ref, "booking_count": len(bookings)}, + ) + db.commit() + return JSONResponse( + payload, + headers={"Content-Disposition": f'attachment; filename="{public_ref}-privacy.json"'}, + ) + + +@router.post("/customers/{public_ref}/anonymize", response_model=CustomerAnonymizeResult) +def anonymize_customer( + public_ref: str, + body: CustomerAnonymizeRequest, + db: Session = Depends(get_db), + actor: CurrentUser = Depends(require_operations_manager), +) -> CustomerAnonymizeResult: + customer = db.scalar( + select(Customer).where(Customer.public_ref == public_ref).with_for_update() + ) + if customer is None: + raise HTTPException(status_code=404, detail="Customer not found") + if body.confirmation != public_ref: + raise HTTPException( + status_code=422, detail="Customer reference confirmation does not match" + ) + if customer.anonymized_at is not None: + return CustomerAnonymizeResult( + public_ref=public_ref, + anonymized_at=customer.anonymized_at, + status="already_anonymized", + ) + if not _customer_is_eligible(db, customer.id): + raise HTTPException( + status_code=409, + detail="Customer has an active/recent booking within the minimum retention period", + ) + anonymized_at = datetime.now(UTC) + customer.first_name = "Anoniem" + customer.last_name = public_ref + customer.email = None + customer.phone = None + customer.postal_code = None + customer.city = None + customer.date_of_birth = None + customer.anonymized_at = anonymized_at + record_audit_event( + db, + actor_type="user", + actor_label=actor.display_name, + action="privacy_customer_anonymized", + entity_type="customer", + entity_id=customer.id, + before={"anonymized": False}, + after={"anonymized": True}, + metadata={"reason": body.reason, "customer_ref": public_ref}, + ) + db.commit() + return CustomerAnonymizeResult( + public_ref=public_ref, + anonymized_at=anonymized_at, + status="anonymized", + ) diff --git a/backend/app/api/routers/search.py b/backend/app/api/routers/search.py new file mode 100644 index 0000000..cc71078 --- /dev/null +++ b/backend/app/api/routers/search.py @@ -0,0 +1,184 @@ +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. `id` is a stable code matching navigation.json's +# `items.*` keys -- the frontend localizes both the section label and its one-line +# detail from `id`, so no English prose is sent over the wire (search.sections. in +# every locale; see docs/fleet-ops-correction/i18n-inventory.md). 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] = [ + { + "id": "overview", + "link": "/dashboard", + # Search terms deliberately span all three supported UI languages (not just + # English) so a query never depends on the operator's selected locale. + "terms": ["overview", "dashboard", "readiness", "overzicht", "aperçu", "tableau de bord"], + }, + { + "id": "fleet", + "link": "/vehicles", + "terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"], + }, + { + "id": "bookings", + "link": "/bookings", + "terms": [ + "booking", + "bookings", + "rental", + "boeking", + "boekingen", + "verhuur", + "réservation", + "réservations", + "location", + ], + }, + { + "id": "quality", + "link": "/data-quality", + "terms": [ + "quality", + "data quality", + "issues", + "kwaliteit", + "datakwaliteit", + "problemen", + "qualité", + "problèmes", + ], + "role": "operations_manager", + }, + { + "id": "knowledge", + "link": "/knowledge", + "terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"], + }, + { + "id": "integrations", + "link": "/automation", + "terms": [ + "automation", + "integrations", + "systems", + "n8n", + "automatisering", + "integraties", + "systemen", + "automatisation", + "intégrations", + "systèmes", + ], + "role": "operations_manager", + }, + { + "id": "audit", + "link": "/audit", + "terms": ["audit", "history", "geschiedenis", "historique"], + "role": "operations_manager", + }, + { + "id": "privacy", + "link": "/privacy", + "terms": ["privacy", "retention", "anonymise", "anonimiseren", "confidentialité"], + "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["id"], + detail_code=section["id"], + 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_code="vehicleSummary", + detail_params={"make": v.make, "model": v.model, "location": 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_code=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_code=i.rule_type, + link=f"/data-quality/{i.public_ref}", + ) + ) + + return SearchResponse(query=query, results=results[:10]) diff --git a/backend/app/api/routers/users.py b/backend/app/api/routers/users.py new file mode 100644 index 0000000..ae79717 --- /dev/null +++ b/backend/app/api/routers/users.py @@ -0,0 +1,100 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_operations_manager +from app.core.security import hash_password +from app.models.user import User +from app.schemas import CreateUserRequest, CurrentUser, UpdateUserRequest, UserOut +from app.services.audit import record_audit_event + +router = APIRouter(prefix="/api/v1/users", tags=["users"]) + + +def _to_out(user: User) -> UserOut: + return UserOut( + public_ref=user.public_ref, + email=user.email, + display_name=user.display_name, + role=user.role, # type: ignore[arg-type] + active=user.active, + ) + + +@router.get("", response_model=list[UserOut]) +def list_users( + db: Session = Depends(get_db), + _manager: CurrentUser = Depends(require_operations_manager), +) -> list[UserOut]: + return [_to_out(user) for user in db.scalars(select(User).order_by(User.display_name)).all()] + + +@router.post("", response_model=UserOut, status_code=201) +def create_user( + body: CreateUserRequest, + db: Session = Depends(get_db), + manager: CurrentUser = Depends(require_operations_manager), +) -> UserOut: + email = body.email.strip().lower() + if db.scalar(select(User.id).where(User.email == email)) is not None: + raise HTTPException(status_code=409, detail="A user with this email already exists") + user = User( + public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}", + email=email, + password_hash=hash_password(body.password), + display_name=body.display_name.strip(), + role=body.role, + active=True, + ) + db.add(user) + db.flush() + record_audit_event( + db, + actor_type="user", + actor_label=manager.display_name, + action="user_created", + entity_type="user", + entity_id=user.id, + after={"public_ref": user.public_ref, "role": user.role, "active": user.active}, + ) + db.commit() + return _to_out(user) + + +@router.patch("/{public_ref}", response_model=UserOut) +def update_user( + public_ref: str, + body: UpdateUserRequest, + db: Session = Depends(get_db), + manager: CurrentUser = Depends(require_operations_manager), +) -> UserOut: + user = db.scalar(select(User).where(User.public_ref == public_ref).with_for_update()) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if user.public_ref == manager.public_ref and body.active is False: + raise HTTPException(status_code=409, detail="You cannot deactivate your own account") + if user.public_ref == manager.public_ref and body.role not in (None, "operations_manager"): + raise HTTPException(status_code=409, detail="You cannot remove your own manager role") + before = {"display_name": user.display_name, "role": user.role, "active": user.active} + if body.display_name is not None: + user.display_name = body.display_name.strip() + if body.role is not None: + user.role = body.role + if body.active is not None: + user.active = body.active + if body.password is not None: + user.password_hash = hash_password(body.password) + record_audit_event( + db, + actor_type="user", + actor_label=manager.display_name, + action="user_updated", + entity_type="user", + entity_id=user.id, + before=before, + after={"display_name": user.display_name, "role": user.role, "active": user.active}, + ) + db.commit() + return _to_out(user) diff --git a/backend/app/api/routers/vehicles.py b/backend/app/api/routers/vehicles.py new file mode 100644 index 0000000..7f91292 --- /dev/null +++ b/backend/app/api/routers/vehicles.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db, require_operations_manager +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.inspection import Inspection +from app.models.maintenance import MaintenanceRecord +from app.models.vehicle import Vehicle +from app.schemas import ( + BookingSummaryOut, + CreateMaintenanceRequest, + CurrentUser, + DataQualityIssueOut, + InspectionOut, + MaintenanceOut, + ReleaseVehicleRequest, + VehicleDetailOut, + VehicleOut, + VehiclePageOut, +) +from app.services.audit import record_audit_event +from app.services.data_quality import open_odometer_regression_issue + +router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) + + +def _attention_vehicle_ids(db: Session) -> set: + rows = db.scalars( + select(DataQualityIssue.entity_id).where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.status == "open", + ) + ).all() + return set(rows) + + +@router.get("", response_model=list[VehicleOut] | VehiclePageOut) +def list_vehicles( + status: str | None = Query(default=None), + attention_only: bool = Query(default=False), + location: str | None = Query(default=None, min_length=1, max_length=120), + query: str | None = Query(default=None, min_length=1, max_length=100), + page: int | None = Query(default=None, ge=1), + page_size: int = Query(default=25, ge=1, le=25), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[VehicleOut] | VehiclePageOut: + stmt = select(Vehicle).order_by(Vehicle.public_ref) + if status: + stmt = stmt.where(Vehicle.operational_status == status) + if location: + stmt = stmt.where(Vehicle.location.ilike(location.strip())) + if query: + term = f"%{query.strip()}%" + stmt = stmt.where( + or_( + Vehicle.public_ref.ilike(term), + Vehicle.make.ilike(term), + Vehicle.model.ilike(term), + Vehicle.location.ilike(term), + Vehicle.registration_number.ilike(term), + ) + ) + attention_ids = _attention_vehicle_ids(db) + if attention_only: + stmt = stmt.where( + or_( + Vehicle.id.in_(attention_ids), + Vehicle.operational_status == "blocked", + Vehicle.next_service_km <= Vehicle.odometer_km, + ) + ) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + page_number = page or 1 + vehicles = db.scalars( + stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) + ).all() + vehicle_ids = [vehicle.id for vehicle in vehicles] + next_bookings: dict[uuid.UUID, Booking] = {} + if vehicle_ids: + for booking in db.scalars( + select(Booking) + .where( + Booking.vehicle_id.in_(vehicle_ids), + Booking.status == "reserved", + Booking.starts_at >= datetime.now(UTC), + ) + .order_by(Booking.starts_at.asc()) + ).all(): + next_bookings.setdefault(booking.vehicle_id, booking) + items = [ + VehicleOut( + public_ref=v.public_ref, + make=v.make, + model=v.model, + model_year=v.model_year, + registration_number=v.registration_number, + location=v.location, + operational_status=v.operational_status, + odometer_km=v.odometer_km, + next_service_km=v.next_service_km, + active=v.active, + attention=( + v.id in attention_ids + or v.operational_status == "blocked" + or v.next_service_km <= v.odometer_km + ), + attention_reason=( + "blocked_status" + if v.operational_status == "blocked" + else "service_due" + if v.next_service_km <= v.odometer_km + else "data_quality" + if v.id in attention_ids + else None + ), + service_remaining_km=v.next_service_km - v.odometer_km, + next_booking_ref=(next_bookings[v.id].public_ref if v.id in next_bookings else None), + next_booking_at=(next_bookings[v.id].starts_at if v.id in next_bookings else None), + ) + for v in vehicles + ] + if page is None: + return items + total_pages = max(1, (total + page_size - 1) // page_size) + return VehiclePageOut( + items=items, + page=min(page_number, total_pages), + page_size=page_size, + total=total, + total_pages=total_pages, + ) + + +@router.get("/{public_ref}", response_model=VehicleDetailOut) +def get_vehicle( + public_ref: str, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> VehicleDetailOut: + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref)) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + + bookings = db.scalars( + select(Booking).where(Booking.vehicle_id == vehicle.id).order_by(Booking.starts_at.desc()) + ).all() + customer_ref_by_id = {c.id: c.public_ref for c in db.scalars(select(Customer)).all()} + inspections = db.scalars( + select(Inspection) + .where(Inspection.vehicle_id == vehicle.id) + .order_by(Inspection.completed_at.desc()) + ).all() + maintenance = db.scalars( + select(MaintenanceRecord) + .where(MaintenanceRecord.vehicle_id == vehicle.id) + .order_by(MaintenanceRecord.occurred_at.desc()) + ).all() + # Detailed data-quality evidence is an operations-manager surface. Employees still + # get the operational vehicle record they need, but never receive hidden evidence in + # the payload merely because the frontend omits the Quality tab. + issues = ( + db.scalars( + select(DataQualityIssue) + .where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + ) + .order_by(DataQualityIssue.detected_at.desc()) + ).all() + if user.role == "operations_manager" + else [] + ) + + booking_by_id = {b.id: b.public_ref for b in bookings} + next_booking = next( + ( + booking + for booking in sorted(bookings, key=lambda item: item.starts_at) + if booking.status == "reserved" and booking.starts_at >= datetime.now(UTC) + ), + None, + ) + + attention_ids = _attention_vehicle_ids(db) + return VehicleDetailOut( + public_ref=vehicle.public_ref, + make=vehicle.make, + model=vehicle.model, + model_year=vehicle.model_year, + registration_number=vehicle.registration_number, + location=vehicle.location, + operational_status=vehicle.operational_status, + odometer_km=vehicle.odometer_km, + next_service_km=vehicle.next_service_km, + active=vehicle.active, + attention=( + vehicle.id in attention_ids + or vehicle.operational_status == "blocked" + or vehicle.next_service_km <= vehicle.odometer_km + ), + attention_reason=( + "blocked_status" + if vehicle.operational_status == "blocked" + else "service_due" + if vehicle.next_service_km <= vehicle.odometer_km + else "data_quality" + if vehicle.id in attention_ids + else None + ), + service_remaining_km=vehicle.next_service_km - vehicle.odometer_km, + next_booking_ref=next_booking.public_ref if next_booking else None, + next_booking_at=next_booking.starts_at if next_booking else None, + bookings=[ + BookingSummaryOut( + public_ref=b.public_ref, + customer_ref=customer_ref_by_id.get(b.customer_id, ""), + vehicle_ref=vehicle.public_ref, + starts_at=b.starts_at, + ends_at=b.ends_at, + status=b.status, + ) + for b in bookings + ], + inspections=[ + InspectionOut( + public_ref=i.public_ref, + booking_ref=booking_by_id.get(i.booking_id, ""), + type=i.type, + fuel_level_percent=i.fuel_level_percent, + cleanliness_ok=i.cleanliness_ok, + damage_reported=i.damage_reported, + technical_warning=i.technical_warning, + odometer_km=i.odometer_km, + completed_at=i.completed_at, + ) + for i in inspections + ], + maintenance=[ + MaintenanceOut( + public_ref=m.public_ref, + occurred_at=m.occurred_at, + odometer_km=m.odometer_km, + category=m.category, + summary=m.summary, + ) + for m in maintenance + ], + quality_issues=[ + DataQualityIssueOut( + public_ref=q.public_ref, + rule_type=q.rule_type, + entity_type=q.entity_type, + entity_ref=vehicle.public_ref, + severity=q.severity, + status=q.status, + evidence=q.evidence_json, + detected_at=q.detected_at, + due_at=q.due_at, + assigned_to_ref=(q.assigned_to_user.public_ref if q.assigned_to_user else None), + assigned_to_name=(q.assigned_to_user.display_name if q.assigned_to_user else None), + overdue=( + q.status == "open" and q.due_at is not None and q.due_at < datetime.now(UTC) + ), + resolved_at=q.resolved_at, + ) + for q in issues + ], + ) + + +@router.post("/{public_ref}/maintenance", response_model=MaintenanceOut, status_code=201) +def create_maintenance_record( + public_ref: str, + body: CreateMaintenanceRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> MaintenanceOut: + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update()) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + correlation_id = uuid.uuid4() + before_vehicle = { + "operational_status": vehicle.operational_status, + "odometer_km": vehicle.odometer_km, + "next_service_km": vehicle.next_service_km, + } + record = MaintenanceRecord( + public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}", + vehicle_id=vehicle.id, + occurred_at=body.occurred_at, + odometer_km=body.odometer_km, + category=body.category, + summary=body.summary.strip(), + ) + db.add(record) + open_odometer_regression_issue( + db, + vehicle=vehicle, + reading_ref=record.public_ref, + reading_km=body.odometer_km, + canonical_km=vehicle.odometer_km, + source_type="maintenance", + related_refs=[record.public_ref], + actor_label=user.display_name, + correlation_id=correlation_id, + ) + vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km) + if body.next_service_km is not None: + if body.next_service_km < vehicle.odometer_km: + raise HTTPException(status_code=422, detail="Next service must not be below odometer") + vehicle.next_service_km = body.next_service_km + if body.mark_maintenance: + vehicle.operational_status = "maintenance" + vehicle.version += 1 + db.flush() + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="maintenance_record_created", + entity_type="vehicle", + entity_id=vehicle.id, + correlation_id=correlation_id, + before=before_vehicle, + after={ + "maintenance_ref": record.public_ref, + "operational_status": vehicle.operational_status, + "odometer_km": vehicle.odometer_km, + "next_service_km": vehicle.next_service_km, + }, + ) + db.commit() + return MaintenanceOut( + public_ref=record.public_ref, + occurred_at=record.occurred_at, + odometer_km=record.odometer_km, + category=record.category, + summary=record.summary, + ) + + +@router.post("/{public_ref}/release", response_model=VehicleOut) +def release_vehicle( + public_ref: str, + body: ReleaseVehicleRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> VehicleOut: + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update()) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}: + raise HTTPException(status_code=409, detail="Vehicle does not require release") + active_booking = db.scalar( + select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active") + ) + open_high_issue = db.scalar( + select(DataQualityIssue.id).where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + DataQualityIssue.status == "open", + DataQualityIssue.severity == "high", + ) + ) + if active_booking is not None or open_high_issue is not None: + raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition") + before = {"status": vehicle.operational_status} + vehicle.operational_status = "available" + vehicle.version += 1 + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="vehicle_released", + entity_type="vehicle", + entity_id=vehicle.id, + before=before, + after={"status": "available", "reason": body.reason.strip()}, + ) + db.commit() + return VehicleOut( + public_ref=vehicle.public_ref, + make=vehicle.make, + model=vehicle.model, + model_year=vehicle.model_year, + registration_number=vehicle.registration_number, + location=vehicle.location, + operational_status=vehicle.operational_status, + odometer_km=vehicle.odometer_km, + next_service_km=vehicle.next_service_km, + active=vehicle.active, + attention=vehicle.next_service_km <= vehicle.odometer_km, + attention_reason=( + "service_due" if vehicle.next_service_km <= vehicle.odometer_km else None + ), + service_remaining_km=vehicle.next_service_km - vehicle.odometer_km, + next_booking_ref=None, + next_booking_at=None, + ) diff --git a/backend/app/api/routers/workflows.py b/backend/app/api/routers/workflows.py new file mode 100644 index 0000000..a5b3ee4 --- /dev/null +++ b/backend/app/api/routers/workflows.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_operations_manager +from app.core.errors import AppError +from app.models.outbox import OutboxEvent, is_demo_scenario_failure +from app.schemas import AutomationRunOut, CurrentUser +from app.services.audit import record_audit_event + +router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"]) + + +def _to_out(event: OutboxEvent) -> AutomationRunOut: + return AutomationRunOut( + event_id=str(event.event_id), + event_type=event.event_type, + aggregate_ref=event.payload_json.get("aggregate_ref", ""), + status=event.delivery_status, + attempts=event.attempts, + last_error=event.last_error, + last_error_code=event.last_error_code, + is_demo_scenario=is_demo_scenario_failure(event), + occurred_at=event.occurred_at, + ) + + +@router.get("", response_model=list[AutomationRunOut]) +def list_workflows( + status: str | None = Query(default=None), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(require_operations_manager), +) -> list[AutomationRunOut]: + stmt = select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()) + if status: + stmt = stmt.where(OutboxEvent.delivery_status == status) + events = db.scalars(stmt).all() + return [_to_out(e) for e in events] + + +@router.post("/{event_id}/retry", response_model=AutomationRunOut) +def retry_workflow( + event_id: str, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> AutomationRunOut: + try: + parsed_id = uuid.UUID(event_id) + except ValueError as exc: + raise AppError("INVALID_EVENT_ID", "event_id must be a UUID.", status_code=422) from exc + + event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == parsed_id)) + if event is None: + raise AppError("EVENT_NOT_FOUND", "Workflow event not found.", status_code=404) + if event.delivery_status != "failed": + raise AppError( + "NOT_RETRYABLE", + f"Event is '{event.delivery_status}', not 'failed'; " + "only failed deliveries can be retried.", + status_code=409, + ) + + # Captured before the status flips, so the audit records what was actually retried. + was_demo_scenario = is_demo_scenario_failure(event) + + event.delivery_status = "pending" + event.next_attempt_at = None + # The retry itself is real either way: the event goes back on the outbox and the + # dispatcher delivers it to the configured n8n webhook like any other. The only + # difference recorded here is *what* was retried -- a staged demo failure or a real + # one -- so the audit trail never implies a production incident was resolved when a + # prop was. + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="workflow_retry", + entity_type="outbox_event", + metadata={ + "event_id": event_id, + "previous_attempts": event.attempts, + "demo_scenario": was_demo_scenario, + }, + ) + db.commit() + return _to_out(event) diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..970ee25 --- /dev/null +++ b/backend/app/cli.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import argparse + +from app.core.db import SessionLocal +from app.seed_loader import reset_and_seed + + +def main() -> None: + parser = argparse.ArgumentParser(prog="app.cli") + subparsers = parser.add_subparsers(dest="command", required=True) + + seed_parser = subparsers.add_parser("seed", help="Load the deterministic demo dataset") + seed_parser.add_argument( + "--reset", action="store_true", help="Clear existing data before loading" + ) + + args = parser.parse_args() + + if args.command == "seed": + if not args.reset: + raise SystemExit( + "Only 'seed --reset' is supported: seeding always rebuilds the demo dataset." + ) + db = SessionLocal() + try: + result = reset_and_seed(db) + for name, count in result.counts.items(): + print(f"{name}: {count}") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..3980e4a --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,128 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + +# The visible product name is fixed and never translated or configured per-deployment -- +# see docs/fleet-ops-correction/current-gap-audit.md section 1. Internal identifiers +# (package name, Compose project, database name, repository) intentionally remain +# "mobilityops"; this constant is only for user-facing surfaces (e.g. the OpenAPI title). +PRODUCT_NAME = "Fleet Ops" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + mobilityops_env: str = "development" + mobilityops_demo_mode: bool = True + database_url: str = "postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops" + knowledge_provider: str = "demo" + ragcore_base_url: str = "http://ragcore-api:8000" + ragcore_tenant: str = "northstar-mobility-demo" + ragcore_workspace: str = "mobilityops" + ragcore_collection: str = "internal-procedures" + ragcore_api_token: str = "" + ragcore_space_id: str = "" + ragcore_http_timeout_seconds: float = 5.0 + ragcore_answers_circuit_breaker_seconds: float = 60.0 + # Search fallback is only labelled grounded above this explicit retrieval threshold. + # RAGcore's fused score is reciprocal-rank based (top ranks are ~1/61), so this + # accepts only leading results while still rejecting absent and low-ranked evidence. + ragcore_min_search_score: float = 0.016 + n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return" + n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token" + n8n_callback_token: str = "replace-me-n8n-callback-token" + n8n_dispatch_enabled: bool = True + n8n_dispatch_interval_seconds: float = 3.0 + # The synchronous n8n workflow performs two bounded, retried callbacks before it + # acknowledges an event. Keep this above that complete workflow budget, while the + # delivery lease remains the wider crash-recovery boundary (enforced by the contract + # check in scripts/check-contracts.py). + n8n_http_timeout_seconds: float = 15.0 + n8n_max_attempts: int = 5 + n8n_delivery_lease_seconds: float = 120.0 + app_secret: str = "replace-in-production" + session_cookie_name: str = "mobilityops_session" + session_ttl_seconds: int = 60 * 60 * 8 + session_cookie_secure: bool = False + seed_dir: str = "/app/seed" + knowledge_dir: str = "/app/knowledge/procedures" + mcp_hub_service_token: str = "replace-me-mcp-hub-token" + mcp_hub_registration_enabled: bool = False + # MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles + # its catalog into the gateway; Fleet Ops never pushes a registration call), so + # these are only used for an honest reachability health check, not self-registration. + mcp_hub_base_url: str = "" + mcp_provider_id: str = "fleet-ops" + cors_allow_origins: str = "http://localhost:1228" + demo_organization_name: str = "Northstar Mobility" + demo_timezone: str = "Europe/Brussels" + demo_allow_reset: bool = True + demo_reset_cooldown_seconds: int = 60 + mcp_hub_health_cache_seconds: int = 60 + initial_admin_email: str = "" + initial_admin_password: str = "" + initial_admin_display_name: str = "Operations Manager" + mobilityops_public_url: str = "http://localhost:1228" + oidc_enabled: bool = False + oidc_provider_name: str = "Organisatieaccount" + oidc_issuer_url: str = "" + oidc_client_id: str = "" + oidc_client_secret: str = "" + oidc_redirect_uri: str = "" + oidc_allowed_email_domains: str = "" + oidc_auto_provision: bool = True + oidc_default_role: str = "rental_employee" + log_level: str = "INFO" + # Failed password logins per client IP before a temporary 429 (0 disables). + login_max_failures: int = 10 + login_failure_window_seconds: int = 900 + knowledge_max_requests: int = 30 + knowledge_rate_limit_window_seconds: int = 60 + metrics_bearer_token: str = "" + privacy_minimum_booking_retention_days: int = 30 + privacy_audit_retention_days: int = 2555 + privacy_audit_export_max_rows: int = 10000 + + +# Secrets that guard *inbound* trust (session cookies, service callbacks). Running +# production with any of these at their placeholder value means forged sessions or +# unauthenticated writes, so startup refuses. +INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = ( + ("app_secret", "replace-in-production"), + ("n8n_callback_token", "replace-me-n8n-callback-token"), + ("mcp_hub_service_token", "replace-me-mcp-hub-token"), +) + + +def insecure_default_secrets(settings: "Settings") -> list[str]: + """Return the names of secret settings that still carry their placeholder value. + + MCP routes are always mounted, independently of the Hub reachability-status flag, so + their inbound token must always be non-placeholder in production. + """ + insecure: list[str] = [] + for name, placeholder in INSECURE_DEFAULT_SECRETS: + value = getattr(settings, name) + if not value or value == placeholder or value.startswith("replace-me"): + insecure.append(name) + return insecure + + +@lru_cache +def get_settings() -> Settings: + settings = Settings() + if settings.mobilityops_env.lower() == "production": + insecure = insecure_default_secrets(settings) + if insecure: + # Refuse to boot rather than run production with forgeable session cookies + # or guessable service tokens. Development/test/demo keep the defaults. + raise RuntimeError( + "Refusing to start in production with placeholder secrets: " + + ", ".join(insecure) + + ". Set real values in the environment (see .env.example)." + ) + if not settings.mobilityops_public_url.lower().startswith("https://"): + raise RuntimeError("Production MOBILITYOPS_PUBLIC_URL must use HTTPS.") + if not settings.session_cookie_secure: + raise RuntimeError("Production SESSION_COOKIE_SECURE must be true.") + return settings diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..b7f08ed --- /dev/null +++ b/backend/app/core/db.py @@ -0,0 +1,54 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine, event, func, select +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.core.config import get_settings + +settings = get_settings() + +engine = create_engine(settings.database_url, pool_pre_ping=True, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + +# Every SQLAlchemy transaction participates in a shared database-wide barrier. Normal +# reads/writes coexist; the short demo reset takes the exclusive form so it can never +# interleave deletes/inserts with an API request, scanner, callback, or dispatcher cycle. +DEMO_DATA_BARRIER_LOCK_ID = 5_344_725_149_212_793_901 +_EXCLUSIVE_RESET_INFO_KEY = "mobilityops_demo_reset_exclusive" + + +@event.listens_for(Session, "after_begin") +def _acquire_demo_data_barrier(session: Session, _transaction, connection) -> None: + if connection.dialect.name != "postgresql": + return + lock = ( + func.pg_advisory_xact_lock(DEMO_DATA_BARRIER_LOCK_ID) + if session.info.get(_EXCLUSIVE_RESET_INFO_KEY) + else func.pg_advisory_xact_lock_shared(DEMO_DATA_BARRIER_LOCK_ID) + ) + connection.execute(select(lock)) + + +def begin_exclusive_demo_reset(db: Session) -> None: + """Make the session's next transaction the exclusive side of the reset barrier.""" + if db.in_transaction(): + # Auth normally already read the user under a shared barrier. End that read-only + # transaction before requesting exclusive; in-place lock upgrades can deadlock. + db.rollback() + db.info[_EXCLUSIVE_RESET_INFO_KEY] = True + + +def end_exclusive_demo_reset(db: Session) -> None: + db.info.pop(_EXCLUSIVE_RESET_INFO_KEY, None) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..238d86b --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import uuid +from typing import Any + + +class AppError(Exception): + def __init__( + self, + code: str, + message: str, + status_code: int = 400, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + self.details = details or {} + self.correlation_id = str(uuid.uuid4()) + + +def error_body(code: str, message: str, correlation_id: str, details: dict[str, Any]) -> dict: + return { + "error": { + "code": code, + "message": message, + "correlation_id": correlation_id, + "details": details, + } + } diff --git a/backend/app/core/observability.py b/backend/app/core/observability.py new file mode 100644 index 0000000..eddafa7 --- /dev/null +++ b/backend/app/core/observability.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +import logging +import time +import uuid +from contextvars import ContextVar +from datetime import UTC, datetime + +from fastapi import Request +from prometheus_client import Counter, Gauge, Histogram + +correlation_id_context: ContextVar[str] = ContextVar("correlation_id", default="") + +HTTP_REQUESTS = Counter( + "mobilityops_http_requests_total", + "Completed MobilityOps HTTP requests.", + ("method", "route", "status"), +) +HTTP_DURATION = Histogram( + "mobilityops_http_request_duration_seconds", + "MobilityOps HTTP request duration.", + ("method", "route"), + buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10), +) +HTTP_IN_PROGRESS = Gauge( + "mobilityops_http_requests_in_progress", + "MobilityOps HTTP requests currently executing.", +) +OUTBOX_EVENTS = Gauge( + "mobilityops_outbox_events", + "Persisted outbox events by state and scenario type.", + ("scenario", "status"), +) +DATABASE_READY = Gauge( + "mobilityops_database_ready", + "Whether the canonical PostgreSQL database answered the most recent readiness probe.", +) +KNOWLEDGE_PROVIDER_REQUESTS = Counter( + "mobilityops_knowledge_provider_requests_total", + "RAGcore adapter requests by stage and outcome.", + ("stage", "outcome"), +) +KNOWLEDGE_RETRIEVAL_SCORE = Histogram( + "mobilityops_knowledge_retrieval_score", + "Observed RAGcore fused/rerank retrieval scores.", + buckets=(0.005, 0.01, 0.015, 0.016, 0.0162, 0.0164, 0.02, 0.05, 0.1, 0.5, 1.0), +) + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, object] = { + "timestamp": datetime.now(UTC).isoformat(), + "level": record.levelname.lower(), + "logger": record.name, + "message": record.getMessage(), + } + correlation_id = correlation_id_context.get() + if correlation_id: + payload["correlation_id"] = correlation_id + for key in ("method", "path", "status_code", "duration_ms", "client_ip"): + value = getattr(record, key, None) + if value is not None: + payload[key] = value + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, separators=(",", ":"), default=str) + + +def configure_logging(level: str) -> None: + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers = [handler] + root.setLevel(level.upper()) + + +def correlation_id_for(request: Request) -> str: + candidate = request.headers.get("X-Correlation-Id", "").strip() + try: + return str(uuid.UUID(candidate)) if candidate else str(uuid.uuid4()) + except ValueError: + return str(uuid.uuid4()) + + +UNMATCHED_ROUTE_LABEL = "" + + +def route_label(request: Request) -> str: + """Return the route *template* for metrics labels. + + Unmatched paths (404 probes, scanners) must not become their own label value: + every distinct URL would otherwise create a new Prometheus time series and the + metric cardinality would grow without bound. + """ + route = request.scope.get("route") + path = getattr(route, "path", None) + return str(path) if path else UNMATCHED_ROUTE_LABEL + + +def request_started() -> float: + HTTP_IN_PROGRESS.inc() + return time.perf_counter() + + +def request_finished(request: Request, status_code: int, started_at: float) -> float: + duration = time.perf_counter() - started_at + route = route_label(request) + HTTP_REQUESTS.labels(request.method, route, str(status_code)).inc() + HTTP_DURATION.labels(request.method, route).observe(duration) + HTTP_IN_PROGRESS.dec() + return duration diff --git a/backend/app/core/ratelimit.py b/backend/app/core/ratelimit.py new file mode 100644 index 0000000..6e7a39d --- /dev/null +++ b/backend/app/core/ratelimit.py @@ -0,0 +1,72 @@ +"""Small in-process failed-attempt limiter for credential endpoints. + +Fleet Ops runs as a single API process per deployment, so an in-memory sliding window +is sufficient to blunt online password guessing (and the scrypt CPU amplification that +comes with it) without adding Redis. Only *failed* attempts count, so legitimate users +and the automated test suite are never throttled. +""" + +from __future__ import annotations + +import threading +import time +from collections import deque + + +class FailedAttemptLimiter: + def __init__(self, *, max_failures: int, window_seconds: float) -> None: + self.max_failures = max_failures + self.window_seconds = window_seconds + self._failures: dict[str, deque[float]] = {} + self._lock = threading.Lock() + + def _prune(self, key: str, now: float) -> deque[float]: + bucket = self._failures.setdefault(key, deque()) + cutoff = now - self.window_seconds + while bucket and bucket[0] <= cutoff: + bucket.popleft() + if not bucket: + self._failures.pop(key, None) + return bucket + + def retry_after_seconds(self, key: str) -> int: + """Return >0 seconds to wait when the key is currently blocked, else 0.""" + now = time.monotonic() + with self._lock: + bucket = self._prune(key, now) + if len(bucket) < self.max_failures: + return 0 + return max(1, int(bucket[0] + self.window_seconds - now + 0.999)) + + def record_failure(self, key: str) -> None: + now = time.monotonic() + with self._lock: + self._prune(key, now) + self._failures.setdefault(key, deque()).append(now) + + def reset(self, key: str) -> None: + with self._lock: + self._failures.pop(key, None) + + +class SlidingWindowLimiter: + """Thread-safe request limiter where every accepted request consumes capacity.""" + + def __init__(self, *, max_requests: int, window_seconds: float) -> None: + self.max_requests = max_requests + self.window_seconds = window_seconds + self._requests: dict[str, deque[float]] = {} + self._lock = threading.Lock() + + def consume(self, key: str) -> int: + """Record an accepted request, or return the seconds until capacity is available.""" + now = time.monotonic() + with self._lock: + bucket = self._requests.setdefault(key, deque()) + cutoff = now - self.window_seconds + while bucket and bucket[0] <= cutoff: + bucket.popleft() + if len(bucket) >= self.max_requests: + return max(1, int(bucket[0] + self.window_seconds - now + 0.999)) + bucket.append(now) + return 0 diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d7751af --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time +from dataclasses import dataclass + +from app.core.config import get_settings + +settings = get_settings() + + +@dataclass(frozen=True) +class SessionPayload: + user_id: str + public_ref: str + role: str + display_name: str + issued_at: int + session_id: str = "" + + +def _sign(data: bytes) -> str: + digest = hmac.new(settings.app_secret.encode(), data, hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +def session_token_hash(token: str) -> str: + """Return a non-reversible identifier safe to persist for token revocation.""" + return hashlib.sha256(token.encode()).hexdigest() + + +def create_session_token(payload: SessionPayload) -> str: + body = json.dumps(payload.__dict__, separators=(",", ":")).encode() + encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=") + signature = _sign(encoded_body.encode()) + return f"{encoded_body}.{signature}" + + +def read_session_token(token: str) -> SessionPayload | None: + try: + encoded_body, signature = token.split(".", 1) + except ValueError: + return None + expected = _sign(encoded_body.encode()) + if not hmac.compare_digest(expected, signature): + return None + padding = "=" * (-len(encoded_body) % 4) + try: + body = json.loads(base64.urlsafe_b64decode(encoded_body + padding)) + except (ValueError, json.JSONDecodeError): + return None + payload = SessionPayload(**body) + if time.time() - payload.issued_at > settings.session_ttl_seconds: + return None + return payload + + +def hash_password(password: str) -> str: + salt = os.urandom(16) + derived = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32) + encoded_salt = base64.urlsafe_b64encode(salt).decode() + encoded_hash = base64.urlsafe_b64encode(derived).decode() + return f"scrypt$16384$8$1${encoded_salt}${encoded_hash}" + + +def verify_password(password: str, encoded: str | None) -> bool: + if not encoded: + return False + try: + algorithm, n, r, p, salt, expected = encoded.split("$") + if algorithm != "scrypt": + return False + derived = hashlib.scrypt( + password.encode(), + salt=base64.urlsafe_b64decode(salt.encode()), + n=int(n), + r=int(r), + p=int(p), + dklen=32, + ) + return hmac.compare_digest(derived, base64.urlsafe_b64decode(expected.encode())) + except (ValueError, TypeError): + return False diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2ae98b4 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,201 @@ +import logging +import uuid +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sqlalchemy import text +from starlette.middleware.sessions import SessionMiddleware + +from app.api.routers import ( + audit, + auth, + bookings, + customers, + dashboard, + data_quality, + demo, + integration_status, + integrations, + knowledge, + mcp_integrations, + observability, + privacy, + search, + users, + vehicles, + workflows, +) +from app.api.routers.auth import bootstrap_initial_admin +from app.core.config import PRODUCT_NAME, get_settings +from app.core.db import SessionLocal +from app.core.errors import AppError, error_body +from app.core.observability import ( + DATABASE_READY, + configure_logging, + correlation_id_context, + correlation_id_for, + request_finished, + request_started, +) +from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher + +settings = get_settings() +configure_logging(settings.log_level) +request_logger = logging.getLogger("mobilityops.request") + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + with SessionLocal() as db: + bootstrap_initial_admin(db) + start_background_dispatcher() + yield + stop_background_dispatcher() + + +production = settings.mobilityops_env.lower() == "production" +app = FastAPI( + title=f"{PRODUCT_NAME} API", + version="0.1.0", + description=( + "Generated contract for Fleet Ops. The visible product name is Fleet Ops; " + "MobilityOps remains the technical repository and service identifier." + ), + servers=[{"url": "http://localhost:8128"}], + lifespan=lifespan, + docs_url=None if production else "/docs", + redoc_url=None if production else "/redoc", + openapi_url=None if production else "/openapi.json", +) + +app.add_middleware( + SessionMiddleware, + secret_key=settings.app_secret, + session_cookie="mobilityops_oidc_state", + max_age=600, + same_site="lax", + https_only=settings.session_cookie_secure, +) + + +@app.middleware("http") +async def request_observability(request: Request, call_next): + correlation_id = correlation_id_for(request) + request.state.correlation_id = correlation_id + token = correlation_id_context.set(correlation_id) + started_at = request_started() + status_code = 500 + try: + response = await call_next(request) + status_code = response.status_code + response.headers["X-Correlation-Id"] = correlation_id + return response + finally: + duration = request_finished(request, status_code, started_at) + request_logger.info( + "request_completed", + extra={ + "method": request.method, + "path": request.url.path, + "status_code": status_code, + "duration_ms": round(duration * 1000, 2), + "client_ip": request.client.host if request.client else None, + }, + ) + correlation_id_context.reset(token) + + +app.add_middleware( + CORSMiddleware, + allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(AppError) +def handle_app_error(request: Request, exc: AppError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=error_body( + exc.code, + exc.message, + request.state.correlation_id, + exc.details, + ), + ) + + +@app.exception_handler(HTTPException) +def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=error_body( + code=str(exc.status_code), + message=str(exc.detail), + correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())), + details={}, + ), + headers=exc.headers, + ) + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok", "service": "mobilityops-api"} + + +@app.get("/health/live") +def liveness() -> dict[str, str]: + """Process liveness only; external dependencies deliberately do not affect it.""" + return {"status": "ok", "service": "mobilityops-api"} + + +@app.get("/health/ready") +def readiness() -> JSONResponse: + """Traffic readiness: the API is useful only while its canonical database responds.""" + try: + with SessionLocal() as db: + db.execute(text("SELECT 1")) + except Exception: # noqa: BLE001 -- readiness must convert infrastructure errors to 503 + DATABASE_READY.set(0) + return JSONResponse( + status_code=503, + content={"status": "not_ready", "service": "mobilityops-api", "database": "down"}, + ) + DATABASE_READY.set(1) + return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"}) + + +@app.get("/api/v1/system/status") +def system_status() -> dict[str, object]: + return { + "service": "mobilityops-api", + "environment": settings.mobilityops_env, + "demo_mode": settings.mobilityops_demo_mode, + "knowledge_provider": settings.knowledge_provider, + "oidc_enabled": auth.oidc_status().enabled, + "oidc_provider_name": auth.oidc_status().provider_name, + } + + +app.include_router(demo.router) +app.include_router(auth.router) +app.include_router(dashboard.router) +app.include_router(vehicles.router) +app.include_router(bookings.router) +app.include_router(customers.router) +app.include_router(audit.router) +app.include_router(data_quality.router) +app.include_router(workflows.router) +app.include_router(integrations.router) +app.include_router(knowledge.router) +app.include_router(mcp_integrations.router) +app.include_router(search.router) +app.include_router(integration_status.router) +app.include_router(users.router) +app.include_router(observability.router) +app.include_router(privacy.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..d6cd777 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,27 @@ +from app.core.db import Base +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.idempotency import IdempotencyRecord +from app.models.inspection import Inspection +from app.models.maintenance import MaintenanceRecord +from app.models.outbox import OutboxEvent +from app.models.revoked_session import RevokedSession +from app.models.user import User +from app.models.vehicle import Vehicle + +__all__ = [ + "Base", + "AuditEvent", + "Booking", + "Customer", + "DataQualityIssue", + "IdempotencyRecord", + "Inspection", + "MaintenanceRecord", + "OutboxEvent", + "RevokedSession", + "User", + "Vehicle", +] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..2b4667f --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Index, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import UUIDPrimaryKeyMixin + +ACTOR_TYPES = ("user", "service", "system") + + +class AuditEvent(UUIDPrimaryKeyMixin, Base): + __tablename__ = "audit_events" + __table_args__ = ( + CheckConstraint("actor_type IN ('user','service','system')", name="ck_audit_actor_type"), + Index("ix_audit_action_occurred", "action", "occurred_at"), + Index("ix_audit_entity", "entity_type", "entity_id"), + ) + + actor_type: Mapped[str] = mapped_column(String(20), nullable=False) + actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + actor_label: Mapped[str] = mapped_column(String(120), nullable=False) + action: Mapped[str] = mapped_column(String(80), nullable=False) + entity_type: Mapped[str] = mapped_column(String(30), nullable=False) + entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + before_json: Mapped[dict | None] = mapped_column(JSONB) + after_json: Mapped[dict | None] = mapped_column(JSONB) + metadata_json: Mapped[dict | None] = mapped_column(JSONB) + occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/backend/app/models/booking.py b/backend/app/models/booking.py new file mode 100644 index 0000000..435f439 --- /dev/null +++ b/backend/app/models/booking.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + +BOOKING_STATUSES = ("reserved", "active", "returned", "cancelled", "blocked") + + +class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "bookings" + __table_args__ = ( + CheckConstraint( + "status IN ('reserved','active','returned','cancelled','blocked')", + name="ck_bookings_status", + ), + CheckConstraint("ends_at > starts_at", name="ck_bookings_time_window"), + CheckConstraint( + "start_odometer_km IS NULL OR start_odometer_km >= 0", + name="ck_bookings_start_odometer", + ), + CheckConstraint( + "end_odometer_km IS NULL OR end_odometer_km >= 0", + name="ck_bookings_end_odometer", + ), + Index("ix_bookings_vehicle_status_window", "vehicle_id", "status", "starts_at", "ends_at"), + ) + + public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + customer_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False + ) + vehicle_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False + ) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False) + start_odometer_km: Mapped[int | None] = mapped_column(Integer) + end_odometer_km: Mapped[int | None] = mapped_column(Integer) + requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py new file mode 100644 index 0000000..dd6a04d --- /dev/null +++ b/backend/app/models/customer.py @@ -0,0 +1,26 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + + +class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "customers" + + public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + first_name: Mapped[str] = mapped_column(String(80), nullable=False) + last_name: Mapped[str] = mapped_column(String(80), nullable=False) + email: Mapped[str | None] = mapped_column(String(200)) + phone: Mapped[str | None] = mapped_column(String(40)) + postal_code: Mapped[str | None] = mapped_column(String(20)) + city: Mapped[str | None] = mapped_column(String(120)) + date_of_birth: Mapped[date | None] = mapped_column(Date) + merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("customers.id") + ) + anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) diff --git a/backend/app/models/data_quality.py b/backend/app/models/data_quality.py new file mode 100644 index 0000000..74502a6 --- /dev/null +++ b/backend/app/models/data_quality.py @@ -0,0 +1,65 @@ +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db import Base +from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from app.models.user import User + +RULE_TYPES = ( + "possible_duplicate_customer", + "missing_required_field", + "odometer_regression", + "booking_overlap", + "vehicle_status_conflict", +) +SEVERITIES = ("low", "medium", "high") +ISSUE_STATUSES = ("open", "deferred", "resolved", "rejected") + + +class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "data_quality_issues" + __table_args__ = ( + CheckConstraint( + "rule_type IN ('possible_duplicate_customer','missing_required_field'," + "'odometer_regression','booking_overlap','vehicle_status_conflict')", + name="ck_data_quality_rule_type", + ), + CheckConstraint("severity IN ('low','medium','high')", name="ck_data_quality_severity"), + CheckConstraint( + "status IN ('open','deferred','resolved','rejected')", + name="ck_data_quality_status", + ), + Index("ix_data_quality_work_queue", "status", "due_at", "severity"), + Index( + "uq_data_quality_one_open_condition", + "rule_type", + "entity_type", + "entity_id", + unique=True, + postgresql_where=text("status = 'open'"), + ), + ) + + public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + rule_type: Mapped[str] = mapped_column(String(40), nullable=False) + entity_type: Mapped[str] = mapped_column(String(30), nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + severity: Mapped[str] = mapped_column(String(10), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="open") + evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + assigned_to_user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True + ) + assigned_to_user: Mapped["User | None"] = relationship(lazy="selectin") + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + resolved_by: Mapped[str | None] = mapped_column(String(120)) diff --git a/backend/app/models/idempotency.py b/backend/app/models/idempotency.py new file mode 100644 index 0000000..97ce633 --- /dev/null +++ b/backend/app/models/idempotency.py @@ -0,0 +1,22 @@ +import uuid + +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + + +class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "idempotency_records" + + idempotency_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False + ) + # SHA-256 of the canonical request body. Replaying a key with a *different* body is + # a client bug and must be rejected instead of silently answered with the old result. + request_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True) + response_status: Mapped[int] = mapped_column(Integer, nullable=False) + response_body: Mapped[dict] = mapped_column(JSONB, nullable=False) diff --git a/backend/app/models/inspection.py b/backend/app/models/inspection.py new file mode 100644 index 0000000..94a8fab --- /dev/null +++ b/backend/app/models/inspection.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + +INSPECTION_TYPES = ("checkout", "return") + + +class Inspection(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "inspections" + + public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False + ) + vehicle_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False + ) + type: Mapped[str] = mapped_column(String(20), nullable=False) + fuel_level_percent: Mapped[int] = mapped_column(Integer, nullable=False) + cleanliness_ok: Mapped[bool] = mapped_column(Boolean, nullable=False) + damage_reported: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + technical_warning: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + notes: Mapped[str | None] = mapped_column(Text) + odometer_km: Mapped[int] = mapped_column(Integer, nullable=False) + completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + completed_by: Mapped[str | None] = mapped_column(String(120)) diff --git a/backend/app/models/maintenance.py b/backend/app/models/maintenance.py new file mode 100644 index 0000000..4b23800 --- /dev/null +++ b/backend/app/models/maintenance.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import UUIDPrimaryKeyMixin + + +class MaintenanceRecord(UUIDPrimaryKeyMixin, Base): + __tablename__ = "maintenance_records" + + public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + vehicle_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False + ) + occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + odometer_km: Mapped[int] = mapped_column(Integer, nullable=False) + category: Mapped[str] = mapped_column(String(60), nullable=False) + summary: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/app/models/mixins.py b/backend/app/models/mixins.py new file mode 100644 index 0000000..7372cc8 --- /dev/null +++ b/backend/app/models/mixins.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + + +class UUIDPrimaryKeyMixin: + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/models/outbox.py b/backend/app/models/outbox.py new file mode 100644 index 0000000..9c43208 --- /dev/null +++ b/backend/app/models/outbox.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Index, Integer, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import TimestampMixin + +DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed") + +# The one delivery failure the demo seed deliberately plants (BK-H-0020, see +# seed/workflow_runs.csv). It exists to show retry and audit working, so it must never +# be read as an integration-health problem: it is a scripted prop, not evidence that +# n8n is unhealthy. A dedicated error code -- rather than the generic +# "connectionError" a real timeout produces -- is what lets every reader tell the two +# apart without guessing from the message text. +# +# It is deliberately a `last_error_code` value and not a new column: the code is +# already persisted, already surfaced to the UI, and already localizable, so no schema +# change or migration is needed. A genuine later failure of this same event overwrites +# the code with the real one, which is exactly right -- from that moment it *is* a real +# failure. +DEMO_SCENARIO_ERROR_CODE = "demoScenarioTimeout" + + +def is_demo_scenario_failure(event: "OutboxEvent") -> bool: + """True for the prepared demo failure, false for every real one.""" + return event.delivery_status == "failed" and event.last_error_code == DEMO_SCENARIO_ERROR_CODE + + +class OutboxEvent(TimestampMixin, Base): + __tablename__ = "outbox_events" + __table_args__ = ( + CheckConstraint( + "delivery_status IN ('pending','delivering','succeeded','failed')", + name="ck_outbox_delivery_status", + ), + CheckConstraint("attempts >= 0", name="ck_outbox_attempts"), + Index("ix_outbox_delivery_next_attempt", "delivery_status", "next_attempt_at"), + ) + + event_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + event_type: Mapped[str] = mapped_column(String(60), nullable=False) + aggregate_type: Mapped[str] = mapped_column(String(30), nullable=False) + aggregate_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + payload_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + delivery_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_error: Mapped[str | None] = mapped_column(Text) + # Stable, localizable classification of last_error -- the frontend renders a + # localized summary from this code as the primary text and shows last_error itself + # only under "Technical details" (section 10 of docs/fleet-ops-correction/ + # current-gap-audit.md). Kept alongside the raw message for backward compatibility. + last_error_code: Mapped[str | None] = mapped_column(String(60)) + external_run_id: Mapped[str | None] = mapped_column(String(120)) diff --git a/backend/app/models/revoked_session.py b/backend/app/models/revoked_session.py new file mode 100644 index 0000000..0d9be8e --- /dev/null +++ b/backend/app/models/revoked_session.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + + +class RevokedSession(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "revoked_sessions" + + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..bed5840 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,23 @@ +from sqlalchemy import Boolean, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + +ROLES = ("operations_manager", "rental_employee") + + +class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "users" + __table_args__ = ( + UniqueConstraint("identity_provider", "external_subject", name="uq_user_external_identity"), + ) + + public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True) + password_hash: Mapped[str | None] = mapped_column(String(512), nullable=True) + display_name: Mapped[str] = mapped_column(String(120), nullable=False) + role: Mapped[str] = mapped_column(String(30), nullable=False) + active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + identity_provider: Mapped[str | None] = mapped_column(String(80), nullable=True) + external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/models/vehicle.py b/backend/app/models/vehicle.py new file mode 100644 index 0000000..e4078f0 --- /dev/null +++ b/backend/app/models/vehicle.py @@ -0,0 +1,33 @@ +from sqlalchemy import Boolean, CheckConstraint, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + +OPERATIONAL_STATUSES = ("available", "rented", "cleaning", "maintenance", "blocked") + + +class Vehicle(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "vehicles" + __table_args__ = ( + CheckConstraint( + "operational_status IN ('available','rented','cleaning','maintenance','blocked')", + name="ck_vehicles_operational_status", + ), + CheckConstraint("model_year BETWEEN 1900 AND 2100", name="ck_vehicles_model_year"), + CheckConstraint("odometer_km >= 0", name="ck_vehicles_odometer"), + CheckConstraint("next_service_km >= 0", name="ck_vehicles_next_service"), + CheckConstraint("version >= 1", name="ck_vehicles_version"), + ) + + public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + make: Mapped[str] = mapped_column(String(80), nullable=False) + model: Mapped[str] = mapped_column(String(80), nullable=False) + model_year: Mapped[int] = mapped_column(Integer, nullable=False) + registration_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False) + location: Mapped[str] = mapped_column(String(120), nullable=False) + operational_status: Mapped[str] = mapped_column(String(20), nullable=False) + odometer_km: Mapped[int] = mapped_column(Integer, nullable=False) + next_service_km: Mapped[int] = mapped_column(Integer, nullable=False) + active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..c82c16d --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,650 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + +Role = Literal["operations_manager", "rental_employee"] +NonBlankOperationalReason = Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=3, max_length=500), +] + + +class DemoLoginRequest(BaseModel): + role: Role + + +class PasswordLoginRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + password: str = Field(min_length=8, max_length=256) + + +class UserOut(BaseModel): + public_ref: str + email: str | None + display_name: str + role: Role + active: bool + + +class CreateUserRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + display_name: str = Field(min_length=2, max_length=120) + role: Role + password: str = Field(min_length=8, max_length=256) + + +class UpdateUserRequest(BaseModel): + display_name: str | None = Field(default=None, min_length=2, max_length=120) + role: Role | None = None + active: bool | None = None + password: str | None = Field(default=None, min_length=8, max_length=256) + + +class CurrentUser(BaseModel): + public_ref: str + display_name: str + role: Role + + +class VehicleOut(BaseModel): + public_ref: str + make: str + model: str + model_year: int + registration_number: str + location: str + operational_status: str + odometer_km: int + next_service_km: int + active: bool + attention: bool = False + attention_reason: str | None = None + service_remaining_km: int + next_booking_ref: str | None = None + next_booking_at: datetime | None = None + + +class VehiclePageOut(BaseModel): + items: list[VehicleOut] + page: int + page_size: int + total: int + total_pages: int + + +class BookingSummaryOut(BaseModel): + public_ref: str + customer_ref: str + vehicle_ref: str + starts_at: datetime + ends_at: datetime + status: str + + +class BookingOut(BookingSummaryOut): + start_odometer_km: int | None + end_odometer_km: int | None + requirements_complete: bool + customer_name: str + + +class CreateBookingRequest(BaseModel): + customer_ref: str = Field(min_length=3, max_length=20) + vehicle_ref: str = Field(min_length=3, max_length=20) + starts_at: datetime + ends_at: datetime + + +class CompleteBookingRequirementsRequest(BaseModel): + confirmation: NonBlankOperationalReason + + +class RescheduleBookingRequest(BaseModel): + starts_at: datetime + ends_at: datetime + reason: NonBlankOperationalReason + + +class CustomerOptionOut(BaseModel): + public_ref: str + display_name: str + email: str | None + + +class AvailableVehicleOut(BaseModel): + public_ref: str + make: str + model: str + registration_number: str + location: str + operational_status: str + + +class CancelBookingRequest(BaseModel): + reason: NonBlankOperationalReason + + +class CheckoutBookingRequest(BaseModel): + start_odometer_km: Annotated[int, Field(ge=0)] + fuel_level_percent: Annotated[int, Field(ge=0, le=100)] + cleanliness_ok: bool + damage_reported: bool = False + technical_warning: bool = False + notes: str | None = Field(default=None, max_length=2000) + + +class CheckoutBookingResult(BaseModel): + booking_ref: str + vehicle_ref: str + inspection_ref: str + booking_status: str + resulting_vehicle_status: str + activated: bool + attention_reasons: list[str] + + +class BookingPageOut(BaseModel): + items: list[BookingOut] + page: int + page_size: int + total: int + total_pages: int + + +class RegisterReturnRequest(BaseModel): + end_odometer_km: Annotated[int, Field(ge=0)] + fuel_level_percent: Annotated[int, Field(ge=0, le=100)] + cleanliness_ok: bool + damage_reported: bool + technical_warning: bool + notes: str | None = Field(default=None, max_length=2000) + + +class NextBookingRisk(BaseModel): + booking_ref: str + starts_at: datetime + at_risk: bool + + +class RegisterReturnResult(BaseModel): + booking_ref: str + vehicle_ref: str + inspection_ref: str + resulting_vehicle_status: str + odometer_regression: bool + quality_issue_ref: str | None + workflow_event_id: str + correlation_id: str + 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 + status_reason_code: str + status_reason_params: dict[str, str | int] = {} + would_create_quality_issue: bool + attention_reasons: list[str] + next_booking_risk: NextBookingRisk | None + + +class InspectionOut(BaseModel): + public_ref: str + booking_ref: str + type: str + fuel_level_percent: int + cleanliness_ok: bool + damage_reported: bool + technical_warning: bool + odometer_km: int + completed_at: datetime + + +class MaintenanceOut(BaseModel): + public_ref: str + occurred_at: datetime + odometer_km: int + category: str + summary: str + + +class CreateMaintenanceRequest(BaseModel): + occurred_at: datetime + odometer_km: Annotated[int, Field(ge=0)] + category: Literal["periodic_service", "repair", "inspection", "tyres", "other"] + summary: str = Field(min_length=3, max_length=2000) + next_service_km: Annotated[int | None, Field(default=None, ge=0)] + mark_maintenance: bool = True + + +class ReleaseVehicleRequest(BaseModel): + reason: NonBlankOperationalReason + + +class DataQualityIssueOut(BaseModel): + public_ref: str + rule_type: str + entity_type: str + entity_ref: str + severity: str + status: str + evidence: dict[str, Any] + detected_at: datetime + due_at: datetime | None = None + assigned_to_ref: str | None = None + assigned_to_name: str | None = None + overdue: bool = False + resolved_at: datetime | None = None + + +class DataQualityIssuePageOut(BaseModel): + items: list[DataQualityIssueOut] + page: int + page_size: int + total: int + total_pages: int + + +class DataQualityIssueDetailOut(DataQualityIssueOut): + entity_snapshot: dict[str, Any] | None = None + related_snapshots: list[dict[str, Any]] = Field(default_factory=list) + + +class BulkDataQualityWorkRequest(BaseModel): + issue_refs: list[str] = Field(min_length=1, max_length=25) + assigned_to_ref: str | None = Field(default=None, min_length=3, max_length=20) + clear_assignment: bool = False + due_at: datetime | None = None + clear_due_at: bool = False + + +class BulkDataQualityWorkResult(BaseModel): + updated: list[DataQualityIssueOut] + + +class MergeCustomersRequest(BaseModel): + survivor_ref: str + field_overrides: dict[str, str] | None = None + + +class MergeCustomersResult(BaseModel): + issue_ref: str + survivor_ref: str + loser_ref: str + rewired_bookings: int + + +class ScanResultOut(BaseModel): + created: dict[str, int] + + +class WorkflowErrorReportIn(BaseModel): + workflow_id: str = Field(max_length=120) + workflow_name: str = Field(max_length=200) + execution_id: str = Field(max_length=120) + failed_at: datetime + error_category: Literal[ + "timeout", "authError", "connectionError", "httpError", "validationError", "unknown" + ] + error_summary: str = Field(max_length=500) + trigger_context: str | None = Field(default=None, max_length=200) + correlation_id: uuid.UUID | None = None + attempt: int = Field(default=1, ge=1, le=1000) + retry_action: str | None = Field(default=None, max_length=200) + + +class WorkflowErrorReportResult(BaseModel): + status: Literal["registered", "already_registered"] + execution_id: str + occurred_at: datetime + + +class ProcedureDocumentOut(BaseModel): + id: str + language: str + document_id: str + title: str + version: str + content: str + content_hash: str + + +class ProcedureListOut(BaseModel): + documents: list[ProcedureDocumentOut] + + +class ProcedureSyncResultIn(BaseModel): + execution_id: str = Field(max_length=120) + synced: int = Field(ge=0) + failed: int = Field(default=0, ge=0) + + +class ProcedureSyncResultResult(BaseModel): + status: Literal["registered", "already_registered"] + execution_id: str + occurred_at: datetime + + +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 VehicleStatusFactsOut(BaseModel): + active_booking_refs: list[str] + overlapping_booking_pairs: list[list[str]] + service_threshold_reached: bool + odometer_km: int + next_service_km: int + open_booking_overlap_issue_ref: str | None = None + + +class StatusRecommendationOut(BaseModel): + current_status: str + recommended_status: str | None + recommendation_code: str + safe_to_apply: bool + manual_review_required: bool + facts: VehicleStatusFactsOut + blocking_reasons: list[str] + recommendation_token: str + + +class ApplyRecommendedStatusRequest(BaseModel): + recommendation_token: str + + +class ApplyRecommendedStatusResult(BaseModel): + issue: DataQualityIssueOut + applied_status: str + reason_code: str + + +class SearchResultItem(BaseModel): + type: Literal["vehicle", "booking", "data_quality_issue", "section"] + label: str + detail_code: str + detail_params: dict[str, str] = {} + link: str + + +class SearchResponse(BaseModel): + query: str + results: list[SearchResultItem] + + +class OidcStatusOut(BaseModel): + enabled: bool + provider_name: str | None = None + + +class PrivacyRetentionOut(BaseModel): + minimum_booking_retention_days: int + audit_retention_days: int + customers_total: int + customers_anonymized: int + customers_eligible: int + + +class CustomerAnonymizeRequest(BaseModel): + confirmation: str = Field(min_length=1, max_length=20) + reason: str = Field(min_length=8, max_length=500) + + +class CustomerAnonymizeResult(BaseModel): + public_ref: str + anonymized_at: datetime + status: Literal["anonymized", "already_anonymized"] + + +class N8nWorkflowEvidence(BaseModel): + name: str + built: bool + last_seen_at: datetime | None + state: Literal["no_evidence", "healthy", "stale", "failed"] = "no_evidence" + last_status: Literal["succeeded", "failed"] | None = None + last_execution_id: str | None = None + + +class ReturnCallbackIn(BaseModel): + """Body of the n8n return follow-up callback. + + n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are ignored; + only the fields we persist are validated and bounded. + """ + + model_config = ConfigDict(extra="ignore") + + event_id: uuid.UUID + correlation_id: uuid.UUID + follow_up: str | None = Field(default=None, max_length=200) + summary: str | None = Field(default=None, max_length=2000) + + +class N8nHeartbeatIn(BaseModel): + workflow_id: str = Field(min_length=1, max_length=120) + workflow_name: str = Field(min_length=1, max_length=200) + execution_id: str = Field(min_length=1, max_length=120) + status: Literal["succeeded", "failed"] + + +class N8nHeartbeatResult(BaseModel): + status: Literal["registered", "already_registered"] + execution_id: str + occurred_at: datetime + + +class N8nErrorHandlerStatus(BaseModel): + total_failures_registered: int + latest_failure_at: datetime | None + latest_failure_workflow: str | None + + +class N8nIntegrationStatus(BaseModel): + configured: bool + dispatch_enabled: bool + state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] + pending: int + #: Every failed delivery, staged and real together -- the number a viewer sees in + #: the run list. + failed: int + #: Failures that were not planted by the demo seed. This is the only failure count + #: that may influence `state`. + unexpected_failed: int = 0 + #: Prepared demo failures (see `app.models.outbox.DEMO_SCENARIO_ERROR_CODE`). + #: Present so the UI can label them instead of implying the automation is broken. + demo_scenario_failed: int = 0 + delivering: int + succeeded: int + latest_success_at: datetime | None + #: Most recent *real* failure; a staged one never sets this. + latest_failure_at: datetime | None + latest_demo_scenario_at: datetime | None = None + expected_workflow_count: int + known_workflow_count: int + workflows: list[N8nWorkflowEvidence] + error_handler: N8nErrorHandlerStatus + + +class McpHubIntegrationStatus(BaseModel): + registration_enabled: bool + state: Literal["not_configured", "no_evidence", "operational"] + total_calls: int + last_tool: str | None = None + last_client: str | None = None + last_called_at: datetime | None = None + hub_reachable: bool | None = None + + +class IntegrationStatusOut(BaseModel): + n8n: N8nIntegrationStatus + mcp_hub: McpHubIntegrationStatus + + +class DemoScenarioOut(BaseModel): + id: str + estimated_minutes: int + required_roles: list[Role] + start_path: str + ready: bool + blocked_reason_code: str | None = None + blocked_reason_params: dict[str, str] = {} + + +class DemoIntegrationSummaryOut(BaseModel): + key: Literal["n8n", "ragcore", "mcp_hub"] + status_code: str + detail_code: str + detail_params: dict[str, str | int] = {} + + +class DemoManifestOut(BaseModel): + demo_mode: bool + organization_name: str + timezone: str + synthetic_data: bool + allow_reset: bool + last_reset_at: datetime | None + anchor_date: str | None + guide_available: bool + required_roles: list[Role] + scenarios: list[DemoScenarioOut] + integrations: list[DemoIntegrationSummaryOut] + + +class VehicleDetailOut(VehicleOut): + bookings: list[BookingSummaryOut] = Field(default_factory=list) + inspections: list[InspectionOut] = Field(default_factory=list) + maintenance: list[MaintenanceOut] = Field(default_factory=list) + quality_issues: list[DataQualityIssueOut] = Field(default_factory=list) + + +class DashboardMetrics(BaseModel): + available: int + rented: int + cleaning: int + maintenance: int + blocked: int + open_quality_issues: int + pending_or_failed_workflows: int + + +class EvidenceSignalOut(BaseModel): + code: str + params: dict[str, Any] = Field(default_factory=dict) + + +class AttentionItem(BaseModel): + kind: Literal["quality_issue", "vehicle"] + severity: str + rule_type: str + evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list) + link_type: Literal["vehicle", "booking", "customer"] + link_ref: str + issue_ref: str | None = None + + +class TodayItem(BaseModel): + kind: Literal["departure", "return"] + booking_ref: str + vehicle_ref: str + scheduled_at: datetime + + +class AutomationRunOut(BaseModel): + event_id: str + event_type: str + aggregate_ref: str + status: str + attempts: int + last_error: str | None + last_error_code: str | None + #: True for the deliberately seeded demo failure. The UI uses this to label the run + #: as a prepared scenario and to offer the demo retry, instead of presenting it as + #: an unexplained production error. + is_demo_scenario: bool = False + occurred_at: datetime + + +class DashboardOut(BaseModel): + metrics: DashboardMetrics + attention_items: list[AttentionItem] + today: list[TodayItem] + recent_automation: list[AutomationRunOut] + + +class OperationsSummaryOut(BaseModel): + tenant: str + metrics: DashboardMetrics + + +class AttentionVehicleOut(BaseModel): + vehicle_ref: str + severity: Literal["low", "medium", "high"] + rule_type: str + summary: str + detected_at: datetime + + +class McpVehicleDetailOut(BaseModel): + public_ref: str + make: str + model: str + model_year: int + location: str + operational_status: str + odometer_km: int + next_service_km: int + open_quality_issue_count: int + current_booking_ref: str | None + + +class McpKnowledgeSearchRequest(BaseModel): + question: str = Field(min_length=3, max_length=1000) + max_sources: int = Field(default=4, ge=1, le=8) + locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB" + + +class AuditEventOut(BaseModel): + id: str + actor_type: str + actor_label: str + action: str + entity_type: str + entity_id: str | None + entity_ref: str | None = None + entity_link: str | None = None + correlation_id: str + occurred_at: datetime + before: dict[str, Any] | None = None + after: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None + + +class AuditEventPageOut(BaseModel): + items: list[AuditEventOut] + page: int + page_size: int + total: int + total_pages: int diff --git a/backend/app/seed_loader.py b/backend/app/seed_loader.py new file mode 100644 index 0000000..bf9ee73 --- /dev/null +++ b/backend/app/seed_loader.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +import csv +import uuid +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from difflib import SequenceMatcher +from pathlib import Path + +from sqlalchemy import delete, insert, update +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset +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.idempotency import IdempotencyRecord +from app.models.inspection import Inspection +from app.models.maintenance import MaintenanceRecord +from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent +from app.models.user import User +from app.models.vehicle import Vehicle +from app.services.audit import record_audit_event + +settings = get_settings() + +DEMO_USERS = [ + { + "public_ref": "USR-OPS", + "display_name": "Amelie De Ridder", + "role": "operations_manager", + }, + { + "public_ref": "USR-EMP", + "display_name": "Karim Boujaddaine", + "role": "rental_employee", + }, +] + +# seed/generate_seed.py authored the committed CSVs relative to this fixed date +# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every +# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps +# right now" scenarios stay true to the actual reset moment instead of decaying as real +# time passes between resets -- a fixed anchor with no shift goes stale within days. +SEED_AUTHORED_ANCHOR = date(2026, 8, 1) + + +def _seed_anchor_shift(today: date) -> timedelta: + return today - SEED_AUTHORED_ANCHOR + + +def _parse_dt(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _parse_bool(value: str) -> bool: + return value.strip().lower() == "true" + + +def _parse_optional_int(value: str) -> int | None: + value = value.strip() + return int(value) if value else None + + +@dataclass +class SeedResult: + counts: dict[str, int] + anchor_date: date + seeded_at: datetime + + +def _seed_dir() -> Path: + return Path(settings.seed_dir) + + +def _read_csv(name: str) -> list[dict[str, str]]: + path = _seed_dir() / name + with path.open(newline="", encoding="utf-8") as handle: + return list(csv.DictReader(handle)) + + +_PERSISTENT_TELEMETRY_ACTIONS = ( + "mcp_tool_request", + "n8n_return_followup_recorded", + "n8n_workflow_failure_registered", + "n8n_procedures_synced", + "n8n_workflow_heartbeat", + "knowledge_question_asked", +) + + +def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None: + # RevokedSession is intentionally NOT cleared: it has no FK to users and wiping it + # would silently re-validate cookies that were logged out before the reset. + for model in ( + OutboxEvent, + IdempotencyRecord, + DataQualityIssue, + Inspection, + MaintenanceRecord, + Booking, + Vehicle, + Customer, + User, + ): + db.execute(delete(model)) + if preserve_integration_telemetry: + db.execute( + delete(AuditEvent).where(AuditEvent.action.not_in(_PERSISTENT_TELEMETRY_ACTIONS)) + ) + else: + db.execute(delete(AuditEvent)) + + +def load_seed(db: Session) -> SeedResult: + counts: dict[str, int] = {} + today = datetime.now(UTC).date() + shift = _seed_anchor_shift(today) + + user_rows = [{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS] + db.execute(insert(User), user_rows) + counts["users"] = len(user_rows) + + customer_id_by_ref: dict[str, uuid.UUID] = {} + customer_rows = [] + customer_row_by_ref: dict[str, dict] = {} + for row in _read_csv("customers.csv"): + cid = uuid.uuid4() + customer_id_by_ref[row["public_ref"]] = cid + customer_row = { + "id": cid, + "public_ref": row["public_ref"], + "first_name": row["first_name"], + "last_name": row["last_name"], + "email": row["email"] or None, + "phone": row["phone"] or None, + "postal_code": row["postal_code"] or None, + "city": row["city"] or None, + } + customer_rows.append(customer_row) + customer_row_by_ref[row["public_ref"]] = customer_row + db.execute(insert(Customer), customer_rows) + counts["customers"] = len(customer_rows) + # Second pass for merged_into (self-referencing FK) since target must exist first. + for row in _read_csv("customers.csv"): + merged_ref = row.get("merged_into") or "" + if merged_ref: + db.execute( + update(Customer) + .where(Customer.id == customer_id_by_ref[row["public_ref"]]) + .values(merged_into_customer_id=customer_id_by_ref[merged_ref]) + ) + + vehicle_id_by_ref: dict[str, uuid.UUID] = {} + vehicle_rows = [] + vehicle_row_by_ref: dict[str, dict] = {} + for row in _read_csv("vehicles.csv"): + vid = uuid.uuid4() + vehicle_id_by_ref[row["public_ref"]] = vid + vehicle_row = { + "id": vid, + "public_ref": row["public_ref"], + "make": row["make"], + "model": row["model"], + "model_year": int(row["model_year"]), + "registration_number": row["registration_number"], + "location": row["location"], + "operational_status": row["operational_status"], + "odometer_km": int(row["odometer_km"]), + "next_service_km": int(row["next_service_km"]), + "active": _parse_bool(row["active"]), + "version": 1, + } + vehicle_rows.append(vehicle_row) + vehicle_row_by_ref[row["public_ref"]] = vehicle_row + db.execute(insert(Vehicle), vehicle_rows) + counts["vehicles"] = len(vehicle_rows) + + booking_id_by_ref: dict[str, uuid.UUID] = {} + booking_rows = [] + booking_row_by_ref: dict[str, dict] = {} + for row in _read_csv("bookings.csv"): + bid = uuid.uuid4() + booking_id_by_ref[row["public_ref"]] = bid + booking_row = { + "id": bid, + "public_ref": row["public_ref"], + "customer_id": customer_id_by_ref[row["customer_ref"]], + "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], + "starts_at": _parse_dt(row["starts_at"]) + shift, + "ends_at": _parse_dt(row["ends_at"]) + shift, + "status": row["status"], + "start_odometer_km": _parse_optional_int(row["start_odometer_km"]), + "end_odometer_km": _parse_optional_int(row["end_odometer_km"]), + "requirements_complete": _parse_bool(row["requirements_complete"]), + } + booking_rows.append(booking_row) + booking_row_by_ref[row["public_ref"]] = booking_row + db.execute(insert(Booking), booking_rows) + counts["bookings"] = len(booking_rows) + + inspection_rows = [] + inspection_source_rows = _read_csv("inspections.csv") + return_inspection_row_by_booking_ref = { + row["booking_ref"]: row for row in inspection_source_rows if row["type"] == "return" + } + for row in inspection_source_rows: + inspection_rows.append( + { + "id": uuid.uuid4(), + "public_ref": row["public_ref"], + "booking_id": booking_id_by_ref[row["booking_ref"]], + "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], + "type": row["type"], + "fuel_level_percent": int(row["fuel_level_percent"]), + "cleanliness_ok": _parse_bool(row["cleanliness_ok"]), + "damage_reported": _parse_bool(row["damage_reported"]), + "technical_warning": _parse_bool(row["technical_warning"]), + "odometer_km": int(row["odometer_km"]), + "completed_at": _parse_dt(row["completed_at"]) + shift, + "completed_by": None, + } + ) + db.execute(insert(Inspection), inspection_rows) + counts["inspections"] = len(inspection_rows) + + maintenance_rows = [] + for row in _read_csv("maintenance.csv"): + maintenance_rows.append( + { + "id": uuid.uuid4(), + "public_ref": row["public_ref"], + "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], + "occurred_at": _parse_dt(row["occurred_at"]) + shift, + "odometer_km": int(row["odometer_km"]), + "category": row["category"], + "summary": row["summary"], + } + ) + db.execute(insert(MaintenanceRecord), maintenance_rows) + counts["maintenance"] = len(maintenance_rows) + + def resolve_entity(entity_ref: str) -> tuple[str, uuid.UUID]: + if entity_ref.startswith("CUS-"): + return "customer", customer_id_by_ref[entity_ref] + return "vehicle", vehicle_id_by_ref[entity_ref] + + def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict: + # Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the + # handful of seed-only rows below -- none of them carry an active rental or a + # real booking conflict (verified against the fixed seed dataset), only a + # genuinely-crossed service threshold or none at all, so those two fields are + # the only ones that vary per vehicle. + vehicle = vehicle_row_by_ref[vehicle_ref] + return { + "active_booking_refs": [], + "overlapping_booking_pairs": [], + "service_threshold_reached": service_threshold_reached, + "odometer_km": vehicle["odometer_km"], + "next_service_km": vehicle["next_service_km"], + "open_booking_overlap_issue_ref": None, + } + + def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]: + later = return_inspection_row_by_booking_ref[later_ref] + earlier = return_inspection_row_by_booking_ref[earlier_ref] + return [ + { + "code": "odometer.regression", + "source_type": "return", + "params": { + "later_ref": later["public_ref"], + "later_km": int(later["odometer_km"]), + "earlier_ref": earlier["public_ref"], + "earlier_km": int(earlier["odometer_km"]), + "booking_ref": later_ref, + }, + } + ] + + def _missing_field_signal(field: str) -> list[dict]: + return [{"code": "missing_field", "params": {"field": field}}] + + # Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios) + # used to carry no structured signal at all -- just the placeholder summary + # "Synthetic deterministic seed issue". Each now cites a real fact about its actual + # entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real + # pair of booking odometer readings engineered into seed/bookings.csv), using the + # exact same signal vocabulary the live scan (app.services.data_quality) already + # renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6. + _SEED_SIGNALS_BY_REF: dict[str, list[dict]] = { + "DQ-0005": [ + { + "code": "vehicle.service_threshold_reached", + "params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True), + } + ], + "DQ-0006": _missing_field_signal("location"), + "DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"), + "DQ-0008": [ + { + "code": "vehicle.rental_ended", + "params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False), + } + ], + "DQ-0009": _missing_field_signal("location"), + "DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"), + "DQ-0011": [ + { + "code": "vehicle.service_threshold_reached", + "params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True), + } + ], + "DQ-0012": _missing_field_signal("registration_number"), + "DQ-0013": _missing_field_signal("location"), + "DQ-0014": _missing_field_signal("registration_number"), + "DQ-0015": _missing_field_signal("location"), + "DQ-0016": _missing_field_signal("location"), + "DQ-0017": _missing_field_signal("location"), + "DQ-0018": _missing_field_signal("registration_number"), + "DQ-0019": _missing_field_signal("location"), + "DQ-0020": _missing_field_signal("location"), + "DQ-0021": _missing_field_signal("location"), + } + + def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]: + # The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so + # they carry real, accurate structured signals (not just a legacy English + # sentence) -- the frontend renders these as the primary, localized evidence; + # see docs/fleet-ops-correction/current-gap-audit.md §6. + if public_ref == "DQ-DEMO-DUPLICATE": + a = customer_row_by_ref[entity_ref] + b = customer_row_by_ref[related_refs[0]] + name_a = f"{a['first_name']} {a['last_name']}".strip().lower() + name_b = f"{b['first_name']} {b['last_name']}".strip().lower() + ratio = SequenceMatcher(None, name_a, name_b).ratio() + return [ + {"code": "duplicate.exact_email"}, + {"code": "duplicate.exact_phone"}, + {"code": "duplicate.same_postal_code"}, + {"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}, + ] + if public_ref == "DQ-DEMO-OVERLAP": + return [{"code": "overlap.reserved_bookings", "params": {"refs": related_refs}}] + if public_ref == "DQ-DEMO-STATUS": + return [{"code": "vehicle.booking_conflict"}] + if public_ref == "DQ-DEMO-ATTENTION": + return [ + { + "code": "attention.upcoming_booking_missing_inspection", + "params": {"booking_ref": related_refs[0] if related_refs else ""}, + } + ] + return _SEED_SIGNALS_BY_REF.get(public_ref, []) + + dq_rows = [] + now = datetime.now(UTC) + for row in _read_csv("data_quality_issues.csv"): + entity_type, entity_id = resolve_entity(row["entity_ref"]) + related_ref = row.get("related_ref") or "" + related_refs = related_ref.split("|") if related_ref else [] + signals = _seed_signals(row["public_ref"], row["entity_ref"], related_refs) + evidence_extra: dict[str, object] = {} + if row["rule_type"] == "odometer_regression" and signals: + params = signals[0].get("params", {}) + later_ref = params.get("later_ref") + earlier_ref = params.get("earlier_ref") + booking_ref = params.get("booking_ref") + if ( + isinstance(earlier_ref, str) + and isinstance(later_ref, str) + and isinstance(booking_ref, str) + ): + # The authored CSV prose predates structured references. Preserve the + # two real source bookings so the detail page can show evidence, while + # only the later (regressing) reading is eligible for correction. + related_refs = [earlier_ref, booking_ref, later_ref] + evidence_extra = { + "source_type": signals[0].get("source_type", "return"), + "source_types": [signals[0].get("source_type", "return")], + "correctable_booking_refs": [booking_ref], + } + severity_due_delta = { + "high": timedelta(hours=4), + "medium": timedelta(days=1), + "low": timedelta(days=3), + }.get(row["severity"], timedelta(days=1)) + dq_rows.append( + { + "id": uuid.uuid4(), + "public_ref": row["public_ref"], + "rule_type": row["rule_type"], + "entity_type": entity_type, + "entity_id": entity_id, + "severity": row["severity"], + "status": row["status"], + "evidence_json": { + "summary": row["evidence"], + "entity_ref": row["entity_ref"], + "related_refs": related_refs, + "signals": signals, + **evidence_extra, + }, + "proposed_action_json": {}, + "detected_at": now, + "due_at": now + severity_due_delta if row["status"] == "open" else None, + "resolved_at": now if row["status"] == "resolved" else None, + "resolved_by": "USR-OPS" if row["status"] == "resolved" else None, + } + ) + db.execute(insert(DataQualityIssue), dq_rows) + counts["data_quality_issues"] = len(dq_rows) + + vehicle_ref_by_booking_ref = { + row["public_ref"]: row["vehicle_ref"] for row in _read_csv("bookings.csv") + } + + outbox_rows = [] + for row in _read_csv("workflow_runs.csv"): + booking_id = booking_id_by_ref.get(row["aggregate_ref"]) + # Build the same schema-complete envelope the live return workflow (M2) produces, + # so a seeded/historical event is redeliverable (e.g. via manual retry) without the + # dispatcher crashing on a missing key. See PROJECT_STATE.md M4 notes. + outbox_rows.append( + { + "event_id": uuid.UUID(row["event_id"]), + "event_type": row["event_type"], + "aggregate_type": "booking", + "aggregate_id": booking_id or uuid.uuid4(), + "payload_json": { + "correlation_id": str(uuid.uuid4()), + "aggregate": { + "type": "booking", + "id": str(booking_id or uuid.uuid4()), + "public_ref": row["aggregate_ref"], + }, + "data": { + "vehicle_ref": vehicle_ref_by_booking_ref.get(row["aggregate_ref"], ""), + "inspection_ref": "", + "resulting_vehicle_status": "cleaning", + "attention_reasons": [], + }, + "aggregate_ref": row["aggregate_ref"], + }, + "occurred_at": _parse_dt(row["occurred_at"]) + shift, + "delivery_status": row["status"], + "attempts": int(row["attempts"]), + "next_attempt_at": None, + "last_error": row["last_error"] or None, + # The seed dataset's one synthetic failure (BK-H-0020) models a + # connection-timeout-style delivery failure -- see workflow_runs.csv. + # It is coded as a *prepared demo scenario*, not as a real + # connectionError, so integration health never degrades because of a + # prop and a viewer is told plainly that this failure is staged. + "last_error_code": DEMO_SCENARIO_ERROR_CODE if row["last_error"] else None, + "external_run_id": None, + } + ) + db.execute(insert(OutboxEvent), outbox_rows) + counts["workflow_runs"] = len(outbox_rows) + + 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, + *, + preserve_integration_telemetry: bool = False, + commit: bool = True, +) -> SeedResult: + from app.services.data_quality import run_scan + + if not db.info.get("mobilityops_demo_reset_exclusive"): + begin_exclusive_demo_reset(db) + clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry) + result = load_seed(db) + scan = run_scan(db, commit=False) + result.counts["data_quality_issues"] += sum(scan.created.values()) + if commit: + db.commit() + end_exclusive_demo_reset(db) + else: + db.flush() + return result diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..6bdddfb --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.orm import Session + +from app.models.audit import AuditEvent + + +def record_audit_event( + db: Session, + *, + actor_type: str, + actor_label: str, + action: str, + entity_type: str, + actor_id: uuid.UUID | None = None, + entity_id: uuid.UUID | None = None, + correlation_id: uuid.UUID | None = None, + before: dict[str, Any] | None = None, + after: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, +) -> AuditEvent: + event = AuditEvent( + actor_type=actor_type, + actor_id=actor_id, + actor_label=actor_label, + action=action, + entity_type=entity_type, + entity_id=entity_id, + correlation_id=correlation_id or uuid.uuid4(), + before_json=before, + after_json=after, + metadata_json=metadata, + occurred_at=datetime.now(UTC), + ) + db.add(event) + return event diff --git a/backend/app/services/data_quality.py b/backend/app/services/data_quality.py new file mode 100644 index 0000000..0e121e7 --- /dev/null +++ b/backend/app/services/data_quality.py @@ -0,0 +1,799 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.inspection import Inspection +from app.models.maintenance import MaintenanceRecord +from app.models.vehicle import Vehicle +from app.schemas import CurrentUser +from app.services.audit import record_audit_event +from app.services.data_quality_common import has_open_issue as _has_open_issue +from app.services.data_quality_common import issue_due_at +from app.services.data_quality_common import load_open_issue as _load_open_issue +from app.services.data_quality_common import new_scan_ref as _new_scan_ref +from app.services.data_quality_duplicate_scan import scan_duplicate_customers +from app.services.data_quality_odometer import ( + open_odometer_regression_issue as open_odometer_regression_issue, +) +from app.services.data_quality_odometer import ( + resolve_odometer_regression as resolve_odometer_regression, +) +from app.services.vehicle_status import ( + RECOMMENDATION_CODE_NO_CONFLICT, + VehicleStatusRecommendation, + compute_recommendation_token, + evaluate_vehicle_status, + gather_vehicle_status_facts, +) + +REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name") +REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location") +DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491 + + +@dataclass +class ScanResult: + created: dict[str, int] = field(default_factory=dict) + + def bump(self, rule_type: str) -> None: + self.created[rule_type] = self.created.get(rule_type, 0) + 1 + + +def _open_issue( + db: Session, + scan: ScanResult, + *, + rule_type: str, + entity_type: str, + entity_id: uuid.UUID, + severity: str, + summary: str, + entity_ref: str, + related_refs: list[str], + signals: list[dict] | None = None, + evidence_extra: dict | None = None, +) -> None: + if _has_open_issue(db, rule_type, entity_type, entity_id): + return + now = datetime.now(UTC) + + # Reintroduced evidence creates a new issue rather than silently reopening the old + # one, but it stays linked to whatever decision was made last time so an operator + # doesn't re-litigate from a blank slate. + previous = db.scalar( + select(DataQualityIssue) + .where( + DataQualityIssue.rule_type == rule_type, + DataQualityIssue.entity_type == entity_type, + DataQualityIssue.entity_id == entity_id, + DataQualityIssue.status != "open", + ) + .order_by(DataQualityIssue.detected_at.desc()) + ) + # `summary` is kept as a technical-fallback string (shown only under "Technical + # details"); `signals` is the stable, localizable structure the frontend renders as + # the primary evidence -- see docs/fleet-ops-correction/current-gap-audit.md §2/§6. + evidence: dict = { + "summary": summary, + "entity_ref": entity_ref, + "related_refs": related_refs, + "signals": signals or [], + } + evidence.update(evidence_extra or {}) + if previous is not None: + evidence["reopened_from"] = previous.public_ref + evidence["previous_decision"] = previous.status + + issue = DataQualityIssue( + public_ref=_new_scan_ref("DQ-SCAN"), + rule_type=rule_type, + entity_type=entity_type, + entity_id=entity_id, + severity=severity, + status="open", + evidence_json=evidence, + proposed_action_json={}, + detected_at=now, + due_at=issue_due_at(now, severity), + ) + db.add(issue) + db.flush() + scan.bump(rule_type) + + +def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None: + # Anonymised customers have had their contact data removed on purpose; flagging + # them as "missing required field" would only be resolvable by re-entering PII. + for customer in db.scalars( + select(Customer).where( + Customer.merged_into_customer_id.is_(None), + Customer.anonymized_at.is_(None), + ) + ).all(): + missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)] + if not customer.email and not customer.phone: + missing.append("email_or_phone") + if missing: + _open_issue( + db, + scan, + rule_type="missing_required_field", + entity_type="customer", + entity_id=customer.id, + severity="low", + summary=f"Missing: {', '.join(missing)}", + entity_ref=customer.public_ref, + related_refs=[], + signals=[{"code": "missing_field", "params": {"field": f}} for f in missing], + ) + + for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all(): + missing = [f for f in REQUIRED_VEHICLE_FIELDS if not getattr(vehicle, f)] + if missing: + _open_issue( + db, + scan, + rule_type="missing_required_field", + entity_type="vehicle", + entity_id=vehicle.id, + severity="low", + summary=f"Missing: {', '.join(missing)}", + entity_ref=vehicle.public_ref, + related_refs=[], + signals=[{"code": "missing_field", "params": {"field": f}} for f in missing], + ) + + +def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None: + vehicles = db.scalars(select(Vehicle)).all() + bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {} + for booking in db.scalars( + select(Booking).where(Booking.status.in_(["reserved", "active"])) + ).all(): + bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking) + + vehicle_by_id = {v.id: v for v in vehicles} + for vehicle_id, bookings in bookings_by_vehicle.items(): + bookings.sort(key=lambda b: b.starts_at) + for i, first in enumerate(bookings): + for second in bookings[i + 1 :]: + if second.starts_at < first.ends_at and first.starts_at < second.ends_at: + vehicle = vehicle_by_id[vehicle_id] + _open_issue( + db, + scan, + rule_type="booking_overlap", + entity_type="vehicle", + entity_id=vehicle_id, + severity="high", + summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}", + entity_ref=vehicle.public_ref, + related_refs=[first.public_ref, second.public_ref], + signals=[ + { + "code": "overlap.reserved_bookings", + "params": {"refs": [first.public_ref, second.public_ref]}, + } + ], + ) + + +def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None: + # Uses the same shared evaluator as the preview/apply flow (app.services.vehicle_status) + # so detection and resolution can never structurally disagree -- see + # docs/fleet-ops-correction/vehicle-status-decision-table.md. + vehicles = db.scalars(select(Vehicle)).all() + for vehicle in vehicles: + facts = gather_vehicle_status_facts(db, vehicle) + recommendation = evaluate_vehicle_status(vehicle, facts) + if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT: + continue + + signals = [{"code": recommendation.recommendation_code, "params": facts.as_dict()}] + summary = ( + f"Recommended status: {recommendation.recommended_status}" + if recommendation.recommended_status + else "Manual review required: active rental conflicts with a blocking condition" + ) + _open_issue( + db, + scan, + rule_type="vehicle_status_conflict", + entity_type="vehicle", + entity_id=vehicle.id, + severity="high", + summary=summary, + entity_ref=vehicle.public_ref, + related_refs=[ + *facts.active_booking_refs, + *(ref for pair in facts.overlapping_booking_pairs for ref in pair), + ], + signals=signals, + ) + + +def _scan_odometer_regressions( + db: Session, + scan: ScanResult, + *, + actor_label: str | None, + actor_type: str, +) -> None: + # Compare the chronological history with itself rather than every historical reading + # to today's canonical value. That detects imported checkout/return/maintenance + # regressions without flagging every legitimate older reading. + # Runtime checkout/return/maintenance mutations take the vehicle lock before they + # inspect or append DQ-03 evidence. Taking the same lock here makes scan-vs-command + # check/merge atomic and prevents a partial-unique race for the open issue. + vehicles = { + v.id: v for v in db.scalars(select(Vehicle).order_by(Vehicle.id).with_for_update()).all() + } + readings_by_vehicle: dict[uuid.UUID, list[tuple[datetime, str, int, str, str | None]]] = {} + inspections = db.scalars(select(Inspection)).all() + return_inspection_booking_ids = { + inspection.booking_id for inspection in inspections if inspection.type == "return" + } + returned_bookings = db.scalars( + select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None)) + ).all() + booking_ref_by_id = {booking.id: booking.public_ref for booking in returned_bookings} + for booking in returned_bookings: + # A real return inspection owns the actual reading timestamp. Using the booking's + # planned ends_at as a duplicate second reading can make an early return appear to + # go backwards after a newer real inspection. Keep booking data only as the legacy + # import fallback when no return inspection exists. + if booking.id in return_inspection_booking_ids: + continue + assert booking.end_odometer_km is not None + readings_by_vehicle.setdefault(booking.vehicle_id, []).append( + ( + booking.ends_at, + booking.public_ref, + booking.end_odometer_km, + "booking", + booking.public_ref, + ) + ) + for inspection in inspections: + readings_by_vehicle.setdefault(inspection.vehicle_id, []).append( + ( + inspection.completed_at, + inspection.public_ref, + inspection.odometer_km, + inspection.type, + ( + booking_ref_by_id.get(inspection.booking_id) + if inspection.type == "return" + else None + ), + ) + ) + for record in db.scalars(select(MaintenanceRecord)).all(): + readings_by_vehicle.setdefault(record.vehicle_id, []).append( + (record.occurred_at, record.public_ref, record.odometer_km, "maintenance", None) + ) + + for vehicle_id, readings in readings_by_vehicle.items(): + readings.sort(key=lambda reading: (reading[0], reading[1])) + highest = readings[0] if readings else None + for later in readings[1:]: + assert highest is not None + if later[2] < highest[2]: + vehicle = vehicles[vehicle_id] + had_open_issue = _has_open_issue( + db, + "odometer_regression", + "vehicle", + vehicle_id, + ) + issue = open_odometer_regression_issue( + db, + vehicle=vehicle, + reading_ref=later[1], + reading_km=later[2], + canonical_km=highest[2], + canonical_ref=highest[1], + source_type=later[3], + related_refs=list( + dict.fromkeys( + [highest[1], later[1], *([later[4]] if later[4] is not None else [])] + ) + ), + correctable_booking_refs=[later[4]] if later[4] is not None else [], + public_ref=_new_scan_ref("DQ-SCAN"), + actor_label=actor_label, + actor_type=actor_type, + ) + if issue is not None and not had_open_issue: + scan.bump("odometer_regression") + if later[2] > highest[2]: + highest = later + + +def run_scan( + db: Session, + *, + actor_label: str | None = None, + actor_type: str = "user", + commit: bool = True, +) -> ScanResult: + # The check-then-insert work below spans several rules. Serialise whole scans at the + # database boundary so API and n8n triggers cannot both observe an empty condition. + db.scalar(select(func.pg_advisory_xact_lock(DATA_QUALITY_SCAN_LOCK_ID))) + scan = ScanResult() + scan_duplicate_customers(db, scan, _open_issue) + _scan_missing_required_fields(db, scan) + _scan_odometer_regressions( + db, + scan, + actor_label=actor_label, + actor_type=actor_type, + ) + _scan_booking_overlaps(db, scan) + _scan_vehicle_status_conflicts(db, scan) + if actor_label is not None: + record_audit_event( + db, + actor_type=actor_type, + actor_label=actor_label, + action="data_quality_scan_run", + entity_type="system", + metadata={"created": scan.created}, + ) + if commit: + db.commit() + else: + db.flush() + return scan + + +def defer_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue: + issue = _load_open_issue(db, public_ref) + issue.status = "deferred" + 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_deferred", + entity_type="data_quality_issue", + entity_id=issue.id, + before={"status": "open"}, + after={"status": "deferred"}, + ) + db.commit() + return issue + + +def reject_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue: + issue = _load_open_issue(db, public_ref) + issue.status = "rejected" + 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_rejected", + entity_type="data_quality_issue", + entity_id=issue.id, + before={"status": "open"}, + after={"status": "rejected"}, + ) + db.commit() + 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_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 _load_vehicle_status_conflict_issue( + db: Session, public_ref: str, *, lock: bool = True +) -> DataQualityIssue: + issue = _load_open_issue(db, public_ref, lock=lock) + 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, + ) + return issue + + +def preview_vehicle_status_recommendation( + db: Session, public_ref: str +) -> tuple[DataQualityIssue, Vehicle, VehicleStatusRecommendation, str]: + """Non-mutating: computes and returns the recommendation only. Never resolves the + issue, never writes an audit event, never queues automation -- safe to call as often + as the UI needs (e.g. every time the panel is opened) with zero side effects.""" + issue = _load_vehicle_status_conflict_issue(db, public_ref, lock=False) + vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id)) + if vehicle is None: + raise AppError( + "VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404 + ) + facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id) + recommendation = evaluate_vehicle_status(vehicle, facts) + token = compute_recommendation_token(vehicle, facts) + return issue, vehicle, recommendation, token + + +def apply_recommended_status( + db: Session, public_ref: str, actor: CurrentUser, expected_token: str +) -> tuple[DataQualityIssue, str, str]: + issue = _load_vehicle_status_conflict_issue(db, public_ref) + # Lock the vehicle row for the remainder of this transaction so a concurrent apply + # (or return/checkout) can't race between our fact-gathering and the write below. + 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 + ) + + facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id) + recommendation = evaluate_vehicle_status(vehicle, facts) + current_token = compute_recommendation_token(vehicle, facts) + + if current_token != expected_token: + raise AppError( + "RECOMMENDATION_STALE", + "The underlying facts changed since this recommendation was shown; " + "review the recommendation again before applying it.", + status_code=409, + ) + if recommendation.manual_review_required or not recommendation.safe_to_apply: + raise AppError( + "MANUAL_REVIEW_REQUIRED", + "This vehicle's state requires manual review; no automatic status change is safe.", + status_code=409, + ) + if recommendation.recommended_status is None: + raise AppError( + "NO_CONFLICT_DETECTED", + "The current vehicle state no longer conflicts; nothing to apply.", + status_code=409, + ) + new_status = recommendation.recommended_status + reason_code = recommendation.recommendation_code + + before = {"operational_status": vehicle.operational_status} + vehicle.operational_status = new_status + vehicle.version += 1 + + # Re-validate against the same shared evaluator, over freshly-gathered facts, that + # applying this change actually leaves no conflict -- never trust the pre-computed + # recommendation alone for the post-condition. + post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id) + post_check = evaluate_vehicle_status(vehicle, post_facts) + if post_check.recommendation_code not in (RECOMMENDATION_CODE_NO_CONFLICT,): + 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_code": reason_code}, + ) + + 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_code + + +MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city") +# Mirrors the column lengths in app/models/customer.py so an override can never fail with +# a database DataError (500) instead of a validation error. +_MERGEABLE_FIELD_MAX_LENGTH = { + "first_name": 80, + "last_name": 80, + "email": 200, + "phone": 40, + "postal_code": 20, + "city": 120, +} + + +def merge_customers( + db: Session, + public_ref: str, + survivor_ref: str, + field_overrides: dict[str, str] | None, + actor: CurrentUser, +) -> dict: + issue = _load_open_issue(db, public_ref) + if issue.rule_type != "possible_duplicate_customer": + raise AppError( + "NOT_A_DUPLICATE_ISSUE", + "This issue is not a possible-duplicate-customer issue.", + status_code=409, + ) + + entity_ref = issue.evidence_json.get("entity_ref") + related_refs = issue.evidence_json.get("related_refs", []) + candidate_refs = {entity_ref, *related_refs} + if survivor_ref not in candidate_refs: + raise AppError( + "INVALID_SURVIVOR", + "The survivor reference must be one of the two customers in this issue.", + status_code=422, + details={"candidates": sorted(candidate_refs)}, + ) + loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref) + + # Lock both rows in a deterministic order (by public_ref) so two concurrent merges + # touching the same customers serialise instead of deadlocking or double-merging. + survivor = None + loser = None + for ref in sorted((survivor_ref, loser_ref)): + customer = db.scalar(select(Customer).where(Customer.public_ref == ref).with_for_update()) + if ref == survivor_ref: + survivor = customer + else: + loser = customer + if survivor is None or loser is None: + raise AppError( + "CUSTOMER_NOT_FOUND", "One of the customers could not be found.", status_code=404 + ) + if survivor.merged_into_customer_id is not None or loser.merged_into_customer_id is not None: + raise AppError( + "CUSTOMER_ALREADY_MERGED", + "One of the customers has already been merged into another record.", + status_code=409, + ) + + before = { + "survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS}, + "loser": {f: getattr(loser, f) for f in MERGEABLE_FIELDS}, + } + + for field_name, value in (field_overrides or {}).items(): + if field_name not in MERGEABLE_FIELDS: + raise AppError( + "INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422 + ) + cleaned = value.strip() if isinstance(value, str) else value + max_length = _MERGEABLE_FIELD_MAX_LENGTH[field_name] + if not cleaned or len(cleaned) > max_length: + raise AppError( + "INVALID_FIELD_OVERRIDE", + f"Field '{field_name}' must be 1 to {max_length} characters.", + status_code=422, + ) + setattr(survivor, field_name, cleaned) + + rewired = db.execute( + update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id) + ) + rewired_count: int = rewired.rowcount # type: ignore[attr-defined] + loser.merged_into_customer_id = survivor.id + + 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="customer_merged", + entity_type="customer", + entity_id=survivor.id, + before=before, + after={"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS}}, + metadata={ + "loser_ref": loser_ref, + "survivor_ref": survivor_ref, + "rewired_bookings": rewired_count, + }, + ) + db.commit() + + return { + "issue_ref": issue.public_ref, + "survivor_ref": survivor_ref, + "loser_ref": loser_ref, + "rewired_bookings": rewired_count, + } diff --git a/backend/app/services/data_quality_common.py b/backend/app/services/data_quality_common.py new file mode 100644 index 0000000..544976d --- /dev/null +++ b/backend/app/services/data_quality_common.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.models.data_quality import DataQualityIssue + + +def issue_due_at(detected_at: datetime, severity: str) -> datetime: + """Return the local operational SLA deadline for a newly detected issue.""" + return detected_at + { + "high": timedelta(hours=4), + "medium": timedelta(days=1), + "low": timedelta(days=3), + }.get(severity, timedelta(days=1)) + + +def has_open_issue( + db: Session, + rule_type: str, + entity_type: str, + entity_id: uuid.UUID, +) -> bool: + return ( + db.scalar( + select(DataQualityIssue.id).where( + DataQualityIssue.rule_type == rule_type, + DataQualityIssue.entity_type == entity_type, + DataQualityIssue.entity_id == entity_id, + DataQualityIssue.status == "open", + ) + ) + is not None + ) + + +def new_scan_ref(prefix: str) -> str: + """Generate a stable human-readable prefix with a concurrent-safe suffix.""" + return f"{prefix}-{uuid.uuid4().hex[:10].upper()}" + + +def load_open_issue(db: Session, public_ref: str, *, lock: bool = True) -> DataQualityIssue: + statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref) + if lock: + statement = statement.with_for_update().execution_options(populate_existing=True) + issue = db.scalar(statement) + if issue is None: + raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404) + if issue.status != "open": + raise AppError( + "ISSUE_NOT_OPEN", + f"Issue is '{issue.status}', not 'open'.", + status_code=409, + ) + return issue diff --git a/backend/app/services/data_quality_duplicate_scan.py b/backend/app/services/data_quality_duplicate_scan.py new file mode 100644 index 0000000..5baacce --- /dev/null +++ b/backend/app/services/data_quality_duplicate_scan.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import uuid +from difflib import SequenceMatcher +from typing import Protocol, TypeVar + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.customer import Customer + +DUPLICATE_THRESHOLD = 70 + + +class ScanAccumulator(Protocol): + def bump(self, rule_type: str) -> None: ... + + +ScanTypeContra = TypeVar("ScanTypeContra", bound=ScanAccumulator, contravariant=True) + + +class OpenIssue(Protocol[ScanTypeContra]): + def __call__( + self, + db: Session, + scan: ScanTypeContra, + *, + rule_type: str, + entity_type: str, + entity_id: uuid.UUID, + severity: str, + summary: str, + entity_ref: str, + related_refs: list[str], + signals: list[dict] | None = None, + ) -> None: ... + + +def _normalize(value: str | None) -> str: + return (value or "").strip().lower() + + +def scan_duplicate_customers[ScanType: ScanAccumulator]( + db: Session, scan: ScanType, open_issue: OpenIssue[ScanType] +) -> None: + customers = list( + db.scalars( + select(Customer).where( + Customer.merged_into_customer_id.is_(None), + Customer.anonymized_at.is_(None), + ) + ).all() + ) + customers.sort(key=lambda customer: customer.public_ref) + + # The threshold cannot be reached without an exact email (60 points) or phone + # (50 points). Block on normalized identifiers so this remains linear for the + # overwhelmingly common case and only scores plausible pairs. + candidate_pairs: set[tuple[int, int]] = set() + for attribute in ("email", "phone"): + blocks: dict[str, list[int]] = {} + for index, customer in enumerate(customers): + key = _normalize(getattr(customer, attribute)) + if key: + blocks.setdefault(key, []).append(index) + for indices in blocks.values(): + for offset, left in enumerate(indices): + candidate_pairs.update((left, right) for right in indices[offset + 1 :]) + + for left, right in sorted(candidate_pairs): + a = customers[left] + b = customers[right] + score = 0 + signals: list[dict] = [] + summary_parts: list[str] = [] + if _normalize(a.email) and _normalize(a.email) == _normalize(b.email): + score += 60 + signals.append({"code": "duplicate.exact_email"}) + summary_parts.append("exact email") + if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone): + score += 50 + signals.append({"code": "duplicate.exact_phone"}) + summary_parts.append("exact phone") + if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code): + score += 10 + signals.append({"code": "duplicate.same_postal_code"}) + summary_parts.append("exact postal code") + name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}" + name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}" + ratio = SequenceMatcher(None, name_a, name_b).ratio() + if ratio >= 0.5: + score += round(ratio * 30) + signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}) + summary_parts.append("similar name") + + if score >= DUPLICATE_THRESHOLD: + open_issue( + db, + scan, + rule_type="possible_duplicate_customer", + entity_type="customer", + entity_id=a.id, + severity="high", + summary="; ".join(summary_parts) + f" (score {score})", + entity_ref=a.public_ref, + related_refs=[b.public_ref], + signals=signals, + ) diff --git a/backend/app/services/data_quality_odometer.py b/backend/app/services/data_quality_odometer.py new file mode 100644 index 0000000..4ce4682 --- /dev/null +++ b/backend/app/services/data_quality_odometer.py @@ -0,0 +1,507 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.models.booking import Booking +from app.models.data_quality import DataQualityIssue +from app.models.inspection import Inspection +from app.models.vehicle import Vehicle +from app.schemas import CurrentUser, ResolveOdometerRegressionRequest +from app.services.audit import record_audit_event +from app.services.data_quality_common import issue_due_at, load_open_issue, new_scan_ref + + +def _odometer_fingerprint( + *, + source_type: str, + later_ref: str, + later_km: int, +) -> dict[str, str | int]: + """Stable identity for one reviewed regression, independent of issue IDs.""" + return { + "source_type": source_type, + "later_ref": later_ref, + "later_km": later_km, + } + + +def _was_odometer_regression_retained( + db: Session, + *, + vehicle_id: uuid.UUID, + fingerprint: dict[str, str | int], +) -> bool: + reviewed = db.scalars( + select(DataQualityIssue).where( + DataQualityIssue.rule_type == "odometer_regression", + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle_id, + DataQualityIssue.status == "resolved", + ) + ).all() + return any( + issue.evidence_json.get("resolution_decision") == "retain_canonical" + and fingerprint in issue.evidence_json.get("retained_odometer_fingerprints", []) + for issue in reviewed + ) + + +def open_odometer_regression_issue( + db: Session, + *, + vehicle: Vehicle, + reading_ref: str, + reading_km: int, + canonical_km: int, + source_type: str, + related_refs: list[str], + correctable_booking_refs: list[str] | None = None, + canonical_ref: str | None = None, + detected_at: datetime | None = None, + public_ref: str | None = None, + actor_label: str | None = None, + actor_type: str = "user", + correlation_id: uuid.UUID | None = None, +) -> DataQualityIssue | None: + """Open one explainable DQ-03 issue while the caller holds the vehicle lock.""" + if reading_km >= canonical_km: + return None + earlier_ref = canonical_ref or vehicle.public_ref + fingerprint = _odometer_fingerprint( + source_type=source_type, + later_ref=reading_ref, + later_km=reading_km, + ) + # A manager's explicit "retain canonical" decision acknowledges this exact source + # fact. Reopening it on every scheduled scan would create churn; only changed or new + # evidence (and therefore a different fingerprint) is actionable again. + if _was_odometer_regression_retained( + db, + vehicle_id=vehicle.id, + fingerprint=fingerprint, + ): + return None + existing = db.scalar( + select(DataQualityIssue) + .where( + DataQualityIssue.rule_type == "odometer_regression", + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + DataQualityIssue.status == "open", + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if existing is not None: + before_related_refs = list(existing.evidence_json.get("related_refs", [])) + before_correctable_refs = list(existing.evidence_json.get("correctable_booking_refs", [])) + new_signal = { + "code": "odometer.regression", + "source_type": source_type, + "params": { + "later_ref": reading_ref, + "later_km": reading_km, + "earlier_ref": earlier_ref, + "earlier_km": canonical_km, + }, + } + existing_signals = list(existing.evidence_json.get("signals", [])) + signal_was_new = not any( + isinstance(signal, dict) + and isinstance(signal.get("params"), dict) + and _odometer_fingerprint( + source_type=str( + signal.get("source_type", existing.evidence_json.get("source_type")) + ), + later_ref=str(signal["params"].get("later_ref")), + later_km=signal["params"].get("later_km"), + ) + == fingerprint + for signal in existing_signals + if isinstance(signal, dict) + and isinstance(signal.get("params"), dict) + and isinstance(signal["params"].get("later_km"), int) + ) + if signal_was_new: + existing_signals.append(new_signal) + merged_related_refs = list( + dict.fromkeys([*before_related_refs, *(related_refs if signal_was_new else [])]) + ) + merged_correctable_refs = list( + dict.fromkeys([*before_correctable_refs, *(correctable_booking_refs or [])]) + ) + if ( + not signal_was_new + and merged_related_refs == before_related_refs + and merged_correctable_refs == before_correctable_refs + ): + return existing + source_types = list(existing.evidence_json.get("source_types", [])) + previous_source_type = existing.evidence_json.get("source_type") + if ( + isinstance(previous_source_type, str) + and previous_source_type != "multiple" + and previous_source_type not in source_types + ): + source_types.append(previous_source_type) + if source_type not in source_types: + source_types.append(source_type) + existing.evidence_json = { + **existing.evidence_json, + "summary": ( + f"{source_type.title()} {reading_ref} recorded {reading_km} km, below " + f"the canonical {canonical_km} km for {vehicle.public_ref}." + ), + "related_refs": merged_related_refs, + "correctable_booking_refs": merged_correctable_refs, + "source_type": source_type if len(source_types) == 1 else "multiple", + "source_types": source_types, + "signals": existing_signals, + } + if actor_label is not None and ( + merged_related_refs != before_related_refs + or merged_correctable_refs != before_correctable_refs + or signal_was_new + ): + record_audit_event( + db, + actor_type=actor_type, + actor_label=actor_label, + action="data_quality_issue_evidence_updated", + entity_type="data_quality_issue", + entity_id=existing.id, + correlation_id=correlation_id, + before={ + "related_refs": before_related_refs, + "correctable_booking_refs": before_correctable_refs, + }, + after={ + "related_refs": merged_related_refs, + "correctable_booking_refs": merged_correctable_refs, + }, + metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref}, + ) + return existing + now = detected_at or datetime.now(UTC) + previous = db.scalar( + select(DataQualityIssue) + .where( + DataQualityIssue.rule_type == "odometer_regression", + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + DataQualityIssue.status != "open", + ) + .order_by(DataQualityIssue.detected_at.desc()) + ) + evidence = { + "summary": ( + f"{source_type.title()} {reading_ref} recorded {reading_km} km, below " + f"the canonical {canonical_km} km for {vehicle.public_ref}." + ), + "entity_ref": vehicle.public_ref, + "related_refs": related_refs, + "correctable_booking_refs": correctable_booking_refs or [], + "source_type": source_type, + "source_types": [source_type], + "signals": [ + { + "code": "odometer.regression", + "source_type": source_type, + "params": { + "later_ref": reading_ref, + "later_km": reading_km, + "earlier_ref": earlier_ref, + "earlier_km": canonical_km, + }, + } + ], + } + if previous is not None: + evidence["reopened_from"] = previous.public_ref + evidence["previous_decision"] = previous.status + issue = DataQualityIssue( + public_ref=public_ref or new_scan_ref("DQ-ODO"), + rule_type="odometer_regression", + entity_type="vehicle", + entity_id=vehicle.id, + severity="medium", + status="open", + evidence_json=evidence, + proposed_action_json={}, + detected_at=now, + due_at=issue_due_at(now, "medium"), + ) + db.add(issue) + db.flush() + if actor_label is not None: + record_audit_event( + db, + actor_type=actor_type, + actor_label=actor_label, + action="data_quality_issue_created", + entity_type="data_quality_issue", + entity_id=issue.id, + correlation_id=correlation_id, + after={"status": "open", "rule_type": "odometer_regression"}, + metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref}, + ) + return issue + + +def resolve_odometer_regression( + db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser +) -> DataQualityIssue: + # Read the routing data without a row lock first. The canonical mutation order is + # booking -> vehicle -> issue -> inspection everywhere, matching checkout/return. + # Locking the issue before the booking creates a resolver-vs-return deadlock. + issue_snapshot = load_open_issue(db, public_ref, lock=False) + if issue_snapshot.rule_type != "odometer_regression": + raise AppError( + "NOT_AN_ODOMETER_ISSUE", + "This issue is not an odometer_regression issue.", + status_code=409, + ) + + def correctable_refs(issue: DataQualityIssue) -> list[str]: + if "correctable_booking_refs" in issue.evidence_json: + refs = issue.evidence_json.get("correctable_booking_refs", []) + else: + # Backward compatibility for issues created before source-aware evidence. + refs = [ + ref + for ref in issue.evidence_json.get("related_refs", []) + if isinstance(ref, str) and ref.startswith("BK-") + ] + return [ref for ref in refs if isinstance(ref, str)] + + booking: Booking | None = None + if body.decision != "retain_canonical": + if body.booking_ref not in correctable_refs(issue_snapshot): + 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, + ) + 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 + ) + vehicle = db.scalar( + select(Vehicle).where(Vehicle.id == issue_snapshot.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 + ) + + # A return or scan may have appended evidence while we waited for the domain locks. + # Lock and refresh the issue only now, then revalidate every decision against that + # current evidence instead of resolving a stale snapshot. + issue = load_open_issue(db, public_ref) + if issue.rule_type != "odometer_regression" or issue.entity_id != vehicle.id: + raise AppError( + "ISSUE_CHANGED", + "The issue changed while the correction was being prepared. Review it again.", + status_code=409, + ) + if booking is not None: + if booking.vehicle_id != vehicle.id or booking.public_ref not in correctable_refs(issue): + raise AppError( + "INVALID_BOOKING_REFERENCE", + "booking_ref must be one of this issue's related bookings.", + status_code=422, + ) + + correlation_id = uuid.uuid4() + + if body.decision == "retain_canonical": + retained_fingerprints: list[dict[str, str | int]] = [] + fallback_source_type = str(issue.evidence_json.get("source_type", "unknown")) + for signal in issue.evidence_json.get("signals", []): + if not isinstance(signal, dict) or signal.get("code") != "odometer.regression": + continue + params = signal.get("params") + if not isinstance(params, dict): + continue + source_type = signal.get("source_type", fallback_source_type) + later_ref = params.get("later_ref") + later_km = params.get("later_km") + earlier_ref = params.get("earlier_ref") + earlier_km = params.get("earlier_km") + if ( + isinstance(source_type, str) + and isinstance(later_ref, str) + and isinstance(later_km, int) + and isinstance(earlier_ref, str) + and isinstance(earlier_km, int) + ): + retained_fingerprints.append( + _odometer_fingerprint( + source_type=source_type, + later_ref=later_ref, + later_km=later_km, + ) + ) + issue.evidence_json = { + **issue.evidence_json, + "resolution_decision": "retain_canonical", + "retained_odometer_fingerprints": retained_fingerprints, + } + 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, + "retained_fingerprint_count": len(retained_fingerprints), + }, + ) + else: + assert booking is not None and body.corrected_odometer_km is not None + # 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, + ) + + # A returned booking has two persisted representations of the same reading. + # Correct every return inspection for the selected booking, including historical + # seed rows whose old issue evidence did not yet cite the inspection explicitly. + related_inspections = db.scalars( + select(Inspection) + .where(Inspection.booking_id == booking.id, Inspection.type == "return") + .with_for_update() + ).all() + before = { + "booking_end_odometer_km": booking.end_odometer_km, + "vehicle_odometer_km": vehicle.odometer_km, + "inspection_odometer_km": { + inspection.public_ref: inspection.odometer_km for inspection in related_inspections + }, + } + booking.end_odometer_km = body.corrected_odometer_km + for inspection in related_inspections: + inspection.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, + "inspection_odometer_km": { + inspection.public_ref: inspection.odometer_km + for inspection in related_inspections + }, + }, + metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref}, + ) + + has_remaining_evidence = False + if booking is not None: + target_refs = {booking.public_ref, *(item.public_ref for item in related_inspections)} + before_related = [ + ref for ref in issue.evidence_json.get("related_refs", []) if isinstance(ref, str) + ] + before_correctable = correctable_refs(issue) + before_signals = [ + signal for signal in issue.evidence_json.get("signals", []) if isinstance(signal, dict) + ] + remaining_signals = [ + signal + for signal in before_signals + if not ( + isinstance(signal.get("params"), dict) + and signal["params"].get("later_ref") in target_refs + ) + ] + remaining_related = [ref for ref in before_related if ref not in target_refs] + remaining_correctable = [ref for ref in before_correctable if ref != booking.public_ref] + has_remaining_evidence = bool(remaining_signals or remaining_correctable) + issue.evidence_json = { + **issue.evidence_json, + "summary": ( + "Additional odometer regression evidence remains for review." + if has_remaining_evidence + else issue.evidence_json.get("summary", "Odometer reading corrected.") + ), + "related_refs": remaining_related, + "correctable_booking_refs": remaining_correctable, + "signals": remaining_signals, + } + if has_remaining_evidence: + record_audit_event( + db, + actor_type="user", + actor_label=actor.display_name, + action="data_quality_issue_partially_resolved", + entity_type="data_quality_issue", + entity_id=issue.id, + correlation_id=correlation_id, + before={ + "related_refs": before_related, + "correctable_booking_refs": before_correctable, + "signal_count": len(before_signals), + }, + after={ + "related_refs": remaining_related, + "correctable_booking_refs": remaining_correctable, + "signal_count": len(remaining_signals), + }, + metadata={ + "decision": body.decision, + "booking_ref": booking.public_ref, + "note": body.note, + }, + ) + + if not has_remaining_evidence: + 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 diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py new file mode 100644 index 0000000..a644aa2 --- /dev/null +++ b/backend/app/services/demo_manifest.py @@ -0,0 +1,220 @@ +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, is_demo_scenario_failure +from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut +from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status +from app.services.knowledge import KnowledgeHealth, get_knowledge_provider + +settings = get_settings() + +_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020" + + +def _knowledge_scenario_ready(health: KnowledgeHealth) -> bool: + """Require a reachable provider and independently verified indexed documents. + + ``source_document_count`` describes local Markdown files, while a sync report only + describes an upload attempt. Neither proves that the active provider can retrieve a + corpus, so an unavailable verification count remains honestly not ready. + """ + return bool( + health.available and health.document_count is not None and health.document_count > 0 + ) + + +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..*) 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 is_demo_scenario_failure(failed_run)) + knowledge_ready = _knowledge_scenario_ready(knowledge_health) + + 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_ready, + blocked_reason_code=None if knowledge_ready else "knowledgeUnavailable", + ), + ] + + +def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: + n8n = derive_n8n_status(db) + knowledge_health = get_knowledge_provider().health() + mcp_hub = derive_mcp_hub_status(db) + + 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" + and _knowledge_scenario_ready(knowledge_health) + else "demoMode" + if knowledge_health.provider == "demo" + and _knowledge_scenario_ready(knowledge_health) + else "unavailable" + ), + detail_code=( + "ragcoreDetail" + if knowledge_health.document_count is not None + else "ragcoreSourceDetail" + ), + detail_params={ + "count": knowledge_health.document_count or 0, + "source_count": knowledge_health.source_document_count, + "collection": knowledge_health.collection, + }, + ), + DemoIntegrationSummaryOut( + key="mcp_hub", + # `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is + # catalog-driven on the Hub's side, so the flag only says Fleet Ops expects + # to be called. Only real recorded `mcp_tool_request` calls make this + # "operational" -- same evidence rule the integration status page uses. + status_code="operational" if mcp_hub.state == "operational" else "notConnected", + detail_code=( + "mcpDetailOperational" if mcp_hub.state == "operational" else "mcpDetailPrepared" + ), + 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), + ) diff --git a/backend/app/services/dispatcher.py b/backend/app/services/dispatcher.py new file mode 100644 index 0000000..c29cbfc --- /dev/null +++ b/backend/app/services/dispatcher.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import logging +import threading +import uuid +from datetime import UTC, datetime, timedelta + +import httpx +from sqlalchemy import select + +from app.core.config import get_settings +from app.core.db import SessionLocal +from app.models.outbox import OutboxEvent + +logger = logging.getLogger("mobilityops.dispatcher") +settings = get_settings() + +_stop_event = threading.Event() + + +def _backoff_seconds(attempts: int) -> int: + return min(2**attempts, 60) + + +def _reclaim_stale_deliveries(batch_size: int = 10) -> int: + """Recover events stuck in 'delivering' because the process that claimed them died + before recording an outcome. Only leases whose deadline has passed are touched, so an + in-flight delivery from a still-alive worker is never disturbed or double-processed; + `attempts` is preserved so the count reflects true history.""" + db = SessionLocal() + try: + now = datetime.now(UTC) + rows = db.scalars( + select(OutboxEvent) + .where( + OutboxEvent.delivery_status == "delivering", + OutboxEvent.next_attempt_at.is_not(None), + OutboxEvent.next_attempt_at <= now, + ) + .limit(batch_size) + .with_for_update(skip_locked=True) + ).all() + for row in rows: + row.delivery_status = "pending" + row.next_attempt_at = None + row.last_error = ( + "Recovered from a stale 'delivering' lease " + f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; " + f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}." + )[:2000] + row.last_error_code = "staleLeaseRecovered" + db.commit() + return len(rows) + finally: + db.close() + + +def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]: + """Claim a batch of due events with a short-lived transaction (no network I/O held open). + Each claimed row gets a lease deadline (next_attempt_at) so a crash between this claim + and the outcome being recorded is recoverable by _reclaim_stale_deliveries.""" + db = SessionLocal() + try: + now = datetime.now(UTC) + rows = db.scalars( + select(OutboxEvent) + .where( + OutboxEvent.delivery_status == "pending", + (OutboxEvent.next_attempt_at.is_(None)) | (OutboxEvent.next_attempt_at <= now), + ) + .order_by(OutboxEvent.occurred_at.asc()) + .limit(batch_size) + .with_for_update(skip_locked=True) + ).all() + claimed_ids = [row.event_id for row in rows] + lease_deadline = now + timedelta(seconds=settings.n8n_delivery_lease_seconds) + for row in rows: + row.delivery_status = "delivering" + row.next_attempt_at = lease_deadline + # The token is stored inside the internal payload (the wire envelope below + # explicitly selects only contract fields). It lets the outcome transaction + # prove that this is still the same lease after network I/O. A stale worker + # must never overwrite a later reclaim/retry or an idempotent callback. + row.payload_json = { + **row.payload_json, + "_delivery_claim_token": str(uuid.uuid4()), + } + db.commit() + return claimed_ids + finally: + db.close() + + +def _deliver_one(event_id: uuid.UUID) -> None: + db = SessionLocal() + try: + event = db.get(OutboxEvent, event_id) + if event is None: + return + # Reconstruct the wire envelope from contracts/events.schema.json: only the fields + # the schema declares (additionalProperties: false), sourced from real columns where + # possible. `payload_json` also carries an internal `aggregate_ref` convenience field + # for our own dashboard/audit reads, which must not be forwarded to n8n. + try: + wire_event = { + "event_id": str(event.event_id), + "event_type": event.event_type, + "occurred_at": event.occurred_at.isoformat(), + "correlation_id": event.payload_json["correlation_id"], + "aggregate": event.payload_json["aggregate"], + "data": event.payload_json["data"], + } + payload_error: str | None = None + except KeyError as exc: + # A malformed payload must still resolve the claimed "delivering" row to a + # terminal-or-retryable state below, rather than leaving it stuck forever. + wire_event = None + payload_error = f"Malformed outbox payload, missing key {exc}" + attempts = event.attempts + claim_token = event.payload_json.get("_delivery_claim_token") + if event.delivery_status != "delivering" or not isinstance(claim_token, str): + return + finally: + db.close() + + error_code: str | None + if wire_event is None: + success, error, body = False, payload_error, None + error_code = "malformedPayload" + else: + try: + response = httpx.post( + settings.n8n_webhook_url, + json=wire_event, + headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token}, + timeout=settings.n8n_http_timeout_seconds, + ) + response.raise_for_status() + try: + body = response.json() + except ValueError: + body = None + if isinstance(body, dict): + acknowledged = body.get("ok") is True + response_event_id = body.get("event_id") + event_id_matches = response_event_id == str(event_id) + result = body.get("result") + execution_id = result.get("execution_id") if isinstance(result, dict) else None + execution_id_valid = isinstance(execution_id, str) and bool(execution_id.strip()) + success = acknowledged and event_id_matches and execution_id_valid + if success: + error = None + error_code = None + elif not acknowledged: + error = ( + "n8n response did not explicitly acknowledge the event with ok=true: " + f"{body}" + ) + error_code = ( + "remoteReportedFailure" if body.get("ok") is False else "malformedResponse" + ) + elif not event_id_matches: + error = ( + "n8n acknowledged a different event ID " + f"(expected {event_id}, received {response_event_id!r})" + ) + error_code = "mismatchedEventId" + else: + error = "n8n response omitted a valid result.execution_id" + error_code = "malformedResponse" + else: + # A 2xx status with a non-object (or unparsable) body means the workflow + # itself errored before its "Respond to Webhook" node ran -- n8n's default + # error response still carries a 2xx-looking status here. Treat it as a + # failure so the event is retried rather than lost or wrongly marked + # succeeded. + success = False + error = ( + f"Unexpected non-JSON-object response from n8n (status {response.status_code})" + ) + error_code = "malformedResponse" + except httpx.HTTPError as exc: + success = False + error = f"{type(exc).__name__}: {exc}" + error_code = "connectionError" + body = None + + db = SessionLocal() + try: + event = db.scalar( + select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update() + ) + if event is None: + return + if ( + event.delivery_status != "delivering" + or event.attempts != attempts + or event.payload_json.get("_delivery_claim_token") != claim_token + ): + logger.info( + "Ignoring stale delivery outcome for event %s because lease ownership changed", + event_id, + ) + return + event.attempts = attempts + 1 + event.payload_json = { + key: value + for key, value in event.payload_json.items() + if key != "_delivery_claim_token" + } + if success: + event.delivery_status = "succeeded" + event.last_error = None + event.last_error_code = None + event.next_attempt_at = None + result = (body or {}).get("result") + execution_id = result.get("execution_id") if isinstance(result, dict) else None + event.external_run_id = execution_id if isinstance(execution_id, str) else None + else: + event.last_error = (error or "delivery failed")[:2000] + event.last_error_code = error_code or "unknownError" + if event.attempts >= settings.n8n_max_attempts: + event.delivery_status = "failed" + event.next_attempt_at = None + else: + event.delivery_status = "pending" + event.next_attempt_at = datetime.now(UTC) + timedelta( + seconds=_backoff_seconds(event.attempts) + ) + db.commit() + finally: + db.close() + + +def run_dispatch_cycle() -> int: + """Run one reclaim+claim+deliver cycle. Returns the number of events processed.""" + _reclaim_stale_deliveries() + claimed = _claim_due_events() + for event_id in claimed: + _deliver_one(event_id) + return len(claimed) + + +def _loop() -> None: + while not _stop_event.is_set(): + try: + run_dispatch_cycle() + except Exception: # noqa: BLE001 + logger.exception("Outbox dispatch cycle failed") + _stop_event.wait(settings.n8n_dispatch_interval_seconds) + + +def start_background_dispatcher() -> None: + if not settings.n8n_dispatch_enabled: + return + _stop_event.clear() + thread = threading.Thread(target=_loop, name="outbox-dispatcher", daemon=True) + thread.start() + + +def stop_background_dispatcher() -> None: + _stop_event.set() diff --git a/backend/app/services/integration_status.py b/backend/app/services/integration_status.py new file mode 100644 index 0000000..78602f1 --- /dev/null +++ b/backend/app/services/integration_status.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import threading +import time +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta +from typing import Any, Literal + +import httpx +from sqlalchemy import Row, func, or_, select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.audit import AuditEvent +from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent +from app.schemas import ( + McpHubIntegrationStatus, + N8nErrorHandlerStatus, + N8nIntegrationStatus, + N8nWorkflowEvidence, +) + +settings = get_settings() +_hub_health_lock = threading.Lock() +_hub_health_cached_at = 0.0 +_hub_health_cached_value: bool | None = None + +# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are +# built (all with their full node set saved). +_CANONICAL_WORKFLOWS = ( + "Fleet Ops — Vehicle Return Orchestration", + "Fleet Ops — Scheduled Data Quality Scan", + "Fleet Ops — RAGcore Procedure Sync", + "Fleet Ops — Workflow Error Handler", +) +_STALE_AFTER = { + "Fleet Ops — Scheduled Data Quality Scan": timedelta(hours=2, minutes=30), + "Fleet Ops — RAGcore Procedure Sync": timedelta(hours=30), +} + + +def _latest_rows_per_workflow(db: Session, action: str) -> Sequence[Row[Any]]: + """Return the most recent audit row per workflow name for one action. + + Heartbeats arrive on every scheduled run, so loading *all* rows and picking the + latest in Python would grow linearly with deployment age. ``DISTINCT ON`` lets + PostgreSQL return exactly one (latest) row per workflow instead. + """ + workflow_name = AuditEvent.after_json["workflow_name"].astext + return db.execute( + select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json) + .where(AuditEvent.action == action) + .distinct(workflow_name) + .order_by(workflow_name, AuditEvent.occurred_at.desc()) + ).all() + + +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) + + # Prepared demo failures are props, not health signals. They stay visible and + # counted -- hiding them would be its own kind of lie -- but they are counted + # *separately*, and only genuinely unexpected failures are allowed to move n8n off + # "operational". Without this split the demo seed's single staged failure pins the + # integration to "degraded" forever, which tells a viewer something untrue about + # the automation. + demo_scenario_failed = ( + db.scalar( + select(func.count()) + .select_from(OutboxEvent) + .where( + OutboxEvent.delivery_status == "failed", + OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE, + ) + ) + or 0 + ) + unexpected_failed = max(failed - demo_scenario_failed, 0) + + latest_success_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") + ) + # Health talks about real failures only, so the "latest failure" a health reader + # sees must exclude the staged one too. + latest_failure_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where( + OutboxEvent.delivery_status == "failed", + or_( + OutboxEvent.last_error_code.is_(None), + OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE, + ), + ) + ) + latest_demo_scenario_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where( + OutboxEvent.delivery_status == "failed", + OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE, + ) + ) + + state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] + if not settings.n8n_dispatch_enabled: + state = "disabled" + elif not settings.n8n_webhook_url: + # Persisted history does not make a currently unconfigured dispatcher green. + state = "unavailable" + elif unexpected_failed > 0 and succeeded == 0: + state = "unavailable" + elif unexpected_failed > 0: + state = "degraded" + # Queued/in-flight work proves only that MobilityOps has work for the dispatcher; + # it does not prove that n8n has ever accepted a delivery. A green state requires + # at least one persisted successful round trip. + elif succeeded > 0: + state = "operational" + else: + state = "no_evidence" + + # Scheduled scan evidence: only service-triggered runs count as n8n evidence, not + # runs an operator triggered manually from the Data Quality page. + latest_scan_at = db.scalar( + select(func.max(AuditEvent.occurred_at)).where( + AuditEvent.action == "data_quality_scan_run", + AuditEvent.actor_type == "service", + ) + ) + + # RAGcore Procedure Sync evidence: result reports posted by the workflow itself once + # it finishes uploading procedures to RAGcore (app/api/routers/integrations.py:: + # procedures_sync_result), the same "the workflow's own callback is the evidence" + # pattern the scheduled scan and error handler already use below. + latest_procedure_sync_row = db.execute( + select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json) + .where(AuditEvent.action == "n8n_procedures_synced") + .order_by(AuditEvent.occurred_at.desc()) + .limit(1) + ).first() + latest_procedure_sync_at = ( + latest_procedure_sync_row[0] if latest_procedure_sync_row is not None else None + ) + + # Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error + # Handler" n8n workflow itself, which also doubles as proof that workflow is wired + # up and firing correctly. + total_failures_registered = ( + db.scalar( + select(func.count(AuditEvent.id)).where( + AuditEvent.action == "n8n_workflow_failure_registered" + ) + ) + or 0 + ) + latest_failure_row = db.execute( + select(AuditEvent.occurred_at, AuditEvent.after_json) + .where(AuditEvent.action == "n8n_workflow_failure_registered") + .order_by(AuditEvent.occurred_at.desc()) + .limit(1) + ).first() + latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None + latest_handler_failure_workflow = ( + (latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None + ) + + legacy_evidence_by_workflow = { + "Fleet Ops — Vehicle Return Orchestration": latest_success_at, + "Fleet Ops — Scheduled Data Quality Scan": latest_scan_at, + "Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at, + "Fleet Ops — Workflow Error Handler": latest_handler_failure_at, + } + heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {} + heartbeat_rows = _latest_rows_per_workflow(db, "n8n_workflow_heartbeat") + for occurred_at, after, metadata in heartbeat_rows: + workflow_name = (after or {}).get("workflow_name") + if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in heartbeat_by_workflow: + heartbeat_by_workflow[workflow_name] = ( + occurred_at, + (after or {}).get("status", "succeeded"), + (metadata or {}).get("execution_id"), + ) + + failure_by_workflow: dict[str, tuple[datetime, str | None]] = {} + failure_rows = _latest_rows_per_workflow(db, "n8n_workflow_failure_registered") + for occurred_at, after, metadata in failure_rows: + workflow_name = (after or {}).get("workflow_name") + if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in failure_by_workflow: + failure_by_workflow[workflow_name] = ( + occurred_at, + (metadata or {}).get("execution_id"), + ) + + now = datetime.now(UTC) + workflows: list[N8nWorkflowEvidence] = [] + for name in _CANONICAL_WORKFLOWS: + legacy_seen = legacy_evidence_by_workflow[name] + heartbeat = heartbeat_by_workflow.get(name) + failure_signal = failure_by_workflow.get(name) + seen_at = heartbeat[0] if heartbeat else legacy_seen + last_status: Literal["succeeded", "failed"] | None = ( + "succeeded" if seen_at is not None else None + ) + execution_id = heartbeat[2] if heartbeat else None + if heartbeat and heartbeat[1] == "failed": + last_status = "failed" + if failure_signal and (seen_at is None or failure_signal[0] > seen_at): + seen_at = failure_signal[0] + last_status = "failed" + execution_id = failure_signal[1] + if name == "Fleet Ops — RAGcore Procedure Sync" and latest_procedure_sync_row: + sync_at, sync_result, sync_metadata = latest_procedure_sync_row + synced = (sync_result or {}).get("synced", 0) + failed_syncs = (sync_result or {}).get("failed", 0) + # The result callback is authoritative for corpus delivery. A generic + # succeeded heartbeat cannot turn a zero/partial upload green. + sync_result_failed = ( + not isinstance(synced, int) + or not isinstance(failed_syncs, int) + or synced <= 0 + or failed_syncs > 0 + ) + if sync_result_failed: + last_status = "failed" + if seen_at is None or sync_at > seen_at: + seen_at = sync_at + if sync_result_failed: + execution_id = (sync_metadata or {}).get("execution_id") + workflow_state: Literal["no_evidence", "healthy", "stale", "failed"] + if seen_at is None: + workflow_state = "no_evidence" + elif last_status == "failed": + workflow_state = "failed" + elif name in _STALE_AFTER and now - seen_at > _STALE_AFTER[name]: + workflow_state = "stale" + else: + workflow_state = "healthy" + workflows.append( + N8nWorkflowEvidence( + name=name, + built=True, + last_seen_at=seen_at, + state=workflow_state, + last_status=last_status, + last_execution_id=execution_id, + ) + ) + + if state in {"operational", "no_evidence"} and any( + workflow.state in {"failed", "stale"} for workflow in workflows + ): + state = "degraded" + + return N8nIntegrationStatus( + configured=bool(settings.n8n_webhook_url), + dispatch_enabled=settings.n8n_dispatch_enabled, + state=state, + pending=pending, + delivering=delivering, + failed=failed, + unexpected_failed=unexpected_failed, + demo_scenario_failed=demo_scenario_failed, + succeeded=succeeded, + latest_success_at=latest_success_at, + latest_failure_at=latest_failure_at, + latest_demo_scenario_at=latest_demo_scenario_at, + expected_workflow_count=len(_CANONICAL_WORKFLOWS), + known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None), + workflows=workflows, + error_handler=N8nErrorHandlerStatus( + total_failures_registered=total_failures_registered, + latest_failure_at=latest_handler_failure_at, + latest_failure_workflow=latest_handler_failure_workflow, + ), + ) + + +def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus: + """Evidence-based MCP Hub status: real tool-call audit history, not just the + `MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call + already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`).""" + total_calls = ( + db.scalar(select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request")) + or 0 + ) + latest_call_row = db.execute( + select(AuditEvent.occurred_at, AuditEvent.actor_label, AuditEvent.metadata_json) + .where(AuditEvent.action == "mcp_tool_request") + .order_by(AuditEvent.occurred_at.desc()) + .limit(1) + ).first() + last_called_at = latest_call_row[0] if latest_call_row else None + last_client = latest_call_row[1] if latest_call_row else None + last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None + + hub_reachable = _check_hub_reachable() + + state: Literal["not_configured", "no_evidence", "operational"] + if not settings.mcp_hub_registration_enabled: + state = "not_configured" + elif total_calls > 0 and hub_reachable is not False: + state = "operational" + else: + # Historical tool calls remain useful telemetry, but cannot support a current + # operational claim when the configured Hub health endpoint is unreachable. + # ``hub_reachable`` stays available separately so consumers can distinguish + # this from a provider that simply has no call evidence yet. + state = "no_evidence" + + return McpHubIntegrationStatus( + registration_enabled=settings.mcp_hub_registration_enabled, + state=state, + total_calls=total_calls, + last_tool=last_tool, + last_client=last_client, + last_called_at=last_called_at, + hub_reachable=hub_reachable, + ) + + +def _check_hub_reachable() -> bool | None: + """Real Hub-side health signal (MCP Hub's own registration is catalog-driven on + its side, so this is the only thing Fleet Ops itself can honestly check). + `None` means not configured / not checked, never a guess.""" + if not settings.mcp_hub_base_url: + return None + global _hub_health_cached_at, _hub_health_cached_value + now = time.monotonic() + with _hub_health_lock: + if now - _hub_health_cached_at < settings.mcp_hub_health_cache_seconds: + return _hub_health_cached_value + try: + response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5) + _hub_health_cached_value = response.status_code == 200 + except httpx.HTTPError: + _hub_health_cached_value = False + _hub_health_cached_at = time.monotonic() + return _hub_health_cached_value diff --git a/backend/app/services/knowledge/__init__.py b/backend/app/services/knowledge/__init__.py new file mode 100644 index 0000000..2d6e59d --- /dev/null +++ b/backend/app/services/knowledge/__init__.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from datetime import datetime +from functools import lru_cache +from typing import Literal, Protocol + +from pydantic import BaseModel + +from app.core.config import get_settings + +EvidenceState = Literal["grounded", "insufficient", "unavailable"] +KnowledgeStatisticsState = Literal["verified", "sync_reported", "not_reported"] + + +class SourceCard(BaseModel): + document_id: str + title: str + version: str + section: str + excerpt: str + + +class GroundedAnswer(BaseModel): + answer: str + evidence_state: EvidenceState + sources: list[SourceCard] + provider: str + correlation_id: str + + +class KnowledgeHealth(BaseModel): + provider: str + available: bool + detail: str + tenant: str + workspace: str + collection: str + # A provider may be healthy without exposing a corpus-size endpoint. `None` means + # unknown, never "zero procedures". + document_count: int | None + source_document_count: int + reported_synced_document_count: int | None + reported_failed_document_count: int | None + last_sync_at: datetime | None + statistics_state: KnowledgeStatisticsState + + +class KnowledgeProvider(Protocol): + name: str + + def health(self, language: str = "en-GB") -> KnowledgeHealth: ... + + def ask( + self, question: str, correlation_id: str, language: str = "en-GB" + ) -> GroundedAnswer: ... + + +@lru_cache +def get_knowledge_provider() -> KnowledgeProvider: + settings = get_settings() + if settings.knowledge_provider == "ragcore": + from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider + + return RAGcoreKnowledgeProvider() + from app.services.knowledge.demo import DemoKnowledgeProvider + + return DemoKnowledgeProvider() diff --git a/backend/app/services/knowledge/demo.py b/backend/app/services/knowledge/demo.py new file mode 100644 index 0000000..f98d480 --- /dev/null +++ b/backend/app/services/knowledge/demo.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field +from pathlib import Path + +from app.core.config import get_settings +from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard +from app.services.knowledge.procedures import parse_frontmatter + +SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE") +DEFAULT_LANGUAGE = "en-GB" + +STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = { + "en-GB": { + "a", + "an", + "the", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "to", + "of", + "in", + "on", + "at", + "for", + "and", + "or", + "but", + "if", + "then", + "do", + "does", + "did", + "must", + "may", + "can", + "could", + "should", + "would", + "i", + "you", + "it", + "we", + "they", + "my", + "your", + "what", + "when", + "how", + "with", + "without", + "this", + "that", + "these", + "those", + "not", + "no", + }, + "nl-BE": { + "een", + "de", + "het", + "is", + "zijn", + "was", + "waren", + "worden", + "wordt", + "van", + "in", + "op", + "voor", + "en", + "of", + "maar", + "als", + "dan", + "moet", + "mag", + "kan", + "kunnen", + "zou", + "zouden", + "ik", + "jij", + "u", + "we", + "wij", + "zij", + "mijn", + "jouw", + "wat", + "wanneer", + "hoe", + "met", + "zonder", + "dit", + "dat", + "deze", + "die", + "niet", + "geen", + }, + "fr-BE": { + "un", + "une", + "le", + "la", + "les", + "des", + "est", + "sont", + "était", + "être", + "de", + "du", + "en", + "sur", + "pour", + "et", + "ou", + "mais", + "si", + "alors", + "doit", + "peut", + "peuvent", + "pourrait", + "devrait", + "je", + "tu", + "vous", + "il", + "elle", + "nous", + "ils", + "mon", + "votre", + "quoi", + "quand", + "comment", + "avec", + "sans", + "ce", + "cette", + "ces", + "cela", + "pas", + "non", + }, +} + +# Includes the Latin-1 accented-letter range (à-ö, ø-ÿ) so French/Dutch words with +# diacritics (véhicule, réservation, geëscaleerd) tokenize as one word instead of +# splitting apart at the accented character -- a plain [a-z0-9]+ pattern silently +# drops every accent and fragments the word either side of it. +_WORD_RE = re.compile(r"[a-zà-öø-ÿ0-9]+") + + +def _stem(word: str) -> str: + # Deterministic, intentionally crude suffix stripping — good enough to match "returns" + # with "return" or "damaged" with "damage" without pulling in a stemming dependency. + for suffix in ("ing", "edly", "ed", "es", "s"): + if len(word) > len(suffix) + 2 and word.endswith(suffix): + return word[: -len(suffix)] + return word + + +def _tokenize(text: str, language: str) -> set[str]: + stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE]) + words = _WORD_RE.findall(text.lower()) + return {_stem(w) for w in words if w not in stopwords and len(w) > 2} + + +@dataclass +class Document: + document_id: str + title: str + version: str + title_tokens: set[str] = field(default_factory=set) + + +@dataclass +class ScoredSection: + document: Document + heading: str + text: str + heading_tokens: set[str] + body_tokens: set[str] + + +def _split_sections(body: str) -> list[tuple[str, str]]: + sections: list[tuple[str, str]] = [] + current_heading = "Overview" + current_lines: list[str] = [] + for line in body.splitlines(): + if line.startswith("## "): + if current_lines: + sections.append((current_heading, "\n".join(current_lines).strip())) + current_heading = line[3:].strip() + current_lines = [] + elif line.startswith("# "): + continue + else: + current_lines.append(line) + if current_lines: + sections.append((current_heading, "\n".join(current_lines).strip())) + return sections + + +def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]: + sections: list[ScoredSection] = [] + for path in sorted(procedures_dir.glob("*.md")): + raw = path.read_text(encoding="utf-8") + meta, body = parse_frontmatter(raw) + title = meta.get("title", path.stem) + doc = Document( + document_id=meta.get("document_id", path.stem), + title=title, + version=meta.get("version", "1.0"), + title_tokens=_tokenize(title, language), + ) + for heading, text in _split_sections(body): + sections.append( + ScoredSection( + document=doc, + heading=heading, + text=text, + heading_tokens=_tokenize(heading, language), + body_tokens=_tokenize(text, language), + ) + ) + return sections + + +_NO_MATCH_TEXT = { + "en-GB": "No matching procedure was found for this question.", + "nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.", + "fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.", +} +_LOW_CONFIDENCE_TEXT = { + "en-GB": ( + "The available procedures do not clearly answer this question. " + "The closest matches are included below for review." + ), + "nl-BE": ( + "De beschikbare procedures beantwoorden deze vraag niet duidelijk. " + "De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling." + ), + "fr-BE": ( + "Les procédures disponibles ne répondent pas clairement à cette question. " + "Les correspondances les plus proches sont indiquées ci-dessous pour examen." + ), +} +_LEAD_ANSWER_TEMPLATE = { + "en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}', + "nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}', + "fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}", +} + + +class DemoKnowledgeProvider: + """Deterministic extractive retrieval over the local procedure Markdown files. + + Not a generative model: it scores sections with TF-IDF-weighted keyword overlap + (downweighting terms common across the whole corpus, like "vehicle", in favor of + distinctive ones, like "damage") and returns real excerpts, never invented text. + + Each supported UI language has its own translated procedure corpus under + knowledge/procedures// -- retrieval searches only within the requested + language's corpus so citations always link to a same-language document. + """ + + name = "demo" + + def __init__(self) -> None: + settings = get_settings() + self._settings = settings + base_dir = Path(settings.knowledge_dir) + self._sections_by_language: dict[str, list[ScoredSection]] = {} + self._idf_by_language: dict[str, dict[str, float]] = {} + self._document_count_by_language: dict[str, int] = {} + for language in SUPPORTED_LANGUAGES: + lang_dir = base_dir / language + sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else [] + self._sections_by_language[language] = sections + self._idf_by_language[language] = self._build_idf(sections) + self._document_count_by_language[language] = len( + {s.document.document_id for s in sections} + ) + + @staticmethod + def _build_idf(sections: list[ScoredSection]) -> dict[str, float]: + n = len(sections) or 1 + doc_freq: dict[str, int] = {} + for section in sections: + doc = section.document + all_tokens = doc.title_tokens | section.heading_tokens | section.body_tokens + for token in all_tokens: + doc_freq[token] = doc_freq.get(token, 0) + 1 + return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()} + + def _normalize_language(self, language: str | None) -> str: + if language in SUPPORTED_LANGUAGES: + return language + return DEFAULT_LANGUAGE + + def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth: + language = self._normalize_language(language) + return KnowledgeHealth( + provider=self.name, + available=True, + detail="Deterministic keyword-matching demo provider; no external service.", + tenant=self._settings.ragcore_tenant, + workspace=self._settings.ragcore_workspace, + collection=self._settings.ragcore_collection, + document_count=self._document_count_by_language[language], + source_document_count=self._document_count_by_language[language], + reported_synced_document_count=None, + reported_failed_document_count=None, + last_sync_at=None, + statistics_state="verified", + ) + + def _score( + self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float] + ) -> float: + # The section body is the strongest relevance signal -- it's the actual + # substance a heading or title can only hint at -- so a body match is weighted + # *above* heading/title matches, not below them. The previous 3x/2x/1x + # (heading/title/body) ordering let a single generic word in a heading (e.g. + # "vehicle", present in nearly every section) or a document's own title + # outrank a section whose body genuinely covers multiple, more distinctive + # query terms -- confirmed to misrank the brief's exact validation question in + # every one of the three languages (see docs/fleet-ops-correction/ + # current-gap-audit.md and i18n-inventory.md): nl-BE picked a checkout section + # over the damage procedure, en-GB and fr-BE picked the return procedure over + # the damage procedure, purely from heading/title overlap on common words. + score = 0.0 + for token in query_tokens: + token_idf = idf.get(token, 0.0) + if token_idf == 0.0: + continue + if token in section.body_tokens: + score += 3 * token_idf + elif token in section.heading_tokens: + score += 2 * token_idf + elif token in section.document.title_tokens: + score += 1.5 * token_idf + return score + + def ask( + self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE + ) -> GroundedAnswer: + language = self._normalize_language(language) + sections = self._sections_by_language[language] + idf = self._idf_by_language[language] + query_tokens = _tokenize(question, language) + scored = [(self._score(query_tokens, section, idf), section) for section in sections] + scored = [(score, section) for score, section in scored if score > 0] + scored.sort(key=lambda item: item[0], reverse=True) + top = scored[:3] + + if not top: + return GroundedAnswer( + answer=_NO_MATCH_TEXT[language], + evidence_state="insufficient", + sources=[], + provider=self.name, + correlation_id=correlation_id, + ) + + sources = [ + SourceCard( + document_id=section.document.document_id, + title=section.document.title, + version=section.document.version, + section=section.heading, + excerpt=(section.text[:400] + "…") if len(section.text) > 400 else section.text, + ) + for _, section in top + ] + + if top[0][0] < 3: + return GroundedAnswer( + answer=_LOW_CONFIDENCE_TEXT[language], + evidence_state="insufficient", + sources=sources, + provider=self.name, + correlation_id=correlation_id, + ) + + lead_section = top[0][1] + answer = _LEAD_ANSWER_TEMPLATE[language].format( + title=lead_section.document.title, + version=lead_section.document.version, + heading=lead_section.heading, + excerpt=lead_section.text.splitlines()[0][:300], + ) + return GroundedAnswer( + answer=answer, + evidence_state="grounded", + sources=sources, + provider=self.name, + correlation_id=correlation_id, + ) diff --git a/backend/app/services/knowledge/procedures.py b/backend/app/services/knowledge/procedures.py new file mode 100644 index 0000000..8c6b10d --- /dev/null +++ b/backend/app/services/knowledge/procedures.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import hashlib +import uuid +from dataclasses import dataclass +from pathlib import Path + +SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE") + +# Stable across runs (and across which language ships first) so a document's RAGcore +# source_id never changes just because the sync ran on a different day or in a +# different order -- required for RAGcore's upload idempotency to work per document. +_SOURCE_ID_NAMESPACE = uuid.uuid5( + uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures" +) + + +def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]: + if not raw.startswith("---"): + return {}, raw + end = raw.find("\n---", 3) + if end == -1: + return {}, raw + block = raw[3:end].strip() + body = raw[end + 4 :].lstrip("\n") + meta: dict[str, str] = {} + for line in block.splitlines(): + if ":" not in line: + continue + key, _, value = line.partition(":") + meta[key.strip()] = value.strip().strip('"') + return meta, body + + +@dataclass(frozen=True) +class ProcedureDocument: + source_id: str + language: str + document_id: str + title: str + version: str + content: str + content_hash: str + + +def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]: + """Read every procedure Markdown file Fleet Ops ships, across every supported + language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter + fields (title, version) come from the same files the demo knowledge provider + already reads -- see parse_frontmatter -- so the two never drift apart.""" + + documents: list[ProcedureDocument] = [] + for language in SUPPORTED_LANGUAGES: + language_dir = knowledge_dir / language + if not language_dir.is_dir(): + continue + for path in sorted(language_dir.glob("*.md")): + raw = path.read_text(encoding="utf-8") + meta, body = parse_frontmatter(raw) + document_id = meta.get("document_id", path.stem) + content = body.strip() + documents.append( + ProcedureDocument( + source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")), + language=language, + document_id=document_id, + title=meta.get("title", path.stem), + version=meta.get("version", "1.0"), + content=content, + content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(), + ) + ) + return documents diff --git a/backend/app/services/knowledge/ragcore.py b/backend/app/services/knowledge/ragcore.py new file mode 100644 index 0000000..748261c --- /dev/null +++ b/backend/app/services/knowledge/ragcore.py @@ -0,0 +1,577 @@ +from __future__ import annotations + +import hashlib +import re +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Lock +from time import monotonic + +import httpx + +from app.core.config import get_settings +from app.core.observability import KNOWLEDGE_PROVIDER_REQUESTS, KNOWLEDGE_RETRIEVAL_SCORE +from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard +from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents + +_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"} +_ANSWERABILITY_STATES = _GROUNDED_ANSWERABILITY | { + "not_answerable", + "conflicting_evidence", +} + +# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited +# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a +# fallback when RAGcore's own /v1/answers (generation + citation validation) is +# unavailable but its retrieval (/v1/search) still returns real, relevant, cited +# results -- see ask() below. Unlike the demo corpus's own markdown frontmatter, RAGcore's +# `document_version_id` is an opaque UUID, not a human-meaningful version string, so it +# is deliberately left out of this sentence (it still appears on the source card itself). +_LEAD_ANSWER_TEMPLATE = { + "en-GB": 'Per "{title}": {excerpt}', + "nl-BE": 'Volgens "{title}": {excerpt}', + "fr-BE": "Selon « {title} » : {excerpt}", +} +_DEFAULT_LANGUAGE = "en-GB" +_MAX_SOURCE_CARDS = 3 +_INDEX_LOOKUP_TIMEOUT_SECONDS = 2.0 +_INDEX_VERIFICATION_TTL_SECONDS = 300.0 +_INDEX_VERIFICATION_WORKERS = 6 + +_DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = { + "damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"), + "vehicle": ("vehicle", "car", "voertuig", "wagen", "véhicule", "voiture"), + "return": ("return", "returned", "retour", "terugbrengen", "restitution"), + "fuel": ("fuel", "brandstof", "carburant"), + "booking": ("booking", "reservation", "boeking", "réservation"), + "odometer": ("odometer", "mileage", "kilometer", "kilométrage", "compteur"), + "customer": ("customer", "client", "klant"), + "cleaning": ("cleaning", "reiniging", "poetsen", "nettoyage"), + "maintenance": ("maintenance", "service", "onderhoud", "entretien"), + "conflict": ("conflict", "overlap", "overlapping", "conflit", "chevauchement"), + "checkout": ("checkout", "departure", "vertrek", "départ"), + "availability": ("available", "availability", "beschikbaar", "disponible", "disponibilité"), + "technical": ("technical", "warning", "technisch", "waarschuwing", "technique", "alerte"), +} +_ANSWERABLE_INTENT_CONCEPTS = frozenset(_DOMAIN_CONCEPTS) - {"vehicle", "customer"} + + +def _normalize_evidence_text(value: str) -> str: + return " ".join(re.findall(r"\w+", value.casefold())) + + +def _question_concepts(question: str) -> set[str]: + normalized = question.casefold() + return { + concept + for concept, terms in _DOMAIN_CONCEPTS.items() + if any(term in normalized for term in terms) + } + + +def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]: + """Collapse duplicate chunks and re-uploaded document versions. + + RAGcore document/version UUIDs change across uploads, so they are not useful + deduplication keys. Human-visible citation identity is the normalized title, + section. Chunks from the same unsectioned document collapse into one card; distinct + named sections remain independently citable. + """ + seen: set[tuple[str, str]] = set() + unique: list[SourceCard] = [] + for source in sources: + key = ( + source.title.strip().casefold(), + source.section.strip().casefold(), + ) + if key in seen: + continue + seen.add(key) + unique.append(source) + if len(unique) == _MAX_SOURCE_CARDS: + break + return unique + + +def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) -> list[SourceCard]: + if "damage" not in concepts: + return sources + return sorted( + sources, + key=lambda source: ( + 0 if "damage" in f"{source.document_id} {source.title}".casefold() else 1 + ), + ) + + +def _retrieval_score(result: object) -> float | None: + if not isinstance(result, dict): + return None + scores = result.get("scores") + if not isinstance(scores, dict): + return None + for name in ("rerank", "fused"): + value = scores.get(name) + if isinstance(value, int | float) and not isinstance(value, bool): + return float(value) + return None + + +class RAGcoreKnowledgeProvider: + """Adapter for the central RAGcore service, against its real `/v1/*` contract + (see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and + owned separately, MobilityOps only ever talks to its documented HTTP API). + + Authenticates as a service account via `Authorization: Bearer ` (RAGcore's + session-cookie auth is for its own browser admin UI only). Any connection error, + timeout, non-2xx response, or malformed body degrades to `evidence_state: + "unavailable"` rather than raising -- this is the adapter that actually exercises the + architecture's reliability boundary: RAGcore failure disables knowledge answers only, + never fabricates an answer, never affects the rest of the app. + + `/v1/answers` (RAGcore's own generation + citation-validation step) is tried first; + if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200 + classifying the question as insufficiently answerable), `ask()` falls back to + RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline + stage with no generation step to fail. The fallback answer is always an extractive + excerpt RAGcore's own search actually found, wrapped in the same fixed citation + template `DemoKnowledgeProvider` uses -- never a fabricated summary. + + Retrieval is scoped to the stable RAGcore ``source_id`` values owned by Fleet Ops for + the requested UI language. Returned internal document/version UUIDs are deliberately + not treated as Fleet Ops identifiers: every citation must instead prove the complete + managed-source chain (source id, URI, locator, checksum and extractive local text). + """ + + name = "ragcore" + + def __init__(self) -> None: + self._settings = get_settings() + self._managed_documents_by_source_id: dict[str, ProcedureDocument] = {} + for document in iter_procedure_documents(Path(self._settings.knowledge_dir)): + self._managed_documents_by_source_id[document.source_id] = document + self._verification_cache: dict[str, tuple[float, int]] = {} + self._verification_lock = Lock() + self._answers_circuit_lock = Lock() + self._answers_circuit_open_until = 0.0 + + def _managed_source(self, citation: object, language: str) -> SourceCard | None: + if not isinstance(citation, dict): + return None + source_id = citation.get("source_id") + if not isinstance(source_id, str): + return None + document = self._managed_documents_by_source_id.get(str(source_id)) + if document is None or document.language != language: + return None + if citation.get("locator") != f"{document.document_id}.md": + return None + if citation.get("source_uri") != f"ragcore://source/{document.source_id}": + return None + for field_name in ("id", "document_id", "document_version_id"): + value = citation.get(field_name) + if not isinstance(value, str): + return None + try: + parsed = uuid.UUID(value) + except (ValueError, TypeError, AttributeError): + return None + if parsed.int == 0: + return None + if not isinstance(citation.get("title"), str): + return None + section = citation.get("section") + if section is not None and not isinstance(section, str): + return None + excerpt = citation.get("excerpt") + if not isinstance(excerpt, str) or not excerpt.strip(): + return None + expected_hash = hashlib.sha256(excerpt.encode("utf-8")).hexdigest() + if citation.get("excerpt_sha256") != expected_hash: + return None + # RAGcore owns chunking, but the cited excerpt must still be extractive evidence + # from the authoritative local procedure. Token normalization tolerates Markdown + # punctuation/whitespace while rejecting provider text that was never uploaded. + normalized_excerpt = _normalize_evidence_text(excerpt) + if not normalized_excerpt or normalized_excerpt not in _normalize_evidence_text( + document.content + ): + return None + return SourceCard( + document_id=document.document_id, + title=document.title, + version=document.version, + section=section or "", + excerpt=excerpt, + ) + + def _managed_source_ids(self, language: str) -> list[str]: + return [ + document.source_id + for document in self._managed_documents_by_source_id.values() + if document.language == language + ] + + def _answers_circuit_is_open(self) -> bool: + with self._answers_circuit_lock: + return monotonic() < self._answers_circuit_open_until + + def _open_answers_circuit(self) -> None: + with self._answers_circuit_lock: + self._answers_circuit_open_until = monotonic() + max( + 0.0, self._settings.ragcore_answers_circuit_breaker_seconds + ) + + def _close_answers_circuit(self) -> None: + with self._answers_circuit_lock: + self._answers_circuit_open_until = 0.0 + + def _client(self) -> httpx.Client: + headers = {} + if self._settings.ragcore_api_token: + headers["Authorization"] = f"Bearer {self._settings.ragcore_api_token}" + return httpx.Client( + base_url=self._settings.ragcore_base_url, + headers=headers, + timeout=self._settings.ragcore_http_timeout_seconds, + ) + + def health(self, language: str = "en-GB") -> KnowledgeHealth: + documents = [ + document + for document in iter_procedure_documents(Path(self._settings.knowledge_dir)) + if document.language == language + ] + verified_document_count: int | None = None + try: + with self._client() as client: + response = client.get("/health/ready") + body = response.json() + if not isinstance(body, dict): + raise ValueError("health response is not a JSON object") + available = response.status_code == 200 and body.get("status") == "ok" + detail = ( + "RAGcore reachable and ready." + if available + else f"RAGcore degraded: {body.get('status', 'unknown')}" + ) + if available: + verified_document_count = self._verify_indexed_documents( + client, language, documents + ) + if verified_document_count is None: + detail += " Index verification is temporarily unavailable." + else: + detail += ( + f" {verified_document_count}/{len(documents)} managed sources have " + "an exact active published document in the configured space." + ) + except (httpx.HTTPError, ValueError) as exc: + available = False + detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}" + return KnowledgeHealth( + provider=self.name, + available=available, + detail=detail, + tenant=self._settings.ragcore_tenant, + workspace=self._settings.ragcore_workspace, + collection=self._settings.ragcore_collection, + # RAGcore deliberately has no browse/count endpoint. Fleet Ops instead + # verifies each managed source through its exact identity lookup and only + # counts an active document with a published active version. RAGcore's + # content_sha256 describes its canonical parsed artifact, not the uploaded + # source bytes, so comparing it with Fleet Ops's source hash would be false. + document_count=verified_document_count, + source_document_count=len(documents), + reported_synced_document_count=None, + reported_failed_document_count=None, + last_sync_at=None, + statistics_state=( + "verified" if verified_document_count is not None else "not_reported" + ), + ) + + def _verify_indexed_documents( + self, client: httpx.Client, language: str, documents: list[ProcedureDocument] + ) -> int | None: + if not self._settings.ragcore_space_id or not documents: + return None + + now = monotonic() + with self._verification_lock: + cached = self._verification_cache.get(language) + if cached is not None and now - cached[0] < _INDEX_VERIFICATION_TTL_SECONDS: + return cached[1] + + def is_verified(document: ProcedureDocument) -> bool: + response = client.get( + "/v1/documents", + params={ + "source_id": document.source_id, + "external_id": f"{document.document_id}.md", + }, + timeout=_INDEX_LOOKUP_TIMEOUT_SECONDS, + ) + if response.status_code != 200: + raise RuntimeError("RAGcore document verification failed") + body = response.json() + items = body.get("items") if isinstance(body, dict) else None + if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict): + return False + item = items[0] + active_version = item.get("active_version") + return bool( + item.get("space_id") == self._settings.ragcore_space_id + and item.get("source_id") == document.source_id + and item.get("external_id") == f"{document.document_id}.md" + and item.get("status") == "active" + and isinstance(active_version, dict) + and active_version.get("status") == "published" + ) + + try: + with ThreadPoolExecutor( + max_workers=min(_INDEX_VERIFICATION_WORKERS, len(documents)) + ) as executor: + verified_count = sum(executor.map(is_verified, documents)) + except (httpx.HTTPError, RuntimeError, TypeError, ValueError): + return None + + with self._verification_lock: + self._verification_cache[language] = (monotonic(), verified_count) + return verified_count + + def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer: + unavailable = GroundedAnswer( + answer="", + evidence_state="unavailable", + sources=[], + provider=self.name, + correlation_id=correlation_id, + ) + if not self._settings.ragcore_space_id: + return unavailable + + if self._answers_circuit_is_open(): + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc() + else: + answered = self._ask_via_answers(question, correlation_id, language) + if answered is not None: + return answered + # /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to + # real retrieval rather than degrading straight to "unavailable". This never + # fabricates an answer to the question: it only ever shows an actually-cited + # excerpt RAGcore's own search already found, using the same extractive + # citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's + # generation step. + return self._ask_via_search_fallback(question, correlation_id, language) + + def _ask_via_answers( + self, question: str, correlation_id: str, language: str + ) -> GroundedAnswer | None: + """Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable, + so the caller can fall back to search -- as opposed to a real 200 response + classifying the question as insufficiently answerable, which is a genuine, + final result, not a reason to fall back.""" + try: + with self._client() as client: + response = client.post( + "/v1/answers", + json={ + "query": question, + "requested_space_ids": [self._settings.ragcore_space_id], + "filters": {"source_ids": self._managed_source_ids(language)}, + }, + ) + if response.status_code != 200: + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "non_2xx").inc() + self._open_answers_circuit() + return None + body = response.json() + except (httpx.HTTPError, ValueError): + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "error").inc() + self._open_answers_circuit() + return None + + try: + if not isinstance(body, dict): + raise TypeError("answer response must be an object") + # Validate the provider's complete AnswerResponse contract before trusting + # generated prose. These IDs and claim bindings are the evidence that RAGcore + # ran its deterministic claim/citation validator; one unrelated but otherwise + # valid citation must never make arbitrary answer text appear grounded. + uuid.UUID(str(body["answer_id"])) + uuid.UUID(str(body["retrieval_run_id"])) + raw_citations = body.get("citations", []) + if not isinstance(raw_citations, list): + raise TypeError("citations must be a list") + citation_ids: set[uuid.UUID] = set() + for citation in raw_citations: + if not isinstance(citation, dict): + raise TypeError("citation must be an object") + citation_ids.add(uuid.UUID(str(citation["id"]))) + raw_claims = body["claims"] + if not isinstance(raw_claims, list): + raise TypeError("claims must be a list") + claims_are_bound = bool(raw_claims) + answer_text = body.get("answer") + for claim in raw_claims: + if not isinstance(claim, dict): + raise TypeError("claim must be an object") + claim_text = claim.get("text") + claim_citation_ids = claim.get("citation_ids") + if ( + not isinstance(claim_text, str) + or not claim_text.strip() + or not isinstance(answer_text, str) + or claim_text.strip() not in answer_text + or not isinstance(claim_citation_ids, list) + or not claim_citation_ids + ): + claims_are_bound = False + continue + try: + bound_ids = {uuid.UUID(str(item)) for item in claim_citation_ids} + except (TypeError, ValueError): + claims_are_bound = False + continue + if not bound_ids.issubset(citation_ids): + claims_are_bound = False + mapped_sources = [ + source + for citation in raw_citations + if (source := self._managed_source(citation, language)) is not None + ] + citations_are_managed = len(mapped_sources) == len(raw_citations) + sources = _deduplicate_sources(mapped_sources) + answerability = body.get("answerability", "not_answerable") + if answerability not in _ANSWERABILITY_STATES: + raise TypeError("answerability is invalid") + if not isinstance(answer_text, str): + raise TypeError("answer must be a string") + is_grounded = ( + answerability in _GROUNDED_ANSWERABILITY + and bool(sources) + and citations_are_managed + and claims_are_bound + and bool(answer_text.strip()) + ) + evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient" + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc() + self._close_answers_circuit() + return GroundedAnswer( + answer=answer_text if evidence_state == "grounded" else "", + evidence_state=evidence_state, + sources=sources if evidence_state == "grounded" else [], + provider=self.name, + correlation_id=correlation_id, + ) + except (TypeError, KeyError, ValueError): + KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "malformed").inc() + self._open_answers_circuit() + return None + + def _ask_via_search_fallback( + self, question: str, correlation_id: str, language: str + ) -> GroundedAnswer: + unavailable = GroundedAnswer( + answer="", + evidence_state="unavailable", + sources=[], + provider=self.name, + correlation_id=correlation_id, + ) + try: + with self._client() as client: + response = client.post( + "/v1/search", + json={ + "query": question, + "requested_space_ids": [self._settings.ragcore_space_id], + "filters": {"source_ids": self._managed_source_ids(language)}, + "max_results": 5, + }, + ) + if response.status_code != 200: + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "non_2xx").inc() + return unavailable + body = response.json() + except (httpx.HTTPError, ValueError): + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "error").inc() + return unavailable + + try: + if not isinstance(body, dict): + raise TypeError("search response must be an object") + results = body.get("results", []) + if not isinstance(results, list): + raise TypeError("results must be a list") + sources = [ + source + for result in results + if isinstance(result, dict) + and (source := self._managed_source(result.get("citation"), language)) is not None + ] + except (TypeError, KeyError, ValueError): + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc() + return unavailable + + for result in results: + if not isinstance(result, dict): + continue + score = _retrieval_score(result) + if score is not None: + KNOWLEDGE_RETRIEVAL_SCORE.observe(score) + + all_sources = _deduplicate_sources(sources) + concepts = _question_concepts(question) + qualified_sources = [ + source + for result in results + if isinstance(result, dict) + and (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score + and (source := self._managed_source(result.get("citation"), language)) is not None + ] + sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts) + if not all_sources: + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc() + return GroundedAnswer( + answer="", + evidence_state="insufficient", + sources=[], + provider=self.name, + correlation_id=correlation_id, + ) + + required_concepts = concepts & _ANSWERABLE_INTENT_CONCEPTS + evidence_text = " ".join( + f"{source.document_id} {source.title} {source.section} {source.excerpt}" + for source in sources + ).casefold() + covered_concepts = { + concept + for concept in required_concepts + if any(term in evidence_text for term in _DOMAIN_CONCEPTS[concept]) + } + if not required_concepts or not sources or covered_concepts != required_concepts: + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc() + return GroundedAnswer( + answer="", + evidence_state="insufficient", + sources=all_sources, + provider=self.name, + correlation_id=correlation_id, + ) + + template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE]) + lead = sources[0] + answer = template.format(title=lead.title, excerpt=lead.excerpt) + KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "grounded").inc() + return GroundedAnswer( + answer=answer, + evidence_state="grounded", + sources=sources, + provider=self.name, + correlation_id=correlation_id, + ) diff --git a/backend/app/services/operations.py b/backend/app/services/operations.py new file mode 100644 index 0000000..f7d75a0 --- /dev/null +++ b/backend/app/services/operations.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from datetime import date + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.data_quality import DataQualityIssue +from app.models.outbox import OutboxEvent +from app.models.vehicle import Vehicle +from app.schemas import DashboardMetrics + +SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} + + +def compute_metrics(db: Session) -> DashboardMetrics: + """Shared operations-summary computation used by both the dashboard and the MCP + provider API, so the two never drift out of sync with two copies of the same query.""" + status_counts: dict[str, int] = dict( + db.execute( + select(Vehicle.operational_status, func.count()).group_by(Vehicle.operational_status) + ).all() # type: ignore[arg-type] + ) + open_issues = db.scalar( + select(func.count()).select_from(DataQualityIssue).where(DataQualityIssue.status == "open") + ) + pending_or_failed = db.scalar( + select(func.count()) + .select_from(OutboxEvent) + .where(OutboxEvent.delivery_status.in_(["pending", "failed"])) + ) + return DashboardMetrics( + available=status_counts.get("available", 0), + rented=status_counts.get("rented", 0), + cleaning=status_counts.get("cleaning", 0), + maintenance=status_counts.get("maintenance", 0), + blocked=status_counts.get("blocked", 0), + open_quality_issues=open_issues or 0, + pending_or_failed_workflows=pending_or_failed or 0, + ) + + +def list_attention_vehicles( + db: Session, minimum_severity: str = "medium", on_or_before: date | None = None, limit: int = 20 +) -> list[dict]: + max_rank = SEVERITY_ORDER.get(minimum_severity, 1) + stmt = ( + select(DataQualityIssue) + .where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.status == "open") + .order_by(DataQualityIssue.detected_at.asc()) + ) + if on_or_before is not None: + stmt = stmt.where(func.date(DataQualityIssue.detected_at) <= on_or_before) + issues = db.scalars(stmt).all() + filtered = [i for i in issues if SEVERITY_ORDER.get(i.severity, 3) <= max_rank] + filtered.sort(key=lambda i: SEVERITY_ORDER.get(i.severity, 3)) + + vehicle_ids = {i.entity_id for i in filtered} + vehicles_by_id = { + v.id: v for v in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all() + } + + results = [] + for issue in filtered[:limit]: + vehicle = vehicles_by_id.get(issue.entity_id) + if vehicle is None: + continue + results.append( + { + "vehicle_ref": vehicle.public_ref, + "severity": issue.severity, + "rule_type": issue.rule_type, + "summary": issue.evidence_json.get("summary", ""), + "detected_at": issue.detected_at, + } + ) + return results diff --git a/backend/app/services/returns.py b/backend/app/services/returns.py new file mode 100644 index 0000000..c479c67 --- /dev/null +++ b/backend/app/services/returns.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +import hashlib +import json +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.models.booking import Booking +from app.models.idempotency import IdempotencyRecord +from app.models.inspection import Inspection +from app.models.outbox import OutboxEvent +from app.models.vehicle import Vehicle +from app.schemas import CurrentUser, RegisterReturnRequest +from app.services.audit import record_audit_event +from app.services.data_quality import open_odometer_regression_issue + + +def _new_inspection_ref() -> str: + """Generate a collision-resistant public reference without reading mutable counts. + + Return commands for different bookings can commit concurrently. A count-based + reference made those independent transactions race for the same unique value. + """ + return f"INSP-{uuid.uuid4().hex[:10].upper()}" + + +def _derive_vehicle_status_with_reason( + body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int +) -> tuple[str, str, dict[str, str | int]]: + # Stable, localizable codes + params -- the backend never emits prose here. The + # frontend renders review.reasonCodes. in the selected locale; the mirrored + # raw-English fallback strings live only in status_reason (shown under "Technical + # details") for backward compatibility. See docs/fleet-ops-correction/i18n-inventory.md. + if body.damage_reported and body.technical_warning: + return ( + "blocked", + "returnBlockedDamageAndTechnical", + {}, + ) + if body.damage_reported: + return "blocked", "returnBlockedDamage", {} + if body.technical_warning: + return "blocked", "returnBlockedTechnicalWarning", {} + if new_odometer >= vehicle.next_service_km: + return ( + "maintenance", + "returnServiceThresholdReached", + {"threshold_km": vehicle.next_service_km}, + ) + return "cleaning", "returnRoutedToCleaning", {} + + +_STATUS_REASON_FALLBACK_TEXT: dict[str, str] = { + "returnBlockedDamageAndTechnical": ( + "Damage and a technical warning were both reported on return." + ), + "returnBlockedDamage": "Damage was reported on return.", + "returnBlockedTechnicalWarning": "A technical warning was reported on return.", + "returnServiceThresholdReached": "Odometer reached the service threshold.", + "returnRoutedToCleaning": ( + "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 + status_reason_code: str + status_reason_params: dict[str, str | int] + 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_code, status_reason_params = _derive_vehicle_status_with_reason( + body, vehicle, resulting_odometer_km + ) + status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code] + + 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, + status_reason_code=status_reason_code, + status_reason_params=status_reason_params, + 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 request_fingerprint(body: RegisterReturnRequest) -> str: + canonical = json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _replay_or_reject( + db: Session, + existing: IdempotencyRecord, + booking_ref: str, + fingerprint: str, +) -> tuple[int, dict]: + booking = db.get(Booking, existing.booking_id) + if booking is None or booking.public_ref != booking_ref: + raise AppError( + "IDEMPOTENCY_KEY_REUSED", + "This idempotency key was already used for a different booking.", + status_code=409, + ) + if existing.request_fingerprint is not None and existing.request_fingerprint != fingerprint: + raise AppError( + "IDEMPOTENCY_KEY_REUSED", + "This idempotency key was already used with a different request body.", + status_code=409, + ) + return existing.response_status, existing.response_body + + +def register_vehicle_return( + db: Session, + booking_ref: str, + body: RegisterReturnRequest, + idempotency_key: str, + actor: CurrentUser, +) -> tuple[int, dict]: + fingerprint = request_fingerprint(body) + existing = db.scalar( + select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key) + ) + if existing is not None: + return _replay_or_reject(db, existing, booking_ref, fingerprint) + + booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True) + + # Re-check after acquiring the row lock: a concurrent identical-key request may have + # just committed while we were waiting. + existing = db.scalar( + select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key) + ) + if existing is not None: + return _replay_or_reject(db, existing, booking_ref, fingerprint) + + if booking.status != "active": + raise AppError( + "INVALID_BOOKING_STATE", + f"Booking is '{booking.status}', not 'active'; it cannot be returned.", + status_code=409, + ) + + now = datetime.now(UTC) + correlation_id = uuid.uuid4() + evaluation = evaluate_return(db, booking, vehicle, body, now=now) + + inspection = Inspection( + public_ref=_new_inspection_ref(), + booking_id=booking.id, + vehicle_id=vehicle.id, + type="return", + fuel_level_percent=body.fuel_level_percent, + cleanliness_ok=body.cleanliness_ok, + damage_reported=body.damage_reported, + technical_warning=body.technical_warning, + notes=body.notes, + odometer_km=body.end_odometer_km, + completed_at=now, + completed_by=actor.display_name, + ) + db.add(inspection) + + before_vehicle = { + "operational_status": vehicle.operational_status, + "odometer_km": vehicle.odometer_km, + } + + booking.status = "returned" + booking.end_odometer_km = body.end_odometer_km + + quality_issue_ref: str | None = None + if evaluation.odometer_regression: + issue = open_odometer_regression_issue( + db, + vehicle=vehicle, + reading_ref=inspection.public_ref, + reading_km=body.end_odometer_km, + canonical_km=evaluation.canonical_odometer_km, + source_type="return", + related_refs=[booking.public_ref, inspection.public_ref], + correctable_booking_refs=[booking.public_ref], + detected_at=now, + public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}", + actor_label=actor.display_name, + correlation_id=correlation_id, + ) + quality_issue_ref = issue.public_ref if issue is not None else None + + resulting_status = evaluation.resulting_vehicle_status + vehicle.odometer_km = evaluation.resulting_odometer_km + vehicle.operational_status = resulting_status + vehicle.version += 1 + + record_audit_event( + db, + actor_type="user", + actor_label=actor.display_name, + action="return_registered", + entity_type="booking", + entity_id=booking.id, + correlation_id=correlation_id, + before={"status": "active"}, + after={"status": "returned", "end_odometer_km": body.end_odometer_km}, + metadata={"idempotency_key": idempotency_key}, + ) + record_audit_event( + db, + actor_type="user", + actor_label=actor.display_name, + action="vehicle_status_changed", + entity_type="vehicle", + entity_id=vehicle.id, + correlation_id=correlation_id, + before=before_vehicle, + after={ + "operational_status": vehicle.operational_status, + "odometer_km": vehicle.odometer_km, + }, + ) + + event = OutboxEvent( + event_id=uuid.uuid4(), + event_type="vehicle.returned.v1", + aggregate_type="booking", + aggregate_id=booking.id, + payload_json={ + "event_type": "vehicle.returned.v1", + "correlation_id": str(correlation_id), + "aggregate": { + "type": "booking", + "id": str(booking.id), + "public_ref": booking.public_ref, + }, + "data": { + "vehicle_ref": vehicle.public_ref, + "inspection_ref": inspection.public_ref, + "resulting_vehicle_status": resulting_status, + "attention_reasons": evaluation.attention_reasons, + }, + "aggregate_ref": booking.public_ref, + }, + occurred_at=now, + delivery_status="pending", + attempts=0, + ) + db.add(event) + + response_body = { + "booking_ref": booking.public_ref, + "vehicle_ref": vehicle.public_ref, + "inspection_ref": inspection.public_ref, + "resulting_vehicle_status": resulting_status, + "odometer_regression": evaluation.odometer_regression, + "quality_issue_ref": quality_issue_ref, + "workflow_event_id": str(event.event_id), + "correlation_id": str(correlation_id), + "next_booking_risk": evaluation.next_booking_risk, + } + + db.add( + IdempotencyRecord( + idempotency_key=idempotency_key, + booking_id=booking.id, + request_fingerprint=fingerprint, + response_status=201, + response_body=response_body, + ) + ) + + try: + db.commit() + except IntegrityError: + db.rollback() + existing = db.scalar( + select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key) + ) + if existing is not None: + return _replay_or_reject(db, existing, booking_ref, fingerprint) + raise + + return 201, response_body diff --git a/backend/app/services/sessions.py b/backend/app/services/sessions.py new file mode 100644 index 0000000..7cf4e2c --- /dev/null +++ b/backend/app/services/sessions.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.security import SessionPayload, session_token_hash +from app.models.revoked_session import RevokedSession + +settings = get_settings() + + +def is_session_revoked(db: Session, token: str) -> bool: + token_hash = session_token_hash(token) + revoked_id = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash)) + return revoked_id is not None + + +def revoke_session(db: Session, token: str, payload: SessionPayload) -> None: + db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC))) + token_hash = session_token_hash(token) + existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash)) + if existing is not None: + return + db.add( + RevokedSession( + token_hash=token_hash, + expires_at=datetime.fromtimestamp( + payload.issued_at + settings.session_ttl_seconds, UTC + ), + ) + ) diff --git a/backend/app/services/vehicle_status.py b/backend/app/services/vehicle_status.py new file mode 100644 index 0000000..2897c19 --- /dev/null +++ b/backend/app/services/vehicle_status.py @@ -0,0 +1,210 @@ +"""The single authoritative vehicle-status evaluator. + +Used by the data-quality scanner (detection), the status-recommendation preview +endpoint, the apply endpoint, and tests -- so scan-time detection and resolve-time +recommendation can never structurally disagree (see docs/fleet-ops-correction/ +vehicle-status-decision-table.md for the full decision table and rationale). + +The evaluator only ever reasons from real, freshly-queried domain facts (an actually +active rental, a real service-threshold breach, a real overlapping-booking conflict) -- +never from a proxy like "does some other high-severity issue happen to be open". It is +therefore also order-independent: resolving, deferring or rejecting an unrelated issue on +the same vehicle never changes what this function returns, because it never looks at +issue history, only at the vehicle's/bookings' current state. +""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from dataclasses import dataclass, field + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.booking import Booking +from app.models.data_quality import DataQualityIssue +from app.models.vehicle import Vehicle + +# Every code below is a stable, localizable identifier -- see +# frontend/src/i18n/messageCodes.ts and quality:statusRecommendation.codes.* for the +# human-language mapping in all three supported locales. The backend never emits prose. +RECOMMENDATION_CODE_ACTIVE_RENTAL = "vehicle.active_rental" +RECOMMENDATION_CODE_SERVICE_THRESHOLD = "vehicle.service_threshold_reached" +RECOMMENDATION_CODE_BOOKING_CONFLICT = "vehicle.booking_conflict" +RECOMMENDATION_CODE_RENTAL_ENDED = "vehicle.rental_ended" +RECOMMENDATION_CODE_MANUAL_REVIEW = "vehicle.manual_review_required" +RECOMMENDATION_CODE_NO_CONFLICT = "vehicle.no_conflict" + + +@dataclass +class VehicleStatusFacts: + active_booking_refs: list[str] = field(default_factory=list) + overlapping_booking_pairs: list[tuple[str, str]] = field(default_factory=list) + service_threshold_reached: bool = False + odometer_km: int = 0 + next_service_km: int = 0 + open_booking_overlap_issue_ref: str | None = None + + @property + def has_active_rental(self) -> bool: + return len(self.active_booking_refs) > 0 + + @property + def has_booking_conflict(self) -> bool: + return ( + len(self.overlapping_booking_pairs) > 0 + or self.open_booking_overlap_issue_ref is not None + ) + + def as_dict(self) -> dict: + return { + "active_booking_refs": self.active_booking_refs, + "overlapping_booking_pairs": [list(pair) for pair in self.overlapping_booking_pairs], + "service_threshold_reached": self.service_threshold_reached, + "odometer_km": self.odometer_km, + "next_service_km": self.next_service_km, + "open_booking_overlap_issue_ref": self.open_booking_overlap_issue_ref, + } + + +@dataclass +class VehicleStatusRecommendation: + current_status: str + recommended_status: str | None + recommendation_code: str + safe_to_apply: bool + manual_review_required: bool + facts: VehicleStatusFacts + blocking_reasons: list[str] + + +def _overlapping_booking_pairs(bookings: list[Booking]) -> list[tuple[Booking, Booking]]: + ordered = sorted(bookings, key=lambda b: b.starts_at) + pairs: list[tuple[Booking, Booking]] = [] + for i, first in enumerate(ordered): + for second in ordered[i + 1 :]: + if second.starts_at < first.ends_at and first.starts_at < second.ends_at: + pairs.append((first, second)) + return pairs + + +def gather_vehicle_status_facts( + db: Session, vehicle: Vehicle, *, exclude_issue_id: uuid.UUID | None = None +) -> VehicleStatusFacts: + """Real, freshly-queried facts only -- see module docstring. Never cached, never + derived from another issue's mere existence (only a *specific* booking_overlap + issue's presence is used, as a cross-reference to that issue's own public_ref).""" + reserved_or_active = list( + db.scalars( + select(Booking).where( + Booking.vehicle_id == vehicle.id, + Booking.status.in_(["reserved", "active"]), + ) + ).all() + ) + active_refs = [b.public_ref for b in reserved_or_active if b.status == "active"] + overlap_pairs = [ + (a.public_ref, b.public_ref) for a, b in _overlapping_booking_pairs(reserved_or_active) + ] + + overlap_issue_query = select(DataQualityIssue.public_ref).where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + DataQualityIssue.status == "open", + DataQualityIssue.rule_type == "booking_overlap", + ) + if exclude_issue_id is not None: + overlap_issue_query = overlap_issue_query.where(DataQualityIssue.id != exclude_issue_id) + open_overlap_ref = db.scalar(overlap_issue_query) + + return VehicleStatusFacts( + active_booking_refs=active_refs, + overlapping_booking_pairs=overlap_pairs, + service_threshold_reached=vehicle.odometer_km >= vehicle.next_service_km, + odometer_km=vehicle.odometer_km, + next_service_km=vehicle.next_service_km, + open_booking_overlap_issue_ref=open_overlap_ref, + ) + + +def compute_recommendation_token(vehicle: Vehicle, facts: VehicleStatusFacts) -> str: + """A short digest of exactly the facts the recommendation was based on, plus the + vehicle's optimistic-lock version. The apply endpoint recomputes this from fresh + facts and rejects the request if it doesn't match the token the client last saw -- + the frontend must never assume a previously-shown preview is still valid without the + server re-checking it (see docs/fleet-ops-correction/current-gap-audit.md §8F).""" + payload = {"version": vehicle.version, "status": vehicle.operational_status, **facts.as_dict()} + digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest() + return digest[:16] + + +def evaluate_vehicle_status( + vehicle: Vehicle, facts: VehicleStatusFacts +) -> VehicleStatusRecommendation: + """Pure decision logic over already-gathered facts -- see + docs/fleet-ops-correction/vehicle-status-decision-table.md. Never mutates anything, + never queries the database itself (call gather_vehicle_status_facts first), so it is + trivial to unit-test every branch in isolation.""" + current = vehicle.operational_status + blocking_reasons: list[str] = [] + if facts.service_threshold_reached: + blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD) + if facts.has_booking_conflict: + blocking_reasons.append(RECOMMENDATION_CODE_BOOKING_CONFLICT) + if current == "maintenance" and RECOMMENDATION_CODE_SERVICE_THRESHOLD not in blocking_reasons: + # Already being in maintenance is itself a real blocking fact -- an active + # booking never overrides it. This is exactly the forbidden shortcut this + # evaluator must never take (maintenance + active booking -> auto "rented"). + blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD) + + def result( + recommended: str | None, code: str, *, safe: bool, manual: bool + ) -> VehicleStatusRecommendation: + return VehicleStatusRecommendation( + current_status=current, + recommended_status=recommended, + recommendation_code=code, + safe_to_apply=safe, + manual_review_required=manual, + facts=facts, + blocking_reasons=blocking_reasons, + ) + + if facts.has_active_rental and not blocking_reasons: + if current == "rented": + return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) + return result("rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False) + + if facts.has_active_rental and blocking_reasons: + # Explicitly forbidden shortcut this evaluator must never take: an active + # booking is not proof the vehicle should be "rented" when a real blocking + # condition also exists (e.g. maintenance-due, or a genuine booking conflict). + # This is a real contradiction in the underlying facts, not something safe to + # resolve automatically. + return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True) + + if facts.service_threshold_reached: + if current == "maintenance": + return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) + return result("maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False) + + if facts.has_booking_conflict: + if current == "blocked": + return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) + return result("blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False) + + # No active rental, no maintenance need, no booking conflict. + if current in ("available", "cleaning", "blocked"): + return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) + if current == "rented": + return result("available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False) + if current == "maintenance": + # No positive fact confirms maintenance is actually finished (no completed + # service record is tracked here) -- clearing "maintenance" without such a + # fact would be exactly the kind of unsafe shortcut this evaluator forbids. + # Releasing a vehicle from maintenance remains an explicit, manual decision. + return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) + + return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..f56e1ab --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + alembic upgrade head +fi +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..35d9ac3 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mobilityops-api" +version = "0.0.1" +description = "MobilityOps API scaffold" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115,<1", + "uvicorn[standard]>=0.30,<1", + "pydantic-settings>=2.5,<3", + "sqlalchemy>=2.0,<3", + "psycopg[binary]>=3.2,<4", + "alembic>=1.13,<2", + "httpx>=0.27,<1", + "authlib>=1.6,<2", + "itsdangerous>=2.2,<3", + "prometheus-client>=0.24,<1" +] + +[project.optional-dependencies] +dev = [ + "pytest>=8,<9", + # Starlette's TestClient (>=1.0) prefers httpx2 and warns when only httpx is present. + "httpx2>=2.10,<3", + "ruff>=0.8,<1", + "mypy>=1.13,<2" +] + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +extend-exclude = ["alembic/versions", "seed"] + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["B008"] diff --git a/backend/requirements-prod.lock b/backend/requirements-prod.lock new file mode 100644 index 0000000..8b9a72e --- /dev/null +++ b/backend/requirements-prod.lock @@ -0,0 +1,173 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --constraint=requirements.lock --output-file=requirements-prod.lock pyproject.toml +# +alembic==1.18.5 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +annotated-doc==0.0.5 + # via + # -c requirements.lock + # fastapi +annotated-types==0.8.0 + # via + # -c requirements.lock + # pydantic +anyio==4.14.2 + # via + # -c requirements.lock + # httpx + # starlette + # watchfiles +authlib==1.7.2 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +certifi==2026.7.22 + # via + # -c requirements.lock + # httpcore + # httpx +cffi==2.1.1 + # via + # -c requirements.lock + # cryptography +click==8.4.2 + # via + # -c requirements.lock + # uvicorn +cryptography==50.0.0 + # via + # -c requirements.lock + # authlib + # joserfc +fastapi==0.141.1 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +greenlet==3.5.4 + # via + # -c requirements.lock + # sqlalchemy +h11==0.16.0 + # via + # -c requirements.lock + # httpcore + # uvicorn +httpcore==1.0.9 + # via + # -c requirements.lock + # httpx +httptools==0.8.0 + # via + # -c requirements.lock + # uvicorn +httpx==0.28.1 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +idna==3.18 + # via + # -c requirements.lock + # anyio + # httpx +itsdangerous==2.2.0 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +joserfc==1.7.4 + # via + # -c requirements.lock + # authlib +mako==1.3.12 + # via + # -c requirements.lock + # alembic +markupsafe==3.0.3 + # via + # -c requirements.lock + # mako +prometheus-client==0.26.0 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +psycopg[binary]==3.3.4 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +psycopg-binary==3.3.4 + # via + # -c requirements.lock + # psycopg +pycparser==3.0 + # via + # -c requirements.lock + # cffi +pydantic==2.13.4 + # via + # -c requirements.lock + # fastapi + # pydantic-settings +pydantic-core==2.46.4 + # via + # -c requirements.lock + # pydantic +pydantic-settings==2.14.2 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +python-dotenv==1.2.2 + # via + # -c requirements.lock + # pydantic-settings + # uvicorn +pyyaml==6.0.3 + # via + # -c requirements.lock + # uvicorn +sqlalchemy==2.0.51 + # via + # -c requirements.lock + # alembic + # mobilityops-api (pyproject.toml) +starlette==1.3.1 + # via + # -c requirements.lock + # fastapi +typing-extensions==4.16.0 + # via + # -c requirements.lock + # alembic + # anyio + # fastapi + # psycopg + # pydantic + # pydantic-core + # sqlalchemy + # starlette + # typing-inspection +typing-inspection==0.4.2 + # via + # -c requirements.lock + # fastapi + # pydantic + # pydantic-settings +uvicorn[standard]==0.52.1 + # via + # -c requirements.lock + # mobilityops-api (pyproject.toml) +uvloop==0.22.1 + # via + # -c requirements.lock + # uvicorn +watchfiles==1.2.0 + # via + # -c requirements.lock + # uvicorn +websockets==17.0.1 + # via + # -c requirements.lock + # uvicorn diff --git a/backend/requirements.lock b/backend/requirements.lock new file mode 100644 index 0000000..01e2a54 --- /dev/null +++ b/backend/requirements.lock @@ -0,0 +1,142 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements.lock pyproject.toml +# +alembic==1.18.5 + # via mobilityops-api (pyproject.toml) +annotated-doc==0.0.5 + # via fastapi +annotated-types==0.8.0 + # via pydantic +anyio==4.14.2 + # via + # httpx + # httpx2 + # starlette + # watchfiles +authlib==1.7.2 + # via mobilityops-api (pyproject.toml) +certifi==2026.7.22 + # via + # httpcore + # httpx +cffi==2.1.1 + # via cryptography +click==8.4.2 + # via uvicorn +cryptography==50.0.0 + # via + # authlib + # joserfc +fastapi==0.141.1 + # via mobilityops-api (pyproject.toml) +greenlet==3.5.4 + # via sqlalchemy +h11==0.16.0 + # via + # httpcore + # httpcore2 + # uvicorn +httpcore==1.0.9 + # via httpx +httpcore2==2.10.0 + # via httpx2 +httptools==0.8.0 + # via uvicorn +httpx==0.28.1 + # via mobilityops-api (pyproject.toml) +httpx2==2.10.0 + # via mobilityops-api (pyproject.toml) +idna==3.18 + # via + # anyio + # httpx + # httpx2 +iniconfig==2.3.0 + # via pytest +itsdangerous==2.2.0 + # via mobilityops-api (pyproject.toml) +joserfc==1.7.4 + # via authlib +librt==0.13.0 + # via mypy +mako==1.3.12 + # via alembic +markupsafe==3.0.3 + # via mako +mypy==1.20.2 + # via mobilityops-api (pyproject.toml) +mypy-extensions==1.1.0 + # via mypy +packaging==26.2 + # via pytest +pathspec==1.1.1 + # via mypy +pluggy==1.6.0 + # via pytest +prometheus-client==0.26.0 + # via mobilityops-api (pyproject.toml) +psycopg[binary]==3.3.4 + # via mobilityops-api (pyproject.toml) +psycopg-binary==3.3.4 + # via psycopg +pycparser==3.0 + # via cffi +pydantic==2.13.4 + # via + # fastapi + # pydantic-settings +pydantic-core==2.46.4 + # via pydantic +pydantic-settings==2.14.2 + # via mobilityops-api (pyproject.toml) +pygments==2.20.0 + # via pytest +pytest==8.4.2 + # via mobilityops-api (pyproject.toml) +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +pyyaml==6.0.3 + # via uvicorn +ruff==0.16.1 + # via mobilityops-api (pyproject.toml) +sqlalchemy==2.0.51 + # via + # alembic + # mobilityops-api (pyproject.toml) +starlette==1.3.1 + # via fastapi +truststore==0.10.4 + # via + # httpcore2 + # httpx2 +typing-extensions==4.16.0 + # via + # alembic + # anyio + # fastapi + # httpx2 + # mypy + # psycopg + # pydantic + # pydantic-core + # sqlalchemy + # starlette + # typing-inspection +typing-inspection==0.4.2 + # via + # fastapi + # pydantic + # pydantic-settings +uvicorn[standard]==0.52.1 + # via mobilityops-api (pyproject.toml) +uvloop==0.22.1 + # via uvicorn +watchfiles==1.2.0 + # via uvicorn +websockets==17.0.1 + # via uvicorn diff --git a/backend/scripts/generate_openapi.py b/backend/scripts/generate_openapi.py new file mode 100644 index 0000000..2e89ca9 --- /dev/null +++ b/backend/scripts/generate_openapi.py @@ -0,0 +1,31 @@ +"""Generate the checked-in API contract from the FastAPI application.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.main import app # noqa: E402 + + +def main() -> None: + schema = app.openapi() + schema["info"]["description"] = ( + "Generated contract for Fleet Ops. The visible product name is Fleet Ops; " + "MobilityOps remains the technical repository and service identifier." + ) + schema["servers"] = [{"url": "http://localhost:8128"}] + output = ROOT / "contracts" / "openapi.yaml" + output.write_text( + yaml.safe_dump(schema, sort_keys=False, allow_unicode=True, width=100), + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..1abc8c8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,38 @@ +import pytest +from fastapi.testclient import TestClient + +from app.core.db import Base, SessionLocal, engine +from app.main import app +from app.seed_loader import reset_and_seed + + +@pytest.fixture(scope="session", autouse=True) +def _seeded_database(): + Base.metadata.create_all(engine) + db = SessionLocal() + try: + reset_and_seed(db) + finally: + db.close() + yield + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def login(client: TestClient, role: str) -> TestClient: + response = client.post("/api/v1/demo/login", json={"role": role}) + assert response.status_code == 200 + return client + + +@pytest.fixture +def ops_client(client: TestClient) -> TestClient: + return login(client, "operations_manager") + + +@pytest.fixture +def employee_client(client: TestClient) -> TestClient: + return login(client, "rental_employee") diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py new file mode 100644 index 0000000..53ba24f --- /dev/null +++ b/backend/tests/test_audit.py @@ -0,0 +1,83 @@ +from sqlalchemy import select + +from app.core.db import SessionLocal +from app.models.booking import Booking +from app.models.vehicle import Vehicle + + +def test_demo_login_is_audited(ops_client): + response = ops_client.get("/api/v1/audit", params={"action": "demo_login"}) + assert response.status_code == 200 + events = response.json() + assert len(events) >= 1 + assert events[0]["action"] == "demo_login" + + +def test_audit_requires_authentication(client): + response = client.get("/api/v1/audit") + assert response.status_code == 401 + + +def test_audit_requires_operations_manager(employee_client): + response = employee_client.get("/api/v1/audit") + assert response.status_code == 403 + + +def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client): + response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25}) + assert response.status_code == 200 + body = response.json() + assert len(body["items"]) <= 25 + assert body["page"] == 1 + assert body["page_size"] == 25 + assert body["total"] >= len(body["items"]) + assert body["total_pages"] >= 1 + + +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"] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..699331e --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,306 @@ +import threading + +import pytest +from sqlalchemy import delete, func, select + +from app.core.db import SessionLocal, engine +from app.models.audit import AuditEvent +from app.models.vehicle import Vehicle +from app.seed_loader import reset_and_seed +from app.services.audit import record_audit_event + + +def test_unauthenticated_dashboard_is_rejected(client): + response = client.get("/api/v1/dashboard") + assert response.status_code == 401 + assert response.json()["error"]["code"] == "401" + + +def test_oidc_status_is_disabled_without_configuration(client): + assert client.get("/api/v1/auth/oidc/status").json() == { + "enabled": False, + "provider_name": None, + } + assert client.get("/api/v1/auth/oidc/login").status_code == 404 + + +def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch): + import app.api.routers.auth as auth_router + + class FakeClient: + async def authorize_access_token(self, _request): + return { + "userinfo": { + "sub": "external-subject-1", + "email": "oidc.user@example.test", + "email_verified": True, + "name": "OIDC User", + } + } + + class FakeOAuth: + def create_client(self, _name): + return FakeClient() + + monkeypatch.setattr(auth_router.settings, "oidc_enabled", True) + monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test") + monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client") + monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret") + monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test") + monkeypatch.setattr(auth_router, "oauth", FakeOAuth()) + + response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"].endswith("/dashboard") + session = client.get("/api/v1/auth/session") + assert session.status_code == 200 + assert session.json()["display_name"] == "OIDC User" + assert session.json()["role"] == "rental_employee" + + +def test_oidc_callback_rejects_missing_email_verification_claim(client, monkeypatch): + import app.api.routers.auth as auth_router + + class FakeClient: + async def authorize_access_token(self, _request): + return {"userinfo": {"sub": "unverified-subject", "email": "new@example.test"}} + + class FakeOAuth: + def create_client(self, _name): + return FakeClient() + + monkeypatch.setattr(auth_router.settings, "oidc_enabled", True) + monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test") + monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client") + monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret") + monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test") + monkeypatch.setattr(auth_router, "oauth", FakeOAuth()) + + response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False) + assert response.status_code == 401 + assert client.get("/api/v1/auth/session").status_code == 401 + + +def test_demo_login_grants_access(ops_client): + response = ops_client.get("/api/v1/dashboard") + assert response.status_code == 200 + + +def test_rental_employee_cannot_reset_demo(employee_client): + response = employee_client.post("/api/v1/demo/reset") + assert response.status_code == 403 + + +def test_operations_manager_can_reset_demo(ops_client): + response = ops_client.post("/api/v1/demo/reset") + assert response.status_code == 200 + 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_demo_reset_rejects_concurrent_rebuild(ops_client): + import app.api.routers.demo as demo_router + + assert demo_router._reset_guard.acquire(blocking=False) + try: + response = ops_client.post("/api/v1/demo/reset") + finally: + demo_router._reset_guard.release() + assert response.status_code == 409 + + +def test_demo_reset_database_lock_blocks_another_api_replica(ops_client): + import app.api.routers.demo as demo_router + + # Use a raw connection: an ordinary Session intentionally participates in the + # shared mutation barrier and would make an exclusive reset wait before it can test + # this separate non-blocking replica lock. + with engine.connect() as lock_connection: + lock_connection.scalar(select(func.pg_advisory_lock(demo_router._RESET_ADVISORY_LOCK_ID))) + try: + response = ops_client.post("/api/v1/demo/reset") + finally: + lock_connection.scalar( + select(func.pg_advisory_unlock(demo_router._RESET_ADVISORY_LOCK_ID)) + ) + assert response.status_code == 409 + + +def test_demo_reset_waits_for_active_mutation_and_then_replaces_it_atomically(): + started = threading.Event() + completed = threading.Event() + errors: list[BaseException] = [] + + def reset_in_other_session() -> None: + started.set() + try: + with SessionLocal() as reset_db: + reset_and_seed(reset_db) + except BaseException as exc: # pragma: no cover - assertion reports thread failures + errors.append(exc) + finally: + completed.set() + + with SessionLocal() as mutation_db: + vehicle = mutation_db.scalar( + select(Vehicle).where(Vehicle.public_ref == "MO-001").with_for_update() + ) + vehicle.location = "Concurrent mutation marker" + mutation_db.flush() + worker = threading.Thread(target=reset_in_other_session, daemon=True) + worker.start() + assert started.wait(timeout=1) + assert not completed.wait(timeout=0.2) + mutation_db.commit() + + worker.join(timeout=10) + assert not worker.is_alive(), "reset deadlocked behind the active mutation" + assert errors == [] + with SessionLocal() as db: + restored = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-001")) + assert restored.location != "Concurrent mutation marker" + + +def test_demo_reset_cooldown_returns_retry_after(ops_client, monkeypatch): + import app.api.routers.demo as demo_router + + monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 60) + with SessionLocal() as db: + cooldown_event = record_audit_event( + db, + actor_type="user", + actor_label="Cooldown test", + action="demo_reset", + entity_type="system", + ) + db.commit() + cooldown_event_id = cooldown_event.id + response = ops_client.post("/api/v1/demo/reset") + assert response.status_code == 429 + assert int(response.headers["retry-after"]) >= 1 + with SessionLocal() as db: + db.execute(delete(AuditEvent).where(AuditEvent.id == cooldown_event_id)) + db.commit() + + +def test_demo_reset_rolls_back_every_change_when_integrity_check_fails(ops_client, monkeypatch): + import app.api.routers.demo as demo_router + + monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 0) + with SessionLocal() as db: + probe = record_audit_event( + db, + actor_type="system", + actor_label="Atomic reset test", + action="reset_atomicity_probe", + entity_type="system", + ) + db.commit() + probe_id = probe.id + + def fail_integrity_check(_db): + raise RuntimeError("Injected integrity-check failure") + + monkeypatch.setattr(demo_router, "scenario_integrity_report", fail_integrity_check) + with pytest.raises(RuntimeError, match="Injected integrity-check failure"): + ops_client.post("/api/v1/demo/reset") + + with SessionLocal() as db: + assert db.scalar(select(AuditEvent).where(AuditEvent.id == probe_id)) is not None + db.execute(delete(AuditEvent).where(AuditEvent.id == probe_id)) + db.commit() + + +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_rejects_a_cookie_even_if_the_browser_retains_it(ops_client): + from app.core.config import get_settings + + cookie_name = get_settings().session_cookie_name + stolen_token = ops_client.cookies.get(cookie_name) + assert stolen_token + + logout = ops_client.post("/api/v1/auth/logout") + assert logout.status_code == 200 + + # Simulate the browser cookie race (or a copied cookie): server-side revocation is + # authoritative and must reject the original signed token independently of deletion. + ops_client.cookies.set(cookie_name, stolen_token) + assert ops_client.get("/api/v1/auth/session").status_code == 401 + + # Remove the deliberately injected hostless cookie before exercising a normal browser + # login. Otherwise httpx sends it alongside the real testserver cookie, which is not a + # state a browser can create for the same origin/path pair. + ops_client.cookies.clear() + + # A fresh login in the same second receives a distinct signed token and remains valid. + fresh_login = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"}) + assert fresh_login.status_code == 200 + assert ops_client.get("/api/v1/auth/session").status_code == 200 + + +def test_logout_without_a_session_is_safe(client): + response = client.post("/api/v1/demo/logout") + assert response.status_code == 200 + + +def test_demo_reset_preserves_integration_telemetry(ops_client, client): + from app.core.config import get_settings + + settings = get_settings() + probe = client.get( + "/api/v1/integrations/mcp/operations-summary", + headers={ + "X-Service-Token": settings.mcp_hub_service_token, + "X-Client-Id": "itworx-mcp-hub:mobilityops:reset-probe", + }, + ) + assert probe.status_code == 200 + assert ops_client.post("/api/v1/demo/reset").status_code == 200 + + assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200 + events = client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() + assert any( + event["actor_label"] == "itworx-mcp-hub" + and event["metadata"].get("reported_client_id", "").endswith(":reset-probe") + for event in events + ) diff --git a/backend/tests/test_bookings.py b/backend/tests/test_bookings.py new file mode 100644 index 0000000..2c65a70 --- /dev/null +++ b/backend/tests/test_bookings.py @@ -0,0 +1,382 @@ +import threading +from datetime import datetime, timedelta + +from fastapi.testclient import TestClient + +from app.main import app + + +def test_list_bookings_filters_by_vehicle(ops_client): + response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"}) + assert response.status_code == 200 + bookings = response.json() + assert len(bookings) >= 1 + assert all(b["vehicle_ref"] == "MO-024" for b in bookings) + + +def test_list_bookings_supports_bounded_search_pages(ops_client): + response = ops_client.get( + "/api/v1/bookings", + params={"query": "BK-", "page": 1, "page_size": 25}, + ) + assert response.status_code == 200 + body = response.json() + assert body["page"] == 1 + assert body["page_size"] == 25 + assert body["total"] > 25 + assert body["total_pages"] > 1 + assert len(body["items"]) == 25 + + +def test_list_bookings_filters_operational_window_and_location(ops_client): + booking = ops_client.get("/api/v1/bookings").json()[0] + vehicle = ops_client.get(f"/api/v1/vehicles/{booking['vehicle_ref']}").json() + starts_at = datetime.fromisoformat(booking["starts_at"]) + response = ops_client.get( + "/api/v1/bookings", + params={ + "starts_from": (starts_at - timedelta(minutes=1)).isoformat(), + "starts_to": (starts_at + timedelta(minutes=1)).isoformat(), + "location": vehicle["location"], + "sort": "starts_asc", + }, + ) + assert response.status_code == 200 + assert booking["public_ref"] in {item["public_ref"] for item in response.json()} + + +def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client): + existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json() + conflict = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": existing["vehicle_ref"], + "starts_at": existing["starts_at"], + "ends_at": existing["ends_at"], + }, + ) + assert conflict.status_code == 409 + available_vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[ + 0 + ]["public_ref"] + created = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": available_vehicle, + "starts_at": "2030-09-01T10:00:00Z", + "ends_at": "2030-09-02T12:00:00Z", + }, + ) + assert created.status_code == 201 + body = created.json() + assert body["status"] == "reserved" + assert body["vehicle_ref"] == available_vehicle + assert body["requirements_complete"] is False + + +def test_booking_requirements_are_explicit_and_audited(ops_client): + window = {"starts_at": "2031-09-01T10:00:00Z", "ends_at": "2031-09-02T12:00:00Z"} + vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0] + booking = ops_client.post( + "/api/v1/bookings", + json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window}, + ).json() + checkout = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/checkout", + json={ + "start_odometer_km": 100000, + "fuel_level_percent": 90, + "cleanliness_ok": True, + "damage_reported": False, + "technical_warning": False, + }, + ) + assert checkout.status_code == 409 + + whitespace_only = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/complete-requirements", + json={"confirmation": " "}, + ) + assert whitespace_only.status_code == 422 + assert ( + ops_client.get(f"/api/v1/bookings/{booking['public_ref']}").json()["requirements_complete"] + is False + ) + + confirmed = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/complete-requirements", + json={"confirmation": "Licence and rental conditions checked"}, + ) + assert confirmed.status_code == 200 + assert confirmed.json()["requirements_complete"] is True + audit = ops_client.get("/api/v1/audit", params={"action": "booking_requirements_completed"}) + assert audit.status_code == 200 + assert any(item["entity_ref"] == booking["public_ref"] for item in audit.json()) + + +def test_booking_creation_cannot_preconfirm_requirements(ops_client): + window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"} + vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0] + + created = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": vehicle["public_ref"], + "requirements_complete": True, + **window, + }, + ) + + assert created.status_code == 201 + assert created.json()["requirements_complete"] is False + audit = ops_client.get( + "/api/v1/audit", + params={"action": "booking_requirements_completed"}, + ) + assert all(item["entity_ref"] != created.json()["public_ref"] for item in audit.json()) + + +def test_customer_search_returns_canonical_customers(ops_client): + response = ops_client.get("/api/v1/customers", params={"query": "CUS-"}) + assert response.status_code == 200 + assert response.json() + assert all(item["public_ref"].startswith("CUS-") for item in response.json()) + + +def test_booking_availability_excludes_overlapping_vehicle(ops_client): + existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json() + response = ops_client.get( + "/api/v1/bookings/availability", + params={"starts_at": existing["starts_at"], "ends_at": existing["ends_at"]}, + ) + assert response.status_code == 200 + assert existing["vehicle_ref"] not in {item["public_ref"] for item in response.json()} + + +def test_reserved_booking_can_be_cancelled_once(ops_client): + window = {"starts_at": "2032-09-01T10:00:00Z", "ends_at": "2032-09-02T12:00:00Z"} + available = ops_client.get("/api/v1/bookings/availability", params=window).json() + assert available + create_response = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": available[0]["public_ref"], + **window, + }, + ) + assert create_response.status_code == 201 + created = create_response.json() + response = ops_client.post( + f"/api/v1/bookings/{created['public_ref']}/cancel", + json={"reason": "Customer changed plans"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + repeated = ops_client.post( + f"/api/v1/bookings/{created['public_ref']}/cancel", + json={"reason": "Customer changed plans"}, + ) + assert repeated.status_code == 409 + + +def test_reserved_booking_can_be_rescheduled_with_overlap_protection(ops_client): + window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"} + existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json() + booking = ops_client.post( + "/api/v1/bookings", + json={"customer_ref": "CUS-0001", "vehicle_ref": existing["vehicle_ref"], **window}, + ).json() + updated = ops_client.patch( + f"/api/v1/bookings/{booking['public_ref']}/schedule", + json={ + "starts_at": "2033-09-03T10:00:00Z", + "ends_at": "2033-09-04T12:00:00Z", + "reason": "Customer requested a later collection", + }, + ) + assert updated.status_code == 200 + assert updated.json()["starts_at"].startswith("2033-09-03T10:00:00") + + conflict = ops_client.patch( + f"/api/v1/bookings/{booking['public_ref']}/schedule", + json={ + "starts_at": existing["starts_at"], + "ends_at": existing["ends_at"], + "reason": "Conflicting test move", + }, + ) + assert conflict.status_code == 409 + + +def test_concurrent_bookings_only_reserve_vehicle_once(): + results: list[int] = [] + seed_client = TestClient(app) + seed_client.post("/api/v1/demo/login", json={"role": "operations_manager"}) + window = {"starts_at": "2040-09-01T10:00:00Z", "ends_at": "2040-09-02T12:00:00Z"} + available = seed_client.get("/api/v1/bookings/availability", params=window).json() + assert available + vehicle_ref = available[0]["public_ref"] + + def submit() -> None: + client = TestClient(app) + client.post("/api/v1/demo/login", json={"role": "operations_manager"}) + response = client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": vehicle_ref, + **window, + }, + ) + results.append(response.status_code) + + threads = [threading.Thread(target=submit) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert results.count(201) == 1 + assert results.count(409) == 1 + + +def test_checkout_records_inspection_and_activates_safe_booking(ops_client): + window = {"starts_at": "2050-09-01T10:00:00Z", "ends_at": "2050-09-02T12:00:00Z"} + available = ops_client.get("/api/v1/bookings/availability", params=window).json() + vehicle_option = next(item for item in available if item["operational_status"] == "available") + vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json() + booking = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": vehicle["public_ref"], + **window, + }, + ).json() + confirmed = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/complete-requirements", + json={"confirmation": "Licence and rental conditions checked"}, + ) + assert confirmed.status_code == 200 + response = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/checkout", + json={ + "start_odometer_km": vehicle["odometer_km"], + "fuel_level_percent": 95, + "cleanliness_ok": True, + "damage_reported": False, + "technical_warning": False, + }, + ) + assert response.status_code == 200 + assert response.json()["activated"] is True + assert response.json()["booking_status"] == "active" + updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json() + assert updated_vehicle["operational_status"] == "rented" + assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"]) + checkout_audit = ops_client.get( + "/api/v1/audit", + params={"action": "booking_checkout_recorded"}, + ).json() + booking_event = next( + item for item in checkout_audit if item["entity_ref"] == booking["public_ref"] + ) + vehicle_audit = ops_client.get( + "/api/v1/audit", + params={ + "action": "vehicle_status_changed", + "correlation_id": booking_event["correlation_id"], + }, + ).json() + assert len(vehicle_audit) == 1 + assert vehicle_audit[0]["entity_ref"] == vehicle["public_ref"] + assert vehicle_audit[0]["before"]["operational_status"] == "available" + assert vehicle_audit[0]["after"]["operational_status"] == "rented" + + +def test_checkout_odometer_regression_keeps_canonical_and_opens_bounded_quality_issue( + ops_client, +): + window = {"starts_at": "2052-09-01T10:00:00Z", "ends_at": "2052-09-02T12:00:00Z"} + existing_issues = ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "rule_type": "odometer_regression"}, + ).json() + unavailable_refs = {issue["entity_ref"] for issue in existing_issues} + available = ops_client.get("/api/v1/bookings/availability", params=window).json() + vehicle_option = next( + item + for item in available + if item["operational_status"] == "available" and item["public_ref"] not in unavailable_refs + ) + vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json() + assert vehicle["odometer_km"] > 0 + booking = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0003", + "vehicle_ref": vehicle["public_ref"], + **window, + }, + ).json() + ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/complete-requirements", + json={"confirmation": "Licence and rental conditions checked"}, + ) + + checkout = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/checkout", + json={ + "start_odometer_km": vehicle["odometer_km"] - 1, + "fuel_level_percent": 90, + "cleanliness_ok": True, + "damage_reported": False, + "technical_warning": False, + }, + ) + + assert checkout.status_code == 200 + assert checkout.json()["booking_status"] == "blocked" + updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json() + assert updated_vehicle["odometer_km"] == vehicle["odometer_km"] + issues = ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "rule_type": "odometer_regression"}, + ).json() + issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"]) + assert issue["evidence"]["source_type"] == "checkout" + assert issue["evidence"]["correctable_booking_refs"] == [] + detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json() + assert any(snapshot["entity_type"] == "inspection" for snapshot in detail["related_snapshots"]) + invalid_correction = ops_client.post( + f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression", + json={ + "decision": "correct_reading", + "booking_ref": booking["public_ref"], + "corrected_odometer_km": vehicle["odometer_km"], + }, + ) + assert invalid_correction.status_code == 422 + retained = ops_client.post( + f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression", + json={"decision": "retain_canonical"}, + ) + assert retained.status_code == 200 + assert retained.json()["status"] == "resolved" + + +def test_get_booking_detail(ops_client): + response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "active" + assert body["vehicle_ref"] == "MO-024" + + +def test_get_booking_404_for_unknown_ref(ops_client): + response = ops_client.get("/api/v1/bookings/BK-UNKNOWN") + assert response.status_code == 404 diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..25cc3fe --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,89 @@ +from sqlalchemy import select + +from app.core.db import SessionLocal +from app.models.vehicle import Vehicle + + +def test_dashboard_metrics_are_persisted_counts(ops_client): + response = ops_client.get("/api/v1/dashboard") + assert response.status_code == 200 + body = response.json() + metrics = body["metrics"] + total = ( + metrics["available"] + + metrics["rented"] + + metrics["cleaning"] + + metrics["maintenance"] + + metrics["blocked"] + ) + assert total == 50 + assert metrics["open_quality_issues"] >= 1 + assert metrics["pending_or_failed_workflows"] >= 1 + + +def test_dashboard_metrics_change_when_persisted_fleet_data_changes(ops_client): + before = ops_client.get("/api/v1/dashboard").json()["metrics"] + db = SessionLocal() + probe = Vehicle( + public_ref="MO-METRIC-PROBE", + make="Synthetic", + model="Metric probe", + model_year=2026, + registration_number="TEST-METRIC-PROBE", + location="Test fixture", + operational_status="blocked", + odometer_km=0, + next_service_km=1, + active=True, + version=1, + ) + try: + db.add(probe) + db.commit() + + after_response = ops_client.get("/api/v1/dashboard") + assert after_response.status_code == 200 + after = after_response.json()["metrics"] + assert after["blocked"] == before["blocked"] + 1 + fleet_statuses = ("available", "rented", "cleaning", "maintenance", "blocked") + assert ( + sum(after[status] for status in fleet_statuses) + == sum(before[status] for status in fleet_statuses) + 1 + ) + finally: + persisted_probe = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-METRIC-PROBE")) + if persisted_probe is not None: + db.delete(persisted_probe) + db.commit() + db.close() + + +def test_dashboard_attention_items_link_to_records(ops_client): + response = ops_client.get("/api/v1/dashboard") + body = response.json() + assert len(body["attention_items"]) > 0 + for item in body["attention_items"]: + assert item["link_ref"] + assert item["severity"] in ("low", "medium", "high") + + +def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client): + """The dashboard subtext used to be raw, untranslated evidence text (and for most + seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue'). + The API must never emit prose here -- only stable signal codes + params, exactly + like the data-quality issue detail page, for the frontend to localize.""" + response = ops_client.get("/api/v1/dashboard") + body = response.json() + assert len(body["attention_items"]) > 0 + for item in body["attention_items"]: + assert "detail" not in item + assert len(item["evidence_signals"]) > 0 + for signal in item["evidence_signals"]: + assert signal["code"] + assert signal["code"] != "Synthetic deterministic seed issue" + + +def test_dashboard_recent_automation_capped_at_five(ops_client): + response = ops_client.get("/api/v1/dashboard") + body = response.json() + assert len(body["recent_automation"]) == 5 diff --git a/backend/tests/test_data_quality.py b/backend/tests/test_data_quality.py new file mode 100644 index 0000000..a1abf91 --- /dev/null +++ b/backend/tests/test_data_quality.py @@ -0,0 +1,1420 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from threading import Barrier + +from sqlalchemy import delete, select, text + +from app.core.db import SessionLocal +from app.models.audit import AuditEvent +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.idempotency import IdempotencyRecord +from app.models.inspection import Inspection +from app.models.maintenance import MaintenanceRecord +from app.models.outbox import OutboxEvent +from app.models.vehicle import Vehicle +from app.schemas import CurrentUser, RegisterReturnRequest, ResolveOdometerRegressionRequest +from app.services.data_quality import ( + open_odometer_regression_issue, + resolve_odometer_regression, +) +from app.services.returns import register_vehicle_return + + +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 _cleanup_odometer_scenario(vehicle_id, customer_id) -> None: + """Remove one isolated DQ-03 scenario in foreign-key-safe order.""" + with SessionLocal() as db: + booking_ids = db.scalars(select(Booking.id).where(Booking.vehicle_id == vehicle_id)).all() + issue_ids = db.scalars( + select(DataQualityIssue.id).where(DataQualityIssue.entity_id == vehicle_id) + ).all() + audit_entity_ids = [vehicle_id, *booking_ids, *issue_ids] + if audit_entity_ids: + db.execute(delete(AuditEvent).where(AuditEvent.entity_id.in_(audit_entity_ids))) + if booking_ids: + db.execute( + delete(IdempotencyRecord).where(IdempotencyRecord.booking_id.in_(booking_ids)) + ) + db.execute(delete(OutboxEvent).where(OutboxEvent.aggregate_id.in_(booking_ids))) + db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id)) + db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id)) + db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id)) + db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id)) + db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id)) + db.execute(delete(Customer).where(Customer.id == customer_id)) + db.commit() + + +def test_list_includes_all_five_rule_types(ops_client): + response = ops_client.get("/api/v1/data-quality/issues") + assert response.status_code == 200 + issues = response.json() + rule_types = {i["rule_type"] for i in issues} + assert rule_types == { + "possible_duplicate_customer", + "missing_required_field", + "odometer_regression", + "booking_overlap", + "vehicle_status_conflict", + } + + +def test_scan_is_idempotent_once_seeded(ops_client): + # The session fixture already ran a scan as part of seeding; running again + # must not create duplicate open issues for the same (rule_type, entity). + response = ops_client.post("/api/v1/data-quality/scan") + assert response.status_code == 200 + assert response.json()["created"] == {} + + +def test_scan_detects_imported_maintenance_odometer_regression(ops_client): + with SessionLocal() as db: + vehicle = Vehicle( + public_ref="MO-DQ-SCAN", + make="Synthetic", + model="Scanner", + model_year=2026, + registration_number="DQ-SCAN-01", + location="Brussels", + operational_status="available", + odometer_km=20_000, + next_service_km=30_000, + active=True, + version=1, + ) + db.add(vehicle) + db.flush() + vehicle_id = vehicle.id + db.add_all( + [ + MaintenanceRecord( + public_ref="MAINT-DQ-SCAN-A", + vehicle_id=vehicle.id, + occurred_at=datetime(2045, 1, 1, tzinfo=UTC), + odometer_km=20_000, + category="inspection", + summary="Synthetic scan baseline", + ), + MaintenanceRecord( + public_ref="MAINT-DQ-SCAN-B", + vehicle_id=vehicle.id, + occurred_at=datetime(2045, 2, 1, tzinfo=UTC), + odometer_km=19_000, + category="inspection", + summary="Synthetic imported regression", + ), + ] + ) + db.commit() + try: + response = ops_client.post("/api/v1/data-quality/scan") + assert response.status_code == 200 + assert response.json()["created"]["odometer_regression"] >= 1 + issues = ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "rule_type": "odometer_regression"}, + ).json() + issue = next(item for item in issues if item["entity_ref"] == "MO-DQ-SCAN") + assert issue["evidence"]["source_type"] == "maintenance" + assert issue["evidence"]["correctable_booking_refs"] == [] + + # A later imported regression must enrich the existing open issue instead of + # being silently dropped by the generic check-then-return scanner path. + with SessionLocal() as db: + db.add( + MaintenanceRecord( + public_ref="MAINT-DQ-SCAN-C", + vehicle_id=vehicle_id, + occurred_at=datetime(2045, 3, 1, tzinfo=UTC), + odometer_km=18_000, + category="inspection", + summary="Second synthetic imported regression", + ) + ) + db.commit() + rescanned = ops_client.post("/api/v1/data-quality/scan") + assert rescanned.status_code == 200 + enriched = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json() + later_refs = {signal["params"]["later_ref"] for signal in enriched["evidence"]["signals"]} + assert {"MAINT-DQ-SCAN-B", "MAINT-DQ-SCAN-C"}.issubset(later_refs) + finally: + with SessionLocal() as db: + db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id)) + db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id)) + db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id)) + db.commit() + + +def test_scan_requires_operations_manager(employee_client): + response = employee_client.post("/api/v1/data-quality/scan") + 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_manager_can_assign_prioritised_quality_work_and_filter_it(ops_client): + assignee = next(user for user in ops_client.get("/api/v1/users").json() if user["active"]) + open_issues = ops_client.get("/api/v1/data-quality/issues", params={"status": "open"}).json() + refs = [issue["public_ref"] for issue in open_issues[:2]] + due_at = "2030-01-15T12:00:00+00:00" + updated = ops_client.post( + "/api/v1/data-quality/issues/bulk-work", + json={ + "issue_refs": refs, + "assigned_to_ref": assignee["public_ref"], + "due_at": due_at, + }, + ) + assert updated.status_code == 200 + assert {issue["public_ref"] for issue in updated.json()["updated"]} == set(refs) + assert all( + issue["assigned_to_ref"] == assignee["public_ref"] for issue in updated.json()["updated"] + ) + + filtered = ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "assigned_to_ref": assignee["public_ref"]}, + ).json() + assert set(refs).issubset({issue["public_ref"] for issue in filtered}) + audits = ops_client.get("/api/v1/audit", params={"action": "data_quality_work_updated"}).json() + assert len(audits) >= 2 + + cleared = ops_client.post( + "/api/v1/data-quality/issues/bulk-work", + json={"issue_refs": refs, "clear_assignment": True}, + ) + assert cleared.status_code == 200 + assert all(issue["assigned_to_ref"] is None for issue in cleared.json()["updated"]) + + +def test_employee_cannot_assign_quality_work(employee_client): + response = employee_client.post( + "/api/v1/data-quality/issues/bulk-work", + json={"issue_refs": ["DQ-DEMO-OVERLAP"], "clear_assignment": True}, + ) + assert response.status_code == 403 + + +def test_issue_page_preserves_severity_filter_and_limits_results(ops_client): + response = ops_client.get( + "/api/v1/data-quality/issues", + params={"severity": "high", "page": 1, "page_size": 25}, + ) + assert response.status_code == 200 + body = response.json() + assert len(body["items"]) <= 25 + assert all(issue["severity"] == "high" for issue in body["items"]) + assert body["total"] >= len(body["items"]) + + +def test_get_issue_requires_operations_manager(employee_client): + response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE") + assert response.status_code == 403 + + +def test_defer_requires_operations_manager(employee_client): + response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/defer") + assert response.status_code == 403 + + +def test_reject_requires_operations_manager(employee_client): + response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/reject") + assert response.status_code == 403 + + +def test_s2_duplicate_customer_issue_detail(ops_client): + response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE") + assert response.status_code == 200 + body = response.json() + assert body["entity_ref"] == "CUS-0012" + assert body["entity_snapshot"]["public_ref"] == "CUS-0012" + assert [s["public_ref"] for s in body["related_snapshots"]] == ["CUS-0178"] + + +def test_s4_booking_overlap_issue_detail(ops_client): + response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP") + assert response.status_code == 200 + body = response.json() + assert body["entity_ref"] == "MO-016" + assert set(body["evidence"]["related_refs"]) == {"BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B"} + + +def test_defer_then_reject_are_rejected_on_closed_issue(ops_client): + issues = ops_client.get( + "/api/v1/data-quality/issues", + params={"rule_type": "missing_required_field", "status": "open"}, + ).json() + target = issues[0]["public_ref"] + + deferred = ops_client.post(f"/api/v1/data-quality/issues/{target}/defer") + assert deferred.status_code == 200 + assert deferred.json()["status"] == "deferred" + + again = ops_client.post(f"/api/v1/data-quality/issues/{target}/reject") + assert again.status_code == 409 + assert again.json()["error"]["code"] == "ISSUE_NOT_OPEN" + + +def test_merge_customers_requires_operations_manager(employee_client): + response = employee_client.post( + "/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers", + json={"survivor_ref": "CUS-0012"}, + ) + assert response.status_code == 403 + + +def test_merge_customers_rejects_unrelated_survivor(ops_client): + response = ops_client.post( + "/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers", + json={"survivor_ref": "CUS-0099"}, + ) + assert response.status_code == 422 + assert response.json()["error"]["code"] == "INVALID_SURVIVOR" + + +def test_merge_customers_s2_scenario_rewires_and_audits(ops_client): + before_bookings = ops_client.get( + "/api/v1/bookings", params={"vehicle_ref": "MO-001"} + ) # warm the client session; irrelevant vehicle, just a cheap authenticated call + assert before_bookings.status_code == 200 + + response = ops_client.post( + "/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers", + json={"survivor_ref": "CUS-0012", "field_overrides": {"city": "Turnhout"}}, + ) + assert response.status_code == 200 + body = response.json() + assert body["survivor_ref"] == "CUS-0012" + assert body["loser_ref"] == "CUS-0178" + + issue = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE").json() + assert issue["status"] == "resolved" + + audit_events = ops_client.get("/api/v1/audit", params={"action": "customer_merged"}).json() + assert len(audit_events) >= 1 + + # Already-resolved issue cannot be merged again. + replay = ops_client.post( + "/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE/merge-customers", + json={"survivor_ref": "CUS-0012"}, + ) + assert replay.status_code == 409 + + +def test_overlap_related_snapshots_are_typed_as_bookings_not_vehicles(ops_client): + body = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP").json() + assert len(body["related_snapshots"]) == 2 + for snap in body["related_snapshots"]: + assert snap["entity_type"] == "booking" + assert snap["public_ref"] in {"BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B"} + assert "starts_at" in snap and "ends_at" in snap + + +def _first_open(ops_client, rule_type: str) -> dict: + issues = ops_client.get( + "/api/v1/data-quality/issues", params={"rule_type": rule_type, "status": "open"} + ).json() + assert issues, f"expected at least one open {rule_type} issue" + return issues[0] + + +def test_provide_fields_requires_operations_manager(employee_client): + response = employee_client.post( + "/api/v1/data-quality/issues/DQ-DEMO-ATTENTION/provide-fields", + json={"fields": {"registration_number": "TST-001"}}, + ) + assert response.status_code == 403 + + +def test_provide_fields_rejects_wrong_rule_type(ops_client): + response = ops_client.post( + "/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/provide-fields", + json={"fields": {"make": "Test"}}, + ) + assert response.status_code == 409 + assert response.json()["error"]["code"] == "NOT_A_MISSING_FIELD_ISSUE" + + +def test_provide_fields_rejects_disallowed_field(ops_client): + target = _first_open(ops_client, "missing_required_field") + detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json() + disallowed = "city" if detail["entity_type"] == "customer" else "next_service_km" + response = ops_client.post( + f"/api/v1/data-quality/issues/{target['public_ref']}/provide-fields", + json={"fields": {disallowed: "anything"}}, + ) + assert response.status_code == 422 + assert response.json()["error"]["code"] == "INVALID_FIELD" + + +def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client): + issues = ops_client.get( + "/api/v1/data-quality/issues", + params={"rule_type": "missing_required_field", "status": "open"}, + ).json() + target = next(i for i in issues if i["entity_type"] == "vehicle") + + response = ops_client.post( + f"/api/v1/data-quality/issues/{target['public_ref']}/provide-fields", + json={ + "fields": { + "registration_number": "TST-999", + "make": "TestMake", + "model": "TestModel", + "location": "Depot", + } + }, + ) + assert response.status_code == 200 + assert response.json()["status"] == "resolved" + + vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json() + assert vehicle["registration_number"] == "TST-999" + + +def test_vehicle_entity_snapshot_includes_registration_number(ops_client): + """The snapshot used to omit registration_number entirely, so the 'provide missing + fields' form always showed it blank -- even for a vehicle whose plate was actually + on file, and even when a *different* field was the genuinely missing one.""" + 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") + vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json() + + detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json() + assert detail["entity_snapshot"]["registration_number"] == vehicle["registration_number"] + + +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_status_recommendation_requires_operations_manager(employee_client): + response = employee_client.post( + "/api/v1/data-quality/issues/DQ-DEMO-STATUS/status-recommendation" + ) + assert response.status_code == 403 + + +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", + json={"recommendation_token": "irrelevant"}, + ) + assert response.status_code == 403 + + +def test_status_recommendation_preview_does_not_mutate_anything(ops_client): + target = _first_open(ops_client, "vehicle_status_conflict") + vehicle_before = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json() + + preview_response = ops_client.post( + f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation" + ) + assert preview_response.status_code == 200 + preview = preview_response.json() + assert preview["current_status"] == vehicle_before["operational_status"] + assert preview["recommendation_token"] + assert "facts" in preview + + # Calling preview again (as the UI would on every open) must still not mutate. + ops_client.post(f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation") + issue_after = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json() + vehicle_after = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json() + assert issue_after["status"] == "open" + assert vehicle_after["operational_status"] == vehicle_before["operational_status"] + + +def test_apply_recommended_status_resolves_conflict(ops_client): + targets = ops_client.get( + "/api/v1/data-quality/issues", + params={"rule_type": "vehicle_status_conflict", "status": "open"}, + ).json() + target = None + preview = None + for candidate in targets: + candidate_preview = ops_client.post( + f"/api/v1/data-quality/issues/{candidate['public_ref']}/status-recommendation" + ).json() + if candidate_preview["safe_to_apply"]: + target = candidate + preview = candidate_preview + break + assert target is not None and preview is not None + assert preview["safe_to_apply"] is True + assert preview["manual_review_required"] is False + + response = ops_client.post( + f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status", + json={"recommendation_token": preview["recommendation_token"]}, + ) + assert response.status_code == 200 + body = response.json() + assert body["issue"]["status"] == "resolved" + assert body["applied_status"] == preview["recommended_status"] + assert body["reason_code"] == preview["recommendation_code"] + + vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json() + assert vehicle["operational_status"] == body["applied_status"] + + +def test_apply_recommended_status_rejects_stale_token(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", + json={"recommendation_token": "not-a-real-token"}, + ) + assert response.status_code == 409 + assert response.json()["error"]["code"] == "RECOMMENDATION_STALE" + + +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): + # Create a live return regression to prove the complete correction chain updates + # both persisted representations of that reading (booking + return inspection). + 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"] + inspection_ref = returned.json()["inspection_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 + with SessionLocal() as db: + inspection = db.scalar(select(Inspection).where(Inspection.public_ref == inspection_ref)) + assert inspection is not None + assert inspection.odometer_km == corrected + + +def test_correcting_one_of_two_regressions_keeps_the_other_open(ops_client): + with SessionLocal() as db: + customer = Customer( + public_ref="CUS-DQ-MULTI", + first_name="Synthetic", + last_name="Multi", + email="dq-multi@example.test", + ) + vehicle = Vehicle( + public_ref="MO-DQ-MULTI", + make="Synthetic", + model="Multi", + model_year=2026, + registration_number="DQ-MULTI", + location="Brussels", + operational_status="available", + odometer_km=1_000, + next_service_km=2_000, + active=True, + version=1, + ) + db.add_all([customer, vehicle]) + db.flush() + bookings: list[Booking] = [] + inspections: list[Inspection] = [] + for index, reading in enumerate((900, 800), start=1): + booking = Booking( + public_ref=f"BK-DQ-MULTI-{index}", + customer_id=customer.id, + vehicle_id=vehicle.id, + starts_at=datetime(2046, index, 1, tzinfo=UTC), + ends_at=datetime(2046, index, 2, tzinfo=UTC), + status="returned", + start_odometer_km=reading - 10, + end_odometer_km=reading, + requirements_complete=True, + ) + db.add(booking) + db.flush() + inspection = Inspection( + public_ref=f"INSP-DQ-M-{index}", + booking_id=booking.id, + vehicle_id=vehicle.id, + type="return", + fuel_level_percent=50, + cleanliness_ok=True, + damage_reported=False, + technical_warning=False, + odometer_km=reading, + completed_at=booking.ends_at, + ) + db.add(inspection) + db.flush() + open_odometer_regression_issue( + db, + vehicle=vehicle, + reading_ref=inspection.public_ref, + reading_km=reading, + canonical_km=vehicle.odometer_km, + source_type="return", + related_refs=[booking.public_ref, inspection.public_ref], + correctable_booking_refs=[booking.public_ref], + public_ref="DQ-MULTI-SOURCE" if index == 1 else None, + ) + bookings.append(booking) + inspections.append(inspection) + booking_refs = [booking.public_ref for booking in bookings] + vehicle_id = vehicle.id + customer_id = customer.id + db.commit() + + try: + first = ops_client.post( + "/api/v1/data-quality/issues/DQ-MULTI-SOURCE/resolve-odometer-regression", + json={ + "decision": "correct_reading", + "booking_ref": booking_refs[0], + "corrected_odometer_km": 1_100, + }, + ) + assert first.status_code == 200, first.text + assert first.json()["status"] == "open" + assert first.json()["evidence"]["correctable_booking_refs"] == [booking_refs[1]] + + second = ops_client.post( + "/api/v1/data-quality/issues/DQ-MULTI-SOURCE/resolve-odometer-regression", + json={ + "decision": "correct_reading", + "booking_ref": booking_refs[1], + "corrected_odometer_km": 1_200, + }, + ) + assert second.status_code == 200, second.text + assert second.json()["status"] == "resolved" + with SessionLocal() as db: + persisted = db.scalars( + select(Inspection) + .where(Inspection.vehicle_id == vehicle_id) + .order_by(Inspection.public_ref) + ).all() + assert [item.odometer_km for item in persisted] == [1_100, 1_200] + finally: + with SessionLocal() as db: + db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id)) + db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id)) + db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id)) + db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id)) + db.execute(delete(Customer).where(Customer.id == customer_id)) + db.commit() + + +def test_retained_regression_is_suppressed_until_source_reading_changes(ops_client): + with SessionLocal() as db: + vehicle = Vehicle( + public_ref="MO-DQ-RETAIN", + make="Synthetic", + model="Retain", + model_year=2026, + registration_number="DQ-RETAIN", + location="Brussels", + operational_status="available", + odometer_km=20_000, + next_service_km=30_000, + active=True, + version=1, + ) + db.add(vehicle) + db.flush() + vehicle_id = vehicle.id + db.add_all( + [ + MaintenanceRecord( + public_ref="MAINT-DQ-RET-A", + vehicle_id=vehicle.id, + occurred_at=datetime(2047, 1, 1, tzinfo=UTC), + odometer_km=20_000, + category="inspection", + summary="Retain baseline", + ), + MaintenanceRecord( + public_ref="MAINT-DQ-RET-B", + vehicle_id=vehicle.id, + occurred_at=datetime(2047, 2, 1, tzinfo=UTC), + odometer_km=19_000, + category="inspection", + summary="Retained source reading", + ), + ] + ) + db.commit() + try: + assert ops_client.post("/api/v1/data-quality/scan").status_code == 200 + issue = next( + item + for item in ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "rule_type": "odometer_regression"}, + ).json() + if item["entity_ref"] == "MO-DQ-RETAIN" + ) + retained = ops_client.post( + f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression", + json={"decision": "retain_canonical", "note": "Verified source entry"}, + ) + assert retained.status_code == 200 + assert retained.json()["status"] == "resolved" + + assert ops_client.post("/api/v1/data-quality/scan").status_code == 200 + with SessionLocal() as db: + assert ( + db.scalar( + select(DataQualityIssue).where( + DataQualityIssue.entity_id == vehicle_id, + DataQualityIssue.status == "open", + ) + ) + is None + ) + changed = db.scalar( + select(MaintenanceRecord).where(MaintenanceRecord.public_ref == "MAINT-DQ-RET-B") + ) + changed.odometer_km = 18_999 + db.commit() + + assert ops_client.post("/api/v1/data-quality/scan").status_code == 200 + reopened = next( + item + for item in ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "rule_type": "odometer_regression"}, + ).json() + if item["entity_ref"] == "MO-DQ-RETAIN" + ) + assert reopened["evidence"]["reopened_from"] == issue["public_ref"] + finally: + with SessionLocal() as db: + db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id)) + db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id)) + db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id)) + db.commit() + + +def test_live_return_retained_as_canonical_is_not_reopened_by_scan(ops_client): + now = datetime.now(UTC) + with SessionLocal() as db: + customer = Customer( + public_ref="CUS-DQ-LIVE-RET", + first_name="Synthetic", + last_name="Retained return", + email="dq-live-retain@example.test", + ) + vehicle = Vehicle( + public_ref="MO-DQ-LIVE-RET", + make="Synthetic", + model="Retained return", + model_year=2026, + registration_number="DQ-LIVE-RET", + location="Brussels", + operational_status="rented", + odometer_km=20_000, + next_service_km=30_000, + active=True, + version=1, + ) + db.add_all([customer, vehicle]) + db.flush() + booking = Booking( + public_ref="BK-DQ-LIVE-RET", + customer_id=customer.id, + vehicle_id=vehicle.id, + starts_at=now - timedelta(days=2), + ends_at=now + timedelta(days=1), + status="active", + start_odometer_km=19_500, + end_odometer_km=None, + requirements_complete=True, + ) + db.add_all( + [ + booking, + MaintenanceRecord( + public_ref="MNT-DQ-LIVE-RET", + vehicle_id=vehicle.id, + occurred_at=now - timedelta(days=3), + odometer_km=20_000, + category="inspection", + summary="Synthetic canonical baseline", + ), + ] + ) + vehicle_id = vehicle.id + customer_id = customer.id + db.commit() + + try: + returned = ops_client.post( + "/api/v1/bookings/BK-DQ-LIVE-RET/return", + json={ + "end_odometer_km": 19_000, + "fuel_level_percent": 50, + "cleanliness_ok": True, + "damage_reported": False, + "technical_warning": False, + }, + headers={"Idempotency-Key": "test-dq-live-retain-001"}, + ) + assert returned.status_code == 201, returned.text + issue_ref = returned.json()["quality_issue_ref"] + inspection_ref = returned.json()["inspection_ref"] + assert issue_ref is not None + + retained = ops_client.post( + f"/api/v1/data-quality/issues/{issue_ref}/resolve-odometer-regression", + json={"decision": "retain_canonical", "note": "Source reading verified as wrong."}, + ) + assert retained.status_code == 200, retained.text + assert retained.json()["status"] == "resolved" + assert retained.json()["evidence"]["retained_odometer_fingerprints"] == [ + { + "source_type": "return", + "later_ref": inspection_ref, + "later_km": 19_000, + } + ] + + scanned = ops_client.post("/api/v1/data-quality/scan") + assert scanned.status_code == 200, scanned.text + with SessionLocal() as db: + issues = db.scalars( + select(DataQualityIssue) + .where( + DataQualityIssue.entity_id == vehicle_id, + DataQualityIssue.rule_type == "odometer_regression", + ) + .order_by(DataQualityIssue.detected_at) + ).all() + assert [(issue.public_ref, issue.status) for issue in issues] == [ + (issue_ref, "resolved") + ] + finally: + _cleanup_odometer_scenario(vehicle_id, customer_id) + + +def test_scanned_return_regression_can_correct_booking_all_inspections_and_vehicle(ops_client): + now = datetime.now(UTC) + with SessionLocal() as db: + customer = Customer( + public_ref="CUS-DQ-SCAN-RET", + first_name="Synthetic", + last_name="Scanned return", + email="dq-scan-return@example.test", + ) + vehicle = Vehicle( + public_ref="MO-DQ-SCAN-RET", + make="Synthetic", + model="Scanned return", + model_year=2026, + registration_number="DQ-SCAN-RET", + location="Brussels", + operational_status="available", + odometer_km=20_000, + next_service_km=30_000, + active=True, + version=1, + ) + db.add_all([customer, vehicle]) + db.flush() + booking = Booking( + public_ref="BK-DQ-SCAN-RET", + customer_id=customer.id, + vehicle_id=vehicle.id, + starts_at=now - timedelta(days=3), + ends_at=now - timedelta(hours=12), + status="returned", + start_odometer_km=19_500, + end_odometer_km=18_500, + requirements_complete=True, + ) + db.add(booking) + db.flush() + db.add_all( + [ + MaintenanceRecord( + public_ref="MNT-DQ-SCAN-RET", + vehicle_id=vehicle.id, + occurred_at=now - timedelta(days=4), + odometer_km=20_000, + category="inspection", + summary="Synthetic canonical baseline", + ), + Inspection( + public_ref="INSP-DQ-SCAN-R1", + booking_id=booking.id, + vehicle_id=vehicle.id, + type="return", + fuel_level_percent=50, + cleanliness_ok=True, + damage_reported=False, + technical_warning=False, + odometer_km=19_000, + completed_at=now - timedelta(days=1), + ), + Inspection( + public_ref="INSP-DQ-SCAN-R2", + booking_id=booking.id, + vehicle_id=vehicle.id, + type="return", + fuel_level_percent=50, + cleanliness_ok=True, + damage_reported=False, + technical_warning=False, + odometer_km=18_500, + completed_at=now - timedelta(hours=12), + ), + ] + ) + vehicle_id = vehicle.id + customer_id = customer.id + db.commit() + + try: + scanned = ops_client.post("/api/v1/data-quality/scan") + assert scanned.status_code == 200, scanned.text + issue = next( + item + for item in ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "rule_type": "odometer_regression"}, + ).json() + if item["entity_ref"] == "MO-DQ-SCAN-RET" + ) + assert issue["evidence"]["correctable_booking_refs"] == ["BK-DQ-SCAN-RET"] + later_refs = {signal["params"]["later_ref"] for signal in issue["evidence"]["signals"]} + assert later_refs == {"INSP-DQ-SCAN-R1", "INSP-DQ-SCAN-R2"} + + corrected = ops_client.post( + f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression", + json={ + "decision": "correct_reading", + "booking_ref": "BK-DQ-SCAN-RET", + "corrected_odometer_km": 20_500, + }, + ) + assert corrected.status_code == 200, corrected.text + assert corrected.json()["status"] == "resolved" + + with SessionLocal() as db: + persisted_vehicle = db.get(Vehicle, vehicle_id) + persisted_booking = db.scalar( + select(Booking).where(Booking.public_ref == "BK-DQ-SCAN-RET") + ) + assert persisted_booking is not None + persisted_inspections = db.scalars( + select(Inspection) + .where(Inspection.booking_id == persisted_booking.id, Inspection.type == "return") + .order_by(Inspection.public_ref) + ).all() + assert persisted_vehicle is not None + assert persisted_vehicle.odometer_km == 20_500 + assert persisted_booking.end_odometer_km == 20_500 + assert [inspection.odometer_km for inspection in persisted_inspections] == [ + 20_500, + 20_500, + ] + finally: + _cleanup_odometer_scenario(vehicle_id, customer_id) + + +def test_resolver_and_concurrent_return_finish_without_deadlock_or_lost_evidence(): + now = datetime.now(UTC) + with SessionLocal() as db: + customer = Customer( + public_ref="CUS-DQ-RACE", + first_name="Synthetic", + last_name="Race", + email="dq-race@example.test", + ) + vehicle = Vehicle( + public_ref="MO-DQ-RACE", + make="Synthetic", + model="Race", + model_year=2026, + registration_number="DQ-RACE", + location="Brussels", + operational_status="rented", + odometer_km=20_000, + next_service_km=30_000, + active=True, + version=1, + ) + db.add_all([customer, vehicle]) + db.flush() + corrected_booking = Booking( + public_ref="BK-DQ-RACE-OLD", + customer_id=customer.id, + vehicle_id=vehicle.id, + starts_at=now - timedelta(days=4), + ends_at=now - timedelta(days=3), + status="returned", + start_odometer_km=19_500, + end_odometer_km=19_000, + requirements_complete=True, + ) + concurrent_booking = Booking( + public_ref="BK-DQ-RACE-NEW", + customer_id=customer.id, + vehicle_id=vehicle.id, + starts_at=now - timedelta(days=1), + ends_at=now + timedelta(days=1), + status="active", + start_odometer_km=19_000, + end_odometer_km=None, + requirements_complete=True, + ) + db.add_all([corrected_booking, concurrent_booking]) + db.flush() + old_inspection = Inspection( + public_ref="INSP-DQ-RACE-OLD", + booking_id=corrected_booking.id, + vehicle_id=vehicle.id, + type="return", + fuel_level_percent=50, + cleanliness_ok=True, + damage_reported=False, + technical_warning=False, + odometer_km=19_000, + completed_at=corrected_booking.ends_at, + ) + db.add(old_inspection) + db.flush() + issue = open_odometer_regression_issue( + db, + vehicle=vehicle, + reading_ref=old_inspection.public_ref, + reading_km=19_000, + canonical_km=20_000, + source_type="return", + related_refs=[corrected_booking.public_ref, old_inspection.public_ref], + correctable_booking_refs=[corrected_booking.public_ref], + public_ref="DQ-ODO-RACE", + ) + assert issue is not None + vehicle_id = vehicle.id + customer_id = customer.id + db.commit() + + actor = CurrentUser( + public_ref="USR-DQ-RACE", + display_name="DQ Race Manager", + role="operations_manager", + ) + start = Barrier(2) + + def correct_existing_reading() -> str: + with SessionLocal() as db: + db.execute(text("SET LOCAL lock_timeout = '5s'")) + start.wait(timeout=5) + resolved = resolve_odometer_regression( + db, + "DQ-ODO-RACE", + ResolveOdometerRegressionRequest( + decision="correct_reading", + booking_ref="BK-DQ-RACE-OLD", + corrected_odometer_km=20_500, + ), + actor, + ) + return resolved.status + + def return_other_booking() -> dict: + with SessionLocal() as db: + db.execute(text("SET LOCAL lock_timeout = '5s'")) + start.wait(timeout=5) + _status, response = register_vehicle_return( + db, + "BK-DQ-RACE-NEW", + RegisterReturnRequest( + end_odometer_km=18_000, + fuel_level_percent=50, + cleanliness_ok=True, + damage_reported=False, + technical_warning=False, + ), + "test-dq-resolve-return-race-001", + actor, + ) + return response + + try: + with ThreadPoolExecutor(max_workers=2) as executor: + correction_future = executor.submit(correct_existing_reading) + return_future = executor.submit(return_other_booking) + correction_status = correction_future.result(timeout=15) + return_result = return_future.result(timeout=15) + + assert correction_status in {"open", "resolved"} + assert return_result["quality_issue_ref"] is not None + with SessionLocal() as db: + persisted_vehicle = db.get(Vehicle, vehicle_id) + bookings = { + booking.public_ref: booking + for booking in db.scalars( + select(Booking).where(Booking.vehicle_id == vehicle_id) + ).all() + } + old_inspections = db.scalars( + select(Inspection).where(Inspection.booking_id == bookings["BK-DQ-RACE-OLD"].id) + ).all() + open_issues = db.scalars( + select(DataQualityIssue).where( + DataQualityIssue.entity_id == vehicle_id, + DataQualityIssue.rule_type == "odometer_regression", + DataQualityIssue.status == "open", + ) + ).all() + assert persisted_vehicle is not None + assert persisted_vehicle.odometer_km == 20_500 + assert bookings["BK-DQ-RACE-OLD"].end_odometer_km == 20_500 + assert [inspection.odometer_km for inspection in old_inspections] == [20_500] + assert bookings["BK-DQ-RACE-NEW"].status == "returned" + assert bookings["BK-DQ-RACE-NEW"].end_odometer_km == 18_000 + assert len(open_issues) == 1 + assert open_issues[0].evidence_json["correctable_booking_refs"] == ["BK-DQ-RACE-NEW"] + later_refs = { + signal["params"]["later_ref"] for signal in open_issues[0].evidence_json["signals"] + } + assert later_refs == {return_result["inspection_ref"]} + finally: + _cleanup_odometer_scenario(vehicle_id, customer_id) + + +def test_early_return_uses_inspection_time_without_duplicate_booking_regression(ops_client): + with SessionLocal() as db: + customer = Customer( + public_ref="CUS-DQ-EARLY", + first_name="Synthetic", + last_name="Early", + email="dq-early@example.test", + ) + vehicle = Vehicle( + public_ref="MO-DQ-EARLY", + make="Synthetic", + model="Early", + model_year=2026, + registration_number="DQ-EARLY", + location="Brussels", + operational_status="available", + odometer_km=200, + next_service_km=10_000, + active=True, + version=1, + ) + db.add_all([customer, vehicle]) + db.flush() + early = Booking( + public_ref="BK-DQ-EARLY-A", + customer_id=customer.id, + vehicle_id=vehicle.id, + starts_at=datetime(2048, 1, 1, tzinfo=UTC), + ends_at=datetime(2048, 3, 1, tzinfo=UTC), + status="returned", + start_odometer_km=90, + end_odometer_km=100, + requirements_complete=True, + ) + later = Booking( + public_ref="BK-DQ-EARLY-B", + customer_id=customer.id, + vehicle_id=vehicle.id, + starts_at=datetime(2048, 1, 10, tzinfo=UTC), + ends_at=datetime(2048, 2, 1, tzinfo=UTC), + status="returned", + start_odometer_km=100, + end_odometer_km=200, + requirements_complete=True, + ) + db.add_all([early, later]) + db.flush() + db.add_all( + [ + Inspection( + public_ref="INSP-DQ-EARLY-A", + booking_id=early.id, + vehicle_id=vehicle.id, + type="return", + fuel_level_percent=50, + cleanliness_ok=True, + damage_reported=False, + technical_warning=False, + odometer_km=100, + completed_at=datetime(2048, 1, 2, tzinfo=UTC), + ), + Inspection( + public_ref="INSP-DQ-EARLY-B", + booking_id=later.id, + vehicle_id=vehicle.id, + type="return", + fuel_level_percent=50, + cleanliness_ok=True, + damage_reported=False, + technical_warning=False, + odometer_km=200, + completed_at=datetime(2048, 2, 1, tzinfo=UTC), + ), + ] + ) + vehicle_id = vehicle.id + customer_id = customer.id + db.commit() + try: + assert ops_client.post("/api/v1/data-quality/scan").status_code == 200 + with SessionLocal() as db: + assert ( + db.scalar( + select(DataQualityIssue).where( + DataQualityIssue.entity_id == vehicle_id, + DataQualityIssue.rule_type == "odometer_regression", + ) + ) + is None + ) + finally: + with SessionLocal() as db: + db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id)) + db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id)) + db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id)) + db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id)) + db.execute(delete(Customer).where(Customer.id == customer_id)) + db.commit() + + +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 _reset_demo(ops_client) -> None: + # /api/v1/demo/reset deletes the session cookie (the reset recreates the users + # table, so the old session's user id no longer exists) -- the caller must log back + # in before making any further authenticated call with the same client. + response = ops_client.post("/api/v1/demo/reset") + assert response.status_code == 200, response.text + login_response = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"}) + assert login_response.status_code == 200, login_response.text + + +def _resolve_overlap_issue(ops_client, *, booking_to_block: str) -> None: + overlap = _first_open(ops_client, "booking_overlap") + response = ops_client.post( + f"/api/v1/data-quality/issues/{overlap['public_ref']}/resolve-overlap", + json={"booking_ref": booking_to_block}, + ) + assert response.status_code == 200, response.text + assert response.json()["status"] == "resolved" + + +def _first_open_for_vehicle(ops_client, rule_type: str, vehicle_ref: str) -> dict: + issues = ops_client.get( + "/api/v1/data-quality/issues", params={"rule_type": rule_type, "status": "open"} + ).json() + match = next((i for i in issues if i["entity_ref"] == vehicle_ref), None) + assert match, f"expected an open {rule_type} issue for {vehicle_ref}" + return match + + +def _apply_status_recommendation(ops_client, public_ref: str) -> dict: + preview = ops_client.post( + f"/api/v1/data-quality/issues/{public_ref}/status-recommendation" + ).json() + apply_response = ops_client.post( + f"/api/v1/data-quality/issues/{public_ref}/apply-recommended-status", + json={"recommendation_token": preview["recommendation_token"]}, + ) + assert apply_response.status_code == 200, apply_response.text + return apply_response.json() + + +def test_mo_016_status_conflict_recommendation_is_order_independent(ops_client): + # MO-016 carries both a booking_overlap (DQ-DEMO-OVERLAP) and a vehicle_status_conflict + # (DQ-DEMO-STATUS) issue at once. Order independence does NOT mean "the same final + # vehicle status regardless of order" -- resolving the overlap first genuinely removes + # the conflict, so there is correctly nothing left to apply. What must hold in either + # order: the recommendation always reflects the real, current facts (never a stale + # "was some other issue open" proxy), and nothing unsafe is ever applied (never + # "rented", never a status change once the underlying condition has already resolved + # itself). See docs/fleet-ops-correction/current-gap-audit.md §6-7 and + # vehicle-status-decision-table.md. + + # Order A: resolve the booking overlap first. The status-conflict issue's own + # recommendation must now correctly report that the conflict is gone -- nothing unsafe + # should be auto-applied, and the vehicle (never touched) stays exactly as it was. + _reset_demo(ops_client) + _resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B") + status_issue_a = _first_open_for_vehicle(ops_client, "vehicle_status_conflict", "MO-016") + preview_a = ops_client.post( + f"/api/v1/data-quality/issues/{status_issue_a['public_ref']}/status-recommendation" + ).json() + assert preview_a["recommendation_code"] == "vehicle.no_conflict" + assert preview_a["recommended_status"] is None + assert preview_a["safe_to_apply"] is False + vehicle_a = ops_client.get("/api/v1/vehicles/MO-016").json() + assert vehicle_a["operational_status"] == "available" + + # Order B: resolve the status conflict first, while the overlap is still open -- the + # conflict genuinely still exists, so the evaluator must still detect it and safely + # resolve it (never "rented"). + _reset_demo(ops_client) + status_issue_b = _first_open_for_vehicle(ops_client, "vehicle_status_conflict", "MO-016") + result_b = _apply_status_recommendation(ops_client, status_issue_b["public_ref"]) + assert result_b["applied_status"] != "rented" + vehicle_b_mid = ops_client.get("/api/v1/vehicles/MO-016").json() + assert vehicle_b_mid["operational_status"] == result_b["applied_status"] + + # Resolving the now-redundant overlap afterwards must not itself change the vehicle's + # status as a side effect. + _resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B") + vehicle_b = ops_client.get("/api/v1/vehicles/MO-016").json() + assert vehicle_b["operational_status"] == result_b["applied_status"] + assert vehicle_b["operational_status"] != "rented" + + _reset_demo(ops_client) + + +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" + + +def test_scan_public_refs_are_collision_resistant() -> None: + from app.services.data_quality import _new_scan_ref + + refs = {_new_scan_ref("DQ-SCAN") for _ in range(1000)} + assert len(refs) == 1000 + assert all(ref.startswith("DQ-SCAN-") and len(ref) == 18 for ref in refs) diff --git a/backend/tests/test_database_constraints.py b/backend/tests/test_database_constraints.py new file mode 100644 index 0000000..ca4804a --- /dev/null +++ b/backend/tests/test_database_constraints.py @@ -0,0 +1,33 @@ +import pytest +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +from app.core.db import SessionLocal + + +@pytest.mark.parametrize( + ("statement", "constraint_name"), + ( + ( + "UPDATE vehicles SET odometer_km = -1 WHERE public_ref = 'MO-001'", + "ck_vehicles_odometer", + ), + ( + "UPDATE bookings SET ends_at = starts_at WHERE public_ref = 'BK-DEMO-RETURN'", + "ck_bookings_time_window", + ), + ( + "UPDATE data_quality_issues SET status = 'invented' " + "WHERE public_ref = 'DQ-DEMO-DUPLICATE'", + "ck_data_quality_status", + ), + ("UPDATE outbox_events SET attempts = -1", "ck_outbox_attempts"), + ), +) +def test_database_rejects_invalid_domain_state(statement: str, constraint_name: str) -> None: + with SessionLocal() as db: + with pytest.raises(IntegrityError) as caught: + db.execute(text(statement)) + db.commit() + db.rollback() + assert constraint_name in str(caught.value) diff --git a/backend/tests/test_demo_manifest.py b/backend/tests/test_demo_manifest.py new file mode 100644 index 0000000..b8989d6 --- /dev/null +++ b/backend/tests/test_demo_manifest.py @@ -0,0 +1,158 @@ +from sqlalchemy import select + +from app.core.db import SessionLocal +from app.models.outbox import OutboxEvent +from app.seed_loader import reset_and_seed +from app.services import demo_manifest +from app.services.knowledge import KnowledgeHealth + + +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" + + +def test_automation_scenario_requires_the_exact_prepared_failure(): + db = SessionLocal() + try: + reset_and_seed(db) + event = db.scalar( + select(OutboxEvent).where(OutboxEvent.event_id == demo_manifest._FAILED_DEMO_EVENT_ID) + ) + assert event is not None + event.last_error_code = "connectionError" + db.flush() + + scenario = next( + item for item in demo_manifest._scenarios(db) if item.id == "automation-retry" + ) + assert scenario.ready is False + finally: + db.rollback() + db.close() + + +def test_knowledge_scenario_rejects_available_provider_with_verified_empty_corpus( + client, monkeypatch +): + class EmptyKnowledgeProvider: + def health(self, language="en-GB"): + return KnowledgeHealth( + provider="ragcore", + available=True, + detail="Ready, but no indexed documents.", + tenant="fleet-ops", + workspace="mobilityops", + collection="procedures", + document_count=0, + source_document_count=4, + reported_synced_document_count=None, + reported_failed_document_count=None, + last_sync_at=None, + statistics_state="verified", + ) + + monkeypatch.setattr(demo_manifest, "get_knowledge_provider", EmptyKnowledgeProvider) + + body = client.get("/api/v1/demo/manifest").json() + scenario = next(item for item in body["scenarios"] if item["id"] == "knowledge-question") + assert scenario["ready"] is False + assert scenario["blocked_reason_code"] == "knowledgeUnavailable" + ragcore = next(item for item in body["integrations"] if item["key"] == "ragcore") + assert ragcore["status_code"] == "unavailable" + + +def test_knowledge_scenario_rejects_local_sources_when_index_count_is_unverified( + client, monkeypatch +): + class SourceAwareKnowledgeProvider: + def health(self, language="en-GB"): + return KnowledgeHealth( + provider="ragcore", + available=True, + detail="Ready; index count endpoint is unavailable.", + tenant="fleet-ops", + workspace="mobilityops", + collection="procedures", + document_count=None, + source_document_count=4, + reported_synced_document_count=None, + reported_failed_document_count=None, + last_sync_at=None, + statistics_state="not_reported", + ) + + monkeypatch.setattr(demo_manifest, "get_knowledge_provider", SourceAwareKnowledgeProvider) + + body = client.get("/api/v1/demo/manifest").json() + scenario = next(item for item in body["scenarios"] if item["id"] == "knowledge-question") + assert scenario["ready"] is False + assert scenario["blocked_reason_code"] == "knowledgeUnavailable" + ragcore = next(item for item in body["integrations"] if item["key"] == "ragcore") + assert ragcore["status_code"] == "unavailable" + + +def test_knowledge_scenario_requires_provider_availability_even_with_documents(): + health = KnowledgeHealth( + provider="ragcore", + available=False, + detail="Provider is unreachable.", + tenant="fleet-ops", + workspace="mobilityops", + collection="procedures", + document_count=4, + source_document_count=4, + reported_synced_document_count=None, + reported_failed_document_count=None, + last_sync_at=None, + statistics_state="verified", + ) + + assert demo_manifest._knowledge_scenario_ready(health) is False diff --git a/backend/tests/test_dispatcher.py b/backend/tests/test_dispatcher.py new file mode 100644 index 0000000..fa64202 --- /dev/null +++ b/backend/tests/test_dispatcher.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest +from sqlalchemy import select + +from app.core.config import get_settings +from app.core.db import SessionLocal +from app.models.booking import Booking +from app.models.outbox import OutboxEvent +from app.services import dispatcher + + +def _make_pending_event(vehicle_ref: str) -> uuid.UUID: + db = SessionLocal() + try: + booking = db.scalar(select(Booking).where(Booking.status == "returned").limit(1)) + event = OutboxEvent( + event_id=uuid.uuid4(), + event_type="vehicle.returned.v1", + aggregate_type="booking", + aggregate_id=booking.id, + payload_json={ + "correlation_id": str(uuid.uuid4()), + "aggregate": { + "type": "booking", + "id": str(booking.id), + "public_ref": booking.public_ref, + }, + "data": { + "vehicle_ref": vehicle_ref, + "inspection_ref": "INSP-TEST", + "resulting_vehicle_status": "cleaning", + "attention_reasons": [], + }, + "aggregate_ref": booking.public_ref, + }, + occurred_at=datetime.now(UTC), + delivery_status="pending", + attempts=0, + ) + db.add(event) + db.commit() + return event.event_id + finally: + db.close() + + +def _get_event(event_id: uuid.UUID) -> OutboxEvent: + db = SessionLocal() + try: + return db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id)) + finally: + db.close() + + +def test_claim_marks_events_delivering(): + event_id = _make_pending_event("MO-001") + claimed = dispatcher._claim_due_events() + assert event_id in claimed + assert _get_event(event_id).delivery_status == "delivering" + + +def test_deliver_one_success(monkeypatch): + event_id = _make_pending_event("MO-002") + execution_id = "n8n-execution-123" + dispatcher._claim_due_events() + + def fake_post(url, json, headers, timeout): + return SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "ok": True, + "event_id": str(event_id), + "result": {"execution_id": execution_id}, + }, + ) + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + dispatcher._deliver_one(event_id) + + event = _get_event(event_id) + assert event.delivery_status == "succeeded" + assert event.attempts == 1 + assert event.external_run_id == execution_id + assert event.last_error is None + assert event.last_error_code is None + + +def test_deliver_one_failure_schedules_retry(monkeypatch): + event_id = _make_pending_event("MO-003") + dispatcher._claim_due_events() + + def fake_post(url, json, headers, timeout): + raise dispatcher.httpx.ConnectError("simulated connection failure") + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + dispatcher._deliver_one(event_id) + + event = _get_event(event_id) + assert event.delivery_status == "pending" + assert event.attempts == 1 + assert event.next_attempt_at is not None + assert "simulated connection failure" in event.last_error + assert event.last_error_code == "connectionError" + + +def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch): + # Reproduces a real failure mode found while live-validating the n8n webhook auth + # fix: a workflow that errors internally before its "Respond to Webhook" node runs + # can still answer with a 2xx status and an empty body. response.json() on that body + # raises json.JSONDecodeError -- this must be treated as a retryable failure, not an + # unhandled exception that leaves the event stuck in "delivering" forever. + event_id = _make_pending_event("MO-005") + dispatcher._claim_due_events() + + def fake_post(url, json, headers, timeout): + def raise_json_error(): + raise ValueError("Expecting value: line 1 column 1 (char 0)") + + return SimpleNamespace( + raise_for_status=lambda: None, json=raise_json_error, status_code=200 + ) + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + dispatcher._deliver_one(event_id) + + event = _get_event(event_id) + assert event.delivery_status == "pending" + assert event.attempts == 1 + assert event.next_attempt_at is not None + assert event.last_error_code == "malformedResponse" + + +def test_deliver_one_rejects_json_object_without_explicit_success_ack(monkeypatch): + event_id = _make_pending_event("MO-010") + dispatcher._claim_due_events() + + def fake_post(url, json, headers, timeout): + return SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: {}, + status_code=200, + ) + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + dispatcher._deliver_one(event_id) + + event = _get_event(event_id) + assert event.delivery_status == "pending" + assert event.attempts == 1 + assert event.last_error_code == "malformedResponse" + assert event.external_run_id is None + + +@pytest.mark.parametrize("result", [{}, {"execution_id": 123}, {"execution_id": " "}]) +def test_deliver_one_rejects_ack_without_valid_execution_id(monkeypatch, result): + event_id = _make_pending_event("MO-EXEC-ID") + dispatcher._claim_due_events() + + def fake_post(url, json, headers, timeout): + return SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: {"ok": True, "event_id": str(event_id), "result": result}, + status_code=200, + ) + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + dispatcher._deliver_one(event_id) + event = _get_event(event_id) + assert event.delivery_status == "pending" + assert event.last_error_code == "malformedResponse" + assert event.external_run_id is None + + +def test_deliver_one_rejects_success_ack_for_a_different_event(monkeypatch): + event_id = _make_pending_event("MO-011") + dispatcher._claim_due_events() + other_event_id = uuid.uuid4() + + def fake_post(url, json, headers, timeout): + return SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: {"ok": True, "event_id": str(other_event_id)}, + status_code=200, + ) + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + dispatcher._deliver_one(event_id) + + event = _get_event(event_id) + assert event.delivery_status == "pending" + assert event.attempts == 1 + assert event.last_error_code == "mismatchedEventId" + assert event.external_run_id is None + + +def test_late_delivery_outcome_cannot_overwrite_a_newer_lease(monkeypatch): + event_id = _make_pending_event("MO-LEASE-RACE") + dispatcher._claim_due_events() + + def fake_post(url, json, headers, timeout): + # Simulate lease expiry/reclaim while the original worker is still in network + # I/O. The later claimant owns a different token and its state must win. + with SessionLocal() as db: + event = db.scalar( + select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update() + ) + event.payload_json = { + **event.payload_json, + "_delivery_claim_token": "newer-worker-token", + } + event.next_attempt_at = datetime.now(UTC) + timedelta(minutes=1) + db.commit() + return SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "ok": True, + "event_id": str(event_id), + "result": {"execution_id": "stale-worker-execution"}, + }, + status_code=200, + ) + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + dispatcher._deliver_one(event_id) + + event = _get_event(event_id) + assert event.delivery_status == "delivering" + assert event.attempts == 0 + assert event.external_run_id is None + assert event.payload_json["_delivery_claim_token"] == "newer-worker-token" + + +def test_deliver_one_exhausts_attempts_to_failed(monkeypatch): + event_id = _make_pending_event("MO-004") + settings = get_settings() + + def fake_post(url, json, headers, timeout): + raise dispatcher.httpx.ConnectError("still down") + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + + for _ in range(settings.n8n_max_attempts): + db = SessionLocal() + try: + event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id)) + event.next_attempt_at = None + db.commit() + finally: + db.close() + claimed = dispatcher._claim_due_events(batch_size=1000) + assert event_id in claimed + dispatcher._deliver_one(event_id) + + event = _get_event(event_id) + assert event.delivery_status == "failed" + assert event.attempts == settings.n8n_max_attempts + + +def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch): + # Regression test: seeded/legacy outbox rows may lack the full event envelope. Delivery + # must resolve the claimed "delivering" row to pending/failed, never leave it stuck. + db = SessionLocal() + try: + booking = db.scalar(select(Booking).limit(1)) + event = OutboxEvent( + event_id=uuid.uuid4(), + event_type="vehicle.returned.v1", + aggregate_type="booking", + aggregate_id=booking.id, + payload_json={"aggregate_ref": booking.public_ref}, # missing correlation_id/etc. + occurred_at=datetime.now(UTC), + delivery_status="pending", + attempts=0, + ) + db.add(event) + db.commit() + event_id = event.event_id + finally: + db.close() + + def fake_post(url, json, headers, timeout): + raise AssertionError("must not attempt delivery with a malformed payload") + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + + dispatcher._claim_due_events() + dispatcher._deliver_one(event_id) + + event = _get_event(event_id) + assert event.delivery_status in ("pending", "failed") + assert event.attempts == 1 + assert "Malformed outbox payload" in event.last_error + assert event.last_error_code == "malformedPayload" + + +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() + assert event.last_error_code == "staleLeaseRecovered" + + # 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, headers, timeout): + return SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "ok": True, + "event_id": str(event_id), + "result": {"execution_id": "n8n-recovered-execution"}, + }, + ) + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + processed = dispatcher.run_dispatch_cycle() + + assert processed >= 1 + assert _get_event(event_id).delivery_status == "succeeded" + + +def test_run_dispatch_cycle_end_to_end(monkeypatch): + event_id = _make_pending_event("MO-005") + + def fake_post(url, json, headers, timeout): + return SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "ok": True, + "event_id": str(event_id), + "result": {"execution_id": "n8n-cycle-execution"}, + }, + ) + + monkeypatch.setattr(dispatcher.httpx, "post", fake_post) + processed = dispatcher.run_dispatch_cycle() + + assert processed >= 1 + assert _get_event(event_id).delivery_status == "succeeded" diff --git a/backend/tests/test_hardening.py b/backend/tests/test_hardening.py new file mode 100644 index 0000000..da6dca8 --- /dev/null +++ b/backend/tests/test_hardening.py @@ -0,0 +1,341 @@ +"""Regression tests for the hardening pass (security, robustness, data-quality edge cases).""" + +from __future__ import annotations + +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from threading import Barrier + +import pytest +from sqlalchemy import delete, func, select + +from app.core.config import Settings, get_settings, insecure_default_secrets +from app.core.db import SessionLocal +from app.core.errors import AppError +from app.core.observability import UNMATCHED_ROUTE_LABEL +from app.core.ratelimit import FailedAttemptLimiter, SlidingWindowLimiter +from app.models.audit import AuditEvent +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.schemas import CurrentUser +from app.services.data_quality import defer_issue, run_scan +from tests.test_return import _activate_booking, _return_body + + +def test_insecure_defaults_include_mounted_mcp_boundary_even_when_status_check_is_disabled(): + defaults = Settings(_env_file=None) + assert "app_secret" in insecure_default_secrets(defaults) + assert "mcp_hub_service_token" in insecure_default_secrets(defaults) + hardened = Settings( + _env_file=None, + app_secret="x" * 32, + n8n_callback_token="c" * 32, + mcp_hub_service_token="m" * 32, + ) + assert insecure_default_secrets(hardened) == [] + + +def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch): + monkeypatch.setenv("MOBILITYOPS_ENV", "production") + monkeypatch.setenv("APP_SECRET", "replace-in-production") + get_settings.cache_clear() + try: + with pytest.raises(RuntimeError, match="placeholder secrets"): + get_settings() + finally: + get_settings.cache_clear() + monkeypatch.undo() + get_settings.cache_clear() + # The cached settings the running app relies on must be intact afterwards. + assert get_settings().mobilityops_env == "test" + + +@pytest.mark.parametrize( + ("public_url", "secure_cookie", "message"), + [ + ("http://fleetops.example.test", "true", "must use HTTPS"), + ("https://fleetops.example.test", "false", "must be true"), + ], +) +def test_production_refuses_cleartext_or_insecure_session_cookie( + monkeypatch, public_url, secure_cookie, message +): + values = { + "MOBILITYOPS_ENV": "production", + "APP_SECRET": "a" * 32, + "N8N_CALLBACK_TOKEN": "c" * 32, + "MCP_HUB_SERVICE_TOKEN": "m" * 32, + "MOBILITYOPS_PUBLIC_URL": public_url, + "SESSION_COOKIE_SECURE": secure_cookie, + } + for name, value in values.items(): + monkeypatch.setenv(name, value) + get_settings.cache_clear() + try: + with pytest.raises(RuntimeError, match=message): + get_settings() + finally: + get_settings.cache_clear() + monkeypatch.undo() + get_settings.cache_clear() + assert get_settings().mobilityops_env == "test" + + +def test_failed_attempt_limiter_blocks_after_threshold_and_resets(): + limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60) + for _ in range(3): + assert limiter.retry_after_seconds("1.2.3.4") == 0 + limiter.record_failure("1.2.3.4") + assert limiter.retry_after_seconds("1.2.3.4") > 0 + assert limiter.retry_after_seconds("5.6.7.8") == 0 + limiter.reset("1.2.3.4") + assert limiter.retry_after_seconds("1.2.3.4") == 0 + + +def test_sliding_window_limiter_counts_successes_and_isolates_keys(): + limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60) + assert limiter.consume("session-a") == 0 + assert limiter.consume("session-a") == 0 + assert limiter.consume("session-a") > 0 + assert limiter.consume("session-b") == 0 + + +def test_login_client_key_uses_proxy_appended_peer_not_spoofed_leading_value(): + from starlette.requests import Request + + from app.api.routers.auth import _client_key + + request = Request( + { + "type": "http", + "headers": [(b"x-forwarded-for", b"203.0.113.99, 198.51.100.7")], + "client": ("172.20.0.3", 12345), + } + ) + assert _client_key(request) == "198.51.100.7" + + +def test_audit_export_accepts_naive_datetimes(ops_client): + response = ops_client.get( + "/api/v1/audit/export.csv", + params={"occurred_from": "2026-01-01T00:00:00", "occurred_to": "2026-01-15T00:00:00"}, + ) + assert response.status_code == 200 + + +def test_audit_list_rejects_non_uuid_correlation_id(ops_client): + response = ops_client.get("/api/v1/audit", params={"correlation_id": "not-a-uuid"}) + assert response.status_code == 422 + + +def test_unmatched_routes_do_not_create_metric_series(client): + probe = f"/api/v1/does-not-exist-{uuid.uuid4().hex}" + assert client.get(probe).status_code == 404 + metrics = client.get("/metrics").text + assert probe not in metrics + assert UNMATCHED_ROUTE_LABEL in metrics + + +def test_bookings_page_for_unknown_vehicle_keeps_page_shape(ops_client): + response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-NOPE", "page": 1}) + assert response.status_code == 200 + assert response.json() == { + "items": [], + "page": 1, + "page_size": 25, + "total": 0, + "total_pages": 1, + } + + +def test_return_idempotency_key_rejects_different_body(ops_client): + booking_ref = _activate_booking("MO-011", start_odometer_km=30000) + key = "test-return-fingerprint-001" + first = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=30500), + headers={"Idempotency-Key": key}, + ) + assert first.status_code == 201 + replay = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=30500), + headers={"Idempotency-Key": key}, + ) + assert replay.status_code == 201 + mismatch = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=30999), + headers={"Idempotency-Key": key}, + ) + assert mismatch.status_code == 409 + assert mismatch.json()["error"]["code"] == "IDEMPOTENCY_KEY_REUSED" + + +def test_return_callback_rejects_malformed_correlation_id(client): + event_id = str(uuid.uuid4()) + response = client.post( + "/api/v1/integrations/n8n/return-callback", + json={"event_id": event_id, "follow_up": "cleaning", "correlation_id": "nope"}, + headers={ + "Idempotency-Key": event_id, + "X-Service-Token": get_settings().n8n_callback_token, + }, + ) + assert response.status_code == 422 + + +def test_blocked_checkout_booking_can_be_cancelled(ops_client): + window = {"starts_at": "2051-03-01T10:00:00Z", "ends_at": "2051-03-02T12:00:00Z"} + available = ops_client.get("/api/v1/bookings/availability", params=window).json() + vehicle_option = next(item for item in available if item["operational_status"] == "available") + vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json() + booking = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0002", + "vehicle_ref": vehicle["public_ref"], + **window, + }, + ).json() + confirmed = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/complete-requirements", + json={"confirmation": "Licence and rental conditions checked"}, + ) + assert confirmed.status_code == 200 + checkout = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/checkout", + json={ + "start_odometer_km": vehicle["odometer_km"], + "fuel_level_percent": 80, + "cleanliness_ok": True, + "damage_reported": True, + "technical_warning": False, + }, + ) + assert checkout.status_code == 200 + assert checkout.json()["booking_status"] == "blocked" + cancelled = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/cancel", + json={"reason": "Vehicle damaged at departure inspection"}, + ) + assert cancelled.status_code == 200 + assert cancelled.json()["status"] == "cancelled" + + +def test_scan_does_not_flag_anonymised_customers_as_missing_fields(ops_client): + with SessionLocal() as db: + customer = Customer( + public_ref="CUS-ANON-SCAN", + first_name="Anoniem", + last_name="Klant", + email=None, + phone=None, + postal_code=None, + city=None, + date_of_birth=None, + anonymized_at=datetime.now(UTC), + ) + db.add(customer) + db.commit() + customer_id = customer.id + try: + with SessionLocal() as db: + run_scan(db) + db.commit() + flagged = db.scalar( + select(DataQualityIssue.id).where( + DataQualityIssue.entity_type == "customer", + DataQualityIssue.entity_id == customer_id, + ) + ) + assert flagged is None + finally: + with SessionLocal() as db: + db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == customer_id)) + db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id)) + db.execute(delete(Customer).where(Customer.id == customer_id)) + db.commit() + + +def test_concurrent_quality_scans_leave_only_one_open_issue_per_condition(): + barrier = Barrier(2) + + def scan() -> None: + with SessionLocal() as db: + barrier.wait() + run_scan(db) + + with ThreadPoolExecutor(max_workers=2) as executor: + list(executor.map(lambda _index: scan(), range(2))) + + with SessionLocal() as db: + duplicates = db.execute( + select( + DataQualityIssue.rule_type, + DataQualityIssue.entity_type, + DataQualityIssue.entity_id, + func.count(DataQualityIssue.id), + ) + .where(DataQualityIssue.status == "open") + .group_by( + DataQualityIssue.rule_type, + DataQualityIssue.entity_type, + DataQualityIssue.entity_id, + ) + .having(func.count(DataQualityIssue.id) > 1) + ).all() + assert duplicates == [] + + +def test_concurrent_issue_resolution_records_exactly_one_decision(): + issue_id = uuid.uuid4() + issue_ref = f"DQ-RACE-{uuid.uuid4().hex[:8].upper()}" + with SessionLocal() as db: + db.add( + DataQualityIssue( + id=issue_id, + public_ref=issue_ref, + rule_type="missing_required_field", + entity_type="customer", + entity_id=uuid.uuid4(), + severity="low", + status="open", + evidence_json={}, + proposed_action_json={}, + detected_at=datetime.now(UTC), + ) + ) + db.commit() + + actor = CurrentUser( + public_ref="USR-RACE", display_name="Race Manager", role="operations_manager" + ) + barrier = Barrier(2) + + def resolve() -> str: + with SessionLocal() as db: + barrier.wait() + try: + return defer_issue(db, issue_ref, actor).status + except AppError as exc: + return exc.code + + try: + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(lambda _index: resolve(), range(2))) + assert sorted(outcomes) == ["ISSUE_NOT_OPEN", "deferred"] + with SessionLocal() as db: + decisions = db.scalar( + select(func.count(AuditEvent.id)).where( + AuditEvent.entity_id == issue_id, + AuditEvent.action == "data_quality_issue_deferred", + ) + ) + assert decisions == 1 + finally: + with SessionLocal() as db: + db.execute(delete(AuditEvent).where(AuditEvent.entity_id == issue_id)) + db.execute(delete(DataQualityIssue).where(DataQualityIssue.id == issue_id)) + db.commit() diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..0658644 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,41 @@ +from fastapi.testclient import TestClient + +from app.main import app + + +def test_health() -> None: + response = TestClient(app).get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "service": "mobilityops-api"} + + +def test_liveness_is_process_only() -> None: + response = TestClient(app).get("/health/live") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_readiness_checks_the_canonical_database() -> None: + response = TestClient(app).get("/health/ready") + assert response.status_code == 200 + assert response.json() == { + "status": "ready", + "service": "mobilityops-api", + "database": "up", + } + + +def test_readiness_degrades_when_database_is_unavailable(monkeypatch) -> None: + import app.main as main_module + + class BrokenSession: + def __enter__(self): + raise ConnectionError("database unavailable") + + def __exit__(self, *_args): + return False + + monkeypatch.setattr(main_module, "SessionLocal", BrokenSession) + response = TestClient(app).get("/health/ready") + assert response.status_code == 503 + assert response.json()["database"] == "down" diff --git a/backend/tests/test_integration_status.py b/backend/tests/test_integration_status.py new file mode 100644 index 0000000..9c58e92 --- /dev/null +++ b/backend/tests/test_integration_status.py @@ -0,0 +1,292 @@ +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy import delete, select + +from app.core.db import SessionLocal +from app.models.audit import AuditEvent +from app.models.outbox import OutboxEvent +from app.seed_loader import reset_and_seed +from app.services import integration_status + + +def _reseed() -> None: + """Restore the canonical demo dataset (19 succeeded + 1 prepared failure). + + The suite shares one session-scoped database and earlier files legitimately mutate + the outbox, so any test that asserts on the *seeded* scenario has to re-establish it + rather than depend on file ordering. + """ + db = SessionLocal() + try: + reset_and_seed(db) + finally: + db.close() + + +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): + _reseed() + 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 + # The seeded failure stays visible and counted... + assert n8n["failed"] >= 1 + assert n8n["demo_scenario_failed"] == 1 + # ...but it is a prepared prop, so it is not an unexpected failure and must not + # move the integration off "operational". A staged failure that degrades the + # health badge tells a viewer something untrue about the automation. + assert n8n["unexpected_failed"] == 0 + assert n8n["state"] == "operational" + assert n8n["latest_success_at"] is not None + # "latest failure" is a health signal, so the staged one never sets it; it is + # reported separately instead. + assert n8n["latest_failure_at"] is None + assert n8n["latest_demo_scenario_at"] is not None + + mcp_hub = body["mcp_hub"] + assert mcp_hub["registration_enabled"] is False + assert mcp_hub["state"] == "not_configured" + + +def test_a_real_failure_still_degrades_the_integration(ops_client): + """The demo carve-out must be narrow: a failure that is not the prepared scenario + still degrades n8n, otherwise this change would hide real breakage.""" + _reseed() + db = SessionLocal() + try: + real_failure = db.scalar( + select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1) + ) + assert real_failure is not None + restore = (real_failure.delivery_status, real_failure.last_error_code) + real_failure.delivery_status = "failed" + real_failure.last_error_code = "connectionError" + db.commit() + + n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"] + assert n8n["unexpected_failed"] == 1 + assert n8n["state"] == "degraded" + assert n8n["latest_failure_at"] is not None + finally: + real_failure.delivery_status, real_failure.last_error_code = restore + db.commit() + db.close() + + +def test_prepared_demo_failure_is_reset_back_by_a_demo_reset(ops_client): + """A demo reset must recreate the intended 19 succeeded + 1 prepared failure, so the + scenario can be shown again after it has been retried away.""" + _reseed() + + n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"] + assert n8n["succeeded"] == 19 + assert n8n["failed"] == 1 + assert n8n["demo_scenario_failed"] == 1 + assert n8n["unexpected_failed"] == 0 + assert n8n["state"] == "operational" + + +def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client): + _reseed() + 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" + + +@pytest.mark.parametrize("delivery_status", ["pending", "delivering"]) +def test_queued_work_without_any_success_is_not_reported_operational(delivery_status): + _reseed() + db = SessionLocal() + try: + for event in db.scalars(select(OutboxEvent)).all(): + event.delivery_status = delivery_status + event.last_error = None + event.last_error_code = None + db.execute( + delete(AuditEvent).where( + AuditEvent.action.in_( + { + "data_quality_scan_run", + "n8n_procedures_synced", + "n8n_workflow_failure_registered", + "n8n_workflow_heartbeat", + } + ) + ) + ) + db.flush() + + status = integration_status.derive_n8n_status(db) + + assert status.succeeded == 0 + assert getattr(status, delivery_status) > 0 + assert status.state == "no_evidence" + finally: + db.rollback() + db.close() + + +def test_historical_success_is_not_green_when_webhook_is_unconfigured(monkeypatch): + _reseed() + with SessionLocal() as db: + monkeypatch.setattr(integration_status.settings, "n8n_dispatch_enabled", True) + monkeypatch.setattr(integration_status.settings, "n8n_webhook_url", "") + status = integration_status.derive_n8n_status(db) + assert status.configured is False + assert status.succeeded > 0 + assert status.state == "unavailable" + + +def test_null_coded_real_failure_sets_latest_failure_timestamp(): + _reseed() + with SessionLocal() as db: + event = db.scalar( + select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1) + ) + event.delivery_status = "failed" + event.last_error_code = None + db.flush() + status = integration_status.derive_n8n_status(db) + assert status.unexpected_failed == 1 + assert status.latest_failure_at is not None + db.rollback() + + +def test_unreachable_hub_invalidates_historical_operational_claim(monkeypatch): + db = SessionLocal() + try: + db.add( + AuditEvent( + actor_type="service", + actor_id=None, + actor_label="itworx-mcp-hub", + action="mcp_tool_request", + entity_type="integration", + entity_id=None, + correlation_id=uuid.uuid4(), + before_json=None, + after_json=None, + metadata_json={"tool": "operations_summary"}, + occurred_at=datetime.now(UTC), + ) + ) + db.flush() + monkeypatch.setattr(integration_status.settings, "mcp_hub_registration_enabled", True) + monkeypatch.setattr(integration_status, "_check_hub_reachable", lambda: False) + + unreachable = integration_status.derive_mcp_hub_status(db) + + assert unreachable.total_calls > 0 + assert unreachable.hub_reachable is False + assert unreachable.state == "no_evidence" + + monkeypatch.setattr(integration_status, "_check_hub_reachable", lambda: True) + reachable = integration_status.derive_mcp_hub_status(db) + assert reachable.state == "operational" + finally: + db.rollback() + db.close() + + +def test_integration_status_lists_all_four_canonical_workflows(ops_client): + body = ops_client.get("/api/v1/integrations/status").json()["n8n"] + assert body["expected_workflow_count"] == 4 + names = {w["name"] for w in body["workflows"]} + assert names == { + "Fleet Ops — Vehicle Return Orchestration", + "Fleet Ops — Scheduled Data Quality Scan", + "Fleet Ops — RAGcore Procedure Sync", + "Fleet Ops — Workflow Error Handler", + } + ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"]) + # All 4 canonical workflows are built (all 6 nodes saved live). This fresh test run + # has reported no real sync result yet, so -- like the other three workflows before + # their own first real signal -- there is no run evidence yet either. + assert ragcore_sync["built"] is True + assert ragcore_sync["last_seen_at"] is None + assert ragcore_sync["state"] == "no_evidence" + + +def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client): + from app.core.config import get_settings + + settings = get_settings() + + before = ops_client.get("/api/v1/integrations/status").json()["n8n"] + scan_workflow = next( + w for w in before["workflows"] if w["name"].endswith("Scheduled Data Quality Scan") + ) + assert scan_workflow["last_seen_at"] is None + + scan = client.post( + "/api/v1/integrations/n8n/scheduled-scan", + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert scan.status_code == 200 + + after = ops_client.get("/api/v1/integrations/status").json()["n8n"] + scan_workflow = next( + w for w in after["workflows"] if w["name"].endswith("Scheduled Data Quality Scan") + ) + assert scan_workflow["last_seen_at"] is not None + assert after["known_workflow_count"] > before["known_workflow_count"] + + +def test_integration_status_reflects_error_handler_registrations(client, ops_client): + import uuid + + from app.core.config import get_settings + + settings = get_settings() + before = ops_client.get("/api/v1/integrations/status").json()["n8n"] + + execution_id = str(uuid.uuid4()) + report = client.post( + "/api/v1/integrations/n8n/workflow-error", + json={ + "workflow_id": "mobilityops-return-processing", + "workflow_name": "Fleet Ops — Vehicle Return Orchestration", + "execution_id": execution_id, + "failed_at": "2026-08-04T10:15:00Z", + "error_category": "httpError", + "error_summary": "Simulated failure for status test", + "trigger_context": "webhook", + "attempt": 1, + }, + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert report.status_code == 200 + + after = ops_client.get("/api/v1/integrations/status").json()["n8n"] + assert ( + after["error_handler"]["total_failures_registered"] + == before["error_handler"]["total_failures_registered"] + 1 + ) + assert after["error_handler"]["latest_failure_workflow"] == ( + "Fleet Ops — Vehicle Return Orchestration" + ) + handler_workflow = next( + w for w in after["workflows"] if w["name"].endswith("Workflow Error Handler") + ) + assert handler_workflow["last_seen_at"] is not None diff --git a/backend/tests/test_integrations.py b/backend/tests/test_integrations.py new file mode 100644 index 0000000..1345dc0 --- /dev/null +++ b/backend/tests/test_integrations.py @@ -0,0 +1,454 @@ +import uuid +from concurrent.futures import ThreadPoolExecutor + +from app.core.config import get_settings + + +def _callback_headers(event_id: str, token: str | None = None): + settings = get_settings() + return { + "Idempotency-Key": event_id, + "X-Service-Token": token if token is not None else settings.n8n_callback_token, + } + + +def test_callback_rejects_wrong_service_token(client): + event_id = str(uuid.uuid4()) + response = client.post( + "/api/v1/integrations/n8n/return-callback", + json={ + "event_id": event_id, + "correlation_id": str(uuid.uuid4()), + "follow_up": "cleaning", + }, + headers=_callback_headers(event_id, token="wrong-token"), + ) + assert response.status_code == 401 + + +def test_callback_unknown_event_returns_404(client): + event_id = str(uuid.uuid4()) + response = client.post( + "/api/v1/integrations/n8n/return-callback", + json={ + "event_id": event_id, + "correlation_id": str(uuid.uuid4()), + "follow_up": "cleaning", + }, + headers=_callback_headers(event_id), + ) + assert response.status_code == 404 + + +def test_callback_is_idempotent_by_event_id(client, ops_client): + from sqlalchemy import select + + from app.core.db import SessionLocal + from app.models.booking import Booking + from app.models.outbox import OutboxEvent + + db = SessionLocal() + try: + booking = db.scalar(select(Booking).limit(1)) + correlation_id = uuid.uuid4() + event = OutboxEvent( + event_id=uuid.uuid4(), + event_type="vehicle.returned.v1", + aggregate_type="booking", + aggregate_id=booking.id, + payload_json={ + "correlation_id": str(correlation_id), + "aggregate": { + "type": "booking", + "id": str(booking.id), + "public_ref": booking.public_ref, + }, + "data": {}, + "aggregate_ref": booking.public_ref, + }, + occurred_at=db.execute(select(Booking.starts_at).limit(1)).scalar(), + delivery_status="delivering", + attempts=1, + ) + db.add(event) + db.commit() + event_id = str(event.event_id) + finally: + db.close() + + first = client.post( + "/api/v1/integrations/n8n/return-callback", + json={ + "event_id": event_id, + "correlation_id": str(correlation_id), + "follow_up": "cleaning", + "summary": "test", + }, + headers=_callback_headers(event_id), + ) + second = client.post( + "/api/v1/integrations/n8n/return-callback", + json={ + "event_id": event_id, + "correlation_id": str(correlation_id), + "follow_up": "cleaning", + "summary": "test", + }, + headers=_callback_headers(event_id), + ) + assert first.status_code == 200 + assert second.status_code == 200 + + audit_events = ops_client.get( + "/api/v1/audit", params={"action": "n8n_return_followup_recorded"} + ).json() + matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id] + assert len(matching) == 1 + assert matching[0]["correlation_id"] == str(correlation_id) + + +def test_callback_rejects_crossed_event_or_correlation(client): + from sqlalchemy import select + + from app.core.db import SessionLocal + from app.models.outbox import OutboxEvent + + with SessionLocal() as db: + event = db.scalar(select(OutboxEvent).limit(1)) + event_id = str(event.event_id) + correlation_id = event.payload_json["correlation_id"] + + crossed_event = client.post( + "/api/v1/integrations/n8n/return-callback", + json={ + "event_id": str(uuid.uuid4()), + "correlation_id": correlation_id, + "follow_up": "cleaning", + }, + headers=_callback_headers(event_id), + ) + assert crossed_event.status_code == 409 + assert crossed_event.json()["error"]["code"] == "CALLBACK_EVENT_MISMATCH" + + crossed_correlation = client.post( + "/api/v1/integrations/n8n/return-callback", + json={ + "event_id": event_id, + "correlation_id": str(uuid.uuid4()), + "follow_up": "cleaning", + }, + headers=_callback_headers(event_id), + ) + assert crossed_correlation.status_code == 409 + assert crossed_correlation.json()["error"]["code"] == "CALLBACK_CORRELATION_MISMATCH" + + +def test_callback_concurrent_retries_record_one_audit(client, ops_client): + from sqlalchemy import select + + from app.core.db import SessionLocal + from app.models.outbox import OutboxEvent + + with SessionLocal() as db: + event = db.scalar(select(OutboxEvent).limit(1)) + event_id = str(event.event_id) + correlation_id = event.payload_json["correlation_id"] + body = { + "event_id": event_id, + "correlation_id": correlation_id, + "follow_up": "cleaning", + } + + def post_callback(_index: int): + return client.post( + "/api/v1/integrations/n8n/return-callback", + json=body, + headers=_callback_headers(event_id), + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + responses = list(pool.map(post_callback, range(2))) + assert [response.status_code for response in responses] == [200, 200] + matching = [ + event + for event in ops_client.get( + "/api/v1/audit", params={"action": "n8n_return_followup_recorded"} + ).json() + if event["metadata"]["event_id"] == event_id + ] + assert len(matching) == 1 + + +def test_scheduled_scan_rejects_wrong_service_token(client): + response = client.post( + "/api/v1/integrations/n8n/scheduled-scan", + headers={"X-Service-Token": "wrong-token"}, + ) + assert response.status_code == 401 + + +def test_scheduled_scan_requires_service_token_header(client): + response = client.post("/api/v1/integrations/n8n/scheduled-scan") + assert response.status_code == 422 + + +def test_scheduled_scan_runs_and_returns_counts_by_rule(client, ops_client): + settings = get_settings() + response = client.post( + "/api/v1/integrations/n8n/scheduled-scan", + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert response.status_code == 200 + assert response.json() == {"created": {}} # already-seeded conditions, nothing new + + audit_events = ops_client.get( + "/api/v1/audit", params={"action": "data_quality_scan_run"} + ).json() + service_triggered = [e for e in audit_events if e["actor_type"] == "service"] + assert len(service_triggered) >= 1 + assert service_triggered[0]["actor_label"] == "n8n scheduled scan" + + +def test_scheduled_scan_is_idempotent_across_repeated_triggers(client): + settings = get_settings() + headers = {"X-Service-Token": settings.n8n_callback_token} + first = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers) + second = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers) + assert first.status_code == 200 + assert second.status_code == 200 + assert second.json()["created"] == {} + + +def _workflow_error_body(execution_id: str, **overrides): + body = { + "workflow_id": "mobilityops-return-processing", + "workflow_name": "Fleet Ops — Vehicle Return Orchestration", + "execution_id": execution_id, + "failed_at": "2026-08-04T10:15:00Z", + "error_category": "httpError", + "error_summary": "Callback request failed with status 500", + "trigger_context": "webhook", + "correlation_id": None, + "attempt": 1, + "retry_action": "n8n will retry automatically", + } + body.update(overrides) + return body + + +def test_workflow_error_rejects_wrong_service_token(client): + response = client.post( + "/api/v1/integrations/n8n/workflow-error", + json=_workflow_error_body(str(uuid.uuid4())), + headers={"X-Service-Token": "wrong-token"}, + ) + assert response.status_code == 401 + + +def test_workflow_error_rejects_unknown_category(client): + settings = get_settings() + response = client.post( + "/api/v1/integrations/n8n/workflow-error", + json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"), + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert response.status_code == 422 + + +def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client): + settings = get_settings() + headers = {"X-Service-Token": settings.n8n_callback_token} + execution_id = str(uuid.uuid4()) + body = _workflow_error_body(execution_id) + + first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers) + second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers) + + assert first.status_code == 200 + assert first.json()["status"] == "registered" + assert second.status_code == 200 + assert second.json()["status"] == "already_registered" + + audit_events = ops_client.get( + "/api/v1/audit", params={"action": "n8n_workflow_failure_registered"} + ).json() + matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id] + assert len(matching) == 1 + assert matching[0]["after"]["error_category"] == "httpError" + assert matching[0]["after"]["retry_action"] == "n8n will retry automatically" + + +def test_workflow_error_preserves_correlation_and_rejects_malformed(client, ops_client): + settings = get_settings() + headers = {"X-Service-Token": settings.n8n_callback_token} + execution_id = str(uuid.uuid4()) + correlation_id = str(uuid.uuid4()) + accepted = client.post( + "/api/v1/integrations/n8n/workflow-error", + json=_workflow_error_body(execution_id, correlation_id=correlation_id), + headers=headers, + ) + assert accepted.status_code == 200 + matching = [ + event + for event in ops_client.get( + "/api/v1/audit", params={"action": "n8n_workflow_failure_registered"} + ).json() + if event["metadata"]["execution_id"] == execution_id + ] + assert len(matching) == 1 + assert matching[0]["correlation_id"] == correlation_id + + malformed_execution = str(uuid.uuid4()) + malformed = client.post( + "/api/v1/integrations/n8n/workflow-error", + json=_workflow_error_body(malformed_execution, correlation_id="not-a-uuid"), + headers=headers, + ) + assert malformed.status_code == 422 + assert all( + event["metadata"]["execution_id"] != malformed_execution + for event in ops_client.get( + "/api/v1/audit", params={"action": "n8n_workflow_failure_registered"} + ).json() + ) + + +def test_workflow_error_bounds_summary_length(client): + settings = get_settings() + response = client.post( + "/api/v1/integrations/n8n/workflow-error", + json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501), + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert response.status_code == 422 + + +def test_procedures_rejects_wrong_service_token(client): + response = client.get( + "/api/v1/integrations/n8n/procedures", headers={"X-Service-Token": "wrong-token"} + ) + assert response.status_code == 401 + + +def test_procedures_lists_every_language_with_stable_ids(client): + settings = get_settings() + response = client.get( + "/api/v1/integrations/n8n/procedures", + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert response.status_code == 200 + documents = response.json()["documents"] + assert len(documents) > 0 + assert {d["language"] for d in documents} == {"en-GB", "nl-BE", "fr-BE"} + checkout_docs = [d for d in documents if d["document_id"] == "vehicle-checkout-procedure"] + assert len(checkout_docs) == 3 # one per language + assert all(d["content"] and d["content_hash"] for d in checkout_docs) + # Same document_id, different language, must not collide on id. + assert len({d["id"] for d in checkout_docs}) == 3 + + second_response = client.get( + "/api/v1/integrations/n8n/procedures", + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + second_ids = {d["id"] for d in second_response.json()["documents"]} + assert second_ids == {d["id"] for d in documents} # ids are stable across requests + + +def test_procedures_sync_result_rejects_wrong_service_token(client): + response = client.post( + "/api/v1/integrations/n8n/procedures-sync-result", + json={"execution_id": str(uuid.uuid4()), "synced": 5, "failed": 0}, + headers={"X-Service-Token": "wrong-token"}, + ) + assert response.status_code == 401 + + +def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client): + settings = get_settings() + headers = {"X-Service-Token": settings.n8n_callback_token} + execution_id = str(uuid.uuid4()) + body = {"execution_id": execution_id, "synced": 33, "failed": 1} + + first = client.post( + "/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers + ) + second = client.post( + "/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers + ) + + assert first.status_code == 200 + assert first.json()["status"] == "registered" + assert second.status_code == 200 + assert second.json()["status"] == "already_registered" + + audit_events = ops_client.get( + "/api/v1/audit", params={"action": "n8n_procedures_synced"} + ).json() + matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id] + assert len(matching) == 1 + assert matching[0]["after"] == {"synced": 33, "failed": 1} + + # The workflow's own callback is its evidence -- same pattern the scheduled scan and + # error handler already use -- so this real report must now show up as run evidence + # in the integration status, not stay hardcoded to "no evidence yet". + status = ops_client.get("/api/v1/integrations/status").json()["n8n"] + ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"]) + assert ragcore_sync["last_seen_at"] is not None + + +def test_failed_or_partial_procedure_sync_is_never_reported_healthy(client, ops_client): + settings = get_settings() + headers = {"X-Service-Token": settings.n8n_callback_token} + for synced, failed in ((0, 33), (32, 1)): + execution_id = str(uuid.uuid4()) + response = client.post( + "/api/v1/integrations/n8n/procedures-sync-result", + json={"execution_id": execution_id, "synced": synced, "failed": failed}, + headers=headers, + ) + assert response.status_code == 200 + + status = ops_client.get("/api/v1/integrations/status").json()["n8n"] + workflow = next(w for w in status["workflows"] if "RAGcore" in w["name"]) + assert workflow["state"] == "failed" + assert workflow["last_status"] == "failed" + assert workflow["last_execution_id"] == execution_id + assert status["state"] == "degraded" + + +def test_workflow_heartbeat_is_idempotent_and_drives_live_status(client, ops_client): + settings = get_settings() + execution_id = str(uuid.uuid4()) + body = { + "workflow_id": "mobilityops-scheduled-quality-scan", + "workflow_name": "Fleet Ops — Scheduled Data Quality Scan", + "execution_id": execution_id, + "status": "succeeded", + } + headers = {"X-Service-Token": settings.n8n_callback_token} + first = client.post("/api/v1/integrations/n8n/heartbeat", json=body, headers=headers) + second = client.post("/api/v1/integrations/n8n/heartbeat", json=body, headers=headers) + assert first.status_code == 200 + assert first.json()["status"] == "registered" + assert second.json()["status"] == "already_registered" + + status = ops_client.get("/api/v1/integrations/status").json()["n8n"] + workflow = next(w for w in status["workflows"] if w["name"] == body["workflow_name"]) + assert workflow["state"] == "healthy" + assert workflow["last_status"] == "succeeded" + assert workflow["last_execution_id"] == execution_id + + +def test_workflow_heartbeat_rejects_unknown_workflow(client): + response = client.post( + "/api/v1/integrations/n8n/heartbeat", + json={ + "workflow_id": "unknown", + "workflow_name": "Unknown workflow", + "execution_id": "test-unknown-001", + "status": "succeeded", + }, + headers={"X-Service-Token": get_settings().n8n_callback_token}, + ) + assert response.status_code == 422 diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py new file mode 100644 index 0000000..8c9db8a --- /dev/null +++ b/backend/tests/test_knowledge.py @@ -0,0 +1,1113 @@ +from __future__ import annotations + +import hashlib +import re +import uuid +from pathlib import Path + +import httpx +import pytest + +from app.api.routers import knowledge as knowledge_router +from app.core.config import get_settings +from app.core.ratelimit import SlidingWindowLimiter +from app.services.knowledge import KnowledgeHealth +from app.services.knowledge.demo import DemoKnowledgeProvider +from app.services.knowledge.procedures import iter_procedure_documents +from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider + + +def test_brief_exact_damage_question_in_all_three_languages(): + # The exact validation questions from docs/fleet-ops-correction/current-gap-audit.md + # -- each must ground on the damage procedure as its *primary* (top-ranked) source, + # not merely appear somewhere in the top-3, and the source/version/section/excerpt + # must all come from that same-language document (never an English fallback). + provider = DemoKnowledgeProvider() + cases = { + "nl-BE": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?", + "en-GB": "What should I do when a vehicle returns with damage?", + "fr-BE": "Que dois-je faire lorsqu'un véhicule revient endommagé ?", + } + for language, question in cases.items(): + answer = provider.ask(question, f"test-brief-{language}", language) + assert answer.evidence_state == "grounded", language + assert answer.sources, language + assert answer.sources[0].document_id == "damage-procedure", ( + f"{language}: expected the damage procedure as the primary source, " + f"got {answer.sources[0].document_id!r}" + ) + assert answer.answer + assert answer.sources[0].excerpt + + +def test_knowledge_procedures_never_mention_mobilityops_or_poc(): + # Section 2 of docs/fleet-ops-correction/current-gap-audit.md: the visible brand + # name is exactly "Fleet Ops", and "PoC" must never appear in visible content -- + # including the demo knowledge base, not just the frontend. + procedures_dir = Path(get_settings().knowledge_dir) + offenders = [] + for path in sorted(procedures_dir.glob("*/*.md")): + text = path.read_text(encoding="utf-8") + if "MobilityOps" in text or re.search(r"\bPoC\b", text): + offenders.append(str(path)) + assert offenders == [] + + +def test_s6_damage_question_is_grounded_with_expected_sources(): + provider = DemoKnowledgeProvider() + answer = provider.ask( + "What must I do when a vehicle returns with damage?", "test-correlation-1" + ) + assert answer.evidence_state == "grounded" + document_ids = {s.document_id for s in answer.sources} + assert "damage-procedure" in document_ids + assert "vehicle-return-procedure" in document_ids + assert answer.answer # never empty for a grounded answer + for source in answer.sources: + assert source.excerpt + + +def test_unrelated_question_is_insufficient(): + provider = DemoKnowledgeProvider() + answer = provider.ask("What is the capital of France?", "test-correlation-2") + assert answer.evidence_state == "insufficient" + assert "France" not in answer.answer # never fabricates an answer beyond the procedures + + +def test_demo_provider_health_reports_document_count(): + provider = DemoKnowledgeProvider() + health = provider.health() + assert health.provider == "demo" + assert health.available is True + assert health.document_count == 11 + assert health.source_document_count == 11 + assert health.statistics_state == "verified" + + +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): + response = ops_client.post( + "/api/v1/knowledge/questions", + json={"question": "What must I do when a vehicle returns with damage?"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["evidence_state"] == "grounded" + assert body["provider"] == "demo" + assert len(body["sources"]) > 0 + + +def test_ask_question_requires_authentication(client): + response = client.post("/api/v1/knowledge/questions", json={"question": "Anything?"}) + assert response.status_code == 401 + + +def test_knowledge_questions_are_bounded_per_session_and_client(ops_client, monkeypatch): + limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60) + monkeypatch.setattr(knowledge_router, "_question_limiter", limiter) + body = {"question": "What is the vehicle return procedure?"} + assert ops_client.post("/api/v1/knowledge/questions", json=body).status_code == 200 + blocked = ops_client.post("/api/v1/knowledge/questions", json=body) + assert blocked.status_code == 429 + assert int(blocked.headers["retry-after"]) >= 1 + + +def test_ask_question_is_audited_without_leaking_full_text(ops_client): + ops_client.post( + "/api/v1/knowledge/questions", + json={"question": "What must I do when a vehicle returns with damage?"}, + ) + events = ops_client.get("/api/v1/audit", params={"action": "knowledge_question_asked"}).json() + assert len(events) >= 1 + metadata = events[0]["metadata"] + assert "evidence_state" in metadata + assert "source_ids" in metadata + assert "question" not in metadata + + +def test_knowledge_status_endpoint(ops_client): + response = ops_client.get("/api/v1/knowledge/status") + assert response.status_code == 200 + assert response.json()["provider"] == "demo" + + +def test_ragcore_status_separates_sync_report_from_unverifiable_index( + client, ops_client, monkeypatch +): + class FakeRagcoreProvider: + def health(self, language="en-GB"): + return KnowledgeHealth( + provider="ragcore", + available=True, + detail="ready", + tenant="fleet-ops", + workspace="operations", + collection="internal-procedures", + document_count=None, + source_document_count=11, + reported_synced_document_count=None, + reported_failed_document_count=None, + last_sync_at=None, + statistics_state="not_reported", + ) + + monkeypatch.setattr(knowledge_router, "get_knowledge_provider", FakeRagcoreProvider) + settings = get_settings() + sync = client.post( + "/api/v1/integrations/n8n/procedures-sync-result", + json={"execution_id": "rag-statistics-test", "synced": 32, "failed": 1}, + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert sync.status_code == 200 + + response = ops_client.get("/api/v1/knowledge/status?language=nl-BE") + assert response.status_code == 200 + status = response.json() + assert status["document_count"] is None + assert status["source_document_count"] == 11 + assert status["reported_synced_document_count"] == 32 + assert status["reported_failed_document_count"] == 1 + assert status["last_sync_at"] is not None + assert status["statistics_state"] == "sync_reported" + + +def test_ragcore_status_preserves_stronger_verified_index_evidence(client, ops_client, monkeypatch): + class VerifiedRagcoreProvider: + def health(self, language="en-GB"): + return KnowledgeHealth( + provider="ragcore", + available=True, + detail="ready and verified", + tenant="fleet-ops", + workspace="operations", + collection="internal-procedures", + document_count=11, + source_document_count=11, + reported_synced_document_count=None, + reported_failed_document_count=None, + last_sync_at=None, + statistics_state="verified", + ) + + monkeypatch.setattr(knowledge_router, "get_knowledge_provider", VerifiedRagcoreProvider) + settings = get_settings() + sync = client.post( + "/api/v1/integrations/n8n/procedures-sync-result", + json={"execution_id": "rag-verified-statistics-test", "synced": 33, "failed": 0}, + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert sync.status_code == 200 + + status = ops_client.get("/api/v1/knowledge/status?language=nl-BE").json() + assert status["document_count"] == 11 + assert status["reported_synced_document_count"] == 33 + assert status["statistics_state"] == "verified" + + +class _FakeResponse: + def __init__(self, status_code: int, body: object): + self.status_code = status_code + self._body = body + + def json(self) -> object: + return self._body + + +class _FakeClient: + def __init__( + self, + get_response=None, + post_response=None, + post_responses=None, + raise_on=None, + get_handler=None, + requests=None, + ): + self._get_response = get_response + self._post_response = post_response + # Maps a path (e.g. "/v1/search") to its own response, for tests that need + # /v1/answers and /v1/search to behave differently in the same call. Falls back + # to the single post_response when a path has no specific entry, so every + # existing single-endpoint test keeps working unchanged. + self._post_responses = post_responses or {} + self._raise_on = raise_on + self._get_handler = get_handler + self.requests = requests if requests is not None else [] + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def get(self, path, params=None, timeout=None): + if self._raise_on == "get": + raise httpx.ConnectError("no ragcore in this environment") + if self._get_handler is not None: + return self._get_handler(path, params, timeout) + if path == "/v1/documents": + return _FakeResponse(503, {}) + return self._get_response + + def post(self, path, json=None): + if self._raise_on == "post": + raise httpx.ConnectError("no ragcore in this environment") + self.requests.append((path, json)) + return self._post_responses.get(path, self._post_response) + + +def test_ragcore_provider_degrades_to_unavailable(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="post")) + answer = provider.ask("Anything?", "test-correlation-3") + assert answer.evidence_state == "unavailable" + assert answer.sources == [] + + +def test_ragcore_provider_without_configured_space_is_unavailable_without_a_network_call( + monkeypatch, +): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "") + + def fail_if_called(): + raise AssertionError("should not call RAGcore without a configured space id") + + monkeypatch.setattr(provider, "_client", fail_if_called) + answer = provider.ask("Anything?", "test-correlation-no-space") + assert answer.evidence_state == "unavailable" + + +def test_ragcore_provider_health_reports_ready_status(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "ok"})), + ) + health = provider.health() + assert health.provider == "ragcore" + assert health.available is True + assert health.document_count is None + assert health.source_document_count == 11 + assert health.statistics_state == "not_reported" + + +def test_ragcore_provider_verifies_published_documents_and_caches_count(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + procedure_documents = [ + document + for document in iter_procedure_documents(Path(provider._settings.knowledge_dir)) + if document.language == "en-GB" + ] + documents = {document.source_id: document for document in procedure_documents} + lookups: list[str] = [] + + def get_handler(path, params, _timeout): + if path == "/health/ready": + return _FakeResponse(200, {"status": "ok"}) + document = documents[params["source_id"]] + lookups.append(document.source_id) + return _FakeResponse( + 200, + { + "items": [ + { + "space_id": "space-1", + "source_id": document.source_id, + "external_id": f"{document.document_id}.md", + "status": "active", + "active_version": { + "status": "published", + "content_sha256": "provider-canonical-hash", + }, + } + ] + }, + ) + + monkeypatch.setattr(provider, "_client", lambda: _FakeClient(get_handler=get_handler)) + + first = provider.health("en-GB") + second = provider.health("en-GB") + assert first.document_count == 11 + assert first.statistics_state == "verified" + assert second.document_count == 11 + assert len(lookups) == 11 + + +def test_ragcore_provider_health_reports_degraded_status(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "degraded"})), + ) + health = provider.health() + assert health.available is False + + +def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="get")) + health = provider.health() + assert health.available is False + assert "unavailable" in health.detail.lower() + + +_EXCERPTS = { + ("en-GB", "damage-procedure"): ( + "When a vehicle returns with visible or reported damage, mark damage in the " + "return inspection, add a concise factual description and keep the vehicle " + "blocked." + ), + ("en-GB", "vehicle-return-procedure"): ( + "Open the active booking and record the ending odometer, fuel level, cleanliness, " + "visible damage, technical warnings and relevant notes." + ), + ("en-GB", "cleaning-checklist"): ( + "Cleaning completion alone does not make a blocked or maintenance vehicle " + "available. Fleet Ops derives availability from all active restrictions." + ), + ("en-GB", "maintenance-escalation"): ( + "Escalate when a technical warning is reported, the service threshold is reached, " + "a safety-related defect is observed or an existing maintenance block remains " + "unresolved." + ), + ("nl-BE", "damage-procedure"): ( + "Wanneer een voertuig terugkomt met zichtbare of gemelde schade, markeer de schade " + "in de retourinspectie, voeg een beknopte feitelijke beschrijving toe en houd het " + "voertuig geblokkeerd." + ), + ("nl-BE", "vehicle-return-procedure"): ( + "Open de actieve boeking en registreer de eindkilometerstand, het brandstofniveau, " + "de netheid, zichtbare schade, technische waarschuwingen en relevante notities." + ), +} + + +def _managed_document(provider: RAGcoreKnowledgeProvider, document_id: str, language: str): + return next( + document + for document in provider._managed_documents_by_source_id.values() + if document.document_id == document_id and document.language == language + ) + + +def _citation( + provider: RAGcoreKnowledgeProvider, + document_id: str, + *, + language: str = "en-GB", + section: str = "Procedure", + seed: str = "1", +) -> dict: + document = _managed_document(provider, document_id, language) + excerpt = _EXCERPTS[(language, document_id)] + return { + "id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"citation:{language}:{document_id}:{seed}")), + # These are deliberately opaque RAGcore-owned UUIDs, not Fleet Ops' stable + # human-readable procedure id. + "document_id": str( + uuid.uuid5(uuid.NAMESPACE_URL, f"ragcore-document:{language}:{document_id}") + ), + "document_version_id": str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"ragcore-document-version:{language}:{document_id}:{seed}", + ) + ), + "title": "untrusted-provider-title.md", + "section": section, + "excerpt": excerpt, + "excerpt_sha256": hashlib.sha256(excerpt.encode("utf-8")).hexdigest(), + "source_uri": f"ragcore://source/{document.source_id}", + "source_id": document.source_id, + "locator": f"{document.document_id}.md", + } + + +def _answers_body(provider: RAGcoreKnowledgeProvider, **overrides) -> dict: + citation = _citation(provider, "damage-procedure", section="Detection") + answer = "Report damage and route the vehicle to maintenance." + body = { + "answer_id": str(uuid.uuid4()), + "retrieval_run_id": str(uuid.uuid4()), + "answer": answer, + "answerability": "answerable", + "citations": [citation], + "claims": [{"text": answer, "citation_ids": [citation["id"]]}], + } + body.update(overrides) + if "claims" not in overrides: + effective_answer = body.get("answer") + effective_citations = body.get("citations") + body["claims"] = ( + [ + { + "text": effective_answer, + "citation_ids": [item["id"] for item in effective_citations], + } + ] + if isinstance(effective_answer, str) + and effective_answer + and isinstance(effective_citations, list) + and effective_citations + and all(isinstance(item, dict) and "id" in item for item in effective_citations) + else [] + ) + return body + + +def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(provider))), + ) + answer = provider.ask("What must I do about damage?", "test-correlation-grounded") + assert answer.evidence_state == "grounded" + assert answer.answer + assert len(answer.sources) == 1 + source = answer.sources[0] + assert source.document_id == "damage-procedure" + assert source.title == "Damage handling procedure" + assert source.version == "1.3" + assert source.section == "Detection" + assert source.excerpt + + +@pytest.mark.parametrize( + "mutate", + [ + lambda body: body.pop("claims"), + lambda body: body.update(claims=[]), + lambda body: body["claims"][0].update(citation_ids=[str(uuid.uuid4())]), + lambda body: body.pop("answer_id"), + lambda body: body.update(retrieval_run_id="not-a-uuid"), + ], +) +def test_ragcore_answer_requires_valid_claim_citation_contract(monkeypatch, mutate): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + body = _answers_body(provider) + mutate(body) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient(post_response=_FakeResponse(200, body)), + ) + + answer = provider.ask("What must I do about damage?", "test-invalid-claim-contract") + + assert answer.evidence_state != "grounded" + assert answer.answer == "" + assert answer.sources == [] + + +def test_ragcore_requests_are_scoped_to_managed_sources_for_requested_language( + monkeypatch, +): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + requests = [] + fake_client = _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": _FakeResponse(200, _search_body(provider, "nl-BE")), + }, + requests=requests, + ) + monkeypatch.setattr(provider, "_client", lambda: fake_client) + + answer = provider.ask( + "Wat is de procedure voor een voertuigretour?", + "language-filter", + "nl-BE", + ) + + assert answer.evidence_state == "grounded" + assert [path for path, _payload in requests] == ["/v1/answers", "/v1/search"] + expected_source_ids = provider._managed_source_ids("nl-BE") + assert len(expected_source_ids) == 11 + assert all(uuid.UUID(source_id) for source_id in expected_source_ids) + for _path, payload in requests: + assert payload["filters"] == {"source_ids": expected_source_ids} + assert payload["requested_space_ids"] == ["space-1"] + + +def test_ragcore_sources_deduplicate_reuploaded_versions_and_cap_cards(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + citations = [] + document_ids = [ + "damage-procedure", + "damage-procedure", + "vehicle-return-procedure", + "cleaning-checklist", + "maintenance-escalation", + ] + for index, document_id in enumerate(document_ids): + citations.append( + _citation( + provider, + document_id, + section="Return", + seed=str(index), + ) + ) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_response=_FakeResponse(200, _answers_body(provider, citations=citations)) + ), + ) + answer = provider.ask("What must I do about vehicle damage?", "dedupe-test") + assert len(answer.sources) == 3 + assert sum(source.title == "Damage handling procedure" for source in answer.sources) == 1 + + +def test_ragcore_answer_rejects_unknown_document_citation(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_response=_FakeResponse( + 200, + _answers_body( + provider, + answer="A plausible-looking but unmanaged answer.", + citations=[ + { + **_citation(provider, "damage-procedure"), + "source_id": str(uuid.uuid4()), + } + ], + ), + ) + ), + ) + + answer = provider.ask("What must I do about damage?", "unknown-answer-citation") + + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + assert answer.sources == [] + + +@pytest.mark.parametrize( + ("field_name", "bad_value"), + [ + ("id", "not-a-uuid"), + ("document_id", "not-a-uuid"), + ("document_version_id", "00000000-0000-0000-0000-000000000000"), + ("source_id", "00000000-0000-4000-8000-000000000099"), + ("source_uri", "ragcore://source/00000000-0000-4000-8000-000000000099"), + ("locator", "another-procedure.md"), + ("excerpt_sha256", "0" * 64), + ("title", None), + ("section", {"not": "a string"}), + ], +) +def test_ragcore_rejects_broken_managed_source_provenance(field_name, bad_value): + provider = RAGcoreKnowledgeProvider() + citation = _citation(provider, "damage-procedure") + citation[field_name] = bad_value + + assert provider._managed_source(citation, "en-GB") is None + + +def test_ragcore_rejects_fabricated_excerpt_even_with_matching_hash(): + provider = RAGcoreKnowledgeProvider() + citation = _citation(provider, "damage-procedure") + fabricated = "Invent a repair price and promise it to the customer." + citation["excerpt"] = fabricated + citation["excerpt_sha256"] = hashlib.sha256(fabricated.encode("utf-8")).hexdigest() + + assert provider._managed_source(citation, "en-GB") is None + + +def test_ragcore_never_relabels_an_english_source_as_dutch(): + provider = RAGcoreKnowledgeProvider() + citation = _citation(provider, "damage-procedure", language="en-GB") + + assert provider._managed_source(citation, "nl-BE") is None + + +def test_knowledge_feedback_is_audited_and_can_be_changed(ops_client): + answer = ops_client.post( + "/api/v1/knowledge/questions", + json={"question": "What must I do when a vehicle returns with damage?"}, + ).json() + payload = {"correlation_id": answer["correlation_id"], "helpful": True} + assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200 + payload["helpful"] = False + assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200 + + events = ops_client.get( + "/api/v1/audit", params={"action": "knowledge_feedback_recorded"} + ).json() + matching = [e for e in events if e["correlation_id"] == answer["correlation_id"]] + assert len(matching) == 1 + assert matching[0]["metadata"]["helpful"] is False + + +def test_knowledge_feedback_cannot_target_another_users_exchange(client): + assert client.post("/api/v1/demo/login", json={"role": "rental_employee"}).status_code == 200 + answer = client.post( + "/api/v1/knowledge/questions", + json={"question": "How do I register a vehicle return?"}, + ).json() + assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200 + response = client.post( + "/api/v1/knowledge/feedback", + json={"correlation_id": answer["correlation_id"], "helpful": True}, + ) + assert response.status_code == 404 + + +def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_response=_FakeResponse( + 200, + _answers_body( + provider, + answer="This should never be shown.", + answerability="not_answerable", + citations=[], + ), + ) + ), + ) + answer = provider.ask("Unrelated question?", "test-correlation-insufficient") + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + assert answer.sources == [] + + +def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_response=_FakeResponse( + 200, + _answers_body(provider, answerability="answerable", citations=[]), + ) + ), + ) + answer = provider.ask("What must I do about damage?", "test-correlation-no-citations") + assert answer.evidence_state == "insufficient" + assert answer.sources == [] + + +def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient(post_response=_FakeResponse(401, {"code": "AUTHENTICATION_REQUIRED"})), + ) + answer = provider.ask("Anything?", "test-correlation-401") + assert answer.evidence_state == "unavailable" + + +def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + # A malformed /v1/answers body triggers the same search fallback a real outage + # would, so the fallback's own /v1/search response must also be malformed here to + # exercise "the whole backend is misbehaving, not just one endpoint" honestly. + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(200, {"citations": "not-a-list"}), + "/v1/search": _FakeResponse(200, {"results": "not-a-list"}), + } + ), + ) + answer = provider.ask("Anything?", "test-correlation-malformed") + assert answer.evidence_state == "unavailable" + + +def test_ragcore_provider_malformed_top_level_bodies_do_not_escape(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(200, ["not", "an", "object"]), + "/v1/search": _FakeResponse(200, None), + } + ), + ) + + answer = provider.ask("Anything?", "test-correlation-malformed-top-level") + + assert answer.evidence_state == "unavailable" + assert answer.answer == "" + assert answer.sources == [] + + +def test_ragcore_answer_with_malformed_citation_is_insufficient(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_response=_FakeResponse( + 200, + _answers_body(provider, citations=[None]), + ) + ), + ) + + answer = provider.ask("What must I do about damage?", "malformed-answer-citation") + + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + assert answer.sources == [] + + +def _search_body( + provider: RAGcoreKnowledgeProvider, + language: str = "en-GB", + **overrides, +) -> dict: + body = { + "retrieval_run_id": str(uuid.uuid4()), + "effective_space_ids": ["00000000-0000-4000-8000-000000000001"], + "results": [ + { + "chunk_id": str(uuid.uuid4()), + "text": _EXCERPTS[(language, "vehicle-return-procedure")], + "citation": _citation( + provider, + "vehicle-return-procedure", + language=language, + section="Return", + ), + "rank": 1, + "scores": {"dense": None, "sparse": None, "fused": 0.5, "rerank": None}, + } + ], + "degraded": False, + } + body.update(overrides) + return body + + +def test_ragcore_search_ignores_malformed_result_and_citation_bodies(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + malformed_search = _search_body( + provider, + results=[None, {"citation": ["not-an-object"], "scores": "not-an-object"}], + ) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": _FakeResponse(200, malformed_search), + } + ), + ) + + answer = provider.ask("What is the vehicle return procedure?", "malformed-search-items") + + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + assert answer.sources == [] + + +def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypatch): + """/v1/answers itself failing (a real RAGcore-side outage in its generation step, + not a real 'insufficient evidence' classification) must not silently degrade + straight to 'unavailable' when RAGcore's own retrieval still works -- it should show + the real, cited excerpt search actually found instead.""" + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}), + "/v1/search": _FakeResponse(200, _search_body(provider)), + } + ), + ) + answer = provider.ask("What is the vehicle return procedure?", "test-correlation-fallback") + assert answer.evidence_state == "grounded" + assert "Open the active booking and record the ending odometer" in answer.answer + assert "Vehicle return procedure" in answer.answer + assert len(answer.sources) == 1 + assert answer.sources[0].title == "Vehicle return procedure" + assert answer.sources[0].excerpt == _EXCERPTS[("en-GB", "vehicle-return-procedure")] + + +def test_ragcore_answers_circuit_skips_repeated_generation_failure(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr(provider._settings, "ragcore_answers_circuit_breaker_seconds", 60.0) + search_response = _FakeResponse(200, _search_body(provider)) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": search_response, + } + ), + ) + assert ( + provider.ask("What is the vehicle return procedure?", "first").evidence_state == "grounded" + ) + + monkeypatch.setattr( + provider, + "_ask_via_answers", + lambda *_args: (_ for _ in ()).throw(AssertionError("open circuit called answers")), + ) + second = provider.ask("What is the vehicle return procedure?", "second") + assert second.evidence_state == "grounded" + + +def test_ragcore_provider_fallback_answer_is_localized(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}), + "/v1/search": _FakeResponse(200, _search_body(provider, "nl-BE")), + } + ), + ) + answer = provider.ask( + "Wat is de procedure voor een voertuigretour?", + "test-correlation-fallback-nl", + language="nl-BE", + ) + assert answer.evidence_state == "grounded" + assert answer.answer.startswith("Volgens ") + + +def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}), + "/v1/search": _FakeResponse(200, _search_body(provider, results=[])), + } + ), + ) + answer = provider.ask("Unrelated question?", "test-correlation-fallback-empty") + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + assert answer.sources == [] + + +def test_ragcore_search_fallback_rejects_out_of_domain_question(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": _FakeResponse(200, _search_body(provider)), + } + ), + ) + answer = provider.ask( + "Who won the football world cup in 1998?", + "test-correlation-out-of-domain", + ) + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + + +def test_ragcore_search_fallback_rejects_unknown_document_citation(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + search = _search_body(provider) + search["results"][0]["citation"]["source_id"] = str(uuid.uuid4()) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": _FakeResponse(200, search), + } + ), + ) + + answer = provider.ask( + "What is the vehicle return procedure?", + "unknown-search-citation", + ) + + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + assert answer.sources == [] + + +def test_ragcore_search_fallback_rejects_vehicle_colour_despite_high_score(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + search = _search_body(provider) + search["results"][0]["scores"]["fused"] = 1.0 + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": _FakeResponse(200, search), + } + ), + ) + + answer = provider.ask("What colour is this vehicle?", "vehicle-colour") + + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + + +def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + search = _search_body(provider, "nl-BE", results=[]) + search["results"].append( + { + "citation": _citation( + provider, + "damage-procedure", + language="nl-BE", + section="Damage", + ), + "rank": 2, + "scores": {"fused": 0.01}, + } + ) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": _FakeResponse(200, search), + } + ), + ) + answer = provider.ask("Wat moet ik doen bij schade?", "test-correlation-damage", "nl-BE") + assert answer.evidence_state == "insufficient" + assert answer.answer == "" + assert any(source.document_id == "damage-procedure" for source in answer.sources) + + +def test_ragcore_search_fallback_accepts_top_ranked_ragcore_damage_evidence(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + search = _search_body(provider, "nl-BE", results=[]) + search["results"].append( + { + "citation": _citation( + provider, + "damage-procedure", + language="nl-BE", + section="Damage", + ), + "rank": 1, + # RAGcore uses reciprocal-rank fusion; a genuine rank-one result is about + # 1 / (60 + 1), not a normalized 0..1 relevance score. + "scores": {"fused": 0.01639344262295082}, + } + ) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient( + post_responses={ + "/v1/answers": _FakeResponse(503, {}), + "/v1/search": _FakeResponse(200, search), + } + ), + ) + answer = provider.ask("Wat moet ik doen bij schade?", "strong-damage", "nl-BE") + assert answer.evidence_state == "grounded" + assert answer.sources[0].document_id == "damage-procedure" diff --git a/backend/tests/test_mcp_integrations.py b/backend/tests/test_mcp_integrations.py new file mode 100644 index 0000000..20d0ace --- /dev/null +++ b/backend/tests/test_mcp_integrations.py @@ -0,0 +1,187 @@ +from app.core.config import get_settings +from app.main import app + +MCP_ROUTE_ALLOWLIST = { + ("GET", "/api/v1/integrations/mcp/operations-summary"), + ("GET", "/api/v1/integrations/mcp/attention-vehicles"), + ("GET", "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"), + ("POST", "/api/v1/integrations/mcp/search-knowledge"), +} + + +def _headers(token: str | None = None, client_id: str = "test-mcp-client"): + settings = get_settings() + return { + "X-Service-Token": token if token is not None else settings.mcp_hub_service_token, + "X-Client-Id": f"itworx-mcp-hub:mobilityops:{client_id}", + } + + +def test_operations_summary_requires_service_token(client): + response = client.get( + "/api/v1/integrations/mcp/operations-summary", headers=_headers(token="wrong") + ) + assert response.status_code == 401 + + +def test_operations_summary_rejects_spoofed_client_identity(client): + settings = get_settings() + response = client.get( + "/api/v1/integrations/mcp/operations-summary", + headers={"X-Service-Token": settings.mcp_hub_service_token, "X-Client-Id": "spoofed"}, + ) + assert response.status_code == 403 + + +def test_operations_summary_rejects_cross_tenant_request(client): + response = client.get( + "/api/v1/integrations/mcp/operations-summary", + headers={**_headers(), "X-Tenant-Id": "another-tenant"}, + ) + assert response.status_code == 403 + + +def test_operations_summary_returns_metrics(client): + response = client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers()) + assert response.status_code == 200 + body = response.json() + assert "metrics" in body + assert body["metrics"]["available"] >= 0 + assert response.headers["x-correlation-id"] + assert response.headers["x-tenant-id"] == get_settings().ragcore_tenant + + +def test_attention_vehicles_filters_by_severity(client): + response = client.get( + "/api/v1/integrations/mcp/attention-vehicles", + params={"minimum_severity": "high", "limit": 50}, + headers=_headers(), + ) + assert response.status_code == 200 + rows = response.json() + assert all(r["severity"] == "high" for r in rows) + + +def test_attention_vehicles_respects_limit(client): + response = client.get( + "/api/v1/integrations/mcp/attention-vehicles", + params={"minimum_severity": "low", "limit": 2}, + headers=_headers(), + ) + assert response.status_code == 200 + assert len(response.json()) <= 2 + + +def test_vehicle_details_known_ref(client): + response = client.get("/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers()) + assert response.status_code == 200 + body = response.json() + assert body["public_ref"] == "MO-016" + assert "registration_number" not in body # narrow read-only view, not the full record + + +def test_vehicle_details_unknown_ref_is_404(client): + response = client.get("/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers()) + assert response.status_code == 404 + + +def test_search_knowledge_grounded_and_respects_max_sources(client): + response = client.post( + "/api/v1/integrations/mcp/search-knowledge", + json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1}, + headers=_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["evidence_state"] == "grounded" + assert len(body["sources"]) == 1 + assert int(response.headers["x-sources-available"]) >= 1 + assert response.headers["x-sources-returned"] == "1" + + +def test_mcp_tool_requests_are_audited(client, ops_client): + client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers(client_id="probe-1")) + events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() + assert len(events) >= 1 + assert events[0]["actor_type"] == "service" + assert events[0]["actor_label"] == "itworx-mcp-hub" + assert events[0]["metadata"]["reported_client_id"].endswith(":probe-1") + + +def test_search_knowledge_respects_requested_locale(client): + response = client.post( + "/api/v1/integrations/mcp/search-knowledge", + json={ + "question": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?", + "max_sources": 1, + "locale": "nl-BE", + }, + headers=_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["evidence_state"] == "grounded" + + +def test_search_knowledge_preserves_inbound_correlation_id(client, ops_client): + inbound = "11111111-1111-1111-1111-111111111111" + response = client.post( + "/api/v1/integrations/mcp/search-knowledge", + json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1}, + headers={**_headers(client_id="correlation-probe"), "X-Correlation-Id": inbound}, + ) + assert response.status_code == 200 + assert response.json()["correlation_id"] == inbound + + events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() + matching = [e for e in events if e["correlation_id"] == inbound] + assert len(matching) == 1 + + +def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_client): + response = client.get( + "/api/v1/integrations/mcp/operations-summary", + headers=_headers(client_id="no-correlation-probe"), + ) + assert response.status_code == 200 + events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() + matching = [ + e + for e in events + if e["metadata"].get("reported_client_id", "").endswith(":no-correlation-probe") + ] + assert len(matching) >= 1 + assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty + + +def test_no_write_endpoints_exist_under_mcp_namespace(client): + for method, path in [ + ("post", "/api/v1/integrations/mcp/vehicles/MO-016"), + ("put", "/api/v1/integrations/mcp/vehicles/MO-016"), + ("delete", "/api/v1/integrations/mcp/vehicles/MO-016"), + ]: + response = getattr(client, method)(path, headers=_headers()) + assert response.status_code in (404, 405) + + +def test_mcp_namespace_matches_exact_route_allowlist_and_rejects_lookalikes(client): + implemented = { + (method.upper(), path) + for path, operations in app.openapi()["paths"].items() + if path.startswith("/api/v1/integrations/mcp/") + for method in operations + if method in {"get", "post", "put", "patch", "delete"} + } + assert implemented == MCP_ROUTE_ALLOWLIST + + lookalikes = [ + ("get", "/api/v1/integrations/mcp-extra/operations-summary"), + ("get", "/api/v1/integrations/mcp/operations-summary-extra"), + ("get", "/api/v1/integrations/mcp/prefix/attention-vehicles"), + ("get", "/api/v1/integrations/mcp/attention-vehicles/suffix"), + ("post", "/api/v1/integrations/mcp/search-knowledge-extra"), + ("post", "/api/v1/integrations/mcp/prefix/search-knowledge"), + ] + for method, path in lookalikes: + response = client.request(method, path, headers=_headers(), json={}) + assert response.status_code == 404, path diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..b5556e0 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -0,0 +1,88 @@ +"""Guard against model/migration drift. + +The functional suite builds its schema with ``Base.metadata.create_all`` for speed, so a +column or index added to a model but never written into an Alembic migration would only +surface on the first real deployment. This test runs the migration chain from an empty +database and asserts that Alembic's autogenerate sees nothing left to do. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from alembic.autogenerate import compare_metadata +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, text +from sqlalchemy.engine import make_url + +from alembic import command +from app.core.config import get_settings +from app.models import Base + +BACKEND_DIR = Path(__file__).resolve().parents[1] +SCRATCH_DB = "mobilityops_migration_check" + + +@pytest.fixture(scope="module") +def migrated_database_url() -> str: + settings = get_settings() + base_url = make_url(settings.database_url) + admin_engine = create_engine( + base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None + ) + with admin_engine.connect() as conn: + conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}"')) + conn.execute(text(f'CREATE DATABASE "{SCRATCH_DB}"')) + scratch_url = base_url.set(database=SCRATCH_DB).render_as_string(hide_password=False) + try: + yield scratch_url + finally: + admin_engine.dispose() + admin_engine = create_engine( + base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None + ) + with admin_engine.connect() as conn: + conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')) + admin_engine.dispose() + + +def _alembic_config(database_url: str) -> Config: + config = Config(str(BACKEND_DIR / "alembic.ini")) + config.set_main_option("script_location", str(BACKEND_DIR / "alembic")) + config.set_main_option("sqlalchemy.url", database_url) + return config + + +def test_migrations_upgrade_from_empty_and_match_models(migrated_database_url: str) -> None: + config = _alembic_config(migrated_database_url) + # env.py reads DATABASE_URL from settings; override it for the scratch database. + import os + + previous = os.environ.get("DATABASE_URL") + os.environ["DATABASE_URL"] = migrated_database_url + get_settings.cache_clear() + try: + command.upgrade(config, "head") + finally: + if previous is None: + os.environ.pop("DATABASE_URL", None) + else: + os.environ["DATABASE_URL"] = previous + get_settings.cache_clear() + + engine = create_engine(migrated_database_url) + try: + with engine.connect() as conn: + context = MigrationContext.configure( + conn, opts={"compare_type": True, "compare_server_default": False} + ) + diff = compare_metadata(context, Base.metadata) + finally: + engine.dispose() + + assert diff == [], ( + "Models and Alembic migrations have drifted; write a migration for:\n" + + "\n".join(repr(entry) for entry in diff) + ) diff --git a/backend/tests/test_observability.py b/backend/tests/test_observability.py new file mode 100644 index 0000000..e3519f6 --- /dev/null +++ b/backend/tests/test_observability.py @@ -0,0 +1,53 @@ +import json +import logging +import uuid + +from app.core.observability import JsonFormatter + + +def test_request_correlation_id_is_echoed_and_used_in_errors(client): + correlation_id = str(uuid.uuid4()) + response = client.get( + "/api/v1/dashboard", + headers={"X-Correlation-Id": correlation_id}, + ) + assert response.status_code == 401 + assert response.headers["X-Correlation-Id"] == correlation_id + assert response.json()["error"]["correlation_id"] == correlation_id + + +def test_invalid_request_correlation_id_is_replaced(client): + response = client.get("/health/live", headers={"X-Correlation-Id": "not-a-uuid"}) + assert response.status_code == 200 + assert uuid.UUID(response.headers["X-Correlation-Id"]) + + +def test_metrics_expose_http_database_and_outbox_state(client): + client.get("/health/ready") + response = client.get("/metrics") + assert response.status_code == 200 + assert "mobilityops_http_requests_total" in response.text + assert "mobilityops_database_ready 1.0" in response.text + assert 'mobilityops_outbox_events{scenario="synthetic",status="failed"}' in response.text + assert "mobilityops_knowledge_provider_requests_total" in response.text + assert "mobilityops_knowledge_retrieval_score" in response.text + + +def test_metrics_token_is_enforced_when_configured(client, monkeypatch): + import app.api.routers.observability as router + + monkeypatch.setattr(router.settings, "metrics_bearer_token", "metrics-secret") + assert client.get("/metrics").status_code == 401 + assert ( + client.get("/metrics", headers={"Authorization": "Bearer metrics-secret"}).status_code + == 200 + ) + + +def test_json_formatter_emits_machine_readable_fields(): + record = logging.LogRecord("mobilityops.test", logging.INFO, __file__, 1, "ready", (), None) + record.status_code = 200 + payload = json.loads(JsonFormatter().format(record)) + assert payload["message"] == "ready" + assert payload["status_code"] == 200 + assert payload["timestamp"].endswith("+00:00") diff --git a/backend/tests/test_operational_auth.py b/backend/tests/test_operational_auth.py new file mode 100644 index 0000000..8d54283 --- /dev/null +++ b/backend/tests/test_operational_auth.py @@ -0,0 +1,36 @@ +from app.api.routers import auth as auth_router +from app.core.db import SessionLocal +from app.core.security import hash_password +from app.models.user import User + + +def test_password_login_is_available_only_outside_demo_mode(ops_client, monkeypatch): + user = User( + public_ref="USR-REAL", + email="manager@example.test", + password_hash=hash_password("correct-horse-battery-staple"), + display_name="Real Manager", + role="operations_manager", + active=True, + ) + with SessionLocal() as db: + db.add(user) + db.commit() + monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False) + + response = ops_client.post( + "/api/v1/auth/login", + json={"email": "manager@example.test", "password": "correct-horse-battery-staple"}, + ) + assert response.status_code == 200 + assert response.json()["public_ref"] == "USR-REAL" + assert ops_client.get("/api/v1/auth/session").json()["display_name"] == "Real Manager" + + +def test_password_login_rejects_invalid_credentials(ops_client, monkeypatch): + monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False) + response = ops_client.post( + "/api/v1/auth/login", + json={"email": "unknown@example.test", "password": "correct-horse-battery-staple"}, + ) + assert response.status_code == 401 diff --git a/backend/tests/test_privacy.py b/backend/tests/test_privacy.py new file mode 100644 index 0000000..2450c66 --- /dev/null +++ b/backend/tests/test_privacy.py @@ -0,0 +1,114 @@ +from sqlalchemy import delete, select + +from app.core.db import SessionLocal +from app.models.audit import AuditEvent +from app.models.customer import Customer + + +def test_privacy_retention_uses_persisted_counts(ops_client): + response = ops_client.get("/api/v1/privacy/retention") + assert response.status_code == 200 + body = response.json() + assert body["customers_total"] >= 180 + assert body["minimum_booking_retention_days"] == 30 + assert body["audit_retention_days"] == 2555 + + +def test_privacy_retention_requires_manager(employee_client): + assert employee_client.get("/api/v1/privacy/retention").status_code == 403 + + +def test_customer_export_is_downloadable_and_audited_without_mutation(ops_client): + response = ops_client.get("/api/v1/privacy/customers/CUS-0001/export") + assert response.status_code == 200 + assert response.headers["content-disposition"] == 'attachment; filename="CUS-0001-privacy.json"' + assert response.json()["customer"]["public_ref"] == "CUS-0001" + assert isinstance(response.json()["bookings"], list) + events = ops_client.get("/api/v1/audit", params={"action": "privacy_customer_exported"}).json() + assert any(event["metadata"]["customer_ref"] == "CUS-0001" for event in events) + + +def test_active_customer_cannot_be_anonymized(ops_client): + response = ops_client.post( + "/api/v1/privacy/customers/CUS-0042/anonymize", + json={"confirmation": "CUS-0042", "reason": "Verified erasure request"}, + ) + assert response.status_code == 409 + + +def test_eligible_customer_is_irreversibly_anonymized_without_pii_in_audit(ops_client): + with SessionLocal() as db: + customer = Customer( + public_ref="CUS-PRIVACY", + first_name="Private", + last_name="Person", + email="private.person@example.test", + phone="+32000000000", + postal_code="1000", + city="Brussels", + date_of_birth=None, + ) + db.add(customer) + db.commit() + customer_id = customer.id + try: + mismatch = ops_client.post( + "/api/v1/privacy/customers/CUS-PRIVACY/anonymize", + json={"confirmation": "CUS-WRONG", "reason": "Verified erasure request"}, + ) + assert mismatch.status_code == 422 + response = ops_client.post( + "/api/v1/privacy/customers/CUS-PRIVACY/anonymize", + json={"confirmation": "CUS-PRIVACY", "reason": "Verified erasure request"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "anonymized" + search = ops_client.get("/api/v1/customers", params={"query": "CUS-PRIVACY"}) + assert search.status_code == 200 + assert search.json() == [] + window = { + "starts_at": "2090-01-01T10:00:00Z", + "ends_at": "2090-01-02T10:00:00Z", + } + available = ops_client.get("/api/v1/bookings/availability", params=window).json() + assert available + booking = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-PRIVACY", + "vehicle_ref": available[0]["public_ref"], + **window, + }, + ) + assert booking.status_code == 422 + with SessionLocal() as db: + customer = db.scalar(select(Customer).where(Customer.id == customer_id)) + assert customer is not None + assert customer.email is None + assert customer.phone is None + assert customer.first_name == "Anoniem" + event = db.scalar( + select(AuditEvent) + .where(AuditEvent.action == "privacy_customer_anonymized") + .order_by(AuditEvent.occurred_at.desc()) + ) + assert event is not None + serialized = f"{event.before_json}{event.after_json}{event.metadata_json}" + assert "private.person@example.test" not in serialized + finally: + with SessionLocal() as db: + db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id)) + db.execute(delete(Customer).where(Customer.id == customer_id)) + db.commit() + + +def test_audit_csv_export_is_bounded_and_audited(ops_client): + response = ops_client.get("/api/v1/audit/export.csv") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/csv") + assert response.text.startswith("id,occurred_at,actor_type") + invalid = ops_client.get( + "/api/v1/audit/export.csv", + params={"occurred_from": "2025-01-01T00:00:00Z", "occurred_to": "2026-01-01T00:00:00Z"}, + ) + assert invalid.status_code == 422 diff --git a/backend/tests/test_return.py b/backend/tests/test_return.py new file mode 100644 index 0000000..6d2f1c6 --- /dev/null +++ b/backend/tests/test_return.py @@ -0,0 +1,317 @@ +import threading +import uuid +from datetime import UTC, datetime, timedelta + +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.db import SessionLocal +from app.main import app +from app.models.audit import AuditEvent +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.outbox import OutboxEvent +from app.models.vehicle import Vehicle + + +def _return_body(**overrides): + body = { + "end_odometer_km": 54200, + "fuel_level_percent": 60, + "cleanliness_ok": True, + "damage_reported": False, + "technical_warning": False, + "notes": "Handed back on time.", + } + body.update(overrides) + return body + + +def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str: + """Flip one returned booking for the given vehicle back to 'active' for a fresh test fixture.""" + 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 _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"] + assert commit_body["correlation_id"] + assert len(commit_body["correlation_id"]) == 36 + + +def test_preview_detects_odometer_regression(ops_client): + booking_ref = _activate_booking("MO-007", start_odometer_km=15000) + vehicle_before = ops_client.get("/api/v1/vehicles/MO-007").json() + low_reading = vehicle_before["odometer_km"] - 100 + + preview = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return-preview", + json=_return_body(end_odometer_km=low_reading), + ) + assert preview.status_code == 200 + body = preview.json() + assert body["odometer_regression"] is True + assert body["would_create_quality_issue"] is True + assert "odometer_regression" in body["attention_reasons"] + assert body["resulting_odometer_km"] == vehicle_before["odometer_km"] + + vehicle_after = ops_client.get("/api/v1/vehicles/MO-007").json() + assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"] + + +def test_preview_detects_service_due(ops_client): + booking_ref = _activate_booking("MO-009", start_odometer_km=18000) + vehicle_before = ops_client.get("/api/v1/vehicles/MO-009").json() + _set_next_service_km("MO-009", vehicle_before["odometer_km"] + 50) + + preview = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return-preview", + json=_return_body(end_odometer_km=vehicle_before["odometer_km"] + 100), + ) + assert preview.status_code == 200 + body = preview.json() + assert body["resulting_vehicle_status"] == "maintenance" + assert "service threshold" in body["status_reason"] + + +def test_preview_detects_next_booking_risk(ops_client): + booking_ref = _activate_booking("MO-011", start_odometer_km=19000) + _add_reserved_booking("MO-011", hours_from_now=2) + + preview = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return-preview", + json=_return_body(end_odometer_km=19500), + ) + assert preview.status_code == 200 + risk = preview.json()["next_booking_risk"] + assert risk is not None + assert risk["at_risk"] is True # less than 4 hours away + + +def test_preview_requires_active_booking(ops_client): + booking_ref = _activate_booking("MO-014", start_odometer_km=21000) + ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=21500), + headers={"Idempotency-Key": "test-preview-requires-active-001"}, + ) + preview = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return-preview", + json=_return_body(end_odometer_km=22000), + ) + assert preview.status_code == 409 + assert preview.json()["error"]["code"] == "INVALID_BOOKING_STATE" + + +def test_register_return_success_updates_canonical_odometer(ops_client): + booking_ref = _activate_booking("MO-003", start_odometer_km=20000) + vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json() + new_reading = vehicle_before["odometer_km"] + 10 + + response = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=new_reading), + headers={"Idempotency-Key": "test-return-success-001"}, + ) + assert response.status_code == 201 + body = response.json() + assert body["resulting_vehicle_status"] in ("cleaning", "maintenance") + assert body["odometer_regression"] is False + assert body["quality_issue_ref"] is None + + booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json() + assert booking["status"] == "returned" + assert booking["end_odometer_km"] == new_reading + + vehicle = ops_client.get("/api/v1/vehicles/MO-003").json() + assert vehicle["odometer_km"] == new_reading + assert vehicle["operational_status"] in ("cleaning", "maintenance") + + +def test_register_return_matches_s1_demo_scenario(ops_client): + vehicle_before = ops_client.get("/api/v1/vehicles/MO-024").json() + low_reading = vehicle_before["odometer_km"] - 500 + + response = ops_client.post( + "/api/v1/bookings/BK-DEMO-RETURN/return", + json=_return_body(end_odometer_km=low_reading), + headers={"Idempotency-Key": "test-return-s1-001"}, + ) + assert response.status_code == 201 + body = response.json() + assert body["odometer_regression"] is True + assert body["quality_issue_ref"] is not None + + vehicle = ops_client.get("/api/v1/vehicles/MO-024").json() + assert vehicle["odometer_km"] == vehicle_before["odometer_km"] # canonical odometer unchanged + booking = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json() + assert booking["status"] == "returned" + assert booking["end_odometer_km"] == low_reading # submitted reading is still recorded + + +def test_register_return_damage_blocks_vehicle(employee_client): + booking_ref = _activate_booking("MO-005", start_odometer_km=22000) + response = employee_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=22500, damage_reported=True), + headers={"Idempotency-Key": "test-return-damage-001"}, + ) + assert response.status_code == 201 + assert response.json()["resulting_vehicle_status"] == "blocked" + + +def test_register_return_replays_on_same_idempotency_key(ops_client): + booking_ref = _activate_booking("MO-008", start_odometer_km=24000) + key = "test-return-replay-001" + first = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=24500), + headers={"Idempotency-Key": key}, + ) + second = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=24500), + headers={"Idempotency-Key": key}, + ) + assert first.status_code == 201 + assert second.status_code == 201 + assert first.json() == second.json() + + +def test_register_return_rejects_already_returned_booking(ops_client): + booking_ref = _activate_booking("MO-010", start_odometer_km=25000) + ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=25500), + headers={"Idempotency-Key": "test-return-double-001"}, + ) + second = ops_client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=25999), + headers={"Idempotency-Key": "test-return-double-002"}, + ) + assert second.status_code == 409 + assert second.json()["error"]["code"] == "INVALID_BOOKING_STATE" + + +def test_register_return_requires_idempotency_key(ops_client): + booking_ref = _activate_booking("MO-012", start_odometer_km=26500) + response = ops_client.post(f"/api/v1/bookings/{booking_ref}/return", json=_return_body()) + assert response.status_code == 422 + + +def test_concurrent_returns_only_one_succeeds(): + booking_ref = _activate_booking("MO-013", start_odometer_km=27000) + results: list[int] = [] + + def submit(key: str) -> None: + client = TestClient(app) + client.post("/api/v1/demo/login", json={"role": "operations_manager"}) + resp = client.post( + f"/api/v1/bookings/{booking_ref}/return", + json=_return_body(end_odometer_km=27500), + headers={"Idempotency-Key": key}, + ) + results.append(resp.status_code) + + threads = [threading.Thread(target=submit, args=(f"concurrent-key-{i}",)) for i in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert results.count(201) == 1 + assert results.count(409) == 2 + + +def test_return_public_refs_are_collision_resistant() -> None: + from app.services.returns import _new_inspection_ref + + refs = {_new_inspection_ref() for _ in range(1000)} + assert len(refs) == 1000 + assert all(ref.startswith("INSP-") and len(ref) == 15 for ref in refs) diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py new file mode 100644 index 0000000..ca80d22 --- /dev/null +++ b/backend/tests/test_search.py @@ -0,0 +1,75 @@ +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" + # The backend must never send localizable prose -- only a stable code plus raw + # data params, so the frontend can render it in the operator's selected language. + assert match["detail_code"] == "vehicleSummary" + assert set(match["detail_params"]) == {"make", "model", "location"} + + +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() + match = next( + (r for r in result["results"] if r["type"] == "section" and r["link"] == "/audit"), None + ) + assert match is not None + # Section results must ship a stable id, not English prose -- the frontend looks up + # navigation:items. and search:sections..detail in the selected locale. + assert match["label"] == "audit" + assert match["detail_code"] == "audit" + + +def test_search_section_matches_dutch_and_french_terms(ops_client): + nl_result = ops_client.get("/api/v1/search", params={"q": "wagenpark"}).json() + assert any(r["type"] == "section" and r["link"] == "/vehicles" for r in nl_result["results"]) + fr_result = ops_client.get("/api/v1/search", params={"q": "réservation"}).json() + assert any(r["type"] == "section" and r["link"] == "/bookings" for r in fr_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"] == [] diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py new file mode 100644 index 0000000..65a6d3a --- /dev/null +++ b/backend/tests/test_seed.py @@ -0,0 +1,296 @@ +from datetime import UTC, datetime + +from sqlalchemy import func, select + +from app.core.db import SessionLocal +from app.models.audit import AuditEvent +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent +from app.models.user import User +from app.models.vehicle import Vehicle +from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed + + +def test_seed_counts_match_deterministic_dataset(): + # Other test modules mutate shared demo state (returns, resets), so this test + # re-seeds immediately before asserting counts rather than trusting whatever + # order pytest happened to run modules in. + db = SessionLocal() + try: + reset_and_seed(db) + assert db.scalar(select(func.count()).select_from(Vehicle)) == 50 + assert db.scalar(select(func.count()).select_from(Customer)) == 180 + # 246 original plus 8 (BK-T-001..008) added so "Today's movements" reads as a + # real day of traffic rather than the same fixed 4 rows on every reset. + assert db.scalar(select(func.count()).select_from(Booking)) == 254 + # 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a + # real open issue) plus a deterministic set discovered by the post-seed scan. The + # shared vehicle-status evaluator (app.services.vehicle_status) also catches + # MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has + # already crossed its service-due odometer threshold -- a genuine conflict the + # previous hand-rolled scanner never checked for. + assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33 + assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20 + assert db.scalar(select(func.count()).select_from(User)) == 2 + finally: + db.close() + + +def test_seed_demo_scenarios_present(): + db = SessionLocal() + try: + reset_and_seed(db) + + booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN")) + assert booking is not None + assert booking.status == "active" + + duplicate_customer = db.scalar(select(Customer).where(Customer.public_ref == "CUS-0178")) + assert duplicate_customer is not None + + duplicate_issue = db.scalar( + select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE") + ) + assert duplicate_issue is not None + assert duplicate_issue.rule_type == "possible_duplicate_customer" + + failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.delivery_status == "failed")) + assert failed_run is not None + finally: + db.close() + + +def _by_ref(db, model, ref): + return db.scalar(select(model).where(model.public_ref == ref)) + + +def test_seed_scenario_s1_odometer_regression_return(): + """S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a + below-canonical odometer reading, using the vehicle's own current odometer.""" + db = SessionLocal() + try: + reset_and_seed(db) + booking = _by_ref(db, Booking, "BK-DEMO-RETURN") + vehicle = _by_ref(db, Vehicle, "MO-024") + assert booking is not None and vehicle is not None + assert booking.vehicle_id == vehicle.id + assert booking.status == "active" + assert booking.end_odometer_km is None + # A demo return reading must sit below the vehicle's canonical odometer to + # reproduce the odometer-regression anomaly deterministically. + assert vehicle.odometer_km > 0 + finally: + db.close() + + +def test_seed_scenario_s2_duplicate_customer_pair(): + """S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue.""" + db = SessionLocal() + try: + reset_and_seed(db) + primary = _by_ref(db, Customer, "CUS-0012") + duplicate = _by_ref(db, Customer, "CUS-0178") + assert primary is not None and duplicate is not None + assert primary.email == duplicate.email + assert duplicate.merged_into_customer_id is None + + issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE") + assert issue is not None + assert issue.rule_type == "possible_duplicate_customer" + assert issue.status == "open" + related = issue.evidence_json.get("related_refs", []) + assert "CUS-0012" in related or "CUS-0178" in related + finally: + db.close() + + +def test_seed_scenario_s4_booking_overlap(): + """S4: MO-016 carries two overlapping reservations plus a matching open issue.""" + db = SessionLocal() + try: + reset_and_seed(db) + vehicle = _by_ref(db, Vehicle, "MO-016") + booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A") + booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B") + assert vehicle is not None and booking_a is not None and booking_b is not None + assert booking_a.vehicle_id == vehicle.id + assert booking_b.vehicle_id == vehicle.id + assert booking_a.starts_at < booking_b.ends_at + assert booking_b.starts_at < booking_a.ends_at + + issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP") + assert issue is not None + assert issue.rule_type == "booking_overlap" + assert issue.status == "open" + finally: + db.close() + + +def test_seed_odometer_issues_expose_only_the_regressing_booking_as_correctable(): + db = SessionLocal() + try: + reset_and_seed(db) + issue = _by_ref(db, DataQualityIssue, "DQ-0007") + assert issue is not None + assert issue.evidence_json["source_type"] == "return" + assert issue.evidence_json["related_refs"] == [ + "INSP-0057", + "BK-H-0007", + "INSP-0007", + ] + assert issue.evidence_json["correctable_booking_refs"] == ["BK-H-0007"] + 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 + # Coded as a prepared demo scenario, not as a real connectionError: the whole + # point of this row is to demonstrate retry and audit, so nothing downstream + # may read it as evidence that the n8n integration is unhealthy. + assert failed.last_error_code == DEMO_SCENARIO_ERROR_CODE + 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() + + +def test_seed_today_movements_are_a_credible_mix(): + """A fresh reset must not land on a thin, always-identical 'Today's movements' + dashboard section: a real day of fleet traffic (>=5 departures, >=5 returns, across + more than 4 distinct vehicles) should fall on the reset day, mirroring the same + status/date rule the dashboard router uses to build the today list.""" + db = SessionLocal() + try: + reset_and_seed(db) + today = datetime.now(UTC).date() + bookings = db.scalars(select(Booking)).all() + departures = [ + b + for b in bookings + if b.starts_at.date() == today and b.status in ("reserved", "active") + ] + returns = [ + b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned") + ] + assert len(departures) >= 5 + assert len(returns) >= 5 + vehicles_involved = {b.vehicle_id for b in departures} | {b.vehicle_id for b in returns} + assert len(vehicles_involved) > 4 + finally: + db.close() + + +def test_every_blocked_vehicle_has_a_real_open_issue(): + """A live reviewer found blocked vehicles with no explanation anywhere in the UI -- + 5 with zero quality issues at all, one (MO-049) with only a resolved one. Every + vehicle seeded as 'blocked' must now have at least one real, currently open + DataQualityIssue an operator can click through to.""" + db = SessionLocal() + try: + reset_and_seed(db) + blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all() + assert len(blocked) > 0 + for vehicle in blocked: + open_issue = db.scalar( + select(DataQualityIssue).where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + DataQualityIssue.status == "open", + ) + ) + assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue" + finally: + db.close() + + +def test_seed_scenario_mo024_service_conflict_is_flagged(): + """Regression lock-in for the live-reported MO-024 defect: 'rented' with a real + active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold, + with nothing surfacing the contradiction. The shared vehicle-status evaluator + already catches this (a real conflict, not a fabricated third status) -- this test + exists so a future change can't silently regress it back to unexplained.""" + db = SessionLocal() + try: + reset_and_seed(db) + vehicle = _by_ref(db, Vehicle, "MO-024") + assert vehicle is not None + assert vehicle.operational_status == "rented" + assert vehicle.odometer_km >= vehicle.next_service_km + + issue = db.scalar( + select(DataQualityIssue).where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + DataQualityIssue.status == "open", + DataQualityIssue.rule_type == "vehicle_status_conflict", + ) + ) + assert issue is not None + signals = issue.evidence_json.get("signals", []) + assert any(s["code"] == "vehicle.manual_review_required" for s in signals) + finally: + db.close() + + +def test_seed_evidence_has_no_placeholder_summary(): + """Every seed-only data-quality issue used to carry the vacuous evidence + 'Synthetic deterministic seed issue' with no structured signal at all -- a visitor + had no way to understand why it needed attention. Every issue must now carry a real + summary and at least one localizable signal (code + params).""" + db = SessionLocal() + try: + reset_and_seed(db) + issues = db.scalars(select(DataQualityIssue)).all() + assert len(issues) > 0 + for issue in issues: + summary = issue.evidence_json.get("summary", "") + assert summary != "Synthetic deterministic seed issue", ( + f"{issue.public_ref} still has the meaningless placeholder summary" + ) + signals = issue.evidence_json.get("signals", []) + assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal" + finally: + db.close() diff --git a/backend/tests/test_seed_generation.py b/backend/tests/test_seed_generation.py new file mode 100644 index 0000000..3a36989 --- /dev/null +++ b/backend/tests/test_seed_generation.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import csv +import subprocess +import sys +from pathlib import Path + +REPOSITORY_ROOT = next( + parent + for parent in Path(__file__).resolve().parents + if (parent / "seed" / "generate_seed.py").is_file() +) +SEED_DIRECTORY = REPOSITORY_ROOT / "seed" + + +def _rows_by_ref(path: Path) -> dict[str, dict[str, str]]: + with path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + assert all(None not in row for row in rows), f"malformed CSV row in {path.name}" + return {row["public_ref"]: row for row in rows} + + +def test_generator_reproduces_committed_seed_snapshot_byte_for_byte(tmp_path: Path): + subprocess.run( + [ + sys.executable, + str(SEED_DIRECTORY / "generate_seed.py"), + "--anchor", + "2026-08-01", + "--seed", + "20260801", + "--out", + str(tmp_path), + ], + cwd=REPOSITORY_ROOT, + check=True, + capture_output=True, + text=True, + ) + + committed_files = sorted(SEED_DIRECTORY.glob("*.csv")) + generated_files = sorted(tmp_path.glob("*.csv")) + assert [path.name for path in generated_files] == [path.name for path in committed_files] + for committed in committed_files: + generated = tmp_path / committed.name + assert generated.read_bytes() == committed.read_bytes(), ( + f"{committed.name} differs from the deterministic generated snapshot" + ) + + bookings = _rows_by_ref(tmp_path / "bookings.csv") + inspections = _rows_by_ref(tmp_path / "inspections.csv") + issues = _rows_by_ref(tmp_path / "data_quality_issues.csv") + + assert int(bookings["BK-H-0007"]["end_odometer_km"]) < int( + bookings["BK-H-0057"]["end_odometer_km"] + ) + assert int(bookings["BK-H-0010"]["end_odometer_km"]) < int( + bookings["BK-H-0060"]["end_odometer_km"] + ) + assert inspections["INSP-0007"]["odometer_km"] == bookings["BK-H-0007"]["end_odometer_km"] + assert inspections["INSP-0010"]["odometer_km"] == bookings["BK-H-0010"]["end_odometer_km"] + assert inspections["INSP-0001"]["completed_at"] == bookings["BK-H-0001"]["ends_at"] + + expected_issue_refs = {f"DQ-{index:04d}" for index in range(5, 22)} + assert expected_issue_refs <= issues.keys() + assert all( + issues[public_ref]["evidence"] != "Synthetic deterministic seed issue" + for public_ref in expected_issue_refs + ) + assert "INSP-0007" in issues["DQ-0007"]["evidence"] + assert "INSP-0057" in issues["DQ-0007"]["evidence"] + assert "INSP-0010" in issues["DQ-0010"]["evidence"] + assert "INSP-0060" in issues["DQ-0010"]["evidence"] diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 0000000..95e1855 --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,44 @@ +def test_manager_can_create_and_update_user(ops_client): + created = ops_client.post( + "/api/v1/users", + json={ + "email": "planner@example.test", + "display_name": "Fleet Planner", + "role": "rental_employee", + "password": "a-secure-demo-password", + }, + ) + assert created.status_code == 201 + public_ref = created.json()["public_ref"] + assert created.json()["active"] is True + updated = ops_client.patch( + f"/api/v1/users/{public_ref}", + json={"display_name": "Senior Fleet Planner", "active": False}, + ) + assert updated.status_code == 200 + assert updated.json()["display_name"] == "Senior Fleet Planner" + assert updated.json()["active"] is False + assert any(user["public_ref"] == public_ref for user in ops_client.get("/api/v1/users").json()) + + +def test_employee_cannot_manage_users(employee_client): + assert employee_client.get("/api/v1/users").status_code == 403 + assert ( + employee_client.post( + "/api/v1/users", + json={ + "email": "unauthorised@example.test", + "display_name": "Unauthorised User", + "role": "rental_employee", + "password": "a-secure-demo-password", + }, + ).status_code + == 403 + ) + assert ( + employee_client.patch( + "/api/v1/users/USR-OPS", + json={"display_name": "Unauthorised Change"}, + ).status_code + == 403 + ) diff --git a/backend/tests/test_vehicle_status.py b/backend/tests/test_vehicle_status.py new file mode 100644 index 0000000..ab4ca85 --- /dev/null +++ b/backend/tests/test_vehicle_status.py @@ -0,0 +1,156 @@ +"""Pure unit tests for the shared vehicle-status evaluator -- no database needed, since +evaluate_vehicle_status() only reasons over an already-gathered VehicleStatusFacts. See +docs/fleet-ops-correction/vehicle-status-decision-table.md for the decision table these +tests are asserting against.""" + +from types import SimpleNamespace + +from app.services.vehicle_status import ( + RECOMMENDATION_CODE_ACTIVE_RENTAL, + RECOMMENDATION_CODE_BOOKING_CONFLICT, + RECOMMENDATION_CODE_MANUAL_REVIEW, + RECOMMENDATION_CODE_NO_CONFLICT, + RECOMMENDATION_CODE_RENTAL_ENDED, + RECOMMENDATION_CODE_SERVICE_THRESHOLD, + VehicleStatusFacts, + compute_recommendation_token, + evaluate_vehicle_status, +) + + +def _vehicle(status: str, *, version: int = 1): + return SimpleNamespace(operational_status=status, version=version) + + +def _facts(**overrides) -> VehicleStatusFacts: + defaults = dict( + active_booking_refs=[], + overlapping_booking_pairs=[], + service_threshold_reached=False, + odometer_km=10_000, + next_service_km=20_000, + open_booking_overlap_issue_ref=None, + ) + defaults.update(overrides) + return VehicleStatusFacts(**defaults) + + +def test_available_with_active_rental_recommends_rented(): + result = evaluate_vehicle_status(_vehicle("available"), _facts(active_booking_refs=["BK-0001"])) + assert result.recommended_status == "rented" + assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL + assert result.safe_to_apply is True + assert result.manual_review_required is False + + +def test_maintenance_with_active_rental_never_auto_recommends_rented(): + # The exact unsafe shortcut this task explicitly forbids: maintenance + an active + # booking must NEVER be auto-resolved to "rented". + result = evaluate_vehicle_status( + _vehicle("maintenance"), _facts(active_booking_refs=["BK-0001"]) + ) + assert result.recommended_status is None + assert result.manual_review_required is True + assert result.safe_to_apply is False + assert result.recommendation_code == RECOMMENDATION_CODE_MANUAL_REVIEW + + +def test_available_with_active_rental_and_service_threshold_requires_manual_review(): + result = evaluate_vehicle_status( + _vehicle("available"), + _facts(active_booking_refs=["BK-0001"], service_threshold_reached=True), + ) + assert result.manual_review_required is True + assert result.recommended_status is None + + +def test_available_with_active_rental_and_booking_conflict_requires_manual_review(): + result = evaluate_vehicle_status( + _vehicle("available"), + _facts(active_booking_refs=["BK-0001"], open_booking_overlap_issue_ref="DQ-0001"), + ) + assert result.manual_review_required is True + assert result.recommended_status is None + + +def test_rented_with_no_active_booking_recommends_available(): + result = evaluate_vehicle_status(_vehicle("rented"), _facts()) + assert result.recommended_status == "available" + assert result.recommendation_code == RECOMMENDATION_CODE_RENTAL_ENDED + assert result.safe_to_apply is True + + +def test_service_threshold_reached_recommends_maintenance(): + result = evaluate_vehicle_status(_vehicle("available"), _facts(service_threshold_reached=True)) + assert result.recommended_status == "maintenance" + assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD + + +def test_booking_conflict_recommends_blocked_not_a_generic_high_severity_proxy(): + # The evaluator must react to a *real* booking-conflict fact, not "does some other + # open high-severity issue happen to exist" (the forbidden proxy). + result = evaluate_vehicle_status( + _vehicle("available"), + _facts(overlapping_booking_pairs=[("BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B")]), + ) + assert result.recommended_status == "blocked" + assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT + + +def test_open_booking_overlap_issue_alone_also_triggers_blocked(): + result = evaluate_vehicle_status( + _vehicle("available"), _facts(open_booking_overlap_issue_ref="DQ-DEMO-OVERLAP") + ) + assert result.recommended_status == "blocked" + assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT + + +def test_maintenance_with_no_active_rental_and_no_blockers_stays_manual(): + # No fact here confirms maintenance is actually finished, so the evaluator must not + # auto-clear it to "available" -- that release remains an explicit, manual decision. + result = evaluate_vehicle_status(_vehicle("maintenance"), _facts()) + assert result.recommended_status is None + assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT + assert result.safe_to_apply is False + + +def test_no_conflict_when_status_already_matches_facts(): + result = evaluate_vehicle_status(_vehicle("available"), _facts()) + assert result.recommended_status is None + assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT + assert result.safe_to_apply is False + + result = evaluate_vehicle_status(_vehicle("rented"), _facts(active_booking_refs=["BK-1"])) + assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT + + result = evaluate_vehicle_status(_vehicle("blocked"), _facts()) + assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT + + result = evaluate_vehicle_status( + _vehicle("maintenance"), _facts(service_threshold_reached=True) + ) + assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT + + +def test_recommendation_token_changes_when_facts_change(): + vehicle = _vehicle("available") + facts_a = _facts() + facts_b = _facts(service_threshold_reached=True) + assert compute_recommendation_token(vehicle, facts_a) != compute_recommendation_token( + vehicle, facts_b + ) + + +def test_recommendation_token_is_stable_for_identical_facts(): + vehicle = _vehicle("available") + facts = _facts(active_booking_refs=["BK-0001"]) + assert compute_recommendation_token(vehicle, facts) == compute_recommendation_token( + vehicle, facts + ) + + +def test_recommendation_token_changes_when_vehicle_version_changes(): + facts = _facts() + assert compute_recommendation_token( + _vehicle("available", version=1), facts + ) != compute_recommendation_token(_vehicle("available", version=2), facts) diff --git a/backend/tests/test_vehicles.py b/backend/tests/test_vehicles.py new file mode 100644 index 0000000..fcceb04 --- /dev/null +++ b/backend/tests/test_vehicles.py @@ -0,0 +1,144 @@ +def test_list_vehicles_filters_by_status(ops_client): + response = ops_client.get("/api/v1/vehicles", params={"status": "maintenance"}) + assert response.status_code == 200 + vehicles = response.json() + assert len(vehicles) > 0 + assert all(v["operational_status"] == "maintenance" for v in vehicles) + + +def test_attention_only_filters_flagged_vehicles(ops_client): + response = ops_client.get("/api/v1/vehicles", params={"attention_only": True}) + assert response.status_code == 200 + vehicles = response.json() + assert len(vehicles) > 0 + assert all(v["attention"] for v in vehicles) + assert all(v["attention_reason"] for v in vehicles) + + +def test_vehicle_list_exposes_planning_and_service_context(ops_client): + vehicles = ops_client.get("/api/v1/vehicles").json() + assert vehicles + assert all("service_remaining_km" in vehicle for vehicle in vehicles) + assert all("next_booking_ref" in vehicle for vehicle in vehicles) + location = vehicles[0]["location"] + filtered = ops_client.get("/api/v1/vehicles", params={"location": location}).json() + assert filtered + assert all(vehicle["location"].casefold() == location.casefold() for vehicle in filtered) + + +def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client): + response = ops_client.get( + "/api/v1/vehicles", + params={"status": "maintenance", "page": 1, "page_size": 25}, + ) + assert response.status_code == 200 + body = response.json() + assert len(body["items"]) <= 25 + assert all(v["operational_status"] == "maintenance" for v in body["items"]) + assert body["total"] >= len(body["items"]) + + +def test_vehicle_detail_includes_related_records(ops_client): + response = ops_client.get("/api/v1/vehicles/MO-016") + assert response.status_code == 200 + body = response.json() + assert body["public_ref"] == "MO-016" + assert len(body["quality_issues"]) >= 1 + + +def test_employee_vehicle_detail_never_exposes_quality_evidence(employee_client): + response = employee_client.get("/api/v1/vehicles/MO-016") + assert response.status_code == 200 + assert response.json()["quality_issues"] == [] + + +def test_vehicle_detail_404_for_unknown_ref(ops_client): + response = ops_client.get("/api/v1/vehicles/MO-999") + assert response.status_code == 404 + + +def test_manager_can_record_maintenance_and_release_vehicle(ops_client): + vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[0] + response = ops_client.post( + f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance", + json={ + "occurred_at": "2051-01-15T09:00:00Z", + "odometer_km": vehicle["odometer_km"] + 100, + "category": "periodic_service", + "summary": "Periodic service completed and safety checks passed.", + "next_service_km": vehicle["odometer_km"] + 20100, + "mark_maintenance": True, + }, + ) + assert response.status_code == 201 + detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json() + assert detail["operational_status"] == "maintenance" + assert any( + item["public_ref"] == response.json()["public_ref"] for item in detail["maintenance"] + ) + released = ops_client.post( + f"/api/v1/vehicles/{vehicle['public_ref']}/release", + json={"reason": "Service completed and vehicle inspected"}, + ) + assert released.status_code == 200 + assert released.json()["operational_status"] == "available" + + +def test_low_maintenance_reading_keeps_canonical_and_opens_quality_issue(ops_client): + existing_issues = ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "rule_type": "odometer_regression"}, + ).json() + unavailable_refs = {issue["entity_ref"] for issue in existing_issues} + vehicles = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json() + vehicle = next( + candidate + for candidate in vehicles + if candidate["odometer_km"] > 0 and candidate["public_ref"] not in unavailable_refs + ) + + response = ops_client.post( + f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance", + json={ + "occurred_at": "2053-01-15T12:00:00Z", + "odometer_km": vehicle["odometer_km"] - 1, + "category": "inspection", + "summary": "Imported workshop reading requires verification.", + "mark_maintenance": True, + }, + ) + + assert response.status_code == 201 + detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json() + assert detail["odometer_km"] == vehicle["odometer_km"] + issues = ops_client.get( + "/api/v1/data-quality/issues", + params={"status": "open", "rule_type": "odometer_regression"}, + ).json() + issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"]) + assert issue["evidence"]["source_type"] == "maintenance" + assert issue["evidence"]["correctable_booking_refs"] == [] + issue_detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json() + assert any( + snapshot["entity_type"] == "maintenance" + and snapshot["public_ref"] == response.json()["public_ref"] + for snapshot in issue_detail["related_snapshots"] + ) + retained = ops_client.post( + f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression", + json={"decision": "retain_canonical"}, + ) + assert retained.status_code == 200 + + +def test_employee_cannot_record_maintenance(employee_client): + response = employee_client.post( + "/api/v1/vehicles/MO-001/maintenance", + json={ + "occurred_at": "2051-01-15T09:00:00Z", + "odometer_km": 100, + "category": "repair", + "summary": "Unauthorised attempt", + }, + ) + assert response.status_code == 403 diff --git a/backend/tests/test_workflows.py b/backend/tests/test_workflows.py new file mode 100644 index 0000000..ad6542d --- /dev/null +++ b/backend/tests/test_workflows.py @@ -0,0 +1,95 @@ +from app.core.db import SessionLocal +from app.seed_loader import reset_and_seed + + +def _reseed() -> None: + """Restore the canonical demo dataset (19 succeeded + 1 prepared failure). + + The suite shares one session-scoped database and earlier files legitimately mutate + the outbox, so any test that asserts on the *seeded* scenario has to re-establish it + rather than depend on file ordering. + """ + db = SessionLocal() + try: + reset_and_seed(db) + finally: + db.close() + + +def test_list_workflows_requires_operations_manager(employee_client): + response = employee_client.get("/api/v1/workflows") + assert response.status_code == 403 + + +def test_list_workflows_includes_seeded_failed_run(ops_client): + _reseed() + response = ops_client.get("/api/v1/workflows", params={"status": "failed"}) + assert response.status_code == 200 + runs = response.json() + assert len(runs) >= 1 + assert all(r["status"] == "failed" for r in runs) + + +def test_retry_requires_failed_status(ops_client): + succeeded = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json() + target = succeeded[0]["event_id"] + response = ops_client.post(f"/api/v1/workflows/{target}/retry") + assert response.status_code == 409 + assert response.json()["error"]["code"] == "NOT_RETRYABLE" + + +def test_retry_failed_run_moves_to_pending_and_audits(ops_client): + _reseed() + failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json() + target = failed[0]["event_id"] + + response = ops_client.post(f"/api/v1/workflows/{target}/retry") + assert response.status_code == 200 + assert response.json()["status"] == "pending" + + audit_events = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json() + assert len(audit_events) >= 1 + + +def test_retry_requires_operations_manager(employee_client): + response = employee_client.post("/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry") + assert response.status_code == 403 + + +def test_seeded_failure_is_labelled_as_a_prepared_demo_scenario(ops_client): + """The one seeded failure must announce itself as staged. An unexplained red row in + a demo reads as a broken product; a labelled one reads as the retry story it is.""" + _reseed() + runs = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json() + demo_runs = [run for run in runs if run["is_demo_scenario"]] + assert len(demo_runs) == 1 + assert demo_runs[0]["last_error_code"] == "demoScenarioTimeout" + assert demo_runs[0]["aggregate_ref"] == "BK-H-0020" + + +def test_succeeded_runs_are_never_marked_as_a_demo_scenario(ops_client): + runs = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json() + assert runs + assert all(run["is_demo_scenario"] is False for run in runs) + + +def test_retry_of_the_demo_scenario_is_audited_as_a_demo_scenario(ops_client): + """The retry is a real redelivery either way; the audit records which kind of + failure it resolved so a staged retry is never mistaken for a production fix.""" + _reseed() + failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json() + demo_run = next(run for run in failed if run["is_demo_scenario"]) + + response = ops_client.post(f"/api/v1/workflows/{demo_run['event_id']}/retry") + assert response.status_code == 200 + assert response.json()["status"] == "pending" + + audit = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json() + entry = next( + event + for event in audit + if (event.get("metadata") or event.get("metadata_json") or {}).get("event_id") + == demo_run["event_id"] + ) + metadata = entry.get("metadata") or entry.get("metadata_json") or {} + assert metadata["demo_scenario"] is True diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..0bf6b2e --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,27 @@ +services: + db: + environment: + POSTGRES_DB: mobilityops_test + POSTGRES_USER: mobilityops_test + POSTGRES_PASSWORD: mobilityops_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mobilityops_test -d mobilityops_test"] + + api: + build: + target: test + environment: + MOBILITYOPS_ENV: test + MOBILITYOPS_DEMO_MODE: "true" + DATABASE_URL: postgresql+psycopg://mobilityops_test:mobilityops_test@db:5432/mobilityops_test + KNOWLEDGE_PROVIDER: demo + MCP_HUB_REGISTRATION_ENABLED: "false" + DEMO_ALLOW_RESET: "true" + DEMO_RESET_COOLDOWN_SECONDS: "0" + ports: !reset [] + + web: + ports: !reset [] + + n8n: + profiles: ["never-in-tests"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c997696 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,125 @@ +services: + db: + image: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685 + environment: + POSTGRES_DB: ${POSTGRES_DB:-mobilityops} + POSTGRES_USER: ${POSTGRES_USER:-mobilityops} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mobilityops} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mobilityops} -d ${POSTGRES_DB:-mobilityops}"] + interval: 5s + timeout: 3s + retries: 20 + volumes: + - mobilityops-db:/var/lib/postgresql/data + networks: [mobilityops] + + api: + build: + context: . + dockerfile: backend/Dockerfile + args: + VCS_REF: ${VCS_REF:-development} + BUILD_DATE: ${BUILD_DATE:-unknown} + environment: + MOBILITYOPS_ENV: ${MOBILITYOPS_ENV:-development} + MOBILITYOPS_DEMO_MODE: ${MOBILITYOPS_DEMO_MODE:-true} + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops} + TZ: ${TZ:-Europe/Brussels} + APP_SECRET: ${APP_SECRET:-replace-in-production} + CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228} + KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo} + RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000} + RAGCORE_TENANT: ${RAGCORE_TENANT:-northstar-mobility-demo} + RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops} + RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures} + RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-} + RAGCORE_SPACE_ID: ${RAGCORE_SPACE_ID:-} + RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS: ${RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS:-60} + N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return} + N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-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_REGISTRATION_ENABLED: ${MCP_HUB_REGISTRATION_ENABLED:-false} + MCP_HUB_BASE_URL: ${MCP_HUB_BASE_URL:-} + DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility} + DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels} + DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true} + DEMO_RESET_COOLDOWN_SECONDS: ${DEMO_RESET_COOLDOWN_SECONDS:-60} + MOBILITYOPS_PUBLIC_URL: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + OIDC_ENABLED: ${OIDC_ENABLED:-false} + OIDC_PROVIDER_NAME: ${OIDC_PROVIDER_NAME:-Organisatieaccount} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} + OIDC_ALLOWED_EMAIL_DOMAINS: ${OIDC_ALLOWED_EMAIL_DOMAINS:-} + OIDC_AUTO_PROVISION: ${OIDC_AUTO_PROVISION:-true} + OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-rental_employee} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + METRICS_BEARER_TOKEN: ${METRICS_BEARER_TOKEN:-} + PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS: ${PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS:-30} + PRIVACY_AUDIT_RETENTION_DAYS: ${PRIVACY_AUDIT_RETENTION_DAYS:-2555} + PRIVACY_AUDIT_EXPORT_MAX_ROWS: ${PRIVACY_AUDIT_EXPORT_MAX_ROWS:-10000} + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + ports: + - "8128:8000" + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready')"] + interval: 10s + timeout: 5s + retries: 10 + networks: [mobilityops] + + web: + build: + context: ./frontend + args: + # Empty by default: the SPA calls its own origin and nginx proxies /api to the API, + # which is what the CSP (connect-src 'self') expects. Only set this for split hosting. + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-} + VCS_REF: ${VCS_REF:-development} + BUILD_DATE: ${BUILD_DATE:-unknown} + ports: + - "1228:80" + depends_on: + api: + condition: service_healthy + networks: [mobilityops] + + n8n: + # Explicit fallback only; production reuses the server's existing central n8n. + image: n8nio/n8n:2.33.7@sha256:3989d9b8ebb77b4ee8f604519eb73e44f4384bfaa689526e0104eed79a237d30 + environment: + TZ: ${TZ:-Europe/Brussels} + GENERIC_TIMEZONE: ${TZ:-Europe/Brussels} + N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:-replace-me} + N8N_BASIC_AUTH_ACTIVE: ${N8N_BASIC_AUTH_ACTIVE:-true} + N8N_BASIC_AUTH_USER: ${N8N_BASIC_AUTH_USER:-admin} + N8N_BASIC_AUTH_PASSWORD: ${N8N_BASIC_AUTH_PASSWORD:-change-me} + N8N_SECURE_COOKIE: "false" + N8N_BLOCK_ENV_ACCESS_IN_NODE: "false" + MOBILITYOPS_ALERT_RECIPIENT: ${MOBILITYOPS_ALERT_RECIPIENT:-alerts@example.test} + MOBILITYOPS_ALERT_SENDER: ${MOBILITYOPS_ALERT_SENDER:-n8n@example.test} + MOBILITYOPS_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token} + ports: + - "5678:5678" + volumes: + - mobilityops-n8n:/home/node/.n8n + - ./n8n:/imports:ro + networks: [mobilityops] + +networks: + mobilityops: + +volumes: + mobilityops-db: + mobilityops-n8n: diff --git a/contracts/events.schema.json b/contracts/events.schema.json new file mode 100644 index 0000000..efa53b0 --- /dev/null +++ b/contracts/events.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.test/schemas/mobilityops-events-v1.json", + "title": "MobilityOps event envelope", + "type": "object", + "required": ["event_id", "event_type", "occurred_at", "correlation_id", "aggregate", "data"], + "properties": { + "event_id": {"type": "string", "format": "uuid"}, + "event_type": {"const": "vehicle.returned.v1"}, + "occurred_at": {"type": "string", "format": "date-time"}, + "correlation_id": {"type": "string"}, + "aggregate": { + "type": "object", + "required": ["type", "id", "public_ref"], + "properties": { + "type": {"const": "booking"}, + "id": {"type": "string", "format": "uuid"}, + "public_ref": {"type": "string"} + }, + "additionalProperties": false + }, + "data": { + "type": "object", + "required": ["vehicle_ref", "inspection_ref", "resulting_vehicle_status", "attention_reasons"], + "properties": { + "vehicle_ref": {"type": "string"}, + "inspection_ref": {"type": "string"}, + "resulting_vehicle_status": {"enum": ["cleaning", "maintenance", "blocked"]}, + "attention_reasons": {"type": "array", "items": {"type": "string"}} + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/contracts/mcp-tools.json b/contracts/mcp-tools.json new file mode 100644 index 0000000..2d2ffe7 --- /dev/null +++ b/contracts/mcp-tools.json @@ -0,0 +1,79 @@ +{ + "_note": "Fleet Ops's own published tool contract. The live ITWorx MCP Hub connector (ITWorx_MCP_Hub repo, connectors/mobilityops/) wraps these under its own dotted namespace (mobilityops.operations.summary, .attention.list, .vehicle.get, .knowledge.search) -- that naming is Hub-owned. deprecated_aliases below are Fleet Ops's own prior internal audit-label names, kept only so existing clients/dashboards referencing them don't break.", + "provider_id": "mobilityops", + "version": "1.2.0", + "required_scope": "mobilityops.read", + "authentication": { + "service_header": "X-Service-Token", + "client_header": "X-Client-Id", + "client_pattern": "^itworx-mcp-hub:(readiness|mobilityops:[A-Za-z0-9][A-Za-z0-9._:-]{0,127})$", + "optional_tenant_header": "X-Tenant-Id", + "tenant_must_match": "northstar-mobility-demo" + }, + "observability": { + "response_headers": ["X-Correlation-Id", "X-Tenant-Id", "Cache-Control"], + "knowledge_response_headers": ["X-Sources-Available", "X-Sources-Returned"] + }, + "tools": [ + { + "name": "fleet_ops_get_operations_summary", + "deprecated_aliases": ["mobilityops_get_operations_summary"], + "description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic Fleet Ops demo tenant.", + "read_only": true, + "inputSchema": {"type": "object", "additionalProperties": false}, + "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"} + }, + { + "name": "fleet_ops_list_attention_vehicles", + "deprecated_aliases": ["mobilityops_list_attention_vehicles"], + "description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.", + "read_only": true, + "inputSchema": { + "type": "object", + "properties": { + "minimum_severity": {"enum": ["low", "medium", "high"], "default": "medium"}, + "date": {"type": "string", "format": "date"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20} + }, + "additionalProperties": false + }, + "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"} + }, + { + "name": "fleet_ops_get_vehicle_details", + "deprecated_aliases": ["mobilityops_get_vehicle_details"], + "description": "Return a read-only operational view of one vehicle by its stable public reference.", + "read_only": true, + "inputSchema": { + "type": "object", + "required": ["vehicle_ref"], + "properties": {"vehicle_ref": {"type": "string", "pattern": "^MO-[0-9]{3}$"}}, + "additionalProperties": false + }, + "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"} + }, + { + "name": "fleet_ops_search_knowledge", + "deprecated_aliases": ["mobilityops_search_knowledge"], + "description": "Search versioned Fleet Ops internal procedures through the dedicated RAGcore workspace and return grounded source references.", + "read_only": true, + "inputSchema": { + "type": "object", + "required": ["question"], + "properties": { + "question": {"type": "string", "minLength": 3, "maxLength": 1000}, + "max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4}, + "locale": {"enum": ["nl-BE", "en-GB", "fr-BE"], "default": "en-GB"} + }, + "additionalProperties": false + }, + "endpoint": {"method": "POST", "path": "/api/v1/integrations/mcp/search-knowledge"}, + "routing": { + "preferred": "ragcore", + "tenant": "northstar-mobility-demo", + "workspace": "mobilityops", + "collection": "internal-procedures" + } + } + ] +} diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml new file mode 100644 index 0000000..698ae0b --- /dev/null +++ b/contracts/openapi.yaml @@ -0,0 +1,4825 @@ +openapi: 3.1.0 +info: + title: Fleet Ops API + description: Generated contract for Fleet Ops. The visible product name is Fleet + Ops; MobilityOps remains the technical repository and service identifier. + version: 0.1.0 +servers: +- url: http://localhost:8128 +paths: + /health: + get: + summary: Health + operationId: health_health_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: + type: string + type: object + title: Response Health Health Get + /health/live: + get: + summary: Liveness + description: Process liveness only; external dependencies deliberately do not + affect it. + operationId: liveness_health_live_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: + type: string + type: object + title: Response Liveness Health Live Get + /health/ready: + get: + summary: Readiness + description: 'Traffic readiness: the API is useful only while its canonical + database responds.' + operationId: readiness_health_ready_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + /api/v1/system/status: + get: + summary: System Status + operationId: system_status_api_v1_system_status_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response System Status Api V1 System Status Get + /api/v1/demo/manifest: + get: + tags: + - demo + summary: Demo Manifest + operationId: demo_manifest_api_v1_demo_manifest_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DemoManifestOut' + /api/v1/demo/login: + post: + tags: + - demo + summary: Demo Login + operationId: demo_login_api_v1_demo_login_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DemoLoginRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUser' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/demo/session: + get: + tags: + - demo + summary: Get Session + operationId: get_session_api_v1_demo_session_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUser' + /api/v1/demo/logout: + post: + tags: + - demo + summary: Demo Logout + operationId: demo_logout_api_v1_demo_logout_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Demo Logout Api V1 Demo Logout Post + /api/v1/demo/reset: + post: + tags: + - demo + summary: Demo Reset + operationId: demo_reset_api_v1_demo_reset_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Demo Reset Api V1 Demo Reset Post + /api/v1/auth/oidc/status: + get: + tags: + - auth + summary: Oidc Status + operationId: oidc_status_api_v1_auth_oidc_status_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OidcStatusOut' + /api/v1/auth/oidc/login: + get: + tags: + - auth + summary: Oidc Login + operationId: oidc_login_api_v1_auth_oidc_login_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + /api/v1/auth/oidc/callback: + get: + tags: + - auth + summary: Oidc Callback + operationId: oidc_callback_api_v1_auth_oidc_callback_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + /api/v1/auth/login: + post: + tags: + - auth + summary: Password Login + operationId: password_login_api_v1_auth_login_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordLoginRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUser' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/auth/session: + get: + tags: + - auth + summary: Get Session + operationId: get_session_api_v1_auth_session_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUser' + /api/v1/auth/logout: + post: + tags: + - auth + summary: Logout + operationId: logout_api_v1_auth_logout_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Logout Api V1 Auth Logout Post + /api/v1/dashboard: + get: + tags: + - dashboard + summary: Get Dashboard + operationId: get_dashboard_api_v1_dashboard_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DashboardOut' + /api/v1/vehicles: + get: + tags: + - vehicles + summary: List Vehicles + operationId: list_vehicles_api_v1_vehicles_get + parameters: + - name: status + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Status + - name: attention_only + in: query + required: false + schema: + type: boolean + default: false + title: Attention Only + - name: location + in: query + required: false + schema: + anyOf: + - type: string + minLength: 1 + maxLength: 120 + - type: 'null' + title: Location + - name: query + in: query + required: false + schema: + anyOf: + - type: string + minLength: 1 + maxLength: 100 + - type: 'null' + title: Query + - name: page + in: query + required: false + schema: + anyOf: + - type: integer + minimum: 1 + - type: 'null' + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 25 + minimum: 1 + default: 25 + title: Page Size + responses: + '200': + description: Successful Response + content: + application/json: + schema: + anyOf: + - type: array + items: + $ref: '#/components/schemas/VehicleOut' + - $ref: '#/components/schemas/VehiclePageOut' + title: Response List Vehicles Api V1 Vehicles Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/vehicles/{public_ref}: + get: + tags: + - vehicles + summary: Get Vehicle + operationId: get_vehicle_api_v1_vehicles__public_ref__get + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/VehicleDetailOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/vehicles/{public_ref}/maintenance: + post: + tags: + - vehicles + summary: Create Maintenance Record + operationId: create_maintenance_record_api_v1_vehicles__public_ref__maintenance_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMaintenanceRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MaintenanceOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/vehicles/{public_ref}/release: + post: + tags: + - vehicles + summary: Release Vehicle + operationId: release_vehicle_api_v1_vehicles__public_ref__release_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseVehicleRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/VehicleOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings: + get: + tags: + - bookings + summary: List Bookings + operationId: list_bookings_api_v1_bookings_get + parameters: + - name: status + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Status + - name: vehicle_ref + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Vehicle Ref + - name: query + in: query + required: false + schema: + anyOf: + - type: string + minLength: 1 + maxLength: 100 + - type: 'null' + title: Query + - name: starts_from + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Starts From + - name: starts_to + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Starts To + - name: location + in: query + required: false + schema: + anyOf: + - type: string + minLength: 1 + maxLength: 120 + - type: 'null' + title: Location + - name: sort + in: query + required: false + schema: + enum: + - operational + - starts_asc + - starts_desc + type: string + default: operational + title: Sort + - name: page + in: query + required: false + schema: + anyOf: + - type: integer + minimum: 1 + - type: 'null' + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 25 + minimum: 1 + default: 25 + title: Page Size + responses: + '200': + description: Successful Response + content: + application/json: + schema: + anyOf: + - type: array + items: + $ref: '#/components/schemas/BookingOut' + - $ref: '#/components/schemas/BookingPageOut' + title: Response List Bookings Api V1 Bookings Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - bookings + summary: Create Booking + operationId: create_booking_api_v1_bookings_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateBookingRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BookingOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings/{public_ref}/checkout: + post: + tags: + - bookings + summary: Checkout Booking + operationId: checkout_booking_api_v1_bookings__public_ref__checkout_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CheckoutBookingRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CheckoutBookingResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings/availability: + get: + tags: + - bookings + summary: List Available Vehicles + operationId: list_available_vehicles_api_v1_bookings_availability_get + parameters: + - name: starts_at + in: query + required: true + schema: + type: string + format: date-time + title: Starts At + - name: ends_at + in: query + required: true + schema: + type: string + format: date-time + title: Ends At + - name: query + in: query + required: false + schema: + anyOf: + - type: string + maxLength: 100 + - type: 'null' + title: Query + - name: limit + in: query + required: false + schema: + type: integer + maximum: 50 + minimum: 1 + default: 25 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AvailableVehicleOut' + title: Response List Available Vehicles Api V1 Bookings Availability + Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings/{public_ref}: + get: + tags: + - bookings + summary: Get Booking + operationId: get_booking_api_v1_bookings__public_ref__get + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BookingOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings/{public_ref}/complete-requirements: + post: + tags: + - bookings + summary: Complete Booking Requirements + operationId: complete_booking_requirements_api_v1_bookings__public_ref__complete_requirements_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteBookingRequirementsRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BookingOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings/{public_ref}/schedule: + patch: + tags: + - bookings + summary: Reschedule Booking + operationId: reschedule_booking_api_v1_bookings__public_ref__schedule_patch + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RescheduleBookingRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BookingOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings/{public_ref}/cancel: + post: + tags: + - bookings + summary: Cancel Booking + operationId: cancel_booking_api_v1_bookings__public_ref__cancel_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CancelBookingRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BookingOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings/{public_ref}/return-preview: + post: + tags: + - bookings + summary: Preview Return + operationId: preview_return_api_v1_bookings__public_ref__return_preview_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterReturnRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ReturnPreviewResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/bookings/{public_ref}/return: + post: + tags: + - bookings + summary: Register Return + operationId: register_return_api_v1_bookings__public_ref__return_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + - name: Idempotency-Key + in: header + required: true + schema: + type: string + minLength: 8 + maxLength: 128 + title: Idempotency-Key + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterReturnRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterReturnResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/customers: + get: + tags: + - customers + summary: Search Customers + operationId: search_customers_api_v1_customers_get + parameters: + - name: query + in: query + required: true + schema: + type: string + minLength: 2 + maxLength: 100 + title: Query + - name: limit + in: query + required: false + schema: + type: integer + maximum: 50 + minimum: 1 + default: 20 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CustomerOptionOut' + title: Response Search Customers Api V1 Customers Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/audit/export.csv: + get: + tags: + - audit + summary: Export Audit Csv + operationId: export_audit_csv_api_v1_audit_export_csv_get + parameters: + - name: occurred_from + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Occurred From + - name: occurred_to + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Occurred To + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/audit: + get: + tags: + - audit + summary: List Audit Events + operationId: list_audit_events_api_v1_audit_get + parameters: + - name: actor_label + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Actor Label + - name: action + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Action + - name: entity_type + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Entity Type + - name: entity_ref + in: query + required: false + schema: + anyOf: + - type: string + minLength: 1 + maxLength: 100 + - type: 'null' + title: Entity Ref + - name: correlation_id + in: query + required: false + schema: + anyOf: + - type: string + format: uuid + - type: 'null' + title: Correlation Id + - name: occurred_from + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Occurred From + - name: occurred_to + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Occurred To + - name: page + in: query + required: false + schema: + anyOf: + - type: integer + minimum: 1 + - type: 'null' + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 25 + minimum: 1 + default: 25 + title: Page Size + responses: + '200': + description: Successful Response + content: + application/json: + schema: + anyOf: + - type: array + items: + $ref: '#/components/schemas/AuditEventOut' + - $ref: '#/components/schemas/AuditEventPageOut' + title: Response List Audit Events Api V1 Audit Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues: + get: + tags: + - data-quality + summary: List Issues + operationId: list_issues_api_v1_data_quality_issues_get + parameters: + - name: status + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Status + - name: rule_type + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Rule Type + - name: severity + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Severity + - name: assigned_to_ref + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Assigned To Ref + - name: overdue + in: query + required: false + schema: + anyOf: + - type: boolean + - type: 'null' + title: Overdue + - name: demo_only + in: query + required: false + schema: + anyOf: + - type: boolean + - type: 'null' + title: Demo Only + - name: page + in: query + required: false + schema: + anyOf: + - type: integer + minimum: 1 + - type: 'null' + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 25 + minimum: 1 + default: 25 + title: Page Size + responses: + '200': + description: Successful Response + content: + application/json: + schema: + anyOf: + - type: array + items: + $ref: '#/components/schemas/DataQualityIssueOut' + - $ref: '#/components/schemas/DataQualityIssuePageOut' + title: Response List Issues Api V1 Data Quality Issues Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/bulk-work: + post: + tags: + - data-quality + summary: Update Issue Work Queue + operationId: update_issue_work_queue_api_v1_data_quality_issues_bulk_work_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BulkDataQualityWorkRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BulkDataQualityWorkResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}: + get: + tags: + - data-quality + summary: Get Issue + operationId: get_issue_api_v1_data_quality_issues__public_ref__get + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataQualityIssueDetailOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}/defer: + post: + tags: + - data-quality + summary: Defer + operationId: defer_api_v1_data_quality_issues__public_ref__defer_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataQualityIssueOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}/reject: + post: + tags: + - data-quality + summary: Reject + operationId: reject_api_v1_data_quality_issues__public_ref__reject_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataQualityIssueOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}/merge-customers: + post: + tags: + - data-quality + summary: Merge + operationId: merge_api_v1_data_quality_issues__public_ref__merge_customers_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MergeCustomersRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MergeCustomersResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}/provide-fields: + post: + tags: + - data-quality + summary: Provide Fields + operationId: provide_fields_api_v1_data_quality_issues__public_ref__provide_fields_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvideFieldsRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataQualityIssueOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}/resolve-odometer-regression: + post: + tags: + - data-quality + summary: Resolve Odometer + operationId: resolve_odometer_api_v1_data_quality_issues__public_ref__resolve_odometer_regression_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveOdometerRegressionRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataQualityIssueOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}/resolve-overlap: + post: + tags: + - data-quality + summary: Resolve Overlap + operationId: resolve_overlap_api_v1_data_quality_issues__public_ref__resolve_overlap_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveOverlapRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataQualityIssueOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}/status-recommendation: + post: + tags: + - data-quality + summary: Status Recommendation + description: 'Non-mutating preview: computes the recommendation without changing + anything, + + resolving no issue and writing no audit event. Safe to call repeatedly.' + operationId: status_recommendation_api_v1_data_quality_issues__public_ref__status_recommendation_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/StatusRecommendationOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/issues/{public_ref}/apply-recommended-status: + post: + tags: + - data-quality + summary: Apply Status + operationId: apply_status_api_v1_data_quality_issues__public_ref__apply_recommended_status_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApplyRecommendedStatusRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ApplyRecommendedStatusResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/data-quality/scan: + post: + tags: + - data-quality + summary: Scan + operationId: scan_api_v1_data_quality_scan_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScanResultOut' + /api/v1/workflows: + get: + tags: + - workflows + summary: List Workflows + operationId: list_workflows_api_v1_workflows_get + parameters: + - name: status + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Status + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AutomationRunOut' + title: Response List Workflows Api V1 Workflows Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/workflows/{event_id}/retry: + post: + tags: + - workflows + summary: Retry Workflow + operationId: retry_workflow_api_v1_workflows__event_id__retry_post + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AutomationRunOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/n8n/heartbeat: + post: + tags: + - integrations + summary: Workflow Heartbeat + description: Authenticated, idempotent execution evidence from a canonical n8n + workflow. + operationId: workflow_heartbeat_api_v1_integrations_n8n_heartbeat_post + parameters: + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/N8nHeartbeatIn' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/N8nHeartbeatResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/n8n/return-callback: + post: + tags: + - integrations + summary: Return Callback + operationId: return_callback_api_v1_integrations_n8n_return_callback_post + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + title: Idempotency-Key + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReturnCallbackIn' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Return Callback Api V1 Integrations N8N Return Callback + Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/n8n/scheduled-scan: + post: + tags: + - integrations + summary: Scheduled Scan + description: '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.' + operationId: scheduled_scan_api_v1_integrations_n8n_scheduled_scan_post + parameters: + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScanResultOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/n8n/workflow-error: + post: + tags: + - integrations + summary: Workflow Error + description: 'Receives a bounded, secret-free failure report from the central + n8n "Fleet Ops -- + + Workflow Error Handler" workflow, which is attached as the Error Workflow + on every + + other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver + the same + + error report (e.g. after a timed-out response), so this must not double-record.' + operationId: workflow_error_api_v1_integrations_n8n_workflow_error_post + parameters: + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowErrorReportIn' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/WorkflowErrorReportResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/n8n/procedures: + get: + tags: + - integrations + summary: List Procedures + description: 'Read-only source list for the RAGcore Procedure Sync workflow: + every procedure + + Markdown file Fleet Ops ships, across every supported language, with a stable + + per-document id (source_id) and a content hash so the caller can detect changes + + without re-fetching content it already has.' + operationId: list_procedures_api_v1_integrations_n8n_procedures_get + parameters: + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ProcedureListOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/n8n/procedures-sync-result: + post: + tags: + - integrations + summary: Procedures Sync Result + description: 'Receives a summary (counts only, no document content) from the + n8n "Fleet Ops -- + + RAGcore Procedure Sync" workflow once it finishes uploading procedures to + RAGcore. + + Idempotent on execution_id, matching the workflow-error and return-callback + pattern.' + operationId: procedures_sync_result_api_v1_integrations_n8n_procedures_sync_result_post + parameters: + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProcedureSyncResultIn' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ProcedureSyncResultResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/knowledge/questions: + post: + tags: + - knowledge + summary: Ask Question + operationId: ask_question_api_v1_knowledge_questions_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AskQuestionRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GroundedAnswer' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/knowledge/feedback: + post: + tags: + - knowledge + summary: Record Feedback + operationId: record_feedback_api_v1_knowledge_feedback_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/KnowledgeFeedbackRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: + type: string + type: object + title: Response Record Feedback Api V1 Knowledge Feedback Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/knowledge/status: + get: + tags: + - knowledge + summary: Knowledge Status + operationId: knowledge_status_api_v1_knowledge_status_get + parameters: + - name: language + in: query + required: false + schema: + enum: + - nl-BE + - en-GB + - fr-BE + type: string + default: en-GB + title: Language + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/KnowledgeHealth' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/mcp/operations-summary: + get: + tags: + - mcp + summary: Operations Summary + operationId: operations_summary_api_v1_integrations_mcp_operations_summary_get + parameters: + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + - name: X-Client-Id + in: header + required: true + schema: + type: string + minLength: 1 + maxLength: 180 + title: X-Client-Id + - name: X-Tenant-Id + in: header + required: false + schema: + anyOf: + - type: string + maxLength: 120 + - type: 'null' + title: X-Tenant-Id + - name: X-Correlation-Id + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Correlation-Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationsSummaryOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/mcp/attention-vehicles: + get: + tags: + - mcp + summary: Attention Vehicles + operationId: attention_vehicles_api_v1_integrations_mcp_attention_vehicles_get + parameters: + - name: minimum_severity + in: query + required: false + schema: + type: string + pattern: ^(low|medium|high)$ + default: medium + title: Minimum Severity + - name: date + in: query + required: false + schema: + anyOf: + - type: string + format: date + - type: 'null' + title: Date + - name: limit + in: query + required: false + schema: + type: integer + maximum: 50 + minimum: 1 + default: 20 + title: Limit + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + - name: X-Client-Id + in: header + required: true + schema: + type: string + minLength: 1 + maxLength: 180 + title: X-Client-Id + - name: X-Tenant-Id + in: header + required: false + schema: + anyOf: + - type: string + maxLength: 120 + - type: 'null' + title: X-Tenant-Id + - name: X-Correlation-Id + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Correlation-Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AttentionVehicleOut' + title: Response Attention Vehicles Api V1 Integrations Mcp Attention + Vehicles Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/mcp/vehicles/{vehicle_ref}: + get: + tags: + - mcp + summary: Vehicle Details + operationId: vehicle_details_api_v1_integrations_mcp_vehicles__vehicle_ref__get + parameters: + - name: vehicle_ref + in: path + required: true + schema: + type: string + title: Vehicle Ref + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + - name: X-Client-Id + in: header + required: true + schema: + type: string + minLength: 1 + maxLength: 180 + title: X-Client-Id + - name: X-Tenant-Id + in: header + required: false + schema: + anyOf: + - type: string + maxLength: 120 + - type: 'null' + title: X-Tenant-Id + - name: X-Correlation-Id + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Correlation-Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/McpVehicleDetailOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/mcp/search-knowledge: + post: + tags: + - mcp + summary: Search Knowledge + operationId: search_knowledge_api_v1_integrations_mcp_search_knowledge_post + parameters: + - name: X-Service-Token + in: header + required: true + schema: + type: string + title: X-Service-Token + - name: X-Client-Id + in: header + required: true + schema: + type: string + minLength: 1 + maxLength: 180 + title: X-Client-Id + - name: X-Tenant-Id + in: header + required: false + schema: + anyOf: + - type: string + maxLength: 120 + - type: 'null' + title: X-Tenant-Id + - name: X-Correlation-Id + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Correlation-Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/McpKnowledgeSearchRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GroundedAnswer' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/search: + get: + tags: + - search + summary: Search + operationId: search_api_v1_search_get + parameters: + - name: q + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 100 + title: Q + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SearchResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/integrations/status: + get: + tags: + - integrations + summary: Integration Status + operationId: integration_status_api_v1_integrations_status_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/IntegrationStatusOut' + /api/v1/users: + get: + tags: + - users + summary: List Users + operationId: list_users_api_v1_users_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + items: + $ref: '#/components/schemas/UserOut' + type: array + title: Response List Users Api V1 Users Get + post: + tags: + - users + summary: Create User + operationId: create_user_api_v1_users_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/UserOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/users/{public_ref}: + patch: + tags: + - users + summary: Update User + operationId: update_user_api_v1_users__public_ref__patch + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/UserOut' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/privacy/retention: + get: + tags: + - privacy + summary: Retention Status + operationId: retention_status_api_v1_privacy_retention_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PrivacyRetentionOut' + /api/v1/privacy/customers/{public_ref}/export: + get: + tags: + - privacy + summary: Export Customer Data + operationId: export_customer_data_api_v1_privacy_customers__public_ref__export_get + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/privacy/customers/{public_ref}/anonymize: + post: + tags: + - privacy + summary: Anonymize Customer + operationId: anonymize_customer_api_v1_privacy_customers__public_ref__anonymize_post + parameters: + - name: public_ref + in: path + required: true + schema: + type: string + title: Public Ref + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerAnonymizeRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerAnonymizeResult' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + ApplyRecommendedStatusRequest: + properties: + recommendation_token: + type: string + title: Recommendation Token + type: object + required: + - recommendation_token + title: ApplyRecommendedStatusRequest + ApplyRecommendedStatusResult: + properties: + issue: + $ref: '#/components/schemas/DataQualityIssueOut' + applied_status: + type: string + title: Applied Status + reason_code: + type: string + title: Reason Code + type: object + required: + - issue + - applied_status + - reason_code + title: ApplyRecommendedStatusResult + AskQuestionRequest: + properties: + question: + type: string + maxLength: 1000 + minLength: 3 + title: Question + language: + type: string + enum: + - nl-BE + - en-GB + - fr-BE + title: Language + default: en-GB + type: object + required: + - question + title: AskQuestionRequest + AttentionItem: + properties: + kind: + type: string + enum: + - quality_issue + - vehicle + title: Kind + severity: + type: string + title: Severity + rule_type: + type: string + title: Rule Type + evidence_signals: + items: + $ref: '#/components/schemas/EvidenceSignalOut' + type: array + title: Evidence Signals + link_type: + type: string + enum: + - vehicle + - booking + - customer + title: Link Type + link_ref: + type: string + title: Link Ref + issue_ref: + anyOf: + - type: string + - type: 'null' + title: Issue Ref + type: object + required: + - kind + - severity + - rule_type + - link_type + - link_ref + title: AttentionItem + AttentionVehicleOut: + properties: + vehicle_ref: + type: string + title: Vehicle Ref + severity: + type: string + enum: + - low + - medium + - high + title: Severity + rule_type: + type: string + title: Rule Type + summary: + type: string + title: Summary + detected_at: + type: string + format: date-time + title: Detected At + type: object + required: + - vehicle_ref + - severity + - rule_type + - summary + - detected_at + title: AttentionVehicleOut + AuditEventOut: + properties: + id: + type: string + title: Id + actor_type: + type: string + title: Actor Type + actor_label: + type: string + title: Actor Label + action: + type: string + title: Action + entity_type: + type: string + title: Entity Type + entity_id: + anyOf: + - type: string + - type: 'null' + title: Entity Id + entity_ref: + anyOf: + - type: string + - type: 'null' + title: Entity Ref + entity_link: + anyOf: + - type: string + - type: 'null' + title: Entity Link + correlation_id: + type: string + title: Correlation Id + occurred_at: + type: string + format: date-time + title: Occurred At + before: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Before + after: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: After + metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Metadata + type: object + required: + - id + - actor_type + - actor_label + - action + - entity_type + - entity_id + - correlation_id + - occurred_at + title: AuditEventOut + AuditEventPageOut: + properties: + items: + items: + $ref: '#/components/schemas/AuditEventOut' + type: array + title: Items + page: + type: integer + title: Page + page_size: + type: integer + title: Page Size + total: + type: integer + title: Total + total_pages: + type: integer + title: Total Pages + type: object + required: + - items + - page + - page_size + - total + - total_pages + title: AuditEventPageOut + AutomationRunOut: + properties: + event_id: + type: string + title: Event Id + event_type: + type: string + title: Event Type + aggregate_ref: + type: string + title: Aggregate Ref + status: + type: string + title: Status + attempts: + type: integer + title: Attempts + last_error: + anyOf: + - type: string + - type: 'null' + title: Last Error + last_error_code: + anyOf: + - type: string + - type: 'null' + title: Last Error Code + is_demo_scenario: + type: boolean + title: Is Demo Scenario + default: false + occurred_at: + type: string + format: date-time + title: Occurred At + type: object + required: + - event_id + - event_type + - aggregate_ref + - status + - attempts + - last_error + - last_error_code + - occurred_at + title: AutomationRunOut + AvailableVehicleOut: + properties: + public_ref: + type: string + title: Public Ref + make: + type: string + title: Make + model: + type: string + title: Model + registration_number: + type: string + title: Registration Number + location: + type: string + title: Location + operational_status: + type: string + title: Operational Status + type: object + required: + - public_ref + - make + - model + - registration_number + - location + - operational_status + title: AvailableVehicleOut + BookingOut: + properties: + public_ref: + type: string + title: Public Ref + customer_ref: + type: string + title: Customer Ref + vehicle_ref: + type: string + title: Vehicle Ref + starts_at: + type: string + format: date-time + title: Starts At + ends_at: + type: string + format: date-time + title: Ends At + status: + type: string + title: Status + start_odometer_km: + anyOf: + - type: integer + - type: 'null' + title: Start Odometer Km + end_odometer_km: + anyOf: + - type: integer + - type: 'null' + title: End Odometer Km + requirements_complete: + type: boolean + title: Requirements Complete + customer_name: + type: string + title: Customer Name + type: object + required: + - public_ref + - customer_ref + - vehicle_ref + - starts_at + - ends_at + - status + - start_odometer_km + - end_odometer_km + - requirements_complete + - customer_name + title: BookingOut + BookingPageOut: + properties: + items: + items: + $ref: '#/components/schemas/BookingOut' + type: array + title: Items + page: + type: integer + title: Page + page_size: + type: integer + title: Page Size + total: + type: integer + title: Total + total_pages: + type: integer + title: Total Pages + type: object + required: + - items + - page + - page_size + - total + - total_pages + title: BookingPageOut + BookingSummaryOut: + properties: + public_ref: + type: string + title: Public Ref + customer_ref: + type: string + title: Customer Ref + vehicle_ref: + type: string + title: Vehicle Ref + starts_at: + type: string + format: date-time + title: Starts At + ends_at: + type: string + format: date-time + title: Ends At + status: + type: string + title: Status + type: object + required: + - public_ref + - customer_ref + - vehicle_ref + - starts_at + - ends_at + - status + title: BookingSummaryOut + BulkDataQualityWorkRequest: + properties: + issue_refs: + items: + type: string + type: array + maxItems: 25 + minItems: 1 + title: Issue Refs + assigned_to_ref: + anyOf: + - type: string + maxLength: 20 + minLength: 3 + - type: 'null' + title: Assigned To Ref + clear_assignment: + type: boolean + title: Clear Assignment + default: false + due_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Due At + clear_due_at: + type: boolean + title: Clear Due At + default: false + type: object + required: + - issue_refs + title: BulkDataQualityWorkRequest + BulkDataQualityWorkResult: + properties: + updated: + items: + $ref: '#/components/schemas/DataQualityIssueOut' + type: array + title: Updated + type: object + required: + - updated + title: BulkDataQualityWorkResult + CancelBookingRequest: + properties: + reason: + type: string + maxLength: 500 + minLength: 3 + title: Reason + type: object + required: + - reason + title: CancelBookingRequest + CheckoutBookingRequest: + properties: + start_odometer_km: + type: integer + minimum: 0.0 + title: Start Odometer Km + fuel_level_percent: + type: integer + maximum: 100.0 + minimum: 0.0 + title: Fuel Level Percent + cleanliness_ok: + type: boolean + title: Cleanliness Ok + damage_reported: + type: boolean + title: Damage Reported + default: false + technical_warning: + type: boolean + title: Technical Warning + default: false + notes: + anyOf: + - type: string + maxLength: 2000 + - type: 'null' + title: Notes + type: object + required: + - start_odometer_km + - fuel_level_percent + - cleanliness_ok + title: CheckoutBookingRequest + CheckoutBookingResult: + properties: + booking_ref: + type: string + title: Booking Ref + vehicle_ref: + type: string + title: Vehicle Ref + inspection_ref: + type: string + title: Inspection Ref + booking_status: + type: string + title: Booking Status + resulting_vehicle_status: + type: string + title: Resulting Vehicle Status + activated: + type: boolean + title: Activated + attention_reasons: + items: + type: string + type: array + title: Attention Reasons + type: object + required: + - booking_ref + - vehicle_ref + - inspection_ref + - booking_status + - resulting_vehicle_status + - activated + - attention_reasons + title: CheckoutBookingResult + CompleteBookingRequirementsRequest: + properties: + confirmation: + type: string + maxLength: 500 + minLength: 3 + title: Confirmation + type: object + required: + - confirmation + title: CompleteBookingRequirementsRequest + CreateBookingRequest: + properties: + customer_ref: + type: string + maxLength: 20 + minLength: 3 + title: Customer Ref + vehicle_ref: + type: string + maxLength: 20 + minLength: 3 + title: Vehicle Ref + starts_at: + type: string + format: date-time + title: Starts At + ends_at: + type: string + format: date-time + title: Ends At + type: object + required: + - customer_ref + - vehicle_ref + - starts_at + - ends_at + title: CreateBookingRequest + CreateMaintenanceRequest: + properties: + occurred_at: + type: string + format: date-time + title: Occurred At + odometer_km: + type: integer + minimum: 0.0 + title: Odometer Km + category: + type: string + enum: + - periodic_service + - repair + - inspection + - tyres + - other + title: Category + summary: + type: string + maxLength: 2000 + minLength: 3 + title: Summary + next_service_km: + anyOf: + - type: integer + minimum: 0.0 + - type: 'null' + title: Next Service Km + mark_maintenance: + type: boolean + title: Mark Maintenance + default: true + type: object + required: + - occurred_at + - odometer_km + - category + - summary + title: CreateMaintenanceRequest + CreateUserRequest: + properties: + email: + type: string + maxLength: 320 + minLength: 3 + title: Email + display_name: + type: string + maxLength: 120 + minLength: 2 + title: Display Name + role: + type: string + enum: + - operations_manager + - rental_employee + title: Role + password: + type: string + maxLength: 256 + minLength: 8 + title: Password + type: object + required: + - email + - display_name + - role + - password + title: CreateUserRequest + CurrentUser: + properties: + public_ref: + type: string + title: Public Ref + display_name: + type: string + title: Display Name + role: + type: string + enum: + - operations_manager + - rental_employee + title: Role + type: object + required: + - public_ref + - display_name + - role + title: CurrentUser + CustomerAnonymizeRequest: + properties: + confirmation: + type: string + maxLength: 20 + minLength: 1 + title: Confirmation + reason: + type: string + maxLength: 500 + minLength: 8 + title: Reason + type: object + required: + - confirmation + - reason + title: CustomerAnonymizeRequest + CustomerAnonymizeResult: + properties: + public_ref: + type: string + title: Public Ref + anonymized_at: + type: string + format: date-time + title: Anonymized At + status: + type: string + enum: + - anonymized + - already_anonymized + title: Status + type: object + required: + - public_ref + - anonymized_at + - status + title: CustomerAnonymizeResult + CustomerOptionOut: + properties: + public_ref: + type: string + title: Public Ref + display_name: + type: string + title: Display Name + email: + anyOf: + - type: string + - type: 'null' + title: Email + type: object + required: + - public_ref + - display_name + - email + title: CustomerOptionOut + DashboardMetrics: + properties: + available: + type: integer + title: Available + rented: + type: integer + title: Rented + cleaning: + type: integer + title: Cleaning + maintenance: + type: integer + title: Maintenance + blocked: + type: integer + title: Blocked + open_quality_issues: + type: integer + title: Open Quality Issues + pending_or_failed_workflows: + type: integer + title: Pending Or Failed Workflows + type: object + required: + - available + - rented + - cleaning + - maintenance + - blocked + - open_quality_issues + - pending_or_failed_workflows + title: DashboardMetrics + DashboardOut: + properties: + metrics: + $ref: '#/components/schemas/DashboardMetrics' + attention_items: + items: + $ref: '#/components/schemas/AttentionItem' + type: array + title: Attention Items + today: + items: + $ref: '#/components/schemas/TodayItem' + type: array + title: Today + recent_automation: + items: + $ref: '#/components/schemas/AutomationRunOut' + type: array + title: Recent Automation + type: object + required: + - metrics + - attention_items + - today + - recent_automation + title: DashboardOut + DataQualityIssueDetailOut: + properties: + public_ref: + type: string + title: Public Ref + rule_type: + type: string + title: Rule Type + entity_type: + type: string + title: Entity Type + entity_ref: + type: string + title: Entity Ref + severity: + type: string + title: Severity + status: + type: string + title: Status + evidence: + additionalProperties: true + type: object + title: Evidence + detected_at: + type: string + format: date-time + title: Detected At + due_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Due At + assigned_to_ref: + anyOf: + - type: string + - type: 'null' + title: Assigned To Ref + assigned_to_name: + anyOf: + - type: string + - type: 'null' + title: Assigned To Name + overdue: + type: boolean + title: Overdue + default: false + resolved_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Resolved At + entity_snapshot: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Entity Snapshot + related_snapshots: + items: + additionalProperties: true + type: object + type: array + title: Related Snapshots + type: object + required: + - public_ref + - rule_type + - entity_type + - entity_ref + - severity + - status + - evidence + - detected_at + title: DataQualityIssueDetailOut + DataQualityIssueOut: + properties: + public_ref: + type: string + title: Public Ref + rule_type: + type: string + title: Rule Type + entity_type: + type: string + title: Entity Type + entity_ref: + type: string + title: Entity Ref + severity: + type: string + title: Severity + status: + type: string + title: Status + evidence: + additionalProperties: true + type: object + title: Evidence + detected_at: + type: string + format: date-time + title: Detected At + due_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Due At + assigned_to_ref: + anyOf: + - type: string + - type: 'null' + title: Assigned To Ref + assigned_to_name: + anyOf: + - type: string + - type: 'null' + title: Assigned To Name + overdue: + type: boolean + title: Overdue + default: false + resolved_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Resolved At + type: object + required: + - public_ref + - rule_type + - entity_type + - entity_ref + - severity + - status + - evidence + - detected_at + title: DataQualityIssueOut + DataQualityIssuePageOut: + properties: + items: + items: + $ref: '#/components/schemas/DataQualityIssueOut' + type: array + title: Items + page: + type: integer + title: Page + page_size: + type: integer + title: Page Size + total: + type: integer + title: Total + total_pages: + type: integer + title: Total Pages + type: object + required: + - items + - page + - page_size + - total + - total_pages + title: DataQualityIssuePageOut + DemoIntegrationSummaryOut: + properties: + key: + type: string + enum: + - n8n + - ragcore + - mcp_hub + title: Key + status_code: + type: string + title: Status Code + detail_code: + type: string + title: Detail Code + detail_params: + additionalProperties: + anyOf: + - type: string + - type: integer + type: object + title: Detail Params + default: {} + type: object + required: + - key + - status_code + - detail_code + title: DemoIntegrationSummaryOut + DemoLoginRequest: + properties: + role: + type: string + enum: + - operations_manager + - rental_employee + title: Role + type: object + required: + - role + title: DemoLoginRequest + DemoManifestOut: + properties: + demo_mode: + type: boolean + title: Demo Mode + organization_name: + type: string + title: Organization Name + timezone: + type: string + title: Timezone + synthetic_data: + type: boolean + title: Synthetic Data + allow_reset: + type: boolean + title: Allow Reset + last_reset_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Reset At + anchor_date: + anyOf: + - type: string + - type: 'null' + title: Anchor Date + guide_available: + type: boolean + title: Guide Available + required_roles: + items: + type: string + enum: + - operations_manager + - rental_employee + type: array + title: Required Roles + scenarios: + items: + $ref: '#/components/schemas/DemoScenarioOut' + type: array + title: Scenarios + integrations: + items: + $ref: '#/components/schemas/DemoIntegrationSummaryOut' + type: array + title: Integrations + type: object + required: + - demo_mode + - organization_name + - timezone + - synthetic_data + - allow_reset + - last_reset_at + - anchor_date + - guide_available + - required_roles + - scenarios + - integrations + title: DemoManifestOut + DemoScenarioOut: + properties: + id: + type: string + title: Id + estimated_minutes: + type: integer + title: Estimated Minutes + required_roles: + items: + type: string + enum: + - operations_manager + - rental_employee + type: array + title: Required Roles + start_path: + type: string + title: Start Path + ready: + type: boolean + title: Ready + blocked_reason_code: + anyOf: + - type: string + - type: 'null' + title: Blocked Reason Code + blocked_reason_params: + additionalProperties: + type: string + type: object + title: Blocked Reason Params + default: {} + type: object + required: + - id + - estimated_minutes + - required_roles + - start_path + - ready + title: DemoScenarioOut + EvidenceSignalOut: + properties: + code: + type: string + title: Code + params: + additionalProperties: true + type: object + title: Params + type: object + required: + - code + title: EvidenceSignalOut + GroundedAnswer: + properties: + answer: + type: string + title: Answer + evidence_state: + type: string + enum: + - grounded + - insufficient + - unavailable + title: Evidence State + sources: + items: + $ref: '#/components/schemas/SourceCard' + type: array + title: Sources + provider: + type: string + title: Provider + correlation_id: + type: string + title: Correlation Id + type: object + required: + - answer + - evidence_state + - sources + - provider + - correlation_id + title: GroundedAnswer + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + InspectionOut: + properties: + public_ref: + type: string + title: Public Ref + booking_ref: + type: string + title: Booking Ref + type: + type: string + title: Type + fuel_level_percent: + type: integer + title: Fuel Level Percent + cleanliness_ok: + type: boolean + title: Cleanliness Ok + damage_reported: + type: boolean + title: Damage Reported + technical_warning: + type: boolean + title: Technical Warning + odometer_km: + type: integer + title: Odometer Km + completed_at: + type: string + format: date-time + title: Completed At + type: object + required: + - public_ref + - booking_ref + - type + - fuel_level_percent + - cleanliness_ok + - damage_reported + - technical_warning + - odometer_km + - completed_at + title: InspectionOut + IntegrationStatusOut: + properties: + n8n: + $ref: '#/components/schemas/N8nIntegrationStatus' + mcp_hub: + $ref: '#/components/schemas/McpHubIntegrationStatus' + type: object + required: + - n8n + - mcp_hub + title: IntegrationStatusOut + KnowledgeFeedbackRequest: + properties: + correlation_id: + type: string + format: uuid + title: Correlation Id + helpful: + type: boolean + title: Helpful + type: object + required: + - correlation_id + - helpful + title: KnowledgeFeedbackRequest + KnowledgeHealth: + properties: + provider: + type: string + title: Provider + available: + type: boolean + title: Available + detail: + type: string + title: Detail + tenant: + type: string + title: Tenant + workspace: + type: string + title: Workspace + collection: + type: string + title: Collection + document_count: + anyOf: + - type: integer + - type: 'null' + title: Document Count + source_document_count: + type: integer + title: Source Document Count + reported_synced_document_count: + anyOf: + - type: integer + - type: 'null' + title: Reported Synced Document Count + reported_failed_document_count: + anyOf: + - type: integer + - type: 'null' + title: Reported Failed Document Count + last_sync_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Sync At + statistics_state: + type: string + enum: + - verified + - sync_reported + - not_reported + title: Statistics State + type: object + required: + - provider + - available + - detail + - tenant + - workspace + - collection + - document_count + - source_document_count + - reported_synced_document_count + - reported_failed_document_count + - last_sync_at + - statistics_state + title: KnowledgeHealth + MaintenanceOut: + properties: + public_ref: + type: string + title: Public Ref + occurred_at: + type: string + format: date-time + title: Occurred At + odometer_km: + type: integer + title: Odometer Km + category: + type: string + title: Category + summary: + type: string + title: Summary + type: object + required: + - public_ref + - occurred_at + - odometer_km + - category + - summary + title: MaintenanceOut + McpHubIntegrationStatus: + properties: + registration_enabled: + type: boolean + title: Registration Enabled + state: + type: string + enum: + - not_configured + - no_evidence + - operational + title: State + total_calls: + type: integer + title: Total Calls + last_tool: + anyOf: + - type: string + - type: 'null' + title: Last Tool + last_client: + anyOf: + - type: string + - type: 'null' + title: Last Client + last_called_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Called At + hub_reachable: + anyOf: + - type: boolean + - type: 'null' + title: Hub Reachable + type: object + required: + - registration_enabled + - state + - total_calls + title: McpHubIntegrationStatus + McpKnowledgeSearchRequest: + properties: + question: + type: string + maxLength: 1000 + minLength: 3 + title: Question + max_sources: + type: integer + maximum: 8.0 + minimum: 1.0 + title: Max Sources + default: 4 + locale: + type: string + enum: + - nl-BE + - en-GB + - fr-BE + title: Locale + default: en-GB + type: object + required: + - question + title: McpKnowledgeSearchRequest + McpVehicleDetailOut: + properties: + public_ref: + type: string + title: Public Ref + make: + type: string + title: Make + model: + type: string + title: Model + model_year: + type: integer + title: Model Year + location: + type: string + title: Location + operational_status: + type: string + title: Operational Status + odometer_km: + type: integer + title: Odometer Km + next_service_km: + type: integer + title: Next Service Km + open_quality_issue_count: + type: integer + title: Open Quality Issue Count + current_booking_ref: + anyOf: + - type: string + - type: 'null' + title: Current Booking Ref + type: object + required: + - public_ref + - make + - model + - model_year + - location + - operational_status + - odometer_km + - next_service_km + - open_quality_issue_count + - current_booking_ref + title: McpVehicleDetailOut + MergeCustomersRequest: + properties: + survivor_ref: + type: string + title: Survivor Ref + field_overrides: + anyOf: + - additionalProperties: + type: string + type: object + - type: 'null' + title: Field Overrides + type: object + required: + - survivor_ref + title: MergeCustomersRequest + MergeCustomersResult: + properties: + issue_ref: + type: string + title: Issue Ref + survivor_ref: + type: string + title: Survivor Ref + loser_ref: + type: string + title: Loser Ref + rewired_bookings: + type: integer + title: Rewired Bookings + type: object + required: + - issue_ref + - survivor_ref + - loser_ref + - rewired_bookings + title: MergeCustomersResult + N8nErrorHandlerStatus: + properties: + total_failures_registered: + type: integer + title: Total Failures Registered + latest_failure_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Latest Failure At + latest_failure_workflow: + anyOf: + - type: string + - type: 'null' + title: Latest Failure Workflow + type: object + required: + - total_failures_registered + - latest_failure_at + - latest_failure_workflow + title: N8nErrorHandlerStatus + N8nHeartbeatIn: + properties: + workflow_id: + type: string + maxLength: 120 + minLength: 1 + title: Workflow Id + workflow_name: + type: string + maxLength: 200 + minLength: 1 + title: Workflow Name + execution_id: + type: string + maxLength: 120 + minLength: 1 + title: Execution Id + status: + type: string + enum: + - succeeded + - failed + title: Status + type: object + required: + - workflow_id + - workflow_name + - execution_id + - status + title: N8nHeartbeatIn + N8nHeartbeatResult: + properties: + status: + type: string + enum: + - registered + - already_registered + title: Status + execution_id: + type: string + title: Execution Id + occurred_at: + type: string + format: date-time + title: Occurred At + type: object + required: + - status + - execution_id + - occurred_at + title: N8nHeartbeatResult + N8nIntegrationStatus: + properties: + configured: + type: boolean + title: Configured + dispatch_enabled: + type: boolean + title: Dispatch Enabled + state: + type: string + enum: + - disabled + - unavailable + - degraded + - operational + - no_evidence + title: State + pending: + type: integer + title: Pending + failed: + type: integer + title: Failed + unexpected_failed: + type: integer + title: Unexpected Failed + default: 0 + demo_scenario_failed: + type: integer + title: Demo Scenario Failed + default: 0 + delivering: + type: integer + title: Delivering + succeeded: + type: integer + title: Succeeded + latest_success_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Latest Success At + latest_failure_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Latest Failure At + latest_demo_scenario_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Latest Demo Scenario At + expected_workflow_count: + type: integer + title: Expected Workflow Count + known_workflow_count: + type: integer + title: Known Workflow Count + workflows: + items: + $ref: '#/components/schemas/N8nWorkflowEvidence' + type: array + title: Workflows + error_handler: + $ref: '#/components/schemas/N8nErrorHandlerStatus' + type: object + required: + - configured + - dispatch_enabled + - state + - pending + - failed + - delivering + - succeeded + - latest_success_at + - latest_failure_at + - expected_workflow_count + - known_workflow_count + - workflows + - error_handler + title: N8nIntegrationStatus + N8nWorkflowEvidence: + properties: + name: + type: string + title: Name + built: + type: boolean + title: Built + last_seen_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Seen At + state: + type: string + enum: + - no_evidence + - healthy + - stale + - failed + title: State + default: no_evidence + last_status: + anyOf: + - type: string + enum: + - succeeded + - failed + - type: 'null' + title: Last Status + last_execution_id: + anyOf: + - type: string + - type: 'null' + title: Last Execution Id + type: object + required: + - name + - built + - last_seen_at + title: N8nWorkflowEvidence + NextBookingRisk: + properties: + booking_ref: + type: string + title: Booking Ref + starts_at: + type: string + format: date-time + title: Starts At + at_risk: + type: boolean + title: At Risk + type: object + required: + - booking_ref + - starts_at + - at_risk + title: NextBookingRisk + OidcStatusOut: + properties: + enabled: + type: boolean + title: Enabled + provider_name: + anyOf: + - type: string + - type: 'null' + title: Provider Name + type: object + required: + - enabled + title: OidcStatusOut + OperationsSummaryOut: + properties: + tenant: + type: string + title: Tenant + metrics: + $ref: '#/components/schemas/DashboardMetrics' + type: object + required: + - tenant + - metrics + title: OperationsSummaryOut + PasswordLoginRequest: + properties: + email: + type: string + maxLength: 320 + minLength: 3 + title: Email + password: + type: string + maxLength: 256 + minLength: 8 + title: Password + type: object + required: + - email + - password + title: PasswordLoginRequest + PrivacyRetentionOut: + properties: + minimum_booking_retention_days: + type: integer + title: Minimum Booking Retention Days + audit_retention_days: + type: integer + title: Audit Retention Days + customers_total: + type: integer + title: Customers Total + customers_anonymized: + type: integer + title: Customers Anonymized + customers_eligible: + type: integer + title: Customers Eligible + type: object + required: + - minimum_booking_retention_days + - audit_retention_days + - customers_total + - customers_anonymized + - customers_eligible + title: PrivacyRetentionOut + ProcedureDocumentOut: + properties: + id: + type: string + title: Id + language: + type: string + title: Language + document_id: + type: string + title: Document Id + title: + type: string + title: Title + version: + type: string + title: Version + content: + type: string + title: Content + content_hash: + type: string + title: Content Hash + type: object + required: + - id + - language + - document_id + - title + - version + - content + - content_hash + title: ProcedureDocumentOut + ProcedureListOut: + properties: + documents: + items: + $ref: '#/components/schemas/ProcedureDocumentOut' + type: array + title: Documents + type: object + required: + - documents + title: ProcedureListOut + ProcedureSyncResultIn: + properties: + execution_id: + type: string + maxLength: 120 + title: Execution Id + synced: + type: integer + minimum: 0.0 + title: Synced + failed: + type: integer + minimum: 0.0 + title: Failed + default: 0 + type: object + required: + - execution_id + - synced + title: ProcedureSyncResultIn + ProcedureSyncResultResult: + properties: + status: + type: string + enum: + - registered + - already_registered + title: Status + execution_id: + type: string + title: Execution Id + occurred_at: + type: string + format: date-time + title: Occurred At + type: object + required: + - status + - execution_id + - occurred_at + title: ProcedureSyncResultResult + ProvideFieldsRequest: + properties: + fields: + additionalProperties: + type: string + type: object + title: Fields + type: object + required: + - fields + title: ProvideFieldsRequest + RegisterReturnRequest: + properties: + end_odometer_km: + type: integer + minimum: 0.0 + title: End Odometer Km + fuel_level_percent: + type: integer + maximum: 100.0 + minimum: 0.0 + title: Fuel Level Percent + cleanliness_ok: + type: boolean + title: Cleanliness Ok + damage_reported: + type: boolean + title: Damage Reported + technical_warning: + type: boolean + title: Technical Warning + notes: + anyOf: + - type: string + maxLength: 2000 + - type: 'null' + title: Notes + type: object + required: + - end_odometer_km + - fuel_level_percent + - cleanliness_ok + - damage_reported + - technical_warning + title: RegisterReturnRequest + RegisterReturnResult: + properties: + booking_ref: + type: string + title: Booking Ref + vehicle_ref: + type: string + title: Vehicle Ref + inspection_ref: + type: string + title: Inspection Ref + resulting_vehicle_status: + type: string + title: Resulting Vehicle Status + odometer_regression: + type: boolean + title: Odometer Regression + quality_issue_ref: + anyOf: + - type: string + - type: 'null' + title: Quality Issue Ref + workflow_event_id: + type: string + title: Workflow Event Id + correlation_id: + type: string + title: Correlation Id + next_booking_risk: + anyOf: + - $ref: '#/components/schemas/NextBookingRisk' + - type: 'null' + type: object + required: + - booking_ref + - vehicle_ref + - inspection_ref + - resulting_vehicle_status + - odometer_regression + - quality_issue_ref + - workflow_event_id + - correlation_id + - next_booking_risk + title: RegisterReturnResult + ReleaseVehicleRequest: + properties: + reason: + type: string + maxLength: 500 + minLength: 3 + title: Reason + type: object + required: + - reason + title: ReleaseVehicleRequest + RescheduleBookingRequest: + properties: + starts_at: + type: string + format: date-time + title: Starts At + ends_at: + type: string + format: date-time + title: Ends At + reason: + type: string + maxLength: 500 + minLength: 3 + title: Reason + type: object + required: + - starts_at + - ends_at + - reason + title: RescheduleBookingRequest + ResolveOdometerRegressionRequest: + properties: + decision: + type: string + enum: + - retain_canonical + - correct_reading + title: Decision + booking_ref: + anyOf: + - type: string + - type: 'null' + title: Booking Ref + corrected_odometer_km: + anyOf: + - type: integer + minimum: 0.0 + - type: 'null' + title: Corrected Odometer Km + note: + anyOf: + - type: string + maxLength: 500 + - type: 'null' + title: Note + type: object + required: + - decision + title: ResolveOdometerRegressionRequest + ResolveOverlapRequest: + properties: + booking_ref: + type: string + title: Booking Ref + note: + anyOf: + - type: string + maxLength: 500 + - type: 'null' + title: Note + type: object + required: + - booking_ref + title: ResolveOverlapRequest + ReturnCallbackIn: + properties: + event_id: + type: string + format: uuid + title: Event Id + correlation_id: + type: string + format: uuid + title: Correlation Id + follow_up: + anyOf: + - type: string + maxLength: 200 + - type: 'null' + title: Follow Up + summary: + anyOf: + - type: string + maxLength: 2000 + - type: 'null' + title: Summary + type: object + required: + - event_id + - correlation_id + title: ReturnCallbackIn + description: 'Body of the n8n return follow-up callback. + + + n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are + ignored; + + only the fields we persist are validated and bounded.' + ReturnPreviewResult: + properties: + booking_ref: + type: string + title: Booking Ref + vehicle_ref: + type: string + title: Vehicle Ref + canonical_odometer_km: + type: integer + title: Canonical Odometer Km + submitted_odometer_km: + type: integer + title: Submitted Odometer Km + odometer_regression: + type: boolean + title: Odometer Regression + resulting_odometer_km: + type: integer + title: Resulting Odometer Km + resulting_vehicle_status: + type: string + title: Resulting Vehicle Status + status_reason: + type: string + title: Status Reason + status_reason_code: + type: string + title: Status Reason Code + status_reason_params: + additionalProperties: + anyOf: + - type: string + - type: integer + type: object + title: Status Reason Params + default: {} + would_create_quality_issue: + type: boolean + title: Would Create Quality Issue + attention_reasons: + items: + type: string + type: array + title: Attention Reasons + next_booking_risk: + anyOf: + - $ref: '#/components/schemas/NextBookingRisk' + - type: 'null' + type: object + required: + - booking_ref + - vehicle_ref + - canonical_odometer_km + - submitted_odometer_km + - odometer_regression + - resulting_odometer_km + - resulting_vehicle_status + - status_reason + - status_reason_code + - would_create_quality_issue + - attention_reasons + - next_booking_risk + title: ReturnPreviewResult + ScanResultOut: + properties: + created: + additionalProperties: + type: integer + type: object + title: Created + type: object + required: + - created + title: ScanResultOut + SearchResponse: + properties: + query: + type: string + title: Query + results: + items: + $ref: '#/components/schemas/SearchResultItem' + type: array + title: Results + type: object + required: + - query + - results + title: SearchResponse + SearchResultItem: + properties: + type: + type: string + enum: + - vehicle + - booking + - data_quality_issue + - section + title: Type + label: + type: string + title: Label + detail_code: + type: string + title: Detail Code + detail_params: + additionalProperties: + type: string + type: object + title: Detail Params + default: {} + link: + type: string + title: Link + type: object + required: + - type + - label + - detail_code + - link + title: SearchResultItem + SourceCard: + properties: + document_id: + type: string + title: Document Id + title: + type: string + title: Title + version: + type: string + title: Version + section: + type: string + title: Section + excerpt: + type: string + title: Excerpt + type: object + required: + - document_id + - title + - version + - section + - excerpt + title: SourceCard + StatusRecommendationOut: + properties: + current_status: + type: string + title: Current Status + recommended_status: + anyOf: + - type: string + - type: 'null' + title: Recommended Status + recommendation_code: + type: string + title: Recommendation Code + safe_to_apply: + type: boolean + title: Safe To Apply + manual_review_required: + type: boolean + title: Manual Review Required + facts: + $ref: '#/components/schemas/VehicleStatusFactsOut' + blocking_reasons: + items: + type: string + type: array + title: Blocking Reasons + recommendation_token: + type: string + title: Recommendation Token + type: object + required: + - current_status + - recommended_status + - recommendation_code + - safe_to_apply + - manual_review_required + - facts + - blocking_reasons + - recommendation_token + title: StatusRecommendationOut + TodayItem: + properties: + kind: + type: string + enum: + - departure + - return + title: Kind + booking_ref: + type: string + title: Booking Ref + vehicle_ref: + type: string + title: Vehicle Ref + scheduled_at: + type: string + format: date-time + title: Scheduled At + type: object + required: + - kind + - booking_ref + - vehicle_ref + - scheduled_at + title: TodayItem + UpdateUserRequest: + properties: + display_name: + anyOf: + - type: string + maxLength: 120 + minLength: 2 + - type: 'null' + title: Display Name + role: + anyOf: + - type: string + enum: + - operations_manager + - rental_employee + - type: 'null' + title: Role + active: + anyOf: + - type: boolean + - type: 'null' + title: Active + password: + anyOf: + - type: string + maxLength: 256 + minLength: 8 + - type: 'null' + title: Password + type: object + title: UpdateUserRequest + UserOut: + properties: + public_ref: + type: string + title: Public Ref + email: + anyOf: + - type: string + - type: 'null' + title: Email + display_name: + type: string + title: Display Name + role: + type: string + enum: + - operations_manager + - rental_employee + title: Role + active: + type: boolean + title: Active + type: object + required: + - public_ref + - email + - display_name + - role + - active + title: UserOut + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + type: object + required: + - loc + - msg + - type + title: ValidationError + VehicleDetailOut: + properties: + public_ref: + type: string + title: Public Ref + make: + type: string + title: Make + model: + type: string + title: Model + model_year: + type: integer + title: Model Year + registration_number: + type: string + title: Registration Number + location: + type: string + title: Location + operational_status: + type: string + title: Operational Status + odometer_km: + type: integer + title: Odometer Km + next_service_km: + type: integer + title: Next Service Km + active: + type: boolean + title: Active + attention: + type: boolean + title: Attention + default: false + attention_reason: + anyOf: + - type: string + - type: 'null' + title: Attention Reason + service_remaining_km: + type: integer + title: Service Remaining Km + next_booking_ref: + anyOf: + - type: string + - type: 'null' + title: Next Booking Ref + next_booking_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Next Booking At + bookings: + items: + $ref: '#/components/schemas/BookingSummaryOut' + type: array + title: Bookings + inspections: + items: + $ref: '#/components/schemas/InspectionOut' + type: array + title: Inspections + maintenance: + items: + $ref: '#/components/schemas/MaintenanceOut' + type: array + title: Maintenance + quality_issues: + items: + $ref: '#/components/schemas/DataQualityIssueOut' + type: array + title: Quality Issues + type: object + required: + - public_ref + - make + - model + - model_year + - registration_number + - location + - operational_status + - odometer_km + - next_service_km + - active + - service_remaining_km + title: VehicleDetailOut + VehicleOut: + properties: + public_ref: + type: string + title: Public Ref + make: + type: string + title: Make + model: + type: string + title: Model + model_year: + type: integer + title: Model Year + registration_number: + type: string + title: Registration Number + location: + type: string + title: Location + operational_status: + type: string + title: Operational Status + odometer_km: + type: integer + title: Odometer Km + next_service_km: + type: integer + title: Next Service Km + active: + type: boolean + title: Active + attention: + type: boolean + title: Attention + default: false + attention_reason: + anyOf: + - type: string + - type: 'null' + title: Attention Reason + service_remaining_km: + type: integer + title: Service Remaining Km + next_booking_ref: + anyOf: + - type: string + - type: 'null' + title: Next Booking Ref + next_booking_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Next Booking At + type: object + required: + - public_ref + - make + - model + - model_year + - registration_number + - location + - operational_status + - odometer_km + - next_service_km + - active + - service_remaining_km + title: VehicleOut + VehiclePageOut: + properties: + items: + items: + $ref: '#/components/schemas/VehicleOut' + type: array + title: Items + page: + type: integer + title: Page + page_size: + type: integer + title: Page Size + total: + type: integer + title: Total + total_pages: + type: integer + title: Total Pages + type: object + required: + - items + - page + - page_size + - total + - total_pages + title: VehiclePageOut + VehicleStatusFactsOut: + properties: + active_booking_refs: + items: + type: string + type: array + title: Active Booking Refs + overlapping_booking_pairs: + items: + items: + type: string + type: array + type: array + title: Overlapping Booking Pairs + service_threshold_reached: + type: boolean + title: Service Threshold Reached + odometer_km: + type: integer + title: Odometer Km + next_service_km: + type: integer + title: Next Service Km + open_booking_overlap_issue_ref: + anyOf: + - type: string + - type: 'null' + title: Open Booking Overlap Issue Ref + type: object + required: + - active_booking_refs + - overlapping_booking_pairs + - service_threshold_reached + - odometer_km + - next_service_km + title: VehicleStatusFactsOut + WorkflowErrorReportIn: + properties: + workflow_id: + type: string + maxLength: 120 + title: Workflow Id + workflow_name: + type: string + maxLength: 200 + title: Workflow Name + execution_id: + type: string + maxLength: 120 + title: Execution Id + failed_at: + type: string + format: date-time + title: Failed At + error_category: + type: string + enum: + - timeout + - authError + - connectionError + - httpError + - validationError + - unknown + title: Error Category + error_summary: + type: string + maxLength: 500 + title: Error Summary + trigger_context: + anyOf: + - type: string + maxLength: 200 + - type: 'null' + title: Trigger Context + correlation_id: + anyOf: + - type: string + format: uuid + - type: 'null' + title: Correlation Id + attempt: + type: integer + maximum: 1000.0 + minimum: 1.0 + title: Attempt + default: 1 + retry_action: + anyOf: + - type: string + maxLength: 200 + - type: 'null' + title: Retry Action + type: object + required: + - workflow_id + - workflow_name + - execution_id + - failed_at + - error_category + - error_summary + title: WorkflowErrorReportIn + WorkflowErrorReportResult: + properties: + status: + type: string + enum: + - registered + - already_registered + title: Status + execution_id: + type: string + title: Execution Id + occurred_at: + type: string + format: date-time + title: Occurred At + type: object + required: + - status + - execution_id + - occurred_at + title: WorkflowErrorReportResult diff --git a/contracts/ragcore-contract-assumptions.md b/contracts/ragcore-contract-assumptions.md new file mode 100644 index 0000000..b25fd20 --- /dev/null +++ b/contracts/ragcore-contract-assumptions.md @@ -0,0 +1,13 @@ +# RAGcore contract assumptions + +RAGcore is being built separately, so Claude must inspect its actual exposed API before finalizing the adapter. Do not edit RAGcore from this repository. + +The MobilityOps adapter needs only these capabilities: + +1. health/status; +2. synchronize or ingest a document with stable ID and metadata; +3. ask/search within one tenant/workspace/collection; +4. receive source document ID, title, version, section and excerpt; +5. distinguish unavailable from insufficient evidence. + +When the actual RAGcore API differs, adapt MobilityOps behind `KnowledgeProvider`; do not leak RAGcore-specific response shapes into UI/domain code. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..0e5a34a --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +playwright-report +test-results +e2e +tsconfig.tsbuildinfo +.env* diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..7289136 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS build +WORKDIR /app +COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./ +RUN npm ci --no-audit --no-fund +COPY public ./public +COPY src ./src +# Vite inlines VITE_* variables at build time; leave empty (default) so the SPA talks to +# the same origin and nginx proxies /api to the backend. +ARG VITE_API_BASE_URL="" +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +RUN npm run build + +FROM nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46 +# The pinned upstream image can lag Alpine security rebuilds. Apply the current +# fixes from its pinned Alpine release before shipping the runtime image. +RUN apk upgrade --no-cache +ARG VCS_REF=development +ARG BUILD_DATE=unknown +LABEL org.opencontainers.image.title="Fleet Ops Web" \ + org.opencontainers.image.revision="$VCS_REF" \ + org.opencontainers.image.created="$BUILD_DATE" \ + org.opencontainers.image.source="https://fleetops.example.test" +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/e2e-live/smoke.spec.ts b/frontend/e2e-live/smoke.spec.ts new file mode 100644 index 0000000..778174b --- /dev/null +++ b/frontend/e2e-live/smoke.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +test("public health and HTTPS-facing shell are available", async ({ page, request, baseURL }) => { + const readiness = await request.get("/health/ready"); + expect(readiness.ok()).toBeTruthy(); + expect(await readiness.json()).toMatchObject({ status: "ready", database: "up" }); + + await page.goto("/login"); + await expect(page).toHaveTitle(/Fleet Ops/); + await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible(); + if (baseURL?.startsWith("https://")) { + expect(page.url()).toMatch(/^https:\/\//); + } +}); + +test("non-destructive operator canary covers routes and grounded knowledge", async ({ page }) => { + const pageErrors: string[] = []; + const serverErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("response", (response) => { + if (response.status() >= 500) serverErrors.push(`${response.status()} ${response.url()}`); + }); + + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + if (pageErrors.some((message) => message.toLowerCase().includes("dynamically imported module"))) { + // A deploy can replace the SPA between loading index.html and its lazy + // dashboard chunk. One clean reload must recover; a persistent chunk or + // server error is collected again and still fails the canary below. + pageErrors.length = 0; + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.locator("main")).toBeVisible(); + } + + for (const [path, heading] of [ + ["/vehicles", "Wagenpark"], + ["/bookings", "Boekingen"], + ["/data-quality", "Datakwaliteit"], + ["/automation", "Integratiebeheer"], + ["/audit", "Auditgeschiedenis"], + ] as const) { + await page.goto(path); + await expect(page.getByRole("heading", { name: heading }).first()).toBeVisible(); + } + + await page.goto("/knowledge"); + await page + .getByRole("textbox", { name: "Vraag" }) + .fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?"); + await page.getByRole("button", { name: "Vraag stellen" }).click(); + await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ + timeout: 20_000, + }); + + expect(pageErrors).toEqual([]); + expect(serverErrors).toEqual([]); +}); diff --git a/frontend/e2e/__screenshots__/highlights-main.png b/frontend/e2e/__screenshots__/highlights-main.png new file mode 100644 index 0000000..68941e8 Binary files /dev/null and b/frontend/e2e/__screenshots__/highlights-main.png differ diff --git a/frontend/e2e/__screenshots__/login-access.png b/frontend/e2e/__screenshots__/login-access.png new file mode 100644 index 0000000..639975e Binary files /dev/null and b/frontend/e2e/__screenshots__/login-access.png differ diff --git a/frontend/e2e/_capture-demo-screenshots.spec.ts b/frontend/e2e/_capture-demo-screenshots.spec.ts new file mode 100644 index 0000000..df65044 --- /dev/null +++ b/frontend/e2e/_capture-demo-screenshots.spec.ts @@ -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 Operationsmanager" }).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$/); + } +}); diff --git a/frontend/e2e/_capture-recruiter-screenshots.spec.ts b/frontend/e2e/_capture-recruiter-screenshots.spec.ts new file mode 100644 index 0000000..ead1a70 --- /dev/null +++ b/frontend/e2e/_capture-recruiter-screenshots.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; + +const OUT = "../artifacts/evidence/screenshots"; + +test("capture current recruiter-facing evidence", 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.getByRole("heading", { name: "Elke overdracht. Eén helder overzicht." })).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/1-login.png`, fullPage: true }); + + await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click(); + await expect(page).toHaveURL(/\/highlights$/); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/2-highlights.png`, fullPage: true }); + + await page.goto("/about"); + await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/3-engineering-story.png`, fullPage: true }); + + await page.goto("/dashboard"); + await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/4-dashboard.png`, fullPage: true }); + + await page.goto("/knowledge"); + 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({ timeout: 15_000 }); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/5-knowledge-evidence.png`, fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/highlights"); + await expect(page.locator(".highlight-card").first()).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/6-highlights-mobile.png` }); +}); diff --git a/frontend/e2e/_capture-screenshots.spec.ts b/frontend/e2e/_capture-screenshots.spec.ts new file mode 100644 index 0000000..d3f0558 --- /dev/null +++ b/frontend/e2e/_capture-screenshots.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +// One-off tooling to capture evidence screenshots for artifacts/evidence/final-summary.md. +// Not part of the regular test suite (prefixed with `_` and excluded from CI runs). + +const OUT = "../artifacts/evidence/screenshots"; + +test("capture the seven main pages", async ({ page, request }) => { + await request.post("http://localhost:8128/api/v1/demo/login", { + data: { role: "operations_manager" }, + }); + await request.post("http://localhost:8128/api/v1/demo/reset"); + + await page.goto("/login"); + await page.screenshot({ path: `${OUT}/1-login.png` }); + + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); + await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true }); + + await page.goto("/vehicles"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/3-vehicles.png`, fullPage: true }); + + await page.goto("/bookings"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/4-bookings.png`, fullPage: true }); + + await page.goto("/data-quality"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/5-data-quality.png`, fullPage: true }); + + await page.goto("/knowledge"); + await page + .getByPlaceholder(/What must I do when a vehicle returns with damage/) + .fill("What must I do when a vehicle returns with damage?"); + await page.getByRole("button", { name: "Ask" }).click(); + await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); + await page.screenshot({ path: `${OUT}/6-knowledge.png`, fullPage: true }); + + await page.goto("/automation"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/7-automation.png`, fullPage: true }); + + await page.goto("/audit"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.screenshot({ path: `${OUT}/8-audit.png`, fullPage: true }); + + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto("/dashboard"); + await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); + await page.screenshot({ path: `${OUT}/9-mobile-dashboard.png` }); +}); diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts new file mode 100644 index 0000000..90b08d5 --- /dev/null +++ b/frontend/e2e/accessibility.spec.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +import { expect, test } from "@playwright/test"; + +test.use({ bypassCSP: true, reducedMotion: "reduce" }); + +const require = createRequire(import.meta.url); +const axeSource = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8"); + +type AxeViolation = { + id: string; + impact: string | null; + help: string; + nodes: Array<{ target: string[] }>; +}; + +async function seriousViolations(page: import("@playwright/test").Page): Promise { + await page.addStyleTag({ + content: "*,*::before,*::after{animation:none!important;transition:none!important}", + }); + await page.addScriptTag({ content: axeSource }); + return page.evaluate(async () => { + const axe = (window as Window & { + axe: { + run: ( + context: Document, + options: object, + ) => Promise<{ violations: AxeViolation[] }>; + }; + }).axe; + const result = await axe.run(document, { + runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] }, + }); + return result.violations.filter( + (violation) => violation.impact === "critical" || violation.impact === "serious", + ); + }); +} + +test("principal routes have no serious automated accessibility violations", async ({ page }) => { + await page.goto("/login"); + expect(await seriousViolations(page)).toEqual([]); + + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + for (const route of ["/dashboard", "/data-quality", "/knowledge", "/automation", "/audit"] as const) { + await page.goto(route); + const violations = await seriousViolations(page); + expect(violations, `${route}: ${JSON.stringify(violations)}`).toEqual([]); + } +}); diff --git a/frontend/e2e/clickable-rows.spec.ts b/frontend/e2e/clickable-rows.spec.ts new file mode 100644 index 0000000..739ee12 --- /dev/null +++ b/frontend/e2e/clickable-rows.spec.ts @@ -0,0 +1,112 @@ +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 link = row.locator(".row-link"); + const [rowBox, linkBox] = await Promise.all([row.boundingBox(), link.boundingBox()]); + expect(rowBox).not.toBeNull(); + expect(linkBox).not.toBeNull(); + // The stretched link itself must cover the whole mobile row. Locator.click handles + // scrolling and fixed mobile chrome before clicking the link's empty right-hand area. + expect(Math.abs(linkBox!.width - rowBox!.width)).toBeLessThanOrEqual(1); + expect(Math.abs(linkBox!.height - rowBox!.height)).toBeLessThanOrEqual(1); + // 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 link.click({ position: { x: linkBox!.width - 10, y: linkBox!.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(); +}); diff --git a/frontend/e2e/context-resilience.spec.ts b/frontend/e2e/context-resilience.spec.ts new file mode 100644 index 0000000..73f8c00 --- /dev/null +++ b/frontend/e2e/context-resilience.spec.ts @@ -0,0 +1,250 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { AutomationRun } from "../src/api/types"; + +const apiErrorBody = JSON.stringify({ + error: { code: "TEST_UNAVAILABLE", message: "Injected test failure", correlation_id: "test-correlation" }, +}); + +async function loginAsManager(page: Page) { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +async function failFirstManifestRequest(page: Page) { + let requestCount = 0; + await page.route("**/api/v1/demo/manifest", async (route) => { + requestCount += 1; + if (requestCount === 1) { + await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody }); + return; + } + await route.continue(); + }); +} + +test("session validation does not wait for a stalled system-status request", async ({ page }) => { + let releaseStatus = () => undefined; + const statusGate = new Promise((resolve) => { + releaseStatus = resolve; + }); + let sessionRequested = false; + + await page.route("**/api/v1/system/status", async (route) => { + await statusGate; + await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody }); + }); + await page.route("**/api/v1/auth/session", async (route) => { + sessionRequested = true; + await route.fulfill({ status: 401, contentType: "application/json", body: apiErrorBody }); + }); + + try { + await page.goto("/login"); + await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeEnabled({ + timeout: 2_000, + }); + expect(sessionRequested).toBe(true); + } finally { + releaseStatus(); + } +}); + +test("guided demo stays gated while its manifest loads, then exposes an explicit retry", async ({ page }) => { + let releaseManifest = () => undefined; + const manifestGate = new Promise((resolve) => { + releaseManifest = resolve; + }); + let markManifestRequested = () => undefined; + const manifestRequested = new Promise((resolve) => { + markManifestRequested = resolve; + }); + let manifestAttempts = 0; + + await page.route("**/api/v1/demo/manifest", async (route) => { + manifestAttempts += 1; + if (manifestAttempts === 1) { + markManifestRequested(); + await manifestGate; + await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody }); + return; + } + await route.continue(); + }); + + try { + await page.goto("/login"); + await manifestRequested; + const guidedDemo = page.getByRole("button", { name: "Start begeleide demo" }); + await expect(guidedDemo).toBeDisabled(); + await expect(page.getByText("Begeleide demo voorbereiden…")).toBeVisible(); + + releaseManifest(); + await expect(page.getByText("De begeleide demo-informatie kon niet geladen worden.")).toBeVisible(); + await expect(guidedDemo).toBeDisabled(); + await page.getByRole("button", { name: "Demo-informatie opnieuw laden" }).click(); + + await expect(guidedDemo).toBeEnabled(); + expect(manifestAttempts).toBeGreaterThanOrEqual(2); + } finally { + releaseManifest(); + } +}); + +test("guided demo does not continue until the manifest reports it ready", async ({ page }) => { + let returnNotReady = true; + await page.route("**/api/v1/demo/manifest", async (route) => { + if (!returnNotReady) { + await route.continue(); + return; + } + const response = await route.fetch(); + const manifest = await response.json() as Record; + await route.fulfill({ response, json: { ...manifest, guide_available: false } }); + }); + + await page.goto("/login"); + const guidedDemo = page.getByRole("button", { name: "Start begeleide demo" }); + await expect(guidedDemo).toBeDisabled(); + await expect(page.getByText("De begeleide demo is momenteel niet klaar om te starten.")).toBeVisible(); + await guidedDemo.evaluate((button) => { + button.removeAttribute("disabled"); + (button as HTMLButtonElement).click(); + }); + await expect(page).toHaveURL(/\/login$/); + + returnNotReady = false; + await page.getByRole("button", { name: "Demo-informatie opnieuw laden" }).click(); + await expect(guidedDemo).toBeEnabled(); +}); + +test("blocked sessionStorage cannot break login or logout", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperties(Storage.prototype, { + setItem: { + configurable: true, + value: () => { + throw new DOMException("Storage is blocked", "SecurityError"); + }, + }, + removeItem: { + configurable: true, + value: () => { + throw new DOMException("Storage is blocked", "SecurityError"); + }, + }, + }); + }); + + await loginAsManager(page); + await expect(page.getByText("Amelie De Ridder").first()).toBeVisible(); + + await page.locator(".operator-menu > summary").click(); + await page.getByRole("button", { name: "Wissel van rol" }).click(); + await expect(page).toHaveURL(/\/login$/); +}); + +test("scenario manifest failure is visible and retryable", async ({ page }) => { + await failFirstManifestRequest(page); + await loginAsManager(page); + + await page.locator('a[href="/scenarios"]').first().click(); + await expect(page).toHaveURL(/\/scenarios$/); + await expect(page.getByText("De demonstratiescenario's konden niet geladen worden.")).toBeVisible(); + + await page.getByRole("button", { name: "Opnieuw proberen" }).click(); + await expect(page.locator(".scenario-card")).toHaveCount(5); +}); + +test("engineering manifest failure is visible and retryable", async ({ page }) => { + await failFirstManifestRequest(page); + await loginAsManager(page); + + await page.locator('a[href="/about"]').first().click(); + await expect(page).toHaveURL(/\/about$/); + await expect(page.getByText("De demo-informatie kon niet geladen worden.")).toBeVisible(); + + await page.getByRole("button", { name: "Opnieuw proberen" }).click(); + await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" })).toBeVisible(); +}); + +test("the latest automation filter wins when an older response completes last", async ({ page }) => { + test.setTimeout(45_000); + let releaseOldResponse = () => undefined; + const oldResponseGate = new Promise((resolve) => { + releaseOldResponse = resolve; + }); + let markOldRequestStarted = () => undefined; + const oldRequestStarted = new Promise((resolve) => { + markOldRequestStarted = resolve; + }); + let markOldResponseAttempted = () => undefined; + const oldResponseAttempted = new Promise((resolve) => { + markOldResponseAttempted = resolve; + }); + + const run = (status: "failed" | "pending", aggregateRef: string): AutomationRun => ({ + event_id: status === "failed" + ? "11111111-1111-4111-8111-111111111111" + : "22222222-2222-4222-8222-222222222222", + event_type: "vehicle.returned.v1", + aggregate_ref: aggregateRef, + status, + attempts: status === "failed" ? 2 : 0, + last_error: null, + last_error_code: null, + is_demo_scenario: false, + occurred_at: "2026-08-23T10:00:00Z", + }); + + await page.route(/\/api\/v1\/workflows(?:\?.*)?$/, async (route) => { + const requestedStatus = new URL(route.request().url()).searchParams.get("status"); + if (requestedStatus === "failed") { + markOldRequestStarted(); + await oldResponseGate; + try { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([run("failed", "BK-RACE-OLD")]), + }); + } catch { + // Chromium cancels the superseded fetch. The delayed route can therefore be + // closed before Playwright gets to fulfil it; either outcome proves the same + // contract as long as it cannot overwrite the newer response. + } finally { + markOldResponseAttempted(); + } + return; + } + if (requestedStatus === "pending") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([run("pending", "BK-RACE-NEW")]), + }); + return; + } + await route.continue(); + }); + + try { + await loginAsManager(page); + await page.goto("/automation"); + await expect(page.getByRole("heading", { name: "Automatiseringsopdrachten" })).toBeVisible(); + + const statusFilter = page.getByRole("combobox", { name: "Status", exact: true }); + await statusFilter.selectOption("failed"); + await oldRequestStarted; + await statusFilter.selectOption("pending"); + + await expect(page.getByText("BK-RACE-NEW", { exact: true })).toBeVisible(); + releaseOldResponse(); + await oldResponseAttempted; + + await expect(page.getByText("BK-RACE-NEW", { exact: true })).toBeVisible(); + await expect(page.getByText("BK-RACE-OLD", { exact: true })).toHaveCount(0); + } finally { + releaseOldResponse(); + } +}); diff --git a/frontend/e2e/demo-accessibility.spec.ts b/frontend/e2e/demo-accessibility.spec.ts new file mode 100644 index 0000000..b3536ae --- /dev/null +++ b/frontend/e2e/demo-accessibility.spec.ts @@ -0,0 +1,144 @@ +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 Operationsmanager" }).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 Operationsmanager" }).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([]); +}); + +test("mobile quality workbench keeps filters compact and bulk actions reachable", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/data-quality?status=open"); + + const filterButton = page.getByRole("button", { name: /Filters/ }); + await expect(filterButton).toBeVisible(); + await expect(page.locator("#quality-filters")).toBeHidden(); + await filterButton.click(); + await expect(page.locator("#quality-filters")).toBeVisible(); + + await page.getByRole("button", { name: "Zichtbare problemen selecteren" }).click(); + await expect(page.getByRole("region", { name: "Geselecteerde werkvoorraad bijwerken" })).toBeVisible(); + const checked = await page.locator(".quality-table tbody input[type=checkbox]:checked").count(); + expect(checked).toBeGreaterThan(1); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth + 1), + ); +}); + +test("status-recommendation panel is fully keyboard operable, respects reduced motion, and never signals status by colour alone", async ({ + page, + request, +}) => { + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + await request.post("/api/v1/demo/reset"); + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/data-quality/DQ-DEMO-STATUS"); + + const reviewButton = page.getByRole("button", { name: "Aanbeveling bekijken" }); + await reviewButton.focus(); + await expect(reviewButton).toBeFocused(); + await page.keyboard.press("Enter"); + + const confirmButton = page.getByRole("button", { name: /^Status wijzigen naar/ }); + await expect(confirmButton).toBeVisible(); + + // Status is never conveyed by colour alone: the badge always carries its own text. + const badge = page.locator(".status-decision .badge").first(); + await expect(badge).not.toHaveText(""); + + // The confirm action itself is a real, focusable, keyboard-activatable button (the + // previous "Aanbeveling bekijken" button is unmounted once the decision panel + // replaces it, so focus is verified directly rather than via a Tab chain from it). + await confirmButton.focus(); + await expect(confirmButton).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByText("Toegepast", { exact: false })).toBeVisible(); +}); diff --git a/frontend/e2e/demo-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts new file mode 100644 index 0000000..0af6b33 --- /dev/null +++ b/frontend/e2e/demo-entry.spec.ts @@ -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 · geen echte klant-/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).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 Operationsmanager" }).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 Operationsmanager" }).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(); +}); diff --git a/frontend/e2e/demo-guide.spec.ts b/frontend/e2e/demo-guide.spec.ts new file mode 100644 index 0000000..cffd91d --- /dev/null +++ b/frontend/e2e/demo-guide.spec.ts @@ -0,0 +1,260 @@ +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 Operationsmanager" }).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 Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/scenarios"); + + const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" }); + await duplicateCard.getByRole("link", { name: "Scenario starten" }).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(); + await expect(page.locator(".demo-guide-trigger")).toBeFocused(); +}); + +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 Verhuurmedewerker" }).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("sidebar reset preserves guide progress on failure and clears it only after success", async ({ page }) => { + let resetShouldFail = true; + await page.route("**/api/v1/demo/reset", async (route) => { + if (resetShouldFail) { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "TEST_UNAVAILABLE", message: "Injected reset failure", correlation_id: "reset-test" }, + }), + }); + return; + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "reset" }) }); + }); + + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await page.getByRole("button", { name: "Volgende" }).click(); + await page.locator(".demo-guide-trigger").click(); + await expect(page.locator(".demo-guide-trigger")).toContainText("1/8"); + + await page.getByRole("button", { name: "Demogegevens herstellen" }).click(); + await page.getByRole("button", { name: "Ja, herstellen" }).click(); + await expect(page.getByText("Demogegevens konden niet hersteld worden.")).toBeVisible(); + await expect(page.locator(".demo-guide-trigger")).toContainText("1/8"); + + resetShouldFail = false; + await page.getByRole("button", { name: "Demogegevens herstellen" }).click(); + await page.getByRole("button", { name: "Ja, herstellen" }).click(); + await expect(page).toHaveURL(/\/login$/); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page.locator(".demo-guide-trigger")).toContainText("0/8"); +}); + +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(); + await expect(page.locator(".demo-guide-trigger")).toBeFocused(); +}); + +test("closing the guide cancels a delayed target lookup and restores trigger focus", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 1000 }); + let releaseKnowledgeChunk = () => undefined; + const knowledgeChunkGate = new Promise((resolve) => { + releaseKnowledgeChunk = resolve; + }); + let markKnowledgeChunkRequested = () => undefined; + const knowledgeChunkRequested = new Promise((resolve) => { + markKnowledgeChunkRequested = resolve; + }); + + await page.route( + /\/(?:assets\/Knowledge-[^/?]+\.js|src\/pages\/Knowledge\.tsx)(?:\?.*)?$/, + async (route) => { + markKnowledgeChunkRequested(); + await knowledgeChunkGate; + await route.continue(); + }, + ); + + try { + 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 knowledgeChunkRequested; + + await page + .locator(".demo-guide-panel.is-wide") + .getByRole("button", { name: "Sluiten" }) + .click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); + await expect(page.locator(".demo-guide-trigger")).toBeFocused(); + + releaseKnowledgeChunk(); + const target = page.locator("#ask-heading"); + await expect(target).toBeVisible(); + await page.waitForTimeout(150); + await expect(target).not.toBeFocused(); + await expect(target).not.toHaveClass(/demo-guide-highlight/); + } finally { + releaseKnowledgeChunk(); + } +}); + +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/); +}); + +test("going to a guide target on the already-active route still focuses and highlights it", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 1000 }); + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await page.getByRole("button", { name: /8\. Bekijk wat echt is/ }).click(); + await page.goto("/about"); + await page.getByRole("button", { name: /^Demo-gids/ }).first().click(); + + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + + const target = page.locator(".engineering-hero"); + await expect(target).toBeFocused(); + await expect(target).toHaveClass(/demo-guide-highlight/); +}); diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts new file mode 100644 index 0000000..c477295 --- /dev/null +++ b/frontend/e2e/demo-legibility.spec.ts @@ -0,0 +1,84 @@ +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 Operationsmanager" }).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: "Volledige verwerking volgen" })).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 Operationsmanager" }).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 Operationsmanager" }).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(); + // The URL-driven filter update can replace the controlled checkbox immediately after + // its click. Assert against the newly rendered control instead of asking `check()` to + // verify the detached pre-navigation node. + await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).click(); + await expect(page).toHaveURL(/demo=true/); + await expect(page.getByRole("checkbox", { name: "Enkel demoscenario's" })).toBeChecked(); + 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 Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/knowledge"); + + // The status badge must name the actual active provider once its check settles. + await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/); + + await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click(); + await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 }); + const sources = page.locator(".exchange").first().locator(".source-card"); + expect(await sources.count()).toBeLessThanOrEqual(3); + const titles = await sources.locator(".source-title").allTextContents(); + expect(new Set(titles.map((title) => title.trim().toLocaleLowerCase())).size).toBe(titles.length); + await page.getByRole("button", { name: "Ja" }).first().click(); + await expect(page.getByText("Feedback opgeslagen")).toBeVisible(); +}); diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts new file mode 100644 index 0000000..39bf0fd --- /dev/null +++ b/frontend/e2e/demo.spec.ts @@ -0,0 +1,159 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; +import type { + AuditEvent, + AutomationRun, + DataQualityIssueDetail, + RegisterReturnResult, +} from "../src/api/types"; + +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("five-minute demo script end to end", async ({ page, request }) => { + const proof: { returned?: RegisterReturnResult } = {}; + + // Reset via a throwaway API session so the UI test starts from the deterministic seed + // regardless of what earlier test runs mutated (S1 return, S2 merge, etc.). + await resetDemoData(request); + + await test.step("1. login as Operations Manager", async () => { + await page.goto("/login"); + await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + }); + + await test.step("2. verify dashboard metrics are loaded", async () => { + await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible(); + const metricValues = page.locator(".metric-cell dd"); + await expect(metricValues.first()).toBeVisible(); + const values = await metricValues.allTextContents(); + expect(values.length).toBeGreaterThan(0); + expect(values.some((v) => Number(v) > 0)).toBeTruthy(); + }); + + await test.step("3. open active demo booking", async () => { + await page.goto("/bookings/BK-DEMO-RETURN"); + await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible(); + await expect(page.getByText("actief", { exact: true })).toBeVisible(); + }); + + await test.step("4. register an odometer-regression return (S1)", async () => { + const vehicleOdometerText = await page + .locator(".detail-grid div", { hasText: "Startkilometerstand" }) + .locator("dd") + .textContent(); + const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10); + const lowReading = Math.max(0, startOdometer - 500); + + await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading)); + await page.getByLabel("Brandstofniveau (%)").fill("55"); + await page.getByRole("button", { name: "Retour nakijken" }).click(); + await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible(); + + const returnResponsePromise = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().endsWith("/api/v1/bookings/BK-DEMO-RETURN/return"), + ); + await page.getByRole("button", { name: "Retour bevestigen" }).click(); + const returnResponse = await returnResponsePromise; + expect(returnResponse.status()).toBe(201); + proof.returned = (await returnResponse.json()) as RegisterReturnResult; + + await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible(); + }); + + await test.step("5. verify the persisted quality issue, outbox event and audit trace", async () => { + const returned = proof.returned; + if (!returned) throw new Error("The return API response was not captured"); + + expect(returned.odometer_regression).toBe(true); + expect(returned.quality_issue_ref).toMatch(/^DQ-RET-/); + const qualityIssueRef = returned.quality_issue_ref; + if (!qualityIssueRef) throw new Error("The regression return did not create a quality issue"); + + await expect(page.getByRole("link", { name: qualityIssueRef })).toBeVisible(); + await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible(); + + // Use the page-bound request context: the throwaway reset session above is + // deliberately invalidated by reset, while this context shares the manager login + // cookie established in step 1. + const issueResponse = await page.request.get(`/api/v1/data-quality/issues/${qualityIssueRef}`); + expect(issueResponse.ok()).toBeTruthy(); + const issue = (await issueResponse.json()) as DataQualityIssueDetail; + expect(issue.public_ref).toBe(qualityIssueRef); + expect(issue.rule_type).toBe("odometer_regression"); + expect(issue.status).toBe("open"); + expect(issue.entity_ref).toBe(returned.vehicle_ref); + + const workflowsResponse = await page.request.get("/api/v1/workflows"); + expect(workflowsResponse.ok()).toBeTruthy(); + const workflows = (await workflowsResponse.json()) as AutomationRun[]; + const workflow = workflows.find((run) => run.event_id === returned.workflow_event_id); + expect(workflow).toBeDefined(); + expect(workflow).toMatchObject({ + event_type: "vehicle.returned.v1", + aggregate_ref: returned.booking_ref, + }); + + const auditResponse = await page.request.get( + `/api/v1/audit?correlation_id=${encodeURIComponent(returned.correlation_id)}`, + ); + expect(auditResponse.ok()).toBeTruthy(); + const trace = (await auditResponse.json()) as AuditEvent[]; + expect(trace.map((event) => event.action)).toEqual( + expect.arrayContaining([ + "data_quality_issue_created", + "return_registered", + "vehicle_status_changed", + ]), + ); + expect(trace.every((event) => event.correlation_id === returned.correlation_id)).toBe(true); + }); + + await test.step("6. resolve the duplicate customer scenario (S2)", async () => { + await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); + await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible(); + await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click(); + await page.getByRole("button", { name: "Ja, samenvoegen" }).click(); + await expect(page.locator(".badge.status-resolved")).toBeVisible(); + }); + + await test.step("7. ask the damage question and inspect citations (S6)", async () => { + await page.goto("/knowledge"); + await page + .getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/) + .fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?"); + await page.getByRole("button", { name: "Vraag stellen" }).click(); + await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 }); + const damageSource = page.locator(".source-card", { hasText: /zichtbare of gemelde schade/i }).first(); + await expect(damageSource).toBeVisible(); + await expect(damageSource).toContainText(/zichtbare of gemelde schade/i); + }); + + await test.step("8. inspect audit entries", async () => { + await page.goto("/audit"); + await page.getByLabel("Actie").fill("return_registered"); + await expect(page.locator(".audit-group-list li").first()).toBeVisible(); + await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible(); + }); + + await test.step("9. verify responsive navigation at mobile width", async () => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto("/dashboard"); + await expect(page.getByRole("button", { name: /Synthetische demo/ })).toBeVisible(); + await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible(); + const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); + const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); + expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); + }); +}); diff --git a/frontend/e2e/error-messages.spec.ts b/frontend/e2e/error-messages.spec.ts new file mode 100644 index 0000000..4de6df1 --- /dev/null +++ b/frontend/e2e/error-messages.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ApiError } from "../src/api/apiError"; +import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages"; + +// Pure Node-context checks for the central API-error-localization function (section 7 / +// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError() +// only depends on a `t` function and a caught error, so it's tested here against the +// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and +// known-HTTP-status paths never leak raw backend English as the primary message, and +// that the raw text is always still available via `.technical` for "Technical details". + +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"] as const; + +function loadNamespace(language: string, namespace: string): Record { + const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`); + return JSON.parse(fs.readFileSync(filePath, "utf-8")); +} + +// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes, +// resolving "namespace:dotted.path" against the real locale files for the given language. +function makeT(language: string): (key: string, options?: Record) => string { + return (key: string, options?: Record) => { + const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key]; + const dottedPath = key.includes(":") ? rest.join(":") : rest.join(""); + const data = loadNamespace(language, ns); + const value = dottedPath.split(".").reduce((acc, part) => { + if (acc && typeof acc === "object") return (acc as Record)[part]; + return undefined; + }, data); + if (typeof value === "string") return value; + if (options && "defaultValue" in options) return String(options.defaultValue); + return key; + }; +} + +const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"]; + +test("every known AppError code has a non-empty title+explanation in all 3 locales", () => { + for (const language of LANGUAGES) { + const codes = loadNamespace(language, "errors").codes as Record; + for (const code of KNOWN_CODES) { + expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy(); + expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0); + expect( + codes[code]?.explanation?.trim().length ?? 0, + `${language}/errors.json:codes.${code}.explanation is empty`, + ).toBeGreaterThan(0); + } + } +}); + +test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => { + for (const language of LANGUAGES) { + const http = loadNamespace(language, "errors").http as Record; + for (const status of KNOWN_HTTP_STATUSES) { + expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy(); + expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0); + expect( + http[status]?.explanation?.trim().length ?? 0, + `${language}/errors.json:http.${status}.explanation is empty`, + ).toBeGreaterThan(0); + } + } +}); + +test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "IntegrityError: duplicate key value violates unique constraint"; + const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1"); + const info = describeApiError(t, err); + const expected = loadNamespace(language, "errors").codes as Record; + expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title); + expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation); + expect(info.title).not.toBe(raw); + expect(info.explanation).not.toBe(raw); + // The raw backend text must still be reachable, just demoted to `.technical`. + expect(info.technical).toBe(raw); + } +}); + +test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => { + const t = makeT("nl-BE"); + const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1")); + expect(withNextStep.nextStep).toBeTruthy(); + + const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2")); + expect(withoutNextStep.nextStep).toBeUndefined(); +}); + +test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "Some brand-new backend code nobody localized yet"; + const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2"); + const info = describeApiError(t, err); + const expected404 = (loadNamespace(language, "errors").http as Record)["404"]; + expect(info.title).toBe(expected404.title); + expect(info.explanation).toBe(expected404.explanation); + expect(info.title).not.toBe(raw); + expect(info.technical).toBe(raw); + } +}); + +test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "418 I'm a teapot (never mapped)"; + const err = new ApiError(418, "418", raw, "corr-3"); + const info = describeApiError(t, err); + const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string }; + expect(info.title).toBe(generic.title); + expect(info.explanation).toBe(generic.explanation); + expect(info.technical).toBe(raw); + } +}); + +test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => { + // Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code) + // (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py. + const t = makeT("fr-BE"); + const err = new ApiError(401, "401", "Not authenticated", "corr-4"); + const info = describeApiError(t, err); + const expected401 = (loadNamespace("fr-BE", "errors").http as Record)["401"]; + expect(info.title).toBe(expected401.title); + expect(info.title).not.toBe("Not authenticated"); +}); + +test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => { + const t = makeT("nl-BE"); + const networkFailure = new TypeError("Failed to fetch"); + // Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace + // already scoped via useTranslation("integrations"); this stub's default namespace is + // "errors", so the fallback key is qualified explicitly here to match. + const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed"); + const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record; + expect(info.explanation).toBe(expectedFallback.retryFailed); + expect(info.explanation).not.toBe("Failed to fetch"); + expect(info.technical).toBe("Failed to fetch"); +}); + +// --- Backend/frontend AppError code drift guard --- +// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the +// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a +// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but- +// still-localized HTTP/generic message rather than raw English -- not a broken build, but +// a missed opportunity for a more specific message. This test surfaces that drift instead +// of letting it go unnoticed indefinitely. +const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app"); + +function collectPyFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return collectPyFiles(full); + return entry.name.endsWith(".py") ? [full] : []; + }); +} + +function collectBackendAppErrorCodes(): Set { + const codes = new Set(); + for (const file of collectPyFiles(BACKEND_APP_DIR)) { + const source = fs.readFileSync(file, "utf-8"); + const pattern = /AppError\(\s*"([A-Z_]+)"/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + codes.add(match[1]); + } + } + return codes; +} + +test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => { + const backendCodes = collectBackendAppErrorCodes(); + const frontendCodes = KNOWN_CODES; + + const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort(); + const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort(); + + expect( + missingFromFrontend, + `Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` + + `(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`, + ).toEqual([]); + expect( + staleInFrontend, + `errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` + + `removed on the backend side: ${staleInFrontend.join(", ")}`, + ).toEqual([]); +}); + +test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const info = describeApiError(t, new TypeError("Failed to fetch")); + const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string }; + expect(info.title).toBe(generic.title); + expect(info.explanation).toBe(generic.explanation); + } +}); diff --git a/frontend/e2e/fleet-ops-correction.spec.ts b/frontend/e2e/fleet-ops-correction.spec.ts new file mode 100644 index 0000000..02d08ee --- /dev/null +++ b/frontend/e2e/fleet-ops-correction.spec.ts @@ -0,0 +1,399 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +// Targeted end-to-end coverage for the Fleet Ops correction brief (docs/fleet-ops- +// correction/): branding, the redesigned status-recommendation flow (preview/apply/ +// manual-review/stale-token), MO-016 order independence, trilingual knowledge +// grounding, and localized audit/automation content. See also i18n-coverage.spec.ts +// (key parity, brand invariant, translation-quality) and responsive-i18n.spec.ts +// (breakpoint matrix) for the complementary static-content checks. + +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(); + // /api/v1/demo/reset deletes the session cookie (it recreates the users table), so any + // further authenticated call through this same request context needs a fresh login. + const relogin = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + expect(relogin.ok()).toBeTruthy(); +} + +const EXPLORE_OPS_MANAGER: Record = { + "nl-BE": "Verken als Operationsmanager", + "en-GB": "Explore as Operations Manager", + "fr-BE": "Explorer en tant que Responsable des opérations", +}; + +const REVIEW_RECOMMENDATION: Record = { + "nl-BE": "Aanbeveling bekijken", + "en-GB": "Review recommendation", + "fr-BE": "Voir la recommandation", +}; + +const CHANGE_STATUS_PREFIX: Record = { + "nl-BE": /^Status wijzigen naar/, + "en-GB": /^Change status to/, + "fr-BE": /^Changer le statut vers/, +}; + +const MANUAL_REVIEW_HEADING: Record = { + "nl-BE": "Handmatige beoordeling vereist", + "en-GB": "Manual review required", + "fr-BE": "Évaluation manuelle requise", +}; + +async function loginAsOpsManager(page: Page, lang: string) { + await page.addInitScript((l) => localStorage.setItem("fleetops.language", l), lang); + await page.goto("/login"); + await page.getByRole("button", { name: EXPLORE_OPS_MANAGER[lang] }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +test.describe.configure({ mode: "serial" }); + +test.describe("branding", () => { + for (const lang of ["nl-BE", "en-GB", "fr-BE"]) { + test(`Fleet Ops is the visible brand and no MobilityOps/PoC leaks through (${lang})`, async ({ + page, + request, + }) => { + await resetDemoData(request); + await loginAsOpsManager(page, lang); + await expect(page.locator(".brand-mark").first()).toBeVisible(); + await expect(page.getByText("Fleet Ops", { exact: true }).first()).toBeVisible(); + await expect(page.locator(".app-footer")).toContainText("Fleet Ops"); + await expect(page.locator("html")).toHaveAttribute("lang", lang); + + for (const path of ["/dashboard", "/vehicles", "/data-quality", "/audit", "/automation", "/knowledge"]) { + await page.goto(path); + const text = await page.locator("body").innerText(); + expect(text, `${path} (${lang})`).not.toContain("MobilityOps"); + expect(text, `${path} (${lang})`).not.toMatch(/\bPoC\b/); + } + }); + } +}); + +test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => { + await resetDemoData(request); + await page.goto("/login"); + const href = await page.locator('link[rel="icon"]').getAttribute("href"); + expect(href).toBe("/favicon.svg"); + const response = await page.request.get(href as string); + expect(response.ok()).toBeTruthy(); + expect(response.headers()["content-type"]).toContain("svg"); +}); + +test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => { + await resetDemoData(request); + // Deliberately not using loginAsOpsManager here: its addInitScript would re-force + // nl-BE on every reload, defeating exactly the persistence behaviour under test. + await page.goto("/login"); + await page.getByRole("button", { name: EXPLORE_OPS_MANAGER["nl-BE"] }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole("heading", { name: "Aandachtspunten" })).toBeVisible(); + + await page.getByRole("combobox", { name: "Taal" }).selectOption("fr-BE"); + await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE"); + + await page.reload(); + await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE"); +}); + +test.describe("status-recommendation flow", () => { + test("preview does not mutate anything, apply names the exact target status", async ({ page, request }) => { + await resetDemoData(request); + await loginAsOpsManager(page, "nl-BE"); + await page.goto("/data-quality/DQ-DEMO-STATUS"); + await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click(); + await expect(page.getByText("Aanbevolen status")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Waarom" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Gevolg" })).toBeVisible(); + + // Previewing must not have resolved the issue -- still open, using the page's own + // authenticated session (page.request shares cookies with the browser context). + const issue = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS"); + expect((await issue.json()).status).toBe("open"); + + const confirmButton = page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }); + await expect(confirmButton).toHaveText(/Geblokkeerd/); + await confirmButton.click(); + await expect(page.getByText("Toegepast", { exact: false })).toBeVisible(); + + const resolved = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS"); + expect((await resolved.json()).status).toBe("resolved"); + }); + + test("manual review state offers no generic apply button for a genuine fact contradiction", async ({ + page, + request, + }) => { + await resetDemoData(request); + const issues = await ( + await request.get("/api/v1/data-quality/issues", { + params: { rule_type: "vehicle_status_conflict", status: "open" }, + }) + ).json(); + const conflicted = issues.find((i: { entity_ref: string }) => i.entity_ref === "MO-024"); + expect(conflicted, "expected MO-024's vehicle_status_conflict issue to exist after reset").toBeTruthy(); + + await loginAsOpsManager(page, "en-GB"); + await page.goto(`/data-quality/${conflicted.public_ref}`); + await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click(); + + await expect(page.getByRole("heading", { name: MANUAL_REVIEW_HEADING["en-GB"] })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Change status to/ })).toHaveCount(0); + }); + + test("a stale recommendation is rejected and the user must review again before applying", async ({ + page, + request, + }) => { + await resetDemoData(request); + await loginAsOpsManager(page, "en-GB"); + await page.goto("/data-quality/DQ-DEMO-STATUS"); + await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click(); + await expect(page.getByRole("button", { name: /^Change status to/ })).toBeVisible(); + + // Simulate the underlying facts changing after the preview was shown (the same + // session resolves the booking overlap in the meantime) -- the previously-fetched + // recommendation token must no longer be accepted. + await page.evaluate(async () => { + await fetch("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ booking_ref: "BK-DEMO-OVERLAP-B" }), + }); + }); + + await page.getByRole("button", { name: /^Change status to/ }).click(); + await expect(page.getByText(/situation has changed/i)).toBeVisible(); + await expect(page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] })).toBeVisible(); + }); +}); + +test.describe("MO-016 status conflict is order-independent", () => { + // Order independence does NOT mean "the same final vehicle status regardless of + // order" -- resolving the booking overlap first genuinely removes the conflict, so + // there is correctly nothing left to apply afterwards. What must hold in either + // order: the recommendation always reflects the real, current facts (never a stale + // "was some other issue open" proxy), and nothing unsafe is ever applied (never + // "rented"). + + test("resolving the booking overlap first correctly leaves nothing to apply", async ({ page, request }) => { + await resetDemoData(request); + await loginAsOpsManager(page, "nl-BE"); + await page.goto("/data-quality/DQ-DEMO-OVERLAP"); + await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check(); + await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click(); + await expect(page.getByText("Opgelost").first()).toBeVisible(); + + await page.goto("/data-quality/DQ-DEMO-STATUS"); + await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click(); + await expect(page.getByRole("heading", { name: "Geen wijziging nodig" })).toBeVisible(); + await expect(page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] })).toHaveCount(0); + + const vehicle = await request.get("/api/v1/vehicles/MO-016"); + expect((await vehicle.json()).operational_status).toBe("available"); + }); + + test("resolving the status conflict first safely blocks the vehicle, unaffected by the later overlap fix", async ({ + page, + request, + }) => { + await resetDemoData(request); + await loginAsOpsManager(page, "nl-BE"); + await page.goto("/data-quality/DQ-DEMO-STATUS"); + await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click(); + await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click(); + await expect(page.getByText("Toegepast", { exact: false })).toBeVisible(); + + const vehicleMid = await request.get("/api/v1/vehicles/MO-016"); + const statusAfterApply = (await vehicleMid.json()).operational_status; + expect(statusAfterApply).not.toBe("rented"); + + await page.goto("/data-quality/DQ-DEMO-OVERLAP"); + await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check(); + await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click(); + await expect(page.getByText("Opgelost").first()).toBeVisible(); + + // Resolving the now-redundant overlap afterwards must not itself change the + // vehicle's status as a side effect. + const vehicleFinal = await request.get("/api/v1/vehicles/MO-016"); + const statusFinal = (await vehicleFinal.json()).operational_status; + expect(statusFinal).toBe(statusAfterApply); + expect(statusFinal).not.toBe("rented"); + + await resetDemoData(request); + }); +}); + +test.describe("knowledge base is grounded in the operator's own language", () => { + const cases: { lang: string; question: string; sourceHint: RegExp; sourceTitle: RegExp }[] = [ + { + lang: "nl-BE", + question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?", + sourceHint: /zichtbare of gemelde schade/i, + sourceTitle: /Procedure schadeafhandeling|Schade bij voertuigretour/i, + }, + { + lang: "en-GB", + question: "What should I do when a vehicle returns with damage?", + sourceHint: /visible or reported damage/i, + sourceTitle: /Damage handling procedure|Vehicle return damage/i, + }, + { + lang: "fr-BE", + question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?", + sourceHint: /dommages visibles ou signalés/i, + sourceTitle: /Procédure de gestion des dommages|Dommages au retour du véhicule/i, + }, + ]; + + for (const { lang, question, sourceHint, sourceTitle } of cases) { + test(`grounded ${lang} answer cites a ${lang} source about damage`, async ({ page, request }) => { + await resetDemoData(request); + await loginAsOpsManager(page, lang); + await page.goto("/knowledge"); + await page.locator("#knowledge-question").fill(question); + await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click(); + const localizedSource = page.locator(".source-card", { hasText: sourceHint }).first(); + await expect(localizedSource).toBeVisible({ timeout: 15_000 }); + await expect(localizedSource).toContainText(sourceTitle); + }); + } +}); + +test("audit trail shows localized action and field labels with raw codes only in technical details", async ({ + page, + request, +}) => { + await resetDemoData(request); + await loginAsOpsManager(page, "nl-BE"); + await page.goto("/data-quality/DQ-DEMO-STATUS"); + await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click(); + await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click(); + await expect(page.getByText("Toegepast", { exact: false })).toBeVisible(); + + await page.goto("/audit"); + + // Applying resolves both the vehicle status and the issue in one correlated action -- + // expand the group's "technical events" toggle so the other audit row's diff renders + // too, regardless of which one the grouping picked as primary. + const toggle = page.getByRole("button", { name: /technische gebeurtenis/ }).first(); + await expect(toggle).toBeVisible(); + await toggle.click(); + await expect(page.getByRole("button", { name: "Technische gebeurtenissen verbergen" })).toBeVisible(); + + await expect(page.getByText("Aanbevolen status toegepast").first()).toBeVisible(); + const diffs = page.locator(".change-diff"); + await expect(diffs.first()).toBeVisible(); + const combinedDiffText = (await diffs.allInnerTexts()).join(" "); + expect(combinedDiffText).toContain("Operationele status"); + expect(combinedDiffText).not.toContain("operational_status"); +}); + +test("automation shows a localized error explanation with the raw error only under technical details", async ({ + page, + request, +}) => { + await resetDemoData(request); + await loginAsOpsManager(page, "nl-BE"); + await page.goto("/automation"); + + const localizedSummary = /Gesimuleerde time-out richting de workflowdienst/; + await expect(page.getByText(localizedSummary).first()).toBeVisible(); + await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible(); + // Scope to the failed job's own row -- the workflow-evidence table above it also has + // "Technische details" toggles (one per workflow), so an unscoped .first() can open + // the wrong one. + const failedJobRow = page.locator("tr", { has: page.getByText(localizedSummary) }); + await failedJobRow.getByText("Technische details").click(); + await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible(); +}); + +test.describe("route matrix (section 11F)", () => { + // Opens every main route in all 3 languages: no console errors, correct html[lang], + // and a real, non-empty page heading (proving the route actually rendered content + // instead of silently falling back to a raw i18next key or a blank screen). Key + // parity across locale files is already proven structurally by i18n-coverage.spec.ts + // (every key that exists in nl-BE also exists, non-empty, in en-GB/fr-BE), so this + // matrix focuses on what only a live render can catch. + const routes = [ + "/dashboard", + "/vehicles", + "/vehicles/MO-001", + "/bookings", + "/bookings/BK-DEMO-RETURN", + "/data-quality", + "/data-quality/DQ-DEMO-STATUS", + "/automation", + "/knowledge", + "/audit", + "/scenarios", + "/about", + ]; + + for (const lang of ["nl-BE", "en-GB", "fr-BE"]) { + test(`every main route renders correctly with no console errors (${lang})`, async ({ page, request }) => { + await resetDemoData(request); + + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() !== "error") return; + if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return; + errors.push(msg.text()); + }); + page.on("pageerror", (err) => errors.push(err.message)); + + await loginAsOpsManager(page, lang); + + for (const route of routes) { + await page.goto(route); + await expect(page.locator("html")).toHaveAttribute("lang", lang); + const heading = page.getByRole("heading", { level: 1 }); + await expect(heading, `${route} (${lang})`).toBeVisible(); + const headingText = (await heading.first().textContent())?.trim() ?? ""; + expect(headingText, `${route} (${lang}) heading text`).not.toBe(""); + // A raw, unresolved i18next key looks like "namespace:some.key.path" -- real + // page headings never contain a colon followed by a dotted identifier. + expect(headingText, `${route} (${lang}) heading looks like a raw i18n key`).not.toMatch( + /^[a-zA-Z]+:[\w.]+$/, + ); + } + + expect(errors, `Console errors across the route matrix (${lang}):\n${errors.join("\n")}`).toEqual([]); + }); + } +}); + +test.describe("data-quality evidence summary is localized, not raw English (section 6)", () => { + // The primary evidence line at the top of every issue's detail page must render the + // structured `evidence.signals` in the operator's language; the legacy English + // `evidence.summary` string is a technical fallback only, visible solely inside + // "Technical details". Live-caught: this line was unconditionally showing raw + // English ("vehicle marked available while reserved bookings conflict") in every + // language until fixed. + const cases: { lang: string; expectedText: RegExp }[] = [ + { lang: "nl-BE", expectedText: /overlappende reserveringen/i }, + { lang: "en-GB", expectedText: /overlapping bookings/i }, + { lang: "fr-BE", expectedText: /chevauchent|chevauchement/i }, + ]; + + for (const { lang, expectedText } of cases) { + test(`vehicle_status_conflict evidence is localized (${lang})`, async ({ page, request }) => { + await resetDemoData(request); + await loginAsOpsManager(page, lang); + await page.goto("/data-quality/DQ-DEMO-STATUS"); + + const summarySection = page.locator(".record-surface-evidence"); + await expect(summarySection).toBeVisible(); + await expect(summarySection).toContainText(expectedText); + await expect(summarySection).not.toContainText("vehicle marked available while reserved bookings conflict"); + }); + } +}); diff --git a/frontend/e2e/greeting-live.spec.ts b/frontend/e2e/greeting-live.spec.ts new file mode 100644 index 0000000..108934d --- /dev/null +++ b/frontend/e2e/greeting-live.spec.ts @@ -0,0 +1,107 @@ +import { expect, type Page, test } from "@playwright/test"; + +// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting +// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app, +// in all 3 languages, at every required boundary instant, using Playwright's clock API to +// control the browser's Date without waiting on real wall-clock time. Also proves the +// greeting updates live (no reload) when the period rolls over while the app stays open. + +// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1. +function cet(hour: number, minute = 0, second = 0): Date { + return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second)); +} + +const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [ + { time: cet(4, 59), label: "04:59", period: "night" }, + { time: cet(5, 0), label: "05:00", period: "morning" }, + { time: cet(11, 59), label: "11:59", period: "morning" }, + { time: cet(12, 0), label: "12:00", period: "afternoon" }, + { time: cet(17, 59), label: "17:59", period: "afternoon" }, + { time: cet(18, 0), label: "18:00", period: "evening" }, + { time: cet(22, 59), label: "22:59", period: "evening" }, + { time: cet(23, 0), label: "23:00", period: "night" }, +]; + +const EXPECTED_TITLE: Record> = { + "nl-BE": { + morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.", + afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.", + evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.", + night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.", + }, + "en-GB": { + morning: "Good morning. Here's today's fleet status.", + afternoon: "Good afternoon. Here's the current overview of your fleet.", + evening: "Good evening. Here's this evening's fleet overview.", + night: "Welcome back. Here's the latest overview of your fleet.", + }, + "fr-BE": { + morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.", + afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.", + evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.", + night: "Bon retour. Voici le dernier aperçu de votre flotte.", + }, +}; + +async function loginAsOperationsManager(page: Page) { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") { + // At the default desktop viewport, only the topbar's compact switcher is visible -- + // the sidebar's full switcher is `display: none` until the <960px breakpoint. + await page.locator(".language-switcher-compact select").selectOption(language); +} + +const dashboardHeading = (page: Page) => page.locator(".page-header h1"); + +for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) { + test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => { + await page.clock.install({ time: BOUNDARY_CASES[0].time }); + await loginAsOperationsManager(page); + if (language !== "nl-BE") { + await switchLanguage(page, language); + } + + for (const { time, label, period } of BOUNDARY_CASES) { + await page.clock.setFixedTime(time); + await page.reload(); + await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText( + EXPECTED_TITLE[language][period], + ); + } + }); +} + +test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => { + // Start at 11:59:31 Brussels -- 29s before the 12:00 boundary. + await page.clock.install({ time: cet(11, 59, 31) }); + await loginAsOperationsManager(page); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning); + + // Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and + // recomputes the period -- no page.reload() call anywhere in this test. + await page.clock.fastForward(30_000); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon); +}); + +test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => { + await page.clock.install({ time: cet(9, 0) }); + await loginAsOperationsManager(page); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning); + + await switchLanguage(page, "en-GB"); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning); + + await switchLanguage(page, "fr-BE"); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning); +}); + +test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => { + await page.clock.install({ time: cet(2, 0) }); + await loginAsOperationsManager(page); + await expect(dashboardHeading(page)).not.toContainText("Goedenacht"); + await expect(dashboardHeading(page)).toContainText("Welkom terug"); +}); diff --git a/frontend/e2e/greeting.spec.ts b/frontend/e2e/greeting.spec.ts new file mode 100644 index 0000000..52c244c --- /dev/null +++ b/frontend/e2e/greeting.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test"; +import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting"; +import { brusselsLocalToIso, toBrusselsDate, tryBrusselsLocalToIso } from "../src/i18n/brusselsDateTime"; + +// Pure Node-context boundary tests for the central, clock-injectable greeting function +// (section 9 of the Fleet Ops final localization brief). Every case below constructs an +// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time -- +// this is what "clock injection" buys: no real time needs to pass, and DST is exercised +// by picking instants either side of the CET/CEST transition. + +// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC. +function cet(hour: number, minute = 0): Date { + return new Date(Date.UTC(2026, 0, 15, hour - 1, minute)); +} + +// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC. +function cest(hour: number, minute = 0): Date { + return new Date(Date.UTC(2026, 6, 15, hour - 2, minute)); +} + +test("period boundaries are correct in winter time (CET, UTC+1)", () => { + expect(getGreetingPeriod(cet(4, 59))).toBe("night"); + expect(getGreetingPeriod(cet(5, 0))).toBe("morning"); + expect(getGreetingPeriod(cet(11, 59))).toBe("morning"); + expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon"); + expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon"); + expect(getGreetingPeriod(cet(18, 0))).toBe("evening"); + expect(getGreetingPeriod(cet(22, 59))).toBe("evening"); + expect(getGreetingPeriod(cet(23, 0))).toBe("night"); +}); + +test("period boundaries are correct in summer time (CEST, UTC+2)", () => { + expect(getGreetingPeriod(cest(4, 59))).toBe("night"); + expect(getGreetingPeriod(cest(5, 0))).toBe("morning"); + expect(getGreetingPeriod(cest(11, 59))).toBe("morning"); + expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon"); + expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon"); + expect(getGreetingPeriod(cest(18, 0))).toBe("evening"); + expect(getGreetingPeriod(cest(22, 59))).toBe("evening"); + expect(getGreetingPeriod(cest(23, 0))).toBe("night"); +}); + +test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => { + // 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless). + const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30)); + expect(getBrusselsHour(beforeTransition)).toBe(1); + expect(getGreetingPeriod(beforeTransition)).toBe("night"); + + // 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning. + const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0)); + expect(getBrusselsHour(afterTransition)).toBe(11); + expect(getGreetingPeriod(afterTransition)).toBe("morning"); + + // Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00 + // CET), so 00:30 UTC is still CEST -> 02:30 Brussels. + const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30)); + expect(getBrusselsHour(beforeFallBack)).toBe(2); + expect(getGreetingPeriod(beforeFallBack)).toBe("night"); + + // 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning. + const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0)); + expect(getBrusselsHour(afterFallBack)).toBe(10); + expect(getGreetingPeriod(afterFallBack)).toBe("morning"); +}); + +test("default argument uses the real current time when no clock is injected", () => { + const period = getGreetingPeriod(); + expect(["morning", "afternoon", "evening", "night"]).toContain(period); +}); + +test("Brussels wall-clock conversion is DST-aware and rejects impossible local times", () => { + expect(brusselsLocalToIso("2026-01-15T12:00")).toBe("2026-01-15T11:00:00.000Z"); + expect(brusselsLocalToIso("2026-07-15T12:00")).toBe("2026-07-15T10:00:00.000Z"); + expect(tryBrusselsLocalToIso("2026-03-29T02:30")).toBeNull(); + expect(tryBrusselsLocalToIso("2026-02-30T12:00")).toBeNull(); + // The repeated autumn hour resolves deterministically to the post-transition CET instant. + expect(brusselsLocalToIso("2026-10-25T02:30")).toBe("2026-10-25T01:30:00.000Z"); +}); + +test("Brussels calendar dates do not depend on the browser or runner timezone", () => { + expect(toBrusselsDate(new Date("2026-01-01T23:30:00.000Z"))).toBe("2026-01-02"); + expect(toBrusselsDate(new Date("2026-07-01T22:30:00.000Z"))).toBe("2026-07-02"); +}); diff --git a/frontend/e2e/guided-demo-full.spec.ts b/frontend/e2e/guided-demo-full.spec.ts new file mode 100644 index 0000000..8127c5c --- /dev/null +++ b/frontend/e2e/guided-demo-full.spec.ts @@ -0,0 +1,121 @@ +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(); + await page.getByLabel("Regeltype").selectOption("odometer_regression"); + const odometerIssue = page.getByRole("row", { + name: /Afwijkende kilometerstand.*MO-024/, + }); + await odometerIssue.locator("a").click(); + await expect(page.getByText("Wat is er mis")).toBeVisible(); + // SLA ordering deliberately outranks recency, so select the issue by its rule. + 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({ timeout: 15_000 }); + 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("dialog", { name: "Gegidste demo" }) + .getByRole("button", { name: "Volgende", exact: true }) + .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 page.getByRole("button", { name: "Demo afronden" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); + const guideTrigger = page.locator(".demo-guide-trigger"); + await expect(guideTrigger).toContainText("8/8"); + await expect(guideTrigger).toBeFocused(); + await page.reload(); + await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("8/8"); + }); + + await test.step("restore the environment", async () => { + await resetDemoData(request); + }); +}); diff --git a/frontend/e2e/i18n-coverage.spec.ts b/frontend/e2e/i18n-coverage.spec.ts new file mode 100644 index 0000000..2cb0c64 --- /dev/null +++ b/frontend/e2e/i18n-coverage.spec.ts @@ -0,0 +1,324 @@ +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).flatMap(([key, nested]) => + collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key), + ); +} + +function loadNamespace(language: string, namespace: string): Record { + 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((acc, part) => { + if (acc && typeof acc === "object") return (acc as Record)[part]; + return undefined; + }, data); + if (typeof value === "string") { + expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0); + } + } + } + } +}); + +// --- Brand-invariant: "Fleet Ops" is a fixed constant, never a translation value --- +// (see frontend/src/product.ts and docs/fleet-ops-correction/current-gap-audit.md §1). +// A regression here means someone re-introduced a per-locale brand key/value instead of +// interpolating {{productName}} from the shared constant. + +test("no locale file defines an 'appName' key or the literal brand string", () => { + for (const language of LANGUAGES) { + for (const namespace of namespaces) { + const data = loadNamespace(language, namespace); + const raw = JSON.stringify(data); + expect( + raw.includes("Fleet Ops"), + `${language}/${namespace}.json contains the literal brand string "Fleet Ops" -- ` + + `use {{productName}} interpolation instead so the brand can never drift per locale`, + ).toBe(false); + const keys = collectKeyPaths(data); + expect( + keys.some((k) => k === "appName" || k.endsWith(".appName")), + `${language}/${namespace}.json defines an "appName" key -- the brand name must come ` + + `from the PRODUCT_NAME constant, never a translatable key`, + ).toBe(false); + } + } +}); + +test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => { + for (const language of LANGUAGES) { + for (const namespace of namespaces) { + const raw = JSON.stringify(loadNamespace(language, namespace)); + expect( + raw.includes("MobilityOps"), + `${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` + + `always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`, + ).toBe(false); + expect( + /\bPoC\b/.test(raw), + `${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` + + `in user-facing copy`, + ).toBe(false); + } + } +}); + +// --- Translation-quality: prove values were actually translated, not copy-pasted --- +// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain +// the literal English string under the right key and still pass). For every "real prose" +// string (>=8 chars, not on the allowlist below), assert nl-BE and fr-BE differ from +// en-GB, and that fr-BE differs from nl-BE -- catching both "still English" and +// "Dutch text copy-pasted into French" in one pass. + +// Exact (namespace, key-path) pairs that are legitimately identical across two or more +// locales: real proper nouns/brand names, deliberately-untranslated role titles, and +// genuine cross-language cognates (identical spelling in Dutch/French/English). This is +// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly +// hide an unrelated real mistranslation under the same key in a different namespace. +const IDENTICAL_VALUE_ALLOWLIST = new Set([ + "audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch + "common.language.nl-BE", // language-picker options show each language's own endonym + "common.language.fr-BE", + "common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text + "common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3 + "dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative + "demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3 + "demo.about.limitationsTitle", // "Limitations" -- identical spelling in French + "demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun + "fleet.list.columns.attention", // "Attention" -- identical spelling in French + "fleet.detail.tabs.inspections", // "Inspections" -- identical spelling in French + "integrations.cards.orchestrationKicker", // "Orchestration" -- identical in French + "knowledge.questionLabel", // "Question" -- identical spelling in French + "knowledge.retrievalFlow.question", + "knowledge.questionLabelExchange", + "returns.result.inspection", // "Inspection" -- identical spelling in French + "integrations.workflows.columns.name", // "Workflow" -- used as-is in Dutch and French +]); + +function isTranslatableProse(value: unknown): value is string { + if (typeof value !== "string") return false; + if (value.trim().length < 8) return false; + // Strip interpolation placeholders and non-letter characters; if nothing substantial + // remains (pure numbers/punctuation/units), it's not "prose" that needs translating. + const stripped = value + .replace(/\{\{[^}]+\}\}/g, " ") + .replace(/[^a-zA-Zà-öø-ÿÀ-ÖØ-ß]/g, ""); + return stripped.trim().length >= 3; +} + +test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or each other", () => { + for (const namespace of namespaces) { + const en = loadNamespace("en-GB", namespace); + const nl = loadNamespace("nl-BE", namespace); + const fr = loadNamespace("fr-BE", namespace); + const keys = collectKeyPaths(en); + + for (const keyPath of keys) { + if (IDENTICAL_VALUE_ALLOWLIST.has(`${namespace}.${keyPath}`)) continue; + const at = (data: Record) => + keyPath.split(".").reduce((acc, part) => { + if (acc && typeof acc === "object") return (acc as Record)[part]; + return undefined; + }, data); + + const enValue = at(en); + if (!isTranslatableProse(enValue)) continue; + const nlValue = at(nl); + const frValue = at(fr); + + expect( + nlValue, + `${namespace}.json:${keyPath} — nl-BE is identical to en-GB ("${enValue}"); ` + + `looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`, + ).not.toBe(enValue); + expect( + frValue, + `${namespace}.json:${keyPath} — fr-BE is identical to en-GB ("${enValue}"); ` + + `looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`, + ).not.toBe(enValue); + expect( + frValue, + `${namespace}.json:${keyPath} — fr-BE is identical to nl-BE ("${nlValue}"); ` + + `looks like Dutch text was copy-pasted into the French locale`, + ).not.toBe(nlValue); + } + } +}); + +// --- Embedded English/Dutch fragments inside otherwise-translated prose --- +// The whole-string identity check above only catches a value that is IDENTICAL to +// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final +// localization pass: a sentence gets 95% translated but a role/status noun phrase is +// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden +// goedgekeurd." This scan flags known English fragments appearing literally inside any +// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste +// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common +// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary). +const FORBIDDEN_ENGLISH_FRAGMENTS = [ + "Operations Manager", + "Operations Managers", + "Rental Employee", + "Rental Employees", + "Audit trail", + "Start scenario", +]; +const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [ + "Operationsmanager", + "Verhuurmedewerker", + "Auditgeschiedenis", + "Scenario starten", +]; + +function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> { + if (typeof value === "string") return [{ path: prefix, value }]; + if (value === null || typeof value !== "object") return []; + return Object.entries(value as Record).flatMap(([key, nested]) => + collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key), + ); +} + +test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => { + const findings: string[] = []; + for (const namespace of namespaces) { + const nl = loadNamespace("nl-BE", namespace); + const fr = loadNamespace("fr-BE", namespace); + for (const { path: keyPath, value } of collectStringLeaves(nl)) { + for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) { + if (value.includes(fragment)) { + findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`); + } + } + } + for (const { path: keyPath, value } of collectStringLeaves(fr)) { + for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) { + if (value.includes(fragment)) { + findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`); + } + } + } + } + expect(findings, findings.join("\n")).toEqual([]); +}); + +// --- Hardcoded JSX text (section 11D) --- +// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a +// `{...}` expression) containing two or more real words are almost always user-facing +// prose that should go through t(...). This is not a full parser, so a short, explicit +// allowlist covers technical tokens/proper nouns that are correctly never translated +// (MobilityOps.md is checked for absence elsewhere; this list is for things that ARE +// expected to appear literally in JSX). +const SRC_DIR = path.resolve(__dirname, "../src"); +const SCAN_DIRS = ["pages", "components"]; + +const ALLOWED_LITERAL_TEXT = new Set([ + "Fleet Ops", // the non-localizable brand name (frontend/src/product.ts) + "Northstar Mobility", // fictional demo org, a proper noun + "ITWorx MCP Hub", // proper noun +]); + +function collectTsxFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return collectTsxFiles(full); + return entry.name.endsWith(".tsx") ? [full] : []; + }); +} + +function findHardcodedJsxText(filePath: string): string[] { + const source = fs.readFileSync(filePath, "utf-8"); + const findings: string[] = []; + // Matches `text` where the closing tag name backreferences the + // opening one -- this specifically excludes TypeScript generics like + // `useState(null)`, which have no matching `` closer, + // unlike a naive `>...<` scan would. Deliberately spans newlines (Prettier commonly + // puts JSX text on its own line) and does not attempt to parse JSX properly -- it is + // a fast, approximate net for the common mistake, not a compiler. + const jsxTextPattern = /<([A-Za-z][\w.]*)(?:\s[^<>]*)?>([^<>{}]{3,200})<\/\1>/gs; + let match: RegExpExecArray | null; + while ((match = jsxTextPattern.exec(source)) !== null) { + const text = match[2].trim(); + if (!text) continue; + if (ALLOWED_LITERAL_TEXT.has(text)) continue; + // Needs at least two alphabetic words to count as "prose" -- filters out numbers, + // single technical words, units (km, %), punctuation-only fragments, and JSX + // whitespace artifacts. + const words = text.match(/[A-Za-z]+/g) ?? []; + if (words.length < 2) continue; + // Skip anything that is itself an i18next interpolation artifact leaking through + // (shouldn't happen, but never flag `{{...}}`-shaped remnants) or looks like a URL + // or path. + if (/^https?:\/\//.test(text) || text.includes("/") || text.includes("{{")) continue; + findings.push(`${path.relative(SRC_DIR, filePath)}: "${text}"`); + } + return findings; +} + +test("no hardcoded user-facing JSX text outside the approved technical-token allowlist", () => { + const allFindings: string[] = []; + for (const dir of SCAN_DIRS) { + const files = collectTsxFiles(path.join(SRC_DIR, dir)); + for (const file of files) { + allFindings.push(...findHardcodedJsxText(file)); + } + } + expect( + allFindings, + `Found ${allFindings.length} likely hardcoded JSX string(s) bypassing t(...). ` + + `Either route it through the translation system, or add the exact literal to ` + + `ALLOWED_LITERAL_TEXT in this test if it's a genuine proper noun/technical token:\n` + + allFindings.join("\n"), + ).toEqual([]); +}); diff --git a/frontend/e2e/interactive-elements.spec.ts b/frontend/e2e/interactive-elements.spec.ts new file mode 100644 index 0000000..e9705c3 --- /dev/null +++ b/frontend/e2e/interactive-elements.spec.ts @@ -0,0 +1,468 @@ +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"); +} + +async function switchRole(page: Page) { + await page.locator(".operator-menu > summary").click(); + await page.getByRole("button", { name: "Switch role" }).click(); +} + +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, request }) => { + // Keep reset's Set-Cookie deletion in the standalone request fixture. Sharing the + // browser cookie jar here can race that deletion against the UI login below. + 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("all manager navigation items navigate correctly", async ({ page }) => { + const items: [string, RegExp][] = [ + ["Overview", /\/dashboard$/], + ["Fleet", /\/vehicles$/], + ["Bookings", /\/bookings$/], + ["Data quality", /\/data-quality$/], + ["Knowledge", /\/knowledge$/], + ["Integrations", /\/automation$/], + ["Audit trail", /\/audit$/], + ["Users", /\/users$/], + ["Privacy", /\/privacy$/], + ]; + const primaryNavigation = page.getByRole("navigation", { name: "Primary navigation" }); + for (const [label, urlPattern] of items) { + await primaryNavigation.getByRole("link", { name: label, exact: true }).click(); + await expect(page).toHaveURL(urlPattern); + } +}); + +test("vehicles page: status filter and attention-only checkbox both work", async ({ page }) => { + await page.goto("/vehicles"); + await expect(page.locator(".data-table")).toBeVisible(); + + await page.getByLabel("Status").selectOption("maintenance"); + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + const statuses = await page.locator(".data-table tbody tr td:nth-child(4)").allTextContents(); + expect(statuses.every((s) => s.includes("maintenance"))).toBeTruthy(); + + await page.getByLabel("Status").selectOption(""); + await expect(page).not.toHaveURL(/status=maintenance/); + await expect(page.getByLabel("Attention only")).not.toBeChecked(); + // Updating the URL replaces this controlled input. Click it, then assert against the + // newly-rendered control rather than asking Playwright to verify the detached node. + await page.getByLabel("Attention only").click(); + await expect(page).toHaveURL(/attention_only=true/); + await expect(page.getByLabel("Attention only")).toBeChecked(); + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + const rows = page.locator(".data-table tbody tr"); + await expect(rows.locator(".attention-flag")).toHaveCount(await rows.count()); + const attentionReasons = await rows.locator(".attention-flag").allTextContents(); + expect(attentionReasons.every((reason) => reason.trim().length > 0)).toBeTruthy(); +}); + +test("vehicles page: free-text search actually filters the rendered rows", async ({ page }) => { + await page.goto("/vehicles"); + await expect(page.locator(".data-table")).toBeVisible(); + const totalRows = await page.locator(".data-table tbody tr").count(); + expect(totalRows).toBeGreaterThan(1); + + const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search"); + await searchBox.fill("MO-001"); + await expect(async () => { + const rows = await page.locator(".data-table tbody tr").count(); + expect(rows).toBe(1); + }).toPass({ timeout: 5000 }); + const refs = await page.locator(".data-table tbody tr th a").allTextContents(); + expect(refs).toEqual(["MO-001"]); + + await searchBox.fill(""); + await expect(async () => { + const rows = await page.locator(".data-table tbody tr").count(); + expect(rows).toBe(totalRows); + }).toPass({ timeout: 5000 }); +}); + +test("vehicle detail: all tabs render distinct content", async ({ page }) => { + await page.goto("/vehicles/MO-016"); + await expect(page.getByRole("heading", { name: /MO-016/ })).toBeVisible(); + for (const tab of ["Overview", "Bookings", "Inspections", "Maintenance", "Quality"]) { + await page.getByRole("tab", { name: tab }).click(); + await expect(page.getByRole("tab", { name: tab })).toHaveAttribute("aria-selected", "true"); + } +}); + +test("bookings page: status filter works", async ({ page }) => { + await page.goto("/bookings"); + await expect(page.locator(".data-table")).toBeVisible(); + await page.getByLabel("Status").selectOption("returned"); + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + const statuses = await page.locator(".data-table tbody tr td:nth-child(5)").allTextContents(); + 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. + // The localized reason is the primary text; the raw code sits behind "Technical + // details" so it isn't visible until expanded. + await expect(page.getByText("Damage was reported on this return.")).toBeVisible(); + const statusRegion = page.locator(".impact-preview"); + await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible(); + await page.getByText("Technical details").click(); + await expect(page.getByText("Damage was reported on return.")).toBeVisible(); +}); + +test("data quality page: status and rule-type filters work", async ({ page }) => { + await page.goto("/data-quality"); + await expect(page.locator(".data-table")).toBeVisible(); + + await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption( + "possible_duplicate_customer", + ); + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents(); + expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy(); + + await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption(""); + await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved"); + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); +}); + +test("data quality issue detail: defer and reject buttons work", async ({ page, request }) => { + await resetDemoData(request); + await page.goto("/data-quality?status=open&rule_type=missing_required_field"); + await expect(page.getByRole("combobox", { name: "Rule type", exact: true })).toHaveValue( + "missing_required_field", + ); + const firstLink = page.locator(".data-table tbody tr").first().locator("a"); + await expect(firstLink).toBeVisible(); + const ref = (await firstLink.textContent())?.trim() ?? ""; + expect(ref).not.toBe(""); + await firstLink.click(); + await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`)); + await expect(page.locator(".page-header .page-eyebrow")).toHaveText(ref); + await page.getByRole("button", { name: "Defer" }).click(); + 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.locator(".page-header .page-eyebrow")).toHaveText("DQ-DEMO-ATTENTION"); + + 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.locator(".page-header .page-eyebrow")).toHaveText("DQ-DEMO-OVERLAP"); + + 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(); + await expect + .poll(async () => { + const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A"); + return (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.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Review recommendation" }).click(); + await page.getByRole("button", { name: /^Change status to/ }).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.getByText(target.public_ref, { exact: true })).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 }) => { + await resetDemoData(request); + await page.goto("/automation"); + const jobsTable = page.getByRole("table", { name: "Automation jobs" }); + await expect(jobsTable).toBeVisible(); + + await page.getByLabel("Status").selectOption("failed"); + await expect(jobsTable.locator("tbody tr").first()).toBeVisible(); + const failedRowCountBefore = await jobsTable.locator("tbody tr").count(); + const retryButton = page.getByRole("button", { name: "Retry" }).first(); + await expect(retryButton).toBeVisible(); + await retryButton.click(); + + // Retrying a failed delivery moves it out of the "failed" status, so — while still + // filtered to "failed" — the row correctly disappears from this view rather than + // showing "pending" in place. Confirm the filtered list shrank by one. + await expect(async () => { + const count = await jobsTable.locator("tbody tr").count(); + expect(count).toBe(failedRowCountBefore - 1); + }).toPass({ timeout: 5000 }); +}); + +test("audit page: action filter works", async ({ page }) => { + await page.goto("/audit"); + await expect(page.locator(".audit-group-list")).toBeVisible(); + await page.getByLabel("Action").fill("demo_login"); + await expect(page.locator(".audit-group").first()).toBeVisible(); + const headings = await page.locator(".audit-group-heading strong").allTextContents(); + expect(headings.every((a) => a === "Logged in")).toBeTruthy(); +}); + +test("audit page: shows human-readable before/after and a safe entity link", async ({ + page, + request, +}) => { + await resetDemoData(request); + // demo/reset deletes the acting session's own cookie, so submit the return through + // page.request instead -- it shares the browser context's still-valid OM session from + // beforeEach rather than the now-logged-out standalone `request` fixture. + const submitted = await page.request.post("/api/v1/bookings/BK-DEMO-RETURN/return", { + data: { + end_odometer_km: 60000, + fuel_level_percent: 55, + cleanliness_ok: true, + damage_reported: false, + technical_warning: false, + }, + headers: { "Idempotency-Key": "e2e-audit-before-after-check" }, + }); + expect(submitted.ok()).toBeTruthy(); + + await page.goto("/audit"); + await page.getByLabel("Action").fill("return_registered"); + const firstGroup = page.locator(".audit-group").first(); + await expect(firstGroup).toBeVisible(); + + const changeDiff = firstGroup.locator(".change-diff"); + await expect(changeDiff).toContainText(/status/i); + await expect(changeDiff).toContainText("returned"); + + await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/); +}); + +test("knowledge page: form submits and clears input", async ({ page }) => { + await page.goto("/knowledge"); + const input = page.getByPlaceholder(/What must I do when a vehicle returns with damage/); + await input.fill("What must I do when a vehicle returns with damage?"); + await page.getByRole("button", { name: "Ask" }).click(); + await expect(page.getByText("Grounded in cited procedures")).toBeVisible({ timeout: 15_000 }); + await expect(input).toHaveValue(""); +}); + +test("switch role button logs out and returns to login", async ({ page }) => { + await switchRole(page); + await expect(page).toHaveURL(/\/login$/); +}); + +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 switchRole(page); + await expect(page).toHaveURL(/\/login$/); + + // Directly re-requesting a protected route after logout must not restore access from a + // stale client cache; the server-side cookie is gone. + await page.goto("/dashboard"); + await expect(page).toHaveURL(/\/login$/); +}); + +test("direct navigation to a protected route without a session redirects to login", async ({ + page, + context, +}) => { + await context.clearCookies(); + await page.goto("/vehicles"); + await expect(page).toHaveURL(/\/login$/); +}); + +test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({ + page, +}) => { + await switchRole(page); + await page.getByRole("button", { name: "Explore as Rental Employee" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + + // 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); + await expect(page.getByRole("link", { name: "Privacy" })).toHaveCount(0); + + // Direct URL navigation is still blocked server-side and shows the same restricted + // message as a defense-in-depth measure, not just a hidden button. + await page.goto("/automation"); + await expect( + page.getByText("Automation is visible to Operations Managers only.").first(), + ).toBeVisible(); + + await page.goto("/data-quality"); + await expect( + page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(), + ).toBeVisible(); + + await page.goto("/audit"); + await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible(); + + await page.goto("/privacy"); + await expect(page.getByText("These governance functions are available to Operations Managers only.")).toBeVisible(); + + await page.goto("/users"); + await expect(page.getByText("User administration is available to Operations Managers only.").first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Add user" })).toHaveCount(0); + + await page.goto("/vehicles/MO-024"); + await expect(page.getByRole("tab", { name: "Quality" })).toHaveCount(0); + + await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0); +}); + +test("rental employee return result exposes operational follow-up but no manager-only evidence links", async ({ + page, +}) => { + await switchRole(page); + await page.getByRole("button", { name: "Explore as Rental Employee" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/bookings/BK-DEMO-RETURN"); + + await page.getByLabel("End odometer (km)").fill("54000"); + await page.getByLabel("Fuel level (%)").fill("75"); + await page.getByRole("button", { name: "Review return" }).click(); + await page.getByRole("button", { name: "Confirm return" }).click(); + await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); + + await expect(page.getByRole("link", { name: /View vehicle MO-024/ })).toBeVisible(); + await expect(page.getByRole("link", { name: "View automation status" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Trace the complete operation" })).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 switchRole(page); + 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); + } +}); diff --git a/frontend/e2e/operational-workflows.spec.ts b/frontend/e2e/operational-workflows.spec.ts new file mode 100644 index 0000000..06ff1c8 --- /dev/null +++ b/frontend/e2e/operational-workflows.spec.ts @@ -0,0 +1,116 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +async function reset(request: APIRequestContext) { + expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy(); + expect((await request.post("/api/v1/demo/reset")).ok()).toBeTruthy(); + expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy(); +} + +async function login(page: Page) { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +test.describe.configure({ mode: "serial" }); + +test("booking creation explains when the end does not follow the start", async ({ page }) => { + await login(page); + await page.goto("/bookings/new"); + const start = page.getByLabel("Start"); + const end = page.getByLabel("Einde"); + const startValue = await start.inputValue(); + + await end.fill(startValue); + + await expect(page.getByRole("alert")).toHaveText("Het einde moet na de start van de boeking vallen."); + await expect(end).toHaveAttribute("aria-invalid", "true"); + await expect(page.getByRole("button", { name: "Boeking aanmaken" })).toBeDisabled(); +}); + +test("operator can create and cancel a booking through the UI", async ({ page, request }) => { + await reset(request); + await login(page); + await page.goto("/bookings/new"); + await page.getByLabel("Klant zoeken").fill("CUS-0001"); + const customer = page.locator(".booking-create-form select").nth(0); + await expect.poll(() => customer.locator("option").count()).toBeGreaterThan(1); + await customer.selectOption({ index: 1 }); + const vehicle = page.locator(".booking-create-form select").nth(1); + await expect.poll(() => vehicle.locator("option").count()).toBeGreaterThan(1); + await vehicle.selectOption({ index: 1 }); + await page.getByRole("button", { name: "Boeking aanmaken" }).click(); + await expect(page).toHaveURL(/\/bookings\/BK-/); + await expect(page.getByText("gereserveerd", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Vereisten controleren" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Voertuig uitchecken" })).toHaveCount(0); + await page.getByLabel("Controlebewijs").fill("Rijbewijs en huurvoorwaarden gecontroleerd"); + await page.getByRole("button", { name: "Vereisten bevestigen" }).click(); + await expect(page.getByRole("heading", { name: "Voertuig uitchecken" })).toBeVisible(); + await page.getByLabel("Reden", { exact: true }).fill("Klant annuleert de geplande rit"); + await page.getByRole("button", { name: "Annulering bevestigen" }).click(); + await expect(page.getByText("geannuleerd", { exact: true })).toBeVisible(); +}); + +test("manager can create, edit and deactivate an operational user", async ({ page, request }) => { + await reset(request); + await login(page); + await page.goto("/users"); + await page.getByLabel("Naam").fill("E2E Planner"); + await page.getByLabel("E-mail").fill("e2e.planner@example.test"); + await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password"); + await page.getByRole("button", { name: "Gebruiker toevoegen" }).click(); + let row = page.getByRole("row", { name: /E2E Planner/ }); + await expect(row).toBeVisible(); + await row.getByRole("button", { name: "Bewerken" }).click(); + const editor = page.locator(".user-edit"); + await editor.getByLabel("Naam").fill("E2E Senior Planner"); + await editor.getByLabel("Rol").selectOption("operations_manager"); + await editor.getByLabel("Nieuw wachtwoord").fill("updated-e2e-password"); + await editor.getByRole("button", { name: "Wijzigingen opslaan" }).click(); + row = page.getByRole("row", { name: /E2E Senior Planner/ }); + await expect(row.getByText("Operationeel beheerder")).toBeVisible(); + await row.getByRole("button", { name: "Deactiveren" }).click(); + await expect(row.getByText("inactief", { exact: true })).toBeVisible(); +}); + +test("manager can assign data-quality work from the queue", async ({ page, request }) => { + await reset(request); + await login(page); + await page.goto("/data-quality?status=open"); + const firstRow = page.locator(".data-table tbody tr").first(); + const issueRef = (await firstRow.locator(".row-link").textContent())?.trim() ?? ""; + expect(issueRef).not.toBe(""); + await firstRow.getByRole("checkbox").check(); + const assignee = page.getByLabel("Toewijzen aan"); + await assignee.selectOption({ index: 1 }); + const selectedLabel = await assignee.locator("option:checked").textContent(); + await page.getByRole("button", { name: "Werkvoorraad bijwerken" }).click(); + const updatedRow = page.locator(".data-table tbody tr").filter({ + has: page.getByRole("link", { name: issueRef, exact: true }), + }); + await expect(updatedRow).toContainText(selectedLabel ?? ""); +}); + +test("manager can record maintenance and release a safe vehicle", async ({ page, request }) => { + await reset(request); + const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json(); + let selected = vehicles[0]; + for (const candidate of vehicles) { + const detail = await (await request.get(`/api/v1/vehicles/${candidate.public_ref}`)).json(); + if (!detail.attention && !detail.bookings.some((booking: { status: string }) => booking.status === "active")) { + selected = candidate; + break; + } + } + await login(page); + await page.goto(`/vehicles/${selected.public_ref}`); + await page.getByRole("tab", { name: "Onderhoud" }).click(); + await page.getByRole("button", { name: "Onderhoud registreren" }).click(); + await page.getByLabel("Uitgevoerd werk").fill("Periodiek onderhoud en veiligheidscontrole voltooid"); + await page.getByRole("button", { name: "Onderhoud opslaan" }).click(); + await expect(page.getByText("onderhoud", { exact: true }).first()).toBeVisible(); + await page.getByLabel("Reden voor vrijgave").fill("Veiligheidscontrole afgerond zonder open punten"); + await page.getByRole("button", { name: "Voertuig vrijgeven" }).click(); + await expect(page.getByText("beschikbaar", { exact: true }).first()).toBeVisible(); +}); diff --git a/frontend/e2e/privacy.spec.ts b/frontend/e2e/privacy.spec.ts new file mode 100644 index 0000000..4ffce71 --- /dev/null +++ b/frontend/e2e/privacy.spec.ts @@ -0,0 +1,30 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +async function resetAndLogin(request: APIRequestContext, page: Page) { + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + await request.post("/api/v1/demo/reset"); + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +test("privacy centre reports policy and produces an audited CSV export", async ({ page, request }) => { + await resetAndLogin(request, page); + await page.goto("/privacy"); + await expect(page.getByRole("heading", { name: "Privacybeheer" })).toBeVisible(); + await expect(page.getByText("30 dagen", { exact: true })).toBeVisible(); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("link", { name: "Audit CSV downloaden" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("mobilityops-audit.csv"); +}); + +test("privacy centre refuses anonymisation of a customer with an active booking", async ({ page, request }) => { + await resetAndLogin(request, page); + await page.goto("/privacy"); + await page.getByLabel("Klantreferentie").first().fill("CUS-0042"); + await page.getByLabel("Gemotiveerde reden").fill("Gevalideerd verzoek van de betrokkene"); + await page.getByLabel(/Typ CUS-0042 ter bevestiging/).fill("CUS-0042"); + await page.getByRole("button", { name: "Definitief anonimiseren" }).click(); + await expect(page.getByText(/kon niet worden uitgevoerd/)).toBeVisible(); +}); diff --git a/frontend/e2e/recruiter-polish.spec.ts b/frontend/e2e/recruiter-polish.spec.ts new file mode 100644 index 0000000..bbd0e0c --- /dev/null +++ b/frontend/e2e/recruiter-polish.spec.ts @@ -0,0 +1,142 @@ +import { expect, test, type Page } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +async function loginAsOperationsManager(page: Page) { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +test("90-second recruiter entry exposes three verifiable engineering highlights", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click(); + + await expect(page).toHaveURL(/\/highlights$/); + await expect(page.getByRole("heading", { name: "De sterkste keuzes in 90 seconden" })).toBeVisible(); + await expect(page.locator(".highlight-card")).toHaveCount(3); + await expect(page.getByText("Transactie · idempotency · outbox · degradatie")).toBeVisible(); + await expect(page.getByText("RAGcore · citations · veilige degradatie")).toBeVisible(); +}); + +test("Engineering Story exposes architecture, reliability and honest integration evidence", async ({ page }) => { + await loginAsOperationsManager(page); + await page.goto("/about"); + + await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible(); + await expect(page.getByText("Commit eerst, orkestreer daarna")).toBeVisible(); + await expect(page.getByText("AI moet bewijs tonen")).toBeVisible(); + await expect(page.locator(".architecture-step", { hasText: "PostgreSQL + audittrail" }).getByRole("button")).toBeVisible(); + const outboxStep = page.locator(".architecture-step", { hasText: "Betrouwbare outbox" }).getByRole("button"); + await outboxStep.click(); + await expect(outboxStep).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("#architecture-active-detail")).toContainText("Post-commit aflevering"); + const architectureTypeMinimums = [ + [".architecture-zones > span", 12], + [".architecture-step button > small", 13], + [".architecture-zone-tag", 12], + [".architecture-commit-note", 12], + [".architecture-interaction-hint", 13], + [".architecture-active-copy p", 13], + [".architecture-active-evidence dt", 12], + [".architecture-active-evidence dd", 13], + ] as const; + for (const [selector, minimumPixels] of architectureTypeMinimums) { + const fontSize = await page.locator(selector).first().evaluate((element) => + Number.parseFloat(window.getComputedStyle(element).fontSize)); + expect(fontSize, `${selector} font-size`).toBeGreaterThanOrEqual(minimumPixels); + } + await expect(page.getByText(/nog niet live gekoppeld/)).toHaveCount(0); + await expect(page.getByText(/unknown procedures/)).toHaveCount(0); +}); + +test("dashboard load failure offers an in-place retry and recovers", async ({ page }) => { + await loginAsOperationsManager(page); + let dashboardAttempts = 0; + let allowDashboardRecovery = false; + await page.route("**/api/v1/dashboard", async (route) => { + dashboardAttempts += 1; + if (!allowDashboardRecovery) { + await route.fulfill({ status: 503, contentType: "application/json", body: "{}" }); + return; + } + await route.continue(); + }); + + await page.reload(); + const retryButton = page.getByRole("button", { name: "Opnieuw proberen" }); + await expect(retryButton).toBeVisible(); + allowDashboardRecovery = true; + await retryButton.click(); + + await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible(); + expect(dashboardAttempts).toBeGreaterThanOrEqual(2); +}); + +test("dashboard header stacks a full-width touch target on mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await loginAsOperationsManager(page); + + const header = page.locator(".page-header"); + const action = page.locator(".page-actions .button"); + const [headerBox, actionBox] = await Promise.all([header.boundingBox(), action.boundingBox()]); + expect(headerBox).not.toBeNull(); + expect(actionBox).not.toBeNull(); + expect(actionBox!.width).toBeGreaterThanOrEqual(headerBox!.width - 1); + expect(Math.round(actionBox!.height)).toBeGreaterThanOrEqual(44); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth + 1), + ); +}); + +test("mobile recruiter surfaces remain readable without horizontal overflow", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click(); + await expect(page).toHaveURL(/\/highlights$/); + + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + await expect(page.locator(".highlight-card").first()).toBeVisible(); +}); + +test("remaining attention items use a compact, legible queue action", async ({ page, request }) => { + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + await request.post("/api/v1/demo/reset"); + await loginAsOperationsManager(page); + + const queueAction = page.locator(".queue-more a"); + await expect(queueAction).toContainText("Nog 2 aandachtspunten"); + await expect(queueAction).toContainText("Open de volledige werklijst"); + const actionBox = await queueAction.boundingBox(); + const arrowBox = await queueAction.locator(".queue-more-arrow svg").boundingBox(); + expect(actionBox?.height).toBeLessThanOrEqual(70); + expect(arrowBox?.width).toBeLessThanOrEqual(16); +}); + +test("Knowledge Hub renders independently verified RAGcore index evidence", async ({ page }) => { + await loginAsOperationsManager(page); + await page.route("**/api/v1/knowledge/status**", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + provider: "ragcore", + available: true, + detail: "11/11 managed sources verified", + tenant: "northstar-mobility-demo", + workspace: "mobilityops", + collection: "internal-procedures", + document_count: 11, + source_document_count: 11, + reported_synced_document_count: 33, + reported_failed_document_count: 0, + last_sync_at: "2026-08-10T16:00:00Z", + statistics_state: "verified", + }), + }); + }); + await page.goto("/knowledge"); + + await expect(page.getByText("11 procedures geïndexeerd")).toBeVisible(); + await expect(page.getByText("exact gevonden en actief gepubliceerd")).toBeVisible(); +}); diff --git a/frontend/e2e/responsive-i18n.spec.ts b/frontend/e2e/responsive-i18n.spec.ts new file mode 100644 index 0000000..65b9c44 --- /dev/null +++ b/frontend/e2e/responsive-i18n.spec.ts @@ -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, + ); + }); + } + }); +} diff --git a/frontend/e2e/roadmap-regression.spec.ts b/frontend/e2e/roadmap-regression.spec.ts new file mode 100644 index 0000000..5e00eef --- /dev/null +++ b/frontend/e2e/roadmap-regression.spec.ts @@ -0,0 +1,45 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +async function reset(request: APIRequestContext) { + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + await request.post("/api/v1/demo/reset"); + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); +} + +async function login(page: Page) { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +test.beforeEach(async ({ request }) => reset(request)); + +test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => { + const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25"); + expect(vehicles.ok()).toBeTruthy(); + const vehiclePage = await vehicles.json(); + expect(vehiclePage.items).toHaveLength(25); + expect(vehiclePage.total).toBeGreaterThanOrEqual(25); + + const audit = await request.get("/api/v1/audit?page=1&page_size=25"); + expect(audit.ok()).toBeTruthy(); + const auditPage = await audit.json(); + expect(auditPage.items.length).toBeLessThanOrEqual(25); + + await login(page); + await page.goto("/vehicles?status=maintenance&page=1"); + await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible(); + await expect(page).toHaveURL(/status=maintenance/); +}); + +test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await login(page); + await page.goto("/bookings/BK-DEMO-RETURN"); + + await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible(); + await expect(page.locator(".page-actions .badge")).toBeVisible(); + await page.goto("/vehicles"); + const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth })); + expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1); +}); diff --git a/frontend/e2e/ui-redesign.spec.ts b/frontend/e2e/ui-redesign.spec.ts new file mode 100644 index 0000000..405ab50 --- /dev/null +++ b/frontend/e2e/ui-redesign.spec.ts @@ -0,0 +1,107 @@ +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"); +} + +// This file's assertions were authored against the English UI copy; nl-BE is now the +// app's default for a fresh session, so force English explicitly rather than rewriting +// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs). +test.beforeEach(async ({ page, request }) => { + await resetDemoData(request); + await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB")); + await page.goto("/login"); + await page.getByRole("button", { name: "Explore as Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +}); + +test("control-centre shell exposes landmarks, persisted readiness and active navigation", async ({ page }) => { + await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible(); + await expect(page.getByRole("main")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible(); + await expect(page.locator(".metric-cell dd").first()).not.toHaveText(""); + await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page"); +}); + +test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => { + const search = page.getByRole("combobox", { name: "Search Fleet Ops" }); + await expect(async () => { + await page.keyboard.press("Control+k"); + await expect(search).toBeFocused(); + }).toPass({ timeout: 5_000 }); + await search.fill("MO-024"); + const result = page.getByRole("option", { name: /MO-024/ }); + await expect(result).toBeVisible(); + await result.click(); + await expect(page).toHaveURL(/\/vehicles\/MO-024$/); + await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible(); +}); + +test("global search supports arrow-key navigation and Enter to select", async ({ page }) => { + const search = page.getByRole("combobox", { name: "Search Fleet Ops" }); + await search.fill("fleet"); + await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible(); + await search.press("ArrowDown"); + await search.press("Enter"); + await expect(page).toHaveURL(/\/vehicles$/); +}); + +test("global search shows a no-results state and closes on Escape", async ({ page }) => { + const search = page.getByRole("combobox", { name: "Search Fleet Ops" }); + await search.fill("zzz-nothing-matches-zzz"); + await expect(page.getByText(/No matches for/)).toBeVisible(); + await search.press("Escape"); + await expect(page.getByRole("listbox")).not.toBeVisible(); +}); + +test("global search finds a booking and a data-quality issue by reference", async ({ page }) => { + const search = page.getByRole("combobox", { name: "Search Fleet Ops" }); + await search.fill("BK-DEMO-RETURN"); + const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ }); + await expect(bookingResult).toBeVisible(); + await bookingResult.click(); + await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/); + + await search.fill("DQ-DEMO-OVERLAP"); + const issueResult = page.getByRole("option", { name: /DQ-DEMO-OVERLAP/ }); + await expect(issueResult).toBeVisible(); + await issueResult.click(); + await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-OVERLAP$/); +}); + +test("return review separates capture from irreversible commit", async ({ page }) => { + await page.goto("/bookings/BK-DEMO-RETURN"); + await page.getByLabel("End odometer (km)").fill("60000"); + await page.getByLabel("Fuel level (%)").fill("65"); + + let commitRequests = 0; + let previewRequests = 0; + page.on("request", (request) => { + 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 expect(page.getByText(/Expected fleet state/)).toBeVisible(); + expect(commitRequests).toBe(0); + // The review step is server-evaluated (not client-guessed), so exactly one non-mutating + // preview call is expected before any commit. + expect(previewRequests).toBe(1); + await expect(page.getByText("Queue automation after the local commit")).toBeVisible(); + + await page.getByRole("button", { name: "Edit details" }).click(); + await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000"); +}); + +for (const width of [390, 768, 1280, 1440]) { + test(`responsive shell has no horizontal overflow at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: width < 700 ? 844 : 900 }); + await page.goto("/dashboard"); + await expect(page.getByRole("heading", { name: "Attention queue" })).toBeVisible(); + const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth })); + expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1); + if (width < 960) await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible(); + }); +} diff --git a/frontend/e2e/visual-regression.spec.ts b/frontend/e2e/visual-regression.spec.ts new file mode 100644 index 0000000..a82875f --- /dev/null +++ b/frontend/e2e/visual-regression.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; + +test("public entry and engineering highlights retain their visual hierarchy", async ({ page }) => { + await page.goto("/login"); + await expect(page.locator(".login-access")).toHaveScreenshot("login-access.png", { + animations: "disabled", + maxDiffPixelRatio: 0.02, + }); + + await page.goto("/highlights"); + await expect(page.locator("main")).toHaveScreenshot("highlights-main.png", { + animations: "disabled", + maxDiffPixelRatio: 0.02, + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..9ae0a3f --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,35 @@ +// @ts-check +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import globals from "globals"; + +export default tseslint.config( + { ignores: ["dist/", "playwright-report/", "test-results/", "node_modules/"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + jsxA11y.flatConfigs.recommended, + { + files: ["src/**/*.{ts,tsx}"], + languageOptions: { globals: { ...globals.browser } }, + rules: { + // Hook dependency mistakes are real bugs in this codebase's data-loading effects. + "react-hooks/exhaustive-deps": "error", + // The React Compiler-oriented rules in react-hooks v7 flag the classic + // "reset state, then fetch in an effect" data-loading pattern this React 18 app + // uses deliberately; they are not bugs here. + "react-hooks/set-state-in-effect": "off", + "react-hooks/purity": "off", + "react-hooks/refs": "off", + // Choice cards wrap the input and put the visible text two spans deep. + "jsx-a11y/label-has-associated-control": ["error", { depth: 3 }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + }, + }, + { + files: ["e2e/**/*.ts", "*.config.ts"], + languageOptions: { globals: { ...globals.node } }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..919576e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + Fleet Ops — connected vehicle operations + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..daf9acf --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,61 @@ +limit_req_zone $binary_remote_addr zone=demo_login:10m rate=600r/m; + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + server_tokens off; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/css application/javascript application/json image/svg+xml font/woff2; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + location = /api/v1/demo/login { + limit_req zone=demo_login burst=100 nodelay; + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/v1/demo/reset { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://api:8000/health; + } + + location / { + expires -1; + try_files $uri /index.html; + } + + # Vite emits content-hashed filenames like "Bookings-BXgkh2jX.js" (hyphen + 8 chars, + # base64url alphabet, one dot); anything under /assets/ matching that is immutable. + location ~* "^/assets/.+-[A-Za-z0-9_-]{8,}\.(css|js|woff2|png|svg)$" { + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header X-Content-Type-Options "nosniff" always; + try_files $uri =404; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..26dc768 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4916 @@ +{ + "name": "mobilityops-web", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mobilityops-web", + "version": "0.0.1", + "dependencies": { + "i18next": "26.3.6", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-i18next": "17.0.11", + "react-router-dom": "7.18.2" + }, + "devDependencies": { + "@eslint/js": "9.39.5", + "@playwright/test": "1.62.1", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "6.0.5", + "axe-core": "4.13.0", + "eslint": "9.39.5", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-react-hooks": "7.1.1", + "globals": "17.11.0", + "typescript": "5.6.3", + "typescript-eslint": "8.67.0", + "vite": "8.2.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.407", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", + "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "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-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d5ac201 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "mobilityops-web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "budget": "node ../scripts/check-frontend-budget.mjs", + "preview": "vite preview --host 0.0.0.0", + "lint": "tsc -b --noEmit && eslint .", + "test:e2e": "playwright test" + }, + "dependencies": { + "i18next": "26.3.6", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-i18next": "17.0.11", + "react-router-dom": "7.18.2" + }, + "devDependencies": { + "@eslint/js": "9.39.5", + "@playwright/test": "1.62.1", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "6.0.5", + "axe-core": "4.13.0", + "eslint": "9.39.5", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-react-hooks": "7.1.1", + "globals": "17.11.0", + "typescript": "5.6.3", + "typescript-eslint": "8.67.0", + "vite": "8.2.1" + }, + "engines": { + "node": ">=22" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..62cc300 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + // _capture-screenshots.spec.ts is evidence-generation tooling, not a regression test — + // it mutates demo data via a reset and is run explicitly, not as part of the suite. + testIgnore: process.env.CAPTURE_EVIDENCE === "1" ? undefined : "**/_*.spec.ts", + timeout: 30_000, + // CI exercises the browser while image scans and container services share the + // same runner. Keep assertions strict, but allow successful API-backed route + // transitions enough time to render under that bounded load. + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}", + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], + use: { + baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228", + // Optional: point at a pre-installed Chromium (CI images, sandboxes) instead of the + // Playwright-managed download. + ...(process.env.PW_CHROMIUM_PATH ? { launchOptions: { executablePath: process.env.PW_CHROMIUM_PATH } } : {}), + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/frontend/playwright.live.config.ts b/frontend/playwright.live.config.ts new file mode 100644 index 0000000..8a704a3 --- /dev/null +++ b/frontend/playwright.live.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e-live", + timeout: 30_000, + fullyParallel: false, + workers: 1, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-live-report" }]], + use: { + baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + ], +}); diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..3005526 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/og-fleet-ops.svg b/frontend/public/og-fleet-ops.svg new file mode 100644 index 0000000..3b6d594 --- /dev/null +++ b/frontend/public/og-fleet-ops.svg @@ -0,0 +1,24 @@ + + Fleet Ops + Connected vehicle operations with reliable workflows, explainable data quality and grounded knowledge. + + + + + + + + + + + Fleet Ops + + CONNECTED VEHICLE OPERATIONS + Every hand-off. + One clear view. + + Reliable workflows + Explainable quality + Grounded knowledge + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9884382 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,68 @@ +import { lazy, Suspense, type ReactNode } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import { DemoManifestProvider } from "./context/DemoManifestContext"; +import { DemoGuideProvider } from "./context/DemoGuideContext"; +import { Layout } from "./components/Layout"; +import { RequireAuth } from "./components/RequireAuth"; +const Login = lazy(() => import("./pages/Login").then((module) => ({ default: module.Login }))); +const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ default: module.Dashboard }))); +const Vehicles = lazy(() => import("./pages/Vehicles").then((module) => ({ default: module.Vehicles }))); +const VehicleDetail = lazy(() => import("./pages/VehicleDetail").then((module) => ({ default: module.VehicleDetail }))); +const Bookings = lazy(() => import("./pages/Bookings").then((module) => ({ default: module.Bookings }))); +const BookingDetail = lazy(() => import("./pages/BookingDetail").then((module) => ({ default: module.BookingDetail }))); +const BookingCreate = lazy(() => import("./pages/BookingCreate").then((module) => ({ default: module.BookingCreate }))); +const DataQuality = lazy(() => import("./pages/DataQuality").then((module) => ({ default: module.DataQuality }))); +const DataQualityIssueDetail = lazy(() => import("./pages/DataQualityIssueDetail").then((module) => ({ default: module.DataQualityIssueDetail }))); +const Automation = lazy(() => import("./pages/Automation").then((module) => ({ default: module.Automation }))); +const Knowledge = lazy(() => import("./pages/Knowledge").then((module) => ({ default: module.Knowledge }))); +const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: module.Audit }))); +const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo }))); +const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios }))); +const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users }))); +const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy }))); +const Highlights = lazy(() => import("./pages/Highlights").then((module) => ({ default: module.Highlights }))); + +function deferredPage(element: ReactNode) { + return
}>{element}
; +} + +export function App() { + return ( + + + + + )} /> + + + + } + > + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + + } /> + } /> + + + + + ); +} diff --git a/frontend/src/api/apiError.ts b/frontend/src/api/apiError.ts new file mode 100644 index 0000000..3f30deb --- /dev/null +++ b/frontend/src/api/apiError.ts @@ -0,0 +1,12 @@ +export class ApiError extends Error { + status: number; + code: string; + correlationId: string; + + constructor(status: number, code: string, message: string, correlationId: string) { + super(message); + this.status = status; + this.code = code; + this.correlationId = correlationId; + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..bfaa1b4 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,86 @@ +import { ApiError } from "./apiError"; + +export { ApiError } from "./apiError"; + +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; + +type UnauthorizedListener = () => void; +const unauthorizedListeners = new Set(); + +export function onUnauthorized(listener: UnauthorizedListener): () => void { + unauthorizedListeners.add(listener); + return () => unauthorizedListeners.delete(listener); +} + +const DEFAULT_TIMEOUT_MS = 15_000; + +/** `AbortSignal.any` with a fallback for browsers that predate it (Safari < 17.4, etc.). */ +function combineSignals(caller: AbortSignal, timeout: AbortSignal): AbortSignal { + if (typeof AbortSignal.any === "function") return AbortSignal.any([caller, timeout]); + const combined = new AbortController(); + const forward = (source: AbortSignal) => () => combined.abort(source.reason); + if (caller.aborted) combined.abort(caller.reason); + else caller.addEventListener("abort", forward(caller), { once: true }); + if (timeout.aborted) combined.abort(timeout.reason); + else timeout.addEventListener("abort", forward(timeout), { once: true }); + return combined.signal; +} + +async function request(path: string, init?: RequestInit): Promise { + const timeoutController = new AbortController(); + const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS); + const signal = init?.signal + ? combineSignals(init.signal, timeoutController.signal) + : timeoutController.signal; + let response: Response; + try { + response = await fetch(`${API_BASE}${path}`, { + ...init, + signal, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + } catch (error) { + if (timeoutController.signal.aborted && !init?.signal?.aborted) { + throw new ApiError(408, "REQUEST_TIMEOUT", "The request took too long.", ""); + } + throw error; + } finally { + window.clearTimeout(timeout); + } + + if (!response.ok) { + let body: { error?: { code: string; message: string; correlation_id: string } } | undefined; + try { + body = await response.json(); + } catch { + body = undefined; + } + const error = body?.error; + if (response.status === 401) { + unauthorizedListeners.forEach((listener) => listener()); + } + throw new ApiError( + response.status, + error?.code ?? String(response.status), + error?.message ?? response.statusText, + error?.correlation_id ?? "", + ); + } + + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; +} + +export const api = { + get: (path: string, init?: Pick) => request(path, init), + post: (path: string, body?: unknown, headers?: Record) => + request(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }), +}; diff --git a/frontend/src/api/errorMessages.ts b/frontend/src/api/errorMessages.ts new file mode 100644 index 0000000..c279a1d --- /dev/null +++ b/frontend/src/api/errorMessages.ts @@ -0,0 +1,106 @@ +import { ApiError } from "./apiError"; + +export interface ApiErrorInfo { + title: string; + explanation: string; + nextStep?: string; + technical: string; +} + +type TFn = (key: string, options?: Record) => string; + +// Backend AppError codes this frontend knows how to present with a localized title, +// explanation and (where useful) a next step -- see +// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site. +// Anything not in this list still gets a sensible HTTP-status-based fallback below, so +// a newly-introduced backend code never regresses to raw English -- it just falls back +// to a generic-but-localized message until this list is extended. +export const KNOWN_CODES = new Set([ + "VEHICLE_NOT_FOUND", + "BOOKING_NOT_FOUND", + "CUSTOMER_NOT_FOUND", + "CUSTOMER_ALREADY_MERGED", + "ENTITY_NOT_FOUND", + "EVENT_NOT_FOUND", + "ISSUE_NOT_FOUND", + "ISSUE_NOT_OPEN", + "ISSUE_CHANGED", + "BOOKING_NOT_ACTIVE", + "INVALID_BOOKING_STATE", + "NOT_RETRYABLE", + "CONFLICT_STILL_PRESENT", + "OVERLAP_STILL_PRESENT", + "EMPTY_VALUE", + "NO_FIELDS_PROVIDED", + "INVALID_FIELD", + "INVALID_FIELD_OVERRIDE", + "INVALID_SURVIVOR", + "INVALID_BOOKING_REFERENCE", + "INVALID_EVENT_ID", + "INVALID_EVENT_CORRELATION", + "CALLBACK_EVENT_MISMATCH", + "CALLBACK_CORRELATION_MISMATCH", + "INVALID_IDEMPOTENCY_KEY", + "IDEMPOTENCY_KEY_REUSED", + "CORRECTED_VALUE_REQUIRED", + "CORRECTION_BELOW_CANONICAL", + "NOT_A_DUPLICATE_ISSUE", + "NOT_A_MISSING_FIELD_ISSUE", + "NOT_AN_ODOMETER_ISSUE", + "NOT_AN_OVERLAP_ISSUE", + "NOT_A_STATUS_CONFLICT_ISSUE", + "UNSUPPORTED_ENTITY", + "MANUAL_REVIEW_REQUIRED", + "NO_CONFLICT_DETECTED", + "RECOMMENDATION_STALE", + "UNAUTHORIZED_SERVICE", + "UNKNOWN_WORKFLOW", +]); + +const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]); + +/** + * Turns a caught error into a localized {title, explanation, nextStep?, technical} + * for display. The raw backend/network text is only ever exposed as `technical` + * (shown under "Technical details" by ApiErrorNotice) -- never as the primary message. + * + * `fallbackKey` is an existing, already-localized `t()` key used as the explanation + * when the error isn't an ApiError at all (e.g. the fetch failed before a response + * existed) and errors:generic doesn't fit the specific action being attempted. + */ +export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo { + if (!(err instanceof ApiError)) { + return { + title: t("errors:generic.title"), + explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"), + technical: err instanceof Error ? err.message : String(err), + }; + } + + if (KNOWN_CODES.has(err.code)) { + const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" }); + return { + title: t(`errors:codes.${err.code}.title`), + explanation: t(`errors:codes.${err.code}.explanation`), + nextStep: nextStep || undefined, + technical: err.message, + }; + } + + const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status); + if (KNOWN_HTTP_STATUSES.has(httpKey)) { + const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" }); + return { + title: t(`errors:http.${httpKey}.title`), + explanation: t(`errors:http.${httpKey}.explanation`), + nextStep: nextStep || undefined, + technical: err.message, + }; + } + + return { + title: t("errors:generic.title"), + explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"), + technical: err.message, + }; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..6a5ac2d --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,442 @@ +export type Role = "operations_manager" | "rental_employee"; + +export interface CurrentUser { + public_ref: string; + display_name: string; + role: Role; +} + +export interface SystemStatus { + service: string; + environment: string; + demo_mode: boolean; + knowledge_provider: string; + oidc_enabled: boolean; + oidc_provider_name: string | null; +} + +export interface Vehicle { + public_ref: string; + make: string; + model: string; + model_year: number; + registration_number: string; + location: string; + operational_status: string; + odometer_km: number; + next_service_km: number; + active: boolean; + attention: boolean; + attention_reason: "blocked_status" | "service_due" | "data_quality" | null; + service_remaining_km: number; + next_booking_ref: string | null; + next_booking_at: string | null; +} + +export interface Page { + items: T[]; + page: number; + page_size: number; + total: number; + total_pages: number; +} + +export interface BookingSummary { + public_ref: string; + customer_ref: string; + vehicle_ref: string; + starts_at: string; + ends_at: string; + status: string; +} + +export interface Booking extends BookingSummary { + start_odometer_km: number | null; + end_odometer_km: number | null; + requirements_complete: boolean; + customer_name: string; +} + +export interface CustomerOption { + public_ref: string; + display_name: string; + email: string | null; +} + +export interface AvailableVehicle { + public_ref: string; + make: string; + model: string; + registration_number: string; + location: string; + operational_status: string; +} + +export interface CheckoutBookingResult { + booking_ref: string; + vehicle_ref: string; + inspection_ref: string; + booking_status: string; + resulting_vehicle_status: string; + activated: boolean; + attention_reasons: string[]; +} + +export interface UserRecord { + public_ref: string; + email: string | null; + display_name: string; + role: Role; + active: boolean; +} + +export interface PrivacyRetention { + minimum_booking_retention_days: number; + audit_retention_days: number; + customers_total: number; + customers_anonymized: number; + customers_eligible: number; +} + +export interface CustomerAnonymizeResult { + public_ref: string; + anonymized_at: string; + status: "anonymized" | "already_anonymized"; +} + +export interface Inspection { + public_ref: string; + booking_ref: string; + type: string; + fuel_level_percent: number; + cleanliness_ok: boolean; + damage_reported: boolean; + technical_warning: boolean; + odometer_km: number; + completed_at: string; +} + +export interface MaintenanceRecord { + public_ref: string; + occurred_at: string; + odometer_km: number; + category: string; + summary: string; +} + +export interface DataQualityIssue { + public_ref: string; + rule_type: string; + entity_type: string; + entity_ref: string; + severity: "low" | "medium" | "high"; + status: "open" | "deferred" | "resolved" | "rejected"; + evidence: Record; + detected_at: string; + due_at: string | null; + assigned_to_ref: string | null; + assigned_to_name: string | null; + overdue: boolean; + resolved_at: string | null; +} + +export interface VehicleDetail extends Vehicle { + bookings: BookingSummary[]; + inspections: Inspection[]; + maintenance: MaintenanceRecord[]; + quality_issues: DataQualityIssue[]; +} + +export interface DashboardMetrics { + available: number; + rented: number; + cleaning: number; + maintenance: number; + blocked: number; + open_quality_issues: number; + pending_or_failed_workflows: number; +} + +export interface EvidenceSignal { + code: string; + params?: Record; +} + +export interface AttentionItem { + kind: string; + severity: "low" | "medium" | "high"; + rule_type: string; + evidence_signals: EvidenceSignal[]; + link_type: "vehicle" | "booking" | "customer"; + link_ref: string; + issue_ref: string | null; +} + +export interface TodayItem { + kind: "departure" | "return"; + booking_ref: string; + vehicle_ref: string; + scheduled_at: string; +} + +export interface AutomationRun { + event_id: string; + event_type: string; + aggregate_ref: string; + status: string; + attempts: number; + last_error: string | null; + last_error_code: string | null; + /** True for the failure the demo seed plants on purpose (see the backend's + * DEMO_SCENARIO_ERROR_CODE). Drives the "prepared scenario" labelling instead of + * showing it as an unexplained production error. */ + is_demo_scenario: boolean; + occurred_at: string; +} + +export interface Dashboard { + metrics: DashboardMetrics; + attention_items: AttentionItem[]; + today: TodayItem[]; + recent_automation: AutomationRun[]; +} + +export interface RegisterReturnRequest { + end_odometer_km: number; + fuel_level_percent: number; + cleanliness_ok: boolean; + damage_reported: boolean; + technical_warning: boolean; + notes?: string; +} + +export interface NextBookingRisk { + booking_ref: string; + starts_at: string; + at_risk: boolean; +} + +export interface RegisterReturnResult { + booking_ref: string; + vehicle_ref: string; + inspection_ref: string; + resulting_vehicle_status: string; + odometer_regression: boolean; + quality_issue_ref: string | null; + workflow_event_id: string; + correlation_id: string; + 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; + status_reason_code: string; + status_reason_params: Record; + would_create_quality_issue: boolean; + attention_reasons: string[]; + next_booking_risk: NextBookingRisk | null; +} + +export interface EntitySnapshot { + entity_type: "customer" | "vehicle" | "booking" | "inspection" | "maintenance"; + public_ref: string; + [key: string]: unknown; +} + +export interface DataQualityIssueDetail extends DataQualityIssue { + entity_snapshot: EntitySnapshot | null; + related_snapshots: EntitySnapshot[]; +} + +export interface ApplyRecommendedStatusResult { + issue: DataQualityIssue; + applied_status: string; + reason_code: string; +} + +export interface VehicleStatusFacts { + active_booking_refs: string[]; + overlapping_booking_pairs: string[][]; + service_threshold_reached: boolean; + odometer_km: number; + next_service_km: number; + open_booking_overlap_issue_ref: string | null; +} + +export interface StatusRecommendation { + current_status: string; + recommended_status: string | null; + recommendation_code: string; + safe_to_apply: boolean; + manual_review_required: boolean; + facts: VehicleStatusFacts; + blocking_reasons: string[]; + recommendation_token: string; +} + +export interface ScanResult { + created: Record; +} + +export interface SearchResultItem { + type: "vehicle" | "booking" | "data_quality_issue" | "section"; + label: string; + detail_code: string; + detail_params: Record; + link: string; +} + +export interface SearchResponse { + query: string; + results: SearchResultItem[]; +} + +export interface MergeCustomersRequest { + survivor_ref: string; + field_overrides?: Record; +} + +export interface MergeCustomersResult { + issue_ref: string; + survivor_ref: string; + loser_ref: string; + rewired_bookings: number; +} + +export interface SourceCard { + document_id: string; + title: string; + version: string; + section: string; + excerpt: string; +} + +export interface GroundedAnswer { + answer: string; + evidence_state: "grounded" | "insufficient" | "unavailable"; + sources: SourceCard[]; + provider: string; + correlation_id: string; +} + +export interface KnowledgeHealth { + provider: string; + available: boolean; + detail: string; + tenant: string; + workspace: string; + collection: string; + document_count: number | null; + source_document_count: number; + reported_synced_document_count: number | null; + reported_failed_document_count: number | null; + last_sync_at: string | null; + statistics_state: "verified" | "sync_reported" | "not_reported"; +} + +export interface N8nWorkflowEvidence { + name: string; + built: boolean; + last_seen_at: string | null; + state: "no_evidence" | "healthy" | "stale" | "failed"; + last_status: "succeeded" | "failed" | null; + last_execution_id: string | null; +} + +export interface N8nErrorHandlerStatus { + total_failures_registered: number; + latest_failure_at: string | null; + latest_failure_workflow: string | null; +} + +export interface N8nIntegrationStatus { + configured: boolean; + dispatch_enabled: boolean; + state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence"; + pending: number; + delivering: number; + /** Every failed delivery, prepared and real together. */ + failed: number; + /** Failures that were not planted by the demo seed -- the only ones that affect state. */ + unexpected_failed: number; + /** Prepared demo failures. Visible and counted, but never a health signal. */ + demo_scenario_failed: number; + succeeded: number; + latest_success_at: string | null; + /** Most recent real failure; a prepared one never sets this. */ + latest_failure_at: string | null; + latest_demo_scenario_at: string | null; + expected_workflow_count: number; + known_workflow_count: number; + workflows: N8nWorkflowEvidence[]; + error_handler: N8nErrorHandlerStatus; +} + +export interface McpHubIntegrationStatus { + registration_enabled: boolean; + state: "not_configured" | "no_evidence" | "operational"; + total_calls: number; + last_tool: string | null; + last_client: string | null; + last_called_at: string | null; + hub_reachable: boolean | null; +} + +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; +} + +export interface DemoIntegrationSummary { + key: "n8n" | "ragcore" | "mcp_hub"; + status_code: string; + detail_code: string; + detail_params: Record; +} + +export interface DemoManifest { + demo_mode: boolean; + organization_name: string; + timezone: string; + synthetic_data: boolean; + allow_reset: boolean; + last_reset_at: string | null; + anchor_date: string | null; + guide_available: boolean; + required_roles: Role[]; + scenarios: DemoScenario[]; + integrations: DemoIntegrationSummary[]; +} + +export interface AuditEvent { + id: string; + actor_type: string; + actor_label: string; + action: string; + entity_type: string; + entity_id: string | null; + entity_ref: string | null; + entity_link: string | null; + correlation_id: string; + occurred_at: string; + before: Record | null; + after: Record | null; + metadata: Record | null; +} diff --git a/frontend/src/components/ArchitectureFlow.tsx b/frontend/src/components/ArchitectureFlow.tsx new file mode 100644 index 0000000..ecef25f --- /dev/null +++ b/frontend/src/components/ArchitectureFlow.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon, type IconName } from "./Icons"; +import "../styles-architecture-flow.css"; + +type ArchitectureStepId = "Frontend" | "Api" | "Database" | "Outbox" | "External"; +type ArchitectureZone = "intent" | "transaction" | "edge"; + +const ARCHITECTURE_STEPS: Array<{ + id: ArchitectureStepId; + icon: IconName; + zone: ArchitectureZone; +}> = [ + { id: "Frontend", icon: "user", zone: "intent" }, + { id: "Api", icon: "shield", zone: "transaction" }, + { id: "Database", icon: "audit", zone: "transaction" }, + { id: "Outbox", icon: "activity", zone: "transaction" }, + { id: "External", icon: "integrations", zone: "edge" }, +]; + +const ZONE_TRANSLATION_KEYS: Record = { + intent: "architectureZoneIntent", + transaction: "architectureZoneTransaction", + edge: "architectureZoneEdge", +}; + +export function ArchitectureFlow() { + const { t } = useTranslation("demo"); + const [activeStepId, setActiveStepId] = useState("Database"); + const activeIndex = ARCHITECTURE_STEPS.findIndex((step) => step.id === activeStepId); + const activeStep = ARCHITECTURE_STEPS[activeIndex]; + + return ( +
+
+ {t("about.architectureZoneIntent")} + {t("about.architectureZoneTransaction")} + {t("about.architectureZoneEdge")} +
+ +
    + {ARCHITECTURE_STEPS.map((step, index) => { + const isActive = step.id === activeStepId; + const isCommitBoundary = step.id === "Outbox"; + return ( +
  1. + + {index < ARCHITECTURE_STEPS.length - 1 ? ( +
  2. + ); + })} +
+ +

{t("about.architectureInteractionHint")}

+
+
+ {t("about.architectureSelectedStep", { current: activeIndex + 1, total: ARCHITECTURE_STEPS.length })} +

{t(`about.architecture${activeStep.id}`)}

+

{t(`about.architectureDetails.${activeStep.id}.body`)}

+
+
+
+
{t("about.architectureBoundaryLabel")}
+
{t(`about.${ZONE_TRANSLATION_KEYS[activeStep.zone]}`)}
+
+
+
{t("about.architectureEvidenceLabel")}
+
+ {t(`about.architectureDetails.${activeStep.id}.proofOne`)} + {t(`about.architectureDetails.${activeStep.id}.proofTwo`)} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx new file mode 100644 index 0000000..d169b99 --- /dev/null +++ b/frontend/src/components/Badge.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from "react-i18next"; + +export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) { + const { t } = useTranslation("quality"); + return {t(`severities.${severity}`)}; +} + +export function StatusBadge({ status, label }: { status: string; label?: string }) { + return {label ?? status.replace(/_/g, " ")}; +} diff --git a/frontend/src/components/CheckoutForm.tsx b/frontend/src/components/CheckoutForm.tsx new file mode 100644 index 0000000..c18df51 --- /dev/null +++ b/frontend/src/components/CheckoutForm.tsx @@ -0,0 +1,65 @@ +import { FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../api/client"; +import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; +import type { CheckoutBookingResult, VehicleDetail } from "../api/types"; +import { ApiErrorNotice, LoadingState, SectionHeading } from "./PageChrome"; + +export function CheckoutForm({ bookingRef, vehicleRef, onRecorded }: { bookingRef: string; vehicleRef: string; onRecorded: (result: CheckoutBookingResult) => void }) { + const { t } = useTranslation(["bookings", "errors"]); + const [odometer, setOdometer] = useState(null); + const [fuel, setFuel] = useState(100); + const [clean, setClean] = useState(true); + const [damage, setDamage] = useState(false); + const [warning, setWarning] = useState(false); + const [notes, setNotes] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.get(`/api/v1/vehicles/${vehicleRef}`) + .then((vehicle) => setOdometer(vehicle.odometer_km)) + .catch((err) => setError(describeApiError(t, err))); + }, [t, vehicleRef]); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (odometer === null) return; + setSaving(true); + setError(null); + try { + const result = await api.post(`/api/v1/bookings/${bookingRef}/checkout`, { + start_odometer_km: odometer, + fuel_level_percent: fuel, + cleanliness_ok: clean, + damage_reported: damage, + technical_warning: warning, + notes: notes || null, + }); + onRecorded(result); + } catch (err) { + setError(describeApiError(t, err, "bookings:checkout.failed")); + } finally { + setSaving(false); + } + } + + if (odometer === null && !error) return ; + return
+ +
+ +
+ + +
+
{t("checkout.condition")} + + + +
+