From 7adc072f5e20c13b90a645de589565b166a94f10 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Tue, 29 Sep 2026 22:03:33 +0200 Subject: [PATCH] Publish curated MobilityOps source snapshot --- .dockerignore | 57 + .env.example | 123 + .gitattributes | 14 + .gitignore | 66 + CONTRIBUTING.md | 12 + LICENSE | 21 + Makefile | 59 + PUBLIC_SOURCE_EXPORT.md | 3 + PUBLIC_SOURCE_MANIFEST.json | 1596 ++++++ README.md | 20 + SECURITY.md | 56 + backend/Dockerfile | 36 + backend/alembic.ini | 38 + backend/alembic/env.py | 46 + backend/alembic/script.py.mako | 26 + ...d2e3f5b_idempotency_request_fingerprint.py | 28 + ...b9c8d7e61_unique_open_quality_condition.py | 27 + .../799d8800e241_outbox_last_error_code.py | 25 + .../versions/a81d0ce9f662_oidc_identity.py | 28 + ...c7b536df85_operational_user_credentials.py | 25 + .../b913a72e8c14_customer_privacy_state.py | 24 + ...c24f6a9d013e_domain_constraints_indexes.py | 68 + .../versions/c9498525abb5_initial_schema.py | 184 + .../versions/d1f83bc64170_revoked_sessions.py | 49 + .../e7b08389f47f_idempotency_records.py | 41 + .../f43d829ab610_quality_work_queue.py | 65 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/deps.py | 94 + backend/app/api/routers/__init__.py | 0 backend/app/api/routers/audit.py | 214 + backend/app/api/routers/auth.py | 312 ++ backend/app/api/routers/bookings.py | 565 ++ backend/app/api/routers/customers.py | 42 + backend/app/api/routers/dashboard.py | 151 + backend/app/api/routers/data_quality.py | 446 ++ backend/app/api/routers/demo.py | 186 + backend/app/api/routers/integration_status.py | 21 + backend/app/api/routers/integrations.py | 302 + backend/app/api/routers/knowledge.py | 169 + backend/app/api/routers/mcp_integrations.py | 204 + backend/app/api/routers/observability.py | 46 + backend/app/api/routers/privacy.py | 166 + backend/app/api/routers/search.py | 184 + backend/app/api/routers/users.py | 100 + backend/app/api/routers/vehicles.py | 408 ++ backend/app/api/routers/workflows.py | 90 + backend/app/cli.py | 35 + backend/app/core/__init__.py | 0 backend/app/core/config.py | 128 + backend/app/core/db.py | 54 + backend/app/core/errors.py | 31 + backend/app/core/observability.py | 113 + backend/app/core/ratelimit.py | 72 + backend/app/core/security.py | 87 + backend/app/main.py | 201 + backend/app/models/__init__.py | 27 + backend/app/models/audit.py | 32 + backend/app/models/booking.py | 45 + backend/app/models/customer.py | 26 + backend/app/models/data_quality.py | 65 + backend/app/models/idempotency.py | 22 + backend/app/models/inspection.py | 32 + backend/app/models/maintenance.py | 22 + backend/app/models/mixins.py | 22 + backend/app/models/outbox.py | 61 + backend/app/models/revoked_session.py | 16 + backend/app/models/user.py | 23 + backend/app/models/vehicle.py | 33 + backend/app/schemas.py | 650 +++ backend/app/seed_loader.py | 500 ++ backend/app/services/__init__.py | 0 backend/app/services/audit.py | 40 + backend/app/services/data_quality.py | 799 +++ backend/app/services/data_quality_common.py | 59 + .../services/data_quality_duplicate_scan.py | 108 + backend/app/services/data_quality_odometer.py | 507 ++ backend/app/services/demo_manifest.py | 220 + backend/app/services/dispatcher.py | 262 + backend/app/services/integration_status.py | 343 ++ backend/app/services/knowledge/__init__.py | 67 + backend/app/services/knowledge/demo.py | 413 ++ backend/app/services/knowledge/procedures.py | 73 + backend/app/services/knowledge/ragcore.py | 577 ++ backend/app/services/operations.py | 77 + backend/app/services/returns.py | 372 ++ backend/app/services/sessions.py | 34 + backend/app/services/vehicle_status.py | 210 + backend/entrypoint.sh | 7 + backend/pyproject.toml | 44 + backend/requirements-prod.lock | 173 + backend/requirements.lock | 142 + backend/scripts/generate_openapi.py | 31 + backend/tests/conftest.py | 38 + backend/tests/test_audit.py | 83 + backend/tests/test_auth.py | 306 + backend/tests/test_bookings.py | 382 ++ backend/tests/test_dashboard.py | 89 + backend/tests/test_data_quality.py | 1420 +++++ backend/tests/test_database_constraints.py | 33 + backend/tests/test_demo_manifest.py | 158 + backend/tests/test_dispatcher.py | 400 ++ backend/tests/test_hardening.py | 341 ++ backend/tests/test_health.py | 41 + backend/tests/test_integration_status.py | 292 + backend/tests/test_integrations.py | 454 ++ backend/tests/test_knowledge.py | 1113 ++++ backend/tests/test_mcp_integrations.py | 187 + backend/tests/test_migrations.py | 88 + backend/tests/test_observability.py | 53 + backend/tests/test_operational_auth.py | 36 + backend/tests/test_privacy.py | 114 + backend/tests/test_return.py | 317 ++ backend/tests/test_search.py | 75 + backend/tests/test_seed.py | 296 + backend/tests/test_seed_generation.py | 73 + backend/tests/test_users.py | 44 + backend/tests/test_vehicle_status.py | 156 + backend/tests/test_vehicles.py | 144 + backend/tests/test_workflows.py | 95 + compose.test.yaml | 27 + compose.yaml | 125 + contracts/events.schema.json | 35 + contracts/mcp-tools.json | 79 + contracts/openapi.yaml | 4825 ++++++++++++++++ contracts/ragcore-contract-assumptions.md | 13 + frontend/.dockerignore | 7 + frontend/Dockerfile | 25 + frontend/e2e-live/smoke.spec.ts | 61 + .../e2e/__screenshots__/highlights-main.png | Bin 0 -> 83029 bytes frontend/e2e/__screenshots__/login-access.png | Bin 0 -> 29827 bytes .../e2e/_capture-demo-screenshots.spec.ts | 94 + .../_capture-recruiter-screenshots.spec.ts | 40 + frontend/e2e/_capture-screenshots.spec.ts | 53 + frontend/e2e/accessibility.spec.ts | 52 + frontend/e2e/clickable-rows.spec.ts | 112 + frontend/e2e/context-resilience.spec.ts | 250 + frontend/e2e/demo-accessibility.spec.ts | 144 + frontend/e2e/demo-entry.spec.ts | 61 + frontend/e2e/demo-guide.spec.ts | 260 + frontend/e2e/demo-legibility.spec.ts | 84 + frontend/e2e/demo.spec.ts | 159 + frontend/e2e/error-messages.spec.ts | 205 + frontend/e2e/fleet-ops-correction.spec.ts | 399 ++ frontend/e2e/greeting-live.spec.ts | 107 + frontend/e2e/greeting.spec.ts | 83 + frontend/e2e/guided-demo-full.spec.ts | 121 + frontend/e2e/i18n-coverage.spec.ts | 324 ++ frontend/e2e/interactive-elements.spec.ts | 468 ++ frontend/e2e/operational-workflows.spec.ts | 116 + frontend/e2e/privacy.spec.ts | 30 + frontend/e2e/recruiter-polish.spec.ts | 142 + frontend/e2e/responsive-i18n.spec.ts | 57 + frontend/e2e/roadmap-regression.spec.ts | 45 + frontend/e2e/ui-redesign.spec.ts | 107 + frontend/e2e/visual-regression.spec.ts | 15 + frontend/eslint.config.js | 35 + frontend/index.html | 23 + frontend/nginx.conf | 61 + frontend/package-lock.json | 4916 +++++++++++++++++ frontend/package.json | 39 + frontend/playwright.config.ts | 31 + frontend/playwright.live.config.ts | 18 + frontend/public/favicon.svg | 6 + frontend/public/og-fleet-ops.svg | 24 + frontend/src/App.tsx | 68 + frontend/src/api/apiError.ts | 12 + frontend/src/api/client.ts | 86 + frontend/src/api/errorMessages.ts | 106 + frontend/src/api/types.ts | 442 ++ frontend/src/components/ArchitectureFlow.tsx | 104 + frontend/src/components/Badge.tsx | 10 + frontend/src/components/CheckoutForm.tsx | 65 + frontend/src/components/DemoBadge.tsx | 74 + frontend/src/components/DemoGuide.tsx | 296 + frontend/src/components/Icons.tsx | 72 + frontend/src/components/LanguageSwitcher.tsx | 33 + frontend/src/components/Layout.tsx | 376 ++ frontend/src/components/PageChrome.tsx | 122 + frontend/src/components/Pagination.tsx | 25 + frontend/src/components/RequireAuth.tsx | 19 + frontend/src/components/ReturnForm.tsx | 272 + .../components/VehicleMaintenanceActions.tsx | 57 + frontend/src/context/AuthContext.tsx | 157 + frontend/src/context/DemoGuideContext.tsx | 130 + frontend/src/context/DemoManifestContext.tsx | 55 + frontend/src/hooks/useViewportTier.ts | 36 + frontend/src/i18n/brusselsDateTime.ts | 78 + frontend/src/i18n/config.ts | 175 + frontend/src/i18n/format.ts | 37 + frontend/src/i18n/greeting.ts | 28 + .../src/i18n/locales/en-GB/accessibility.json | 7 + frontend/src/i18n/locales/en-GB/audit.json | 104 + frontend/src/i18n/locales/en-GB/auth.json | 34 + frontend/src/i18n/locales/en-GB/bookings.json | 132 + frontend/src/i18n/locales/en-GB/common.json | 49 + .../src/i18n/locales/en-GB/dashboard.json | 85 + frontend/src/i18n/locales/en-GB/demo.json | 270 + frontend/src/i18n/locales/en-GB/errors.json | 206 + frontend/src/i18n/locales/en-GB/fleet.json | 91 + .../src/i18n/locales/en-GB/integrations.json | 125 + .../src/i18n/locales/en-GB/knowledge.json | 73 + .../src/i18n/locales/en-GB/navigation.json | 53 + .../src/i18n/locales/en-GB/operations.json | 4 + frontend/src/i18n/locales/en-GB/privacy.json | 19 + frontend/src/i18n/locales/en-GB/quality.json | 265 + frontend/src/i18n/locales/en-GB/returns.json | 82 + .../src/i18n/locales/fr-BE/accessibility.json | 7 + frontend/src/i18n/locales/fr-BE/audit.json | 104 + frontend/src/i18n/locales/fr-BE/auth.json | 34 + frontend/src/i18n/locales/fr-BE/bookings.json | 132 + frontend/src/i18n/locales/fr-BE/common.json | 49 + .../src/i18n/locales/fr-BE/dashboard.json | 85 + frontend/src/i18n/locales/fr-BE/demo.json | 270 + frontend/src/i18n/locales/fr-BE/errors.json | 206 + frontend/src/i18n/locales/fr-BE/fleet.json | 91 + .../src/i18n/locales/fr-BE/integrations.json | 125 + .../src/i18n/locales/fr-BE/knowledge.json | 73 + .../src/i18n/locales/fr-BE/navigation.json | 53 + .../src/i18n/locales/fr-BE/operations.json | 4 + frontend/src/i18n/locales/fr-BE/privacy.json | 19 + frontend/src/i18n/locales/fr-BE/quality.json | 265 + frontend/src/i18n/locales/fr-BE/returns.json | 82 + .../src/i18n/locales/nl-BE/accessibility.json | 7 + frontend/src/i18n/locales/nl-BE/audit.json | 104 + frontend/src/i18n/locales/nl-BE/auth.json | 34 + frontend/src/i18n/locales/nl-BE/bookings.json | 132 + frontend/src/i18n/locales/nl-BE/common.json | 49 + .../src/i18n/locales/nl-BE/dashboard.json | 85 + frontend/src/i18n/locales/nl-BE/demo.json | 270 + frontend/src/i18n/locales/nl-BE/errors.json | 206 + frontend/src/i18n/locales/nl-BE/fleet.json | 91 + .../src/i18n/locales/nl-BE/integrations.json | 125 + .../src/i18n/locales/nl-BE/knowledge.json | 73 + .../src/i18n/locales/nl-BE/navigation.json | 53 + .../src/i18n/locales/nl-BE/operations.json | 4 + frontend/src/i18n/locales/nl-BE/privacy.json | 19 + frontend/src/i18n/locales/nl-BE/quality.json | 265 + frontend/src/i18n/locales/nl-BE/returns.json | 82 + frontend/src/i18n/useGreetingPeriod.ts | 23 + frontend/src/main.tsx | 14 + frontend/src/pages/AboutDemo.tsx | 77 + frontend/src/pages/Audit.tsx | 153 + frontend/src/pages/Automation.tsx | 454 ++ frontend/src/pages/BookingCreate.tsx | 129 + frontend/src/pages/BookingDetail.tsx | 236 + frontend/src/pages/Bookings.tsx | 111 + frontend/src/pages/Dashboard.tsx | 337 ++ frontend/src/pages/DataQuality.tsx | 340 ++ frontend/src/pages/DataQualityIssueDetail.tsx | 664 +++ frontend/src/pages/Highlights.tsx | 60 + frontend/src/pages/Knowledge.tsx | 292 + frontend/src/pages/Login.tsx | 147 + frontend/src/pages/Privacy.tsx | 82 + frontend/src/pages/Scenarios.tsx | 76 + frontend/src/pages/Users.tsx | 157 + frontend/src/pages/VehicleDetail.tsx | 132 + frontend/src/pages/Vehicles.tsx | 127 + .../data-quality/DuplicateCustomerPanel.tsx | 215 + frontend/src/product.ts | 5 + frontend/src/styles-architecture-flow.css | 77 + frontend/src/styles-data-quality.css | 49 + frontend/src/styles.css | 551 ++ frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 21 + frontend/vite.config.ts | 6 + knowledge/manifest.json | 107 + .../procedures/en-GB/01-vehicle-checkout.md | 21 + .../procedures/en-GB/02-vehicle-return.md | 25 + .../procedures/en-GB/03-damage-handling.md | 21 + .../procedures/en-GB/04-odometer-anomalies.md | 21 + .../procedures/en-GB/05-cleaning-checklist.md | 21 + .../en-GB/06-maintenance-escalation.md | 21 + .../procedures/en-GB/07-customer-documents.md | 21 + knowledge/procedures/en-GB/08-privacy.md | 21 + .../procedures/en-GB/09-booking-conflicts.md | 21 + .../en-GB/10-roles-and-escalation.md | 21 + .../en-GB/11-vehicle-availability.md | 21 + .../procedures/fr-BE/01-vehicle-checkout.md | 21 + .../procedures/fr-BE/02-vehicle-return.md | 25 + .../procedures/fr-BE/03-damage-handling.md | 21 + .../procedures/fr-BE/04-odometer-anomalies.md | 21 + .../procedures/fr-BE/05-cleaning-checklist.md | 21 + .../fr-BE/06-maintenance-escalation.md | 21 + .../procedures/fr-BE/07-customer-documents.md | 21 + knowledge/procedures/fr-BE/08-privacy.md | 21 + .../procedures/fr-BE/09-booking-conflicts.md | 21 + .../fr-BE/10-roles-and-escalation.md | 21 + .../fr-BE/11-vehicle-availability.md | 21 + .../procedures/nl-BE/01-vehicle-checkout.md | 21 + .../procedures/nl-BE/02-vehicle-return.md | 25 + .../procedures/nl-BE/03-damage-handling.md | 21 + .../procedures/nl-BE/04-odometer-anomalies.md | 21 + .../procedures/nl-BE/05-cleaning-checklist.md | 21 + .../nl-BE/06-maintenance-escalation.md | 21 + .../procedures/nl-BE/07-customer-documents.md | 21 + knowledge/procedures/nl-BE/08-privacy.md | 21 + .../procedures/nl-BE/09-booking-conflicts.md | 21 + .../nl-BE/10-roles-and-escalation.md | 21 + .../nl-BE/11-vehicle-availability.md | 21 + scripts/check-contracts.py | 207 + scripts/check-frontend-budget.mjs | 18 + scripts/check-source-budgets.py | 32 + scripts/generate-openapi.py | 15 + scripts/generate-release-provenance.py | 61 + scripts/run-isolated-tests.sh | 13 + scripts/run-readonly-load-smoke.py | 81 + scripts/scan-ci-image.sh | 29 + seed/README.md | 11 + seed/bookings.csv | 255 + seed/customers.csv | 181 + seed/data_quality_issues.csv | 22 + seed/generate_seed.py | 659 +++ seed/inspections.csv | 76 + seed/maintenance.csv | 41 + seed/vehicles.csv | 51 + seed/workflow_runs.csv | 21 + 317 files changed, 48851 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 PUBLIC_SOURCE_EXPORT.md create mode 100644 PUBLIC_SOURCE_MANIFEST.json create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py create mode 100644 backend/alembic/versions/4f2b9c8d7e61_unique_open_quality_condition.py create mode 100644 backend/alembic/versions/799d8800e241_outbox_last_error_code.py create mode 100644 backend/alembic/versions/a81d0ce9f662_oidc_identity.py create mode 100644 backend/alembic/versions/b7c7b536df85_operational_user_credentials.py create mode 100644 backend/alembic/versions/b913a72e8c14_customer_privacy_state.py create mode 100644 backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py create mode 100644 backend/alembic/versions/c9498525abb5_initial_schema.py create mode 100644 backend/alembic/versions/d1f83bc64170_revoked_sessions.py create mode 100644 backend/alembic/versions/e7b08389f47f_idempotency_records.py create mode 100644 backend/alembic/versions/f43d829ab610_quality_work_queue.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/routers/__init__.py create mode 100644 backend/app/api/routers/audit.py create mode 100644 backend/app/api/routers/auth.py create mode 100644 backend/app/api/routers/bookings.py create mode 100644 backend/app/api/routers/customers.py create mode 100644 backend/app/api/routers/dashboard.py create mode 100644 backend/app/api/routers/data_quality.py create mode 100644 backend/app/api/routers/demo.py create mode 100644 backend/app/api/routers/integration_status.py create mode 100644 backend/app/api/routers/integrations.py create mode 100644 backend/app/api/routers/knowledge.py create mode 100644 backend/app/api/routers/mcp_integrations.py create mode 100644 backend/app/api/routers/observability.py create mode 100644 backend/app/api/routers/privacy.py create mode 100644 backend/app/api/routers/search.py create mode 100644 backend/app/api/routers/users.py create mode 100644 backend/app/api/routers/vehicles.py create mode 100644 backend/app/api/routers/workflows.py create mode 100644 backend/app/cli.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/db.py create mode 100644 backend/app/core/errors.py create mode 100644 backend/app/core/observability.py create mode 100644 backend/app/core/ratelimit.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/models/booking.py create mode 100644 backend/app/models/customer.py create mode 100644 backend/app/models/data_quality.py create mode 100644 backend/app/models/idempotency.py create mode 100644 backend/app/models/inspection.py create mode 100644 backend/app/models/maintenance.py create mode 100644 backend/app/models/mixins.py create mode 100644 backend/app/models/outbox.py create mode 100644 backend/app/models/revoked_session.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/models/vehicle.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/seed_loader.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/audit.py create mode 100644 backend/app/services/data_quality.py create mode 100644 backend/app/services/data_quality_common.py create mode 100644 backend/app/services/data_quality_duplicate_scan.py create mode 100644 backend/app/services/data_quality_odometer.py create mode 100644 backend/app/services/demo_manifest.py create mode 100644 backend/app/services/dispatcher.py create mode 100644 backend/app/services/integration_status.py create mode 100644 backend/app/services/knowledge/__init__.py create mode 100644 backend/app/services/knowledge/demo.py create mode 100644 backend/app/services/knowledge/procedures.py create mode 100644 backend/app/services/knowledge/ragcore.py create mode 100644 backend/app/services/operations.py create mode 100644 backend/app/services/returns.py create mode 100644 backend/app/services/sessions.py create mode 100644 backend/app/services/vehicle_status.py create mode 100644 backend/entrypoint.sh create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements-prod.lock create mode 100644 backend/requirements.lock create mode 100644 backend/scripts/generate_openapi.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_audit.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_bookings.py create mode 100644 backend/tests/test_dashboard.py create mode 100644 backend/tests/test_data_quality.py create mode 100644 backend/tests/test_database_constraints.py create mode 100644 backend/tests/test_demo_manifest.py create mode 100644 backend/tests/test_dispatcher.py create mode 100644 backend/tests/test_hardening.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_integration_status.py create mode 100644 backend/tests/test_integrations.py create mode 100644 backend/tests/test_knowledge.py create mode 100644 backend/tests/test_mcp_integrations.py create mode 100644 backend/tests/test_migrations.py create mode 100644 backend/tests/test_observability.py create mode 100644 backend/tests/test_operational_auth.py create mode 100644 backend/tests/test_privacy.py create mode 100644 backend/tests/test_return.py create mode 100644 backend/tests/test_search.py create mode 100644 backend/tests/test_seed.py create mode 100644 backend/tests/test_seed_generation.py create mode 100644 backend/tests/test_users.py create mode 100644 backend/tests/test_vehicle_status.py create mode 100644 backend/tests/test_vehicles.py create mode 100644 backend/tests/test_workflows.py create mode 100644 compose.test.yaml create mode 100644 compose.yaml create mode 100644 contracts/events.schema.json create mode 100644 contracts/mcp-tools.json create mode 100644 contracts/openapi.yaml create mode 100644 contracts/ragcore-contract-assumptions.md create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/e2e-live/smoke.spec.ts create mode 100644 frontend/e2e/__screenshots__/highlights-main.png create mode 100644 frontend/e2e/__screenshots__/login-access.png create mode 100644 frontend/e2e/_capture-demo-screenshots.spec.ts create mode 100644 frontend/e2e/_capture-recruiter-screenshots.spec.ts create mode 100644 frontend/e2e/_capture-screenshots.spec.ts create mode 100644 frontend/e2e/accessibility.spec.ts create mode 100644 frontend/e2e/clickable-rows.spec.ts create mode 100644 frontend/e2e/context-resilience.spec.ts create mode 100644 frontend/e2e/demo-accessibility.spec.ts create mode 100644 frontend/e2e/demo-entry.spec.ts create mode 100644 frontend/e2e/demo-guide.spec.ts create mode 100644 frontend/e2e/demo-legibility.spec.ts create mode 100644 frontend/e2e/demo.spec.ts create mode 100644 frontend/e2e/error-messages.spec.ts create mode 100644 frontend/e2e/fleet-ops-correction.spec.ts create mode 100644 frontend/e2e/greeting-live.spec.ts create mode 100644 frontend/e2e/greeting.spec.ts create mode 100644 frontend/e2e/guided-demo-full.spec.ts create mode 100644 frontend/e2e/i18n-coverage.spec.ts create mode 100644 frontend/e2e/interactive-elements.spec.ts create mode 100644 frontend/e2e/operational-workflows.spec.ts create mode 100644 frontend/e2e/privacy.spec.ts create mode 100644 frontend/e2e/recruiter-polish.spec.ts create mode 100644 frontend/e2e/responsive-i18n.spec.ts create mode 100644 frontend/e2e/roadmap-regression.spec.ts create mode 100644 frontend/e2e/ui-redesign.spec.ts create mode 100644 frontend/e2e/visual-regression.spec.ts create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/playwright.live.config.ts create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/og-fleet-ops.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/apiError.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/errorMessages.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/components/ArchitectureFlow.tsx create mode 100644 frontend/src/components/Badge.tsx create mode 100644 frontend/src/components/CheckoutForm.tsx create mode 100644 frontend/src/components/DemoBadge.tsx create mode 100644 frontend/src/components/DemoGuide.tsx create mode 100644 frontend/src/components/Icons.tsx create mode 100644 frontend/src/components/LanguageSwitcher.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/PageChrome.tsx create mode 100644 frontend/src/components/Pagination.tsx create mode 100644 frontend/src/components/RequireAuth.tsx create mode 100644 frontend/src/components/ReturnForm.tsx create mode 100644 frontend/src/components/VehicleMaintenanceActions.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/context/DemoGuideContext.tsx create mode 100644 frontend/src/context/DemoManifestContext.tsx create mode 100644 frontend/src/hooks/useViewportTier.ts create mode 100644 frontend/src/i18n/brusselsDateTime.ts create mode 100644 frontend/src/i18n/config.ts create mode 100644 frontend/src/i18n/format.ts create mode 100644 frontend/src/i18n/greeting.ts create mode 100644 frontend/src/i18n/locales/en-GB/accessibility.json create mode 100644 frontend/src/i18n/locales/en-GB/audit.json create mode 100644 frontend/src/i18n/locales/en-GB/auth.json create mode 100644 frontend/src/i18n/locales/en-GB/bookings.json create mode 100644 frontend/src/i18n/locales/en-GB/common.json create mode 100644 frontend/src/i18n/locales/en-GB/dashboard.json create mode 100644 frontend/src/i18n/locales/en-GB/demo.json create mode 100644 frontend/src/i18n/locales/en-GB/errors.json create mode 100644 frontend/src/i18n/locales/en-GB/fleet.json create mode 100644 frontend/src/i18n/locales/en-GB/integrations.json create mode 100644 frontend/src/i18n/locales/en-GB/knowledge.json create mode 100644 frontend/src/i18n/locales/en-GB/navigation.json create mode 100644 frontend/src/i18n/locales/en-GB/operations.json create mode 100644 frontend/src/i18n/locales/en-GB/privacy.json create mode 100644 frontend/src/i18n/locales/en-GB/quality.json create mode 100644 frontend/src/i18n/locales/en-GB/returns.json create mode 100644 frontend/src/i18n/locales/fr-BE/accessibility.json create mode 100644 frontend/src/i18n/locales/fr-BE/audit.json create mode 100644 frontend/src/i18n/locales/fr-BE/auth.json create mode 100644 frontend/src/i18n/locales/fr-BE/bookings.json create mode 100644 frontend/src/i18n/locales/fr-BE/common.json create mode 100644 frontend/src/i18n/locales/fr-BE/dashboard.json create mode 100644 frontend/src/i18n/locales/fr-BE/demo.json create mode 100644 frontend/src/i18n/locales/fr-BE/errors.json create mode 100644 frontend/src/i18n/locales/fr-BE/fleet.json create mode 100644 frontend/src/i18n/locales/fr-BE/integrations.json create mode 100644 frontend/src/i18n/locales/fr-BE/knowledge.json create mode 100644 frontend/src/i18n/locales/fr-BE/navigation.json create mode 100644 frontend/src/i18n/locales/fr-BE/operations.json create mode 100644 frontend/src/i18n/locales/fr-BE/privacy.json create mode 100644 frontend/src/i18n/locales/fr-BE/quality.json create mode 100644 frontend/src/i18n/locales/fr-BE/returns.json create mode 100644 frontend/src/i18n/locales/nl-BE/accessibility.json create mode 100644 frontend/src/i18n/locales/nl-BE/audit.json create mode 100644 frontend/src/i18n/locales/nl-BE/auth.json create mode 100644 frontend/src/i18n/locales/nl-BE/bookings.json create mode 100644 frontend/src/i18n/locales/nl-BE/common.json create mode 100644 frontend/src/i18n/locales/nl-BE/dashboard.json create mode 100644 frontend/src/i18n/locales/nl-BE/demo.json create mode 100644 frontend/src/i18n/locales/nl-BE/errors.json create mode 100644 frontend/src/i18n/locales/nl-BE/fleet.json create mode 100644 frontend/src/i18n/locales/nl-BE/integrations.json create mode 100644 frontend/src/i18n/locales/nl-BE/knowledge.json create mode 100644 frontend/src/i18n/locales/nl-BE/navigation.json create mode 100644 frontend/src/i18n/locales/nl-BE/operations.json create mode 100644 frontend/src/i18n/locales/nl-BE/privacy.json create mode 100644 frontend/src/i18n/locales/nl-BE/quality.json create mode 100644 frontend/src/i18n/locales/nl-BE/returns.json create mode 100644 frontend/src/i18n/useGreetingPeriod.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AboutDemo.tsx create mode 100644 frontend/src/pages/Audit.tsx create mode 100644 frontend/src/pages/Automation.tsx create mode 100644 frontend/src/pages/BookingCreate.tsx create mode 100644 frontend/src/pages/BookingDetail.tsx create mode 100644 frontend/src/pages/Bookings.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/DataQuality.tsx create mode 100644 frontend/src/pages/DataQualityIssueDetail.tsx create mode 100644 frontend/src/pages/Highlights.tsx create mode 100644 frontend/src/pages/Knowledge.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Privacy.tsx create mode 100644 frontend/src/pages/Scenarios.tsx create mode 100644 frontend/src/pages/Users.tsx create mode 100644 frontend/src/pages/VehicleDetail.tsx create mode 100644 frontend/src/pages/Vehicles.tsx create mode 100644 frontend/src/pages/data-quality/DuplicateCustomerPanel.tsx create mode 100644 frontend/src/product.ts create mode 100644 frontend/src/styles-architecture-flow.css create mode 100644 frontend/src/styles-data-quality.css create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 knowledge/manifest.json create mode 100644 knowledge/procedures/en-GB/01-vehicle-checkout.md create mode 100644 knowledge/procedures/en-GB/02-vehicle-return.md create mode 100644 knowledge/procedures/en-GB/03-damage-handling.md create mode 100644 knowledge/procedures/en-GB/04-odometer-anomalies.md create mode 100644 knowledge/procedures/en-GB/05-cleaning-checklist.md create mode 100644 knowledge/procedures/en-GB/06-maintenance-escalation.md create mode 100644 knowledge/procedures/en-GB/07-customer-documents.md create mode 100644 knowledge/procedures/en-GB/08-privacy.md create mode 100644 knowledge/procedures/en-GB/09-booking-conflicts.md create mode 100644 knowledge/procedures/en-GB/10-roles-and-escalation.md create mode 100644 knowledge/procedures/en-GB/11-vehicle-availability.md create mode 100644 knowledge/procedures/fr-BE/01-vehicle-checkout.md create mode 100644 knowledge/procedures/fr-BE/02-vehicle-return.md create mode 100644 knowledge/procedures/fr-BE/03-damage-handling.md create mode 100644 knowledge/procedures/fr-BE/04-odometer-anomalies.md create mode 100644 knowledge/procedures/fr-BE/05-cleaning-checklist.md create mode 100644 knowledge/procedures/fr-BE/06-maintenance-escalation.md create mode 100644 knowledge/procedures/fr-BE/07-customer-documents.md create mode 100644 knowledge/procedures/fr-BE/08-privacy.md create mode 100644 knowledge/procedures/fr-BE/09-booking-conflicts.md create mode 100644 knowledge/procedures/fr-BE/10-roles-and-escalation.md create mode 100644 knowledge/procedures/fr-BE/11-vehicle-availability.md create mode 100644 knowledge/procedures/nl-BE/01-vehicle-checkout.md create mode 100644 knowledge/procedures/nl-BE/02-vehicle-return.md create mode 100644 knowledge/procedures/nl-BE/03-damage-handling.md create mode 100644 knowledge/procedures/nl-BE/04-odometer-anomalies.md create mode 100644 knowledge/procedures/nl-BE/05-cleaning-checklist.md create mode 100644 knowledge/procedures/nl-BE/06-maintenance-escalation.md create mode 100644 knowledge/procedures/nl-BE/07-customer-documents.md create mode 100644 knowledge/procedures/nl-BE/08-privacy.md create mode 100644 knowledge/procedures/nl-BE/09-booking-conflicts.md create mode 100644 knowledge/procedures/nl-BE/10-roles-and-escalation.md create mode 100644 knowledge/procedures/nl-BE/11-vehicle-availability.md create mode 100644 scripts/check-contracts.py create mode 100644 scripts/check-frontend-budget.mjs create mode 100644 scripts/check-source-budgets.py create mode 100644 scripts/generate-openapi.py create mode 100644 scripts/generate-release-provenance.py create mode 100644 scripts/run-isolated-tests.sh create mode 100644 scripts/run-readonly-load-smoke.py create mode 100644 scripts/scan-ci-image.sh create mode 100644 seed/README.md create mode 100644 seed/bookings.csv create mode 100644 seed/customers.csv create mode 100644 seed/data_quality_issues.csv create mode 100644 seed/generate_seed.py create mode 100644 seed/inspections.csv create mode 100644 seed/maintenance.csv create mode 100644 seed/vehicles.csv create mode 100644 seed/workflow_runs.csv 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 0000000000000000000000000000000000000000..68941e82493e37cc0de1dee26b9ac319f54fef86 GIT binary patch literal 83029 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≫nvE!saZ(y7#W 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 MN=dRv-0<7~1&8lm3jhEB literal 0 HcmV?d00001 diff --git a/frontend/e2e/__screenshots__/login-access.png b/frontend/e2e/__screenshots__/login-access.png new file mode 100644 index 0000000000000000000000000000000000000000..639975e051ad283bc1a14adfecd9338cb5145aab GIT binary patch literal 29827 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%0AU^B_3RIR>9M{64`0iCdtN_qYhK-62q;YA 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 literal 0 HcmV?d00001 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")} + + + +
+