M39: harden application and acceptance gates
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Backend image build context is the repository root (see compose.yaml); keep it small.
|
||||
.git
|
||||
.gitea
|
||||
.state
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.pytest_cache
|
||||
**/__pycache__
|
||||
**/.venv
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/playwright-report
|
||||
**/test-results
|
||||
artifacts
|
||||
docs
|
||||
deploy
|
||||
n8n
|
||||
frontend
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
.env
|
||||
+5
-1
@@ -2,11 +2,15 @@ COMPOSE_PROJECT_NAME=mobilityops
|
||||
MOBILITYOPS_ENV=development
|
||||
MOBILITYOPS_DEMO_MODE=true
|
||||
MOBILITYOPS_PUBLIC_URL=http://localhost:1228
|
||||
MOBILITYOPS_API_URL=http://localhost:8128
|
||||
# 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. Keep false for LAN/plain-HTTP deployments (including the
|
||||
|
||||
+49
-1
@@ -46,9 +46,57 @@ jobs:
|
||||
- name: Install locked dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: Lint (tsc + ESLint with react-hooks and jsx-a11y)
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
- name: Typecheck and production build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
- name: Dependency audit
|
||||
working-directory: frontend
|
||||
run: npm audit
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
e2e:
|
||||
# The five-minute Playwright demo is part of the definition of done
|
||||
# (docs/14-testing-and-acceptance.md); run it against the real Compose stack.
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Start the demo stack
|
||||
run: |
|
||||
cp .env.example .env
|
||||
docker compose -p mobilityops-e2e up --build -d db api web
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:1228/health/ready >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
curl -fsS http://localhost:1228/health/ready
|
||||
docker compose -p mobilityops-e2e exec -T api python -m app.cli seed --reset
|
||||
- name: Install Playwright
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Run browser acceptance suite
|
||||
working-directory: frontend
|
||||
env:
|
||||
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
|
||||
run: npx playwright test
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report
|
||||
- name: Stack logs on failure
|
||||
if: failure()
|
||||
run: docker compose -p mobilityops-e2e logs --tail=200 api web
|
||||
- name: Remove e2e stack
|
||||
if: always()
|
||||
run: docker compose -p mobilityops-e2e down -v --remove-orphans
|
||||
|
||||
@@ -16,3 +16,9 @@ test-results/
|
||||
*.tsbuildinfo
|
||||
*.zip
|
||||
*.tar.gz
|
||||
|
||||
# Local Claude/Codex per-user settings and scratch archives
|
||||
.claude/settings.local.json
|
||||
*.tgz
|
||||
*.dump
|
||||
backups/
|
||||
|
||||
+305
-12
@@ -1,25 +1,147 @@
|
||||
# File index
|
||||
|
||||
Tracked source, contract, documentation and configuration files. Release evidence and
|
||||
screenshots live under `artifacts/<release>/` and are omitted here for brevity.
|
||||
Regenerate with `git ls-files` when the tree changes.
|
||||
|
||||
- `.env.example`
|
||||
- `.gitattributes`
|
||||
- `.gitea/workflows/ci.yml`
|
||||
- `.gitignore`
|
||||
- `AGENTS.md`
|
||||
- `CLAUDE.md`
|
||||
- `FILE_INDEX.md`
|
||||
- `MASTER_BUILD_PROMPT.md`
|
||||
- `Makefile`
|
||||
- `PROJECT_STATE.md`
|
||||
- `README.md`
|
||||
- `START_HERE.md`
|
||||
- `backend/Dockerfile`
|
||||
- `backend/alembic.ini`
|
||||
- `backend/alembic/env.py`
|
||||
- `backend/alembic/script.py.mako`
|
||||
- `backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py`
|
||||
- `backend/alembic/versions/799d8800e241_outbox_last_error_code.py`
|
||||
- `backend/alembic/versions/a81d0ce9f662_oidc_identity.py`
|
||||
- `backend/alembic/versions/b7c7b536df85_operational_user_credentials.py`
|
||||
- `backend/alembic/versions/b913a72e8c14_customer_privacy_state.py`
|
||||
- `backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py`
|
||||
- `backend/alembic/versions/c9498525abb5_initial_schema.py`
|
||||
- `backend/alembic/versions/d1f83bc64170_revoked_sessions.py`
|
||||
- `backend/alembic/versions/e7b08389f47f_idempotency_records.py`
|
||||
- `backend/alembic/versions/f43d829ab610_quality_work_queue.py`
|
||||
- `backend/app/__init__.py`
|
||||
- `backend/app/api/__init__.py`
|
||||
- `backend/app/api/deps.py`
|
||||
- `backend/app/api/routers/__init__.py`
|
||||
- `backend/app/api/routers/audit.py`
|
||||
- `backend/app/api/routers/auth.py`
|
||||
- `backend/app/api/routers/bookings.py`
|
||||
- `backend/app/api/routers/customers.py`
|
||||
- `backend/app/api/routers/dashboard.py`
|
||||
- `backend/app/api/routers/data_quality.py`
|
||||
- `backend/app/api/routers/demo.py`
|
||||
- `backend/app/api/routers/integration_status.py`
|
||||
- `backend/app/api/routers/integrations.py`
|
||||
- `backend/app/api/routers/knowledge.py`
|
||||
- `backend/app/api/routers/mcp_integrations.py`
|
||||
- `backend/app/api/routers/observability.py`
|
||||
- `backend/app/api/routers/privacy.py`
|
||||
- `backend/app/api/routers/search.py`
|
||||
- `backend/app/api/routers/users.py`
|
||||
- `backend/app/api/routers/vehicles.py`
|
||||
- `backend/app/api/routers/workflows.py`
|
||||
- `backend/app/cli.py`
|
||||
- `backend/app/core/__init__.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `backend/app/core/db.py`
|
||||
- `backend/app/core/errors.py`
|
||||
- `backend/app/core/observability.py`
|
||||
- `backend/app/core/ratelimit.py`
|
||||
- `backend/app/core/security.py`
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/models/__init__.py`
|
||||
- `backend/app/models/audit.py`
|
||||
- `backend/app/models/booking.py`
|
||||
- `backend/app/models/customer.py`
|
||||
- `backend/app/models/data_quality.py`
|
||||
- `backend/app/models/idempotency.py`
|
||||
- `backend/app/models/inspection.py`
|
||||
- `backend/app/models/maintenance.py`
|
||||
- `backend/app/models/mixins.py`
|
||||
- `backend/app/models/outbox.py`
|
||||
- `backend/app/models/revoked_session.py`
|
||||
- `backend/app/models/user.py`
|
||||
- `backend/app/models/vehicle.py`
|
||||
- `backend/app/schemas.py`
|
||||
- `backend/app/seed_loader.py`
|
||||
- `backend/app/services/__init__.py`
|
||||
- `backend/app/services/audit.py`
|
||||
- `backend/app/services/data_quality.py`
|
||||
- `backend/app/services/demo_manifest.py`
|
||||
- `backend/app/services/dispatcher.py`
|
||||
- `backend/app/services/integration_status.py`
|
||||
- `backend/app/services/knowledge/__init__.py`
|
||||
- `backend/app/services/knowledge/demo.py`
|
||||
- `backend/app/services/knowledge/procedures.py`
|
||||
- `backend/app/services/knowledge/ragcore.py`
|
||||
- `backend/app/services/operations.py`
|
||||
- `backend/app/services/returns.py`
|
||||
- `backend/app/services/sessions.py`
|
||||
- `backend/app/services/vehicle_status.py`
|
||||
- `backend/entrypoint.sh`
|
||||
- `backend/pyproject.toml`
|
||||
- `backend/requirements.lock`
|
||||
- `backend/scripts/generate_openapi.py`
|
||||
- `backend/tests/conftest.py`
|
||||
- `backend/tests/test_audit.py`
|
||||
- `backend/tests/test_auth.py`
|
||||
- `backend/tests/test_bookings.py`
|
||||
- `backend/tests/test_dashboard.py`
|
||||
- `backend/tests/test_data_quality.py`
|
||||
- `backend/tests/test_database_constraints.py`
|
||||
- `backend/tests/test_demo_manifest.py`
|
||||
- `backend/tests/test_dispatcher.py`
|
||||
- `backend/tests/test_hardening.py`
|
||||
- `backend/tests/test_health.py`
|
||||
- `backend/tests/test_integration_status.py`
|
||||
- `backend/tests/test_integrations.py`
|
||||
- `backend/tests/test_knowledge.py`
|
||||
- `backend/tests/test_mcp_integrations.py`
|
||||
- `backend/tests/test_migrations.py`
|
||||
- `backend/tests/test_observability.py`
|
||||
- `backend/tests/test_operational_auth.py`
|
||||
- `backend/tests/test_privacy.py`
|
||||
- `backend/tests/test_return.py`
|
||||
- `backend/tests/test_search.py`
|
||||
- `backend/tests/test_seed.py`
|
||||
- `backend/tests/test_users.py`
|
||||
- `backend/tests/test_vehicle_status.py`
|
||||
- `backend/tests/test_vehicles.py`
|
||||
- `backend/tests/test_workflows.py`
|
||||
- `compose.observability.yaml`
|
||||
- `compose.test.yaml`
|
||||
- `compose.unraid.yaml`
|
||||
- `compose.yaml`
|
||||
- `contracts/events.schema.json`
|
||||
- `contracts/mcp-tools.json`
|
||||
- `contracts/openapi.yaml`
|
||||
- `contracts/ragcore-contract-assumptions.md`
|
||||
- `deploy/observability/alerts.yml`
|
||||
- `deploy/observability/grafana/dashboards/mobilityops-overview.json`
|
||||
- `deploy/observability/grafana/provisioning/dashboards/mobilityops.yml`
|
||||
- `deploy/observability/grafana/provisioning/datasources/prometheus.yml`
|
||||
- `deploy/observability/prometheus.yml`
|
||||
- `deploy/unraid/README.md`
|
||||
- `deploy/unraid/backup-postgres.sh`
|
||||
- `deploy/unraid/configure-env.sh`
|
||||
- `deploy/unraid/prune-postgres-backups.sh`
|
||||
- `deploy/unraid/restore-postgres.sh`
|
||||
- `deploy/unraid/scheduled-backup.sh`
|
||||
- `deploy/unraid/setup-existing-n8n.sh`
|
||||
- `deploy/unraid/setup-n8n.sh`
|
||||
- `deploy/unraid/setup-scheduled-scan.sh`
|
||||
- `deploy/unraid/verify-postgres-backups.sh`
|
||||
- `docs/00-product-brief.md`
|
||||
- `docs/01-scope-and-non-goals.md`
|
||||
- `docs/02-user-stories.md`
|
||||
@@ -38,32 +160,203 @@
|
||||
- `docs/15-build-plan.md`
|
||||
- `docs/16-portfolio-case-study.md`
|
||||
- `docs/17-runbook.md`
|
||||
- `docs/18-privacy-governance.md`
|
||||
- `docs/19-visual-product-roadmap.md`
|
||||
- `docs/deferred.md`
|
||||
- `docs/demo-release/current-demo-gap-audit.md`
|
||||
- `docs/demo-release/demo-concept.md`
|
||||
- `docs/demo-release/demo-data.md`
|
||||
- `docs/demo-release/demo-guide.md`
|
||||
- `docs/demo-release/demo-runbook.md`
|
||||
- `docs/demo-release/demo-scenarios.md`
|
||||
- `docs/design/current-ux-audit.md`
|
||||
- `docs/design/design-directions.md`
|
||||
- `docs/design/design-system.md`
|
||||
- `docs/design/implementation-validation.md`
|
||||
- `docs/design/stitch-manifest.md`
|
||||
- `docs/final-integrations/ai-operations-brief-runbook.md`
|
||||
- `docs/final-integrations/current-state-audit.md`
|
||||
- `docs/final-product-polish/audit.md`
|
||||
- `docs/fleet-ops-correction/current-gap-audit.md`
|
||||
- `docs/fleet-ops-correction/i18n-inventory.md`
|
||||
- `docs/fleet-ops-correction/vehicle-status-decision-table.md`
|
||||
- `docs/fleet-ops-final-localization/audit.md`
|
||||
- `docs/functional-completion/current-functional-audit.md`
|
||||
- `docs/functional-completion/server-baseline.md`
|
||||
- `docs/live-ai-integration/n8n-current-state.md`
|
||||
- `frontend/Dockerfile`
|
||||
- `frontend/e2e/_capture-demo-screenshots.spec.ts`
|
||||
- `frontend/e2e/_capture-recruiter-screenshots.spec.ts`
|
||||
- `frontend/e2e/_capture-screenshots.spec.ts`
|
||||
- `frontend/e2e/clickable-rows.spec.ts`
|
||||
- `frontend/e2e/demo-accessibility.spec.ts`
|
||||
- `frontend/e2e/demo-entry.spec.ts`
|
||||
- `frontend/e2e/demo-guide.spec.ts`
|
||||
- `frontend/e2e/demo-legibility.spec.ts`
|
||||
- `frontend/e2e/demo.spec.ts`
|
||||
- `frontend/e2e/error-messages.spec.ts`
|
||||
- `frontend/e2e/fleet-ops-correction.spec.ts`
|
||||
- `frontend/e2e/greeting-live.spec.ts`
|
||||
- `frontend/e2e/greeting.spec.ts`
|
||||
- `frontend/e2e/guided-demo-full.spec.ts`
|
||||
- `frontend/e2e/i18n-coverage.spec.ts`
|
||||
- `frontend/e2e/interactive-elements.spec.ts`
|
||||
- `frontend/e2e/operational-workflows.spec.ts`
|
||||
- `frontend/e2e/privacy.spec.ts`
|
||||
- `frontend/e2e/recruiter-polish.spec.ts`
|
||||
- `frontend/e2e/responsive-i18n.spec.ts`
|
||||
- `frontend/e2e/roadmap-regression.spec.ts`
|
||||
- `frontend/e2e/ui-redesign.spec.ts`
|
||||
- `frontend/index.html`
|
||||
- `frontend/nginx.conf`
|
||||
- `frontend/package-lock.json`
|
||||
- `frontend/package.json`
|
||||
- `frontend/playwright.config.ts`
|
||||
- `frontend/public/favicon.svg`
|
||||
- `frontend/public/og-fleet-ops.svg`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/api/apiError.ts`
|
||||
- `frontend/src/api/client.ts`
|
||||
- `frontend/src/api/errorMessages.ts`
|
||||
- `frontend/src/api/types.ts`
|
||||
- `frontend/src/components/Badge.tsx`
|
||||
- `frontend/src/components/CheckoutForm.tsx`
|
||||
- `frontend/src/components/DemoBadge.tsx`
|
||||
- `frontend/src/components/DemoGuide.tsx`
|
||||
- `frontend/src/components/Icons.tsx`
|
||||
- `frontend/src/components/LanguageSwitcher.tsx`
|
||||
- `frontend/src/components/Layout.tsx`
|
||||
- `frontend/src/components/PageChrome.tsx`
|
||||
- `frontend/src/components/Pagination.tsx`
|
||||
- `frontend/src/components/RequireAuth.tsx`
|
||||
- `frontend/src/components/ReturnForm.tsx`
|
||||
- `frontend/src/components/VehicleMaintenanceActions.tsx`
|
||||
- `frontend/src/context/AuthContext.tsx`
|
||||
- `frontend/src/context/DemoGuideContext.tsx`
|
||||
- `frontend/src/context/DemoManifestContext.tsx`
|
||||
- `frontend/src/data/demoGuideSteps.ts`
|
||||
- `frontend/src/data/evidenceSignals.ts`
|
||||
- `frontend/src/data/integrationLabels.ts`
|
||||
- `frontend/src/hooks/useViewportTier.ts`
|
||||
- `frontend/src/i18n/brusselsDateTime.ts`
|
||||
- `frontend/src/i18n/config.ts`
|
||||
- `frontend/src/i18n/format.ts`
|
||||
- `frontend/src/i18n/greeting.ts`
|
||||
- `frontend/src/i18n/locales/en-GB/accessibility.json`
|
||||
- `frontend/src/i18n/locales/en-GB/audit.json`
|
||||
- `frontend/src/i18n/locales/en-GB/auth.json`
|
||||
- `frontend/src/i18n/locales/en-GB/bookings.json`
|
||||
- `frontend/src/i18n/locales/en-GB/common.json`
|
||||
- `frontend/src/i18n/locales/en-GB/dashboard.json`
|
||||
- `frontend/src/i18n/locales/en-GB/demo.json`
|
||||
- `frontend/src/i18n/locales/en-GB/errors.json`
|
||||
- `frontend/src/i18n/locales/en-GB/fleet.json`
|
||||
- `frontend/src/i18n/locales/en-GB/integrations.json`
|
||||
- `frontend/src/i18n/locales/en-GB/knowledge.json`
|
||||
- `frontend/src/i18n/locales/en-GB/navigation.json`
|
||||
- `frontend/src/i18n/locales/en-GB/operations.json`
|
||||
- `frontend/src/i18n/locales/en-GB/privacy.json`
|
||||
- `frontend/src/i18n/locales/en-GB/quality.json`
|
||||
- `frontend/src/i18n/locales/en-GB/returns.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/accessibility.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/audit.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/auth.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/bookings.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/common.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/dashboard.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/demo.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/errors.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/fleet.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/integrations.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/knowledge.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/navigation.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/operations.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/privacy.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/quality.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/returns.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/accessibility.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/audit.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/auth.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/bookings.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/common.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/dashboard.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/demo.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/errors.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/fleet.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/integrations.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/knowledge.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/navigation.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/operations.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/privacy.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/quality.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/returns.json`
|
||||
- `frontend/src/i18n/useGreetingPeriod.ts`
|
||||
- `frontend/src/main.tsx`
|
||||
- `frontend/src/pages/AboutDemo.tsx`
|
||||
- `frontend/src/pages/Audit.tsx`
|
||||
- `frontend/src/pages/Automation.tsx`
|
||||
- `frontend/src/pages/BookingCreate.tsx`
|
||||
- `frontend/src/pages/BookingDetail.tsx`
|
||||
- `frontend/src/pages/Bookings.tsx`
|
||||
- `frontend/src/pages/Dashboard.tsx`
|
||||
- `frontend/src/pages/DataQuality.tsx`
|
||||
- `frontend/src/pages/DataQualityIssueDetail.tsx`
|
||||
- `frontend/src/pages/Highlights.tsx`
|
||||
- `frontend/src/pages/Knowledge.tsx`
|
||||
- `frontend/src/pages/Login.tsx`
|
||||
- `frontend/src/pages/Privacy.tsx`
|
||||
- `frontend/src/pages/Scenarios.tsx`
|
||||
- `frontend/src/pages/Users.tsx`
|
||||
- `frontend/src/pages/VehicleDetail.tsx`
|
||||
- `frontend/src/pages/Vehicles.tsx`
|
||||
- `frontend/src/product.ts`
|
||||
- `frontend/src/styles.css`
|
||||
- `frontend/src/vite-env.d.ts`
|
||||
- `frontend/tsconfig.json`
|
||||
- `frontend/vite.config.ts`
|
||||
- `knowledge/manifest.json`
|
||||
- `knowledge/procedures/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/02-vehicle-return.md`
|
||||
- `knowledge/procedures/03-damage-handling.md`
|
||||
- `knowledge/procedures/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/07-customer-documents.md`
|
||||
- `knowledge/procedures/08-privacy.md`
|
||||
- `knowledge/procedures/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/en-GB/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/en-GB/02-vehicle-return.md`
|
||||
- `knowledge/procedures/en-GB/03-damage-handling.md`
|
||||
- `knowledge/procedures/en-GB/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/en-GB/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/en-GB/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/en-GB/07-customer-documents.md`
|
||||
- `knowledge/procedures/en-GB/08-privacy.md`
|
||||
- `knowledge/procedures/en-GB/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/en-GB/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/en-GB/11-vehicle-availability.md`
|
||||
- `knowledge/procedures/fr-BE/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/fr-BE/02-vehicle-return.md`
|
||||
- `knowledge/procedures/fr-BE/03-damage-handling.md`
|
||||
- `knowledge/procedures/fr-BE/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/fr-BE/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/fr-BE/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/fr-BE/07-customer-documents.md`
|
||||
- `knowledge/procedures/fr-BE/08-privacy.md`
|
||||
- `knowledge/procedures/fr-BE/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/fr-BE/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/fr-BE/11-vehicle-availability.md`
|
||||
- `knowledge/procedures/nl-BE/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/nl-BE/02-vehicle-return.md`
|
||||
- `knowledge/procedures/nl-BE/03-damage-handling.md`
|
||||
- `knowledge/procedures/nl-BE/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/nl-BE/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/nl-BE/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/nl-BE/07-customer-documents.md`
|
||||
- `knowledge/procedures/nl-BE/08-privacy.md`
|
||||
- `knowledge/procedures/nl-BE/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/nl-BE/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/nl-BE/11-vehicle-availability.md`
|
||||
- `n8n/README.md`
|
||||
- `n8n/workflows/MANIFEST.md`
|
||||
- `n8n/workflows/fleet-ops-vehicle-return.json`
|
||||
- `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||
- `n8n/workflows/check_drift.py`
|
||||
- `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||
- `n8n/workflows/fleet-ops-error-handler.json`
|
||||
- `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`
|
||||
- `n8n/workflows/fleet-ops-vehicle-return.json`
|
||||
- `n8n/workflows/merge_credential_refs.py`
|
||||
- `scripts/run-isolated-tests.sh`
|
||||
- `seed/README.md`
|
||||
- `seed/bookings.csv`
|
||||
- `seed/customers.csv`
|
||||
|
||||
@@ -15,6 +15,7 @@ test:
|
||||
lint:
|
||||
docker compose run --rm api ruff check .
|
||||
docker compose run --rm api mypy app
|
||||
cd frontend && npm run lint
|
||||
|
||||
seed:
|
||||
docker compose exec api python -m app.cli seed --reset
|
||||
|
||||
@@ -1,5 +1,78 @@
|
||||
# Project state
|
||||
|
||||
## M39 — Hardening review (2026-08-16)
|
||||
|
||||
Full-repository audit (backend, frontend, infra, docs) followed by targeted fixes. Every
|
||||
change is covered by the existing gates plus new regression tests; nothing in the locked
|
||||
scope changed.
|
||||
|
||||
- **Security**: `get_settings()` refuses to boot with `MOBILITYOPS_ENV=production` while
|
||||
`APP_SECRET`/`MOBILITYOPS_CALLBACK_TOKEN` (or the MCP token when registration is on)
|
||||
still hold placeholder values (`insecure_default_secrets`). `POST /api/v1/demo/reset`
|
||||
now returns 404 outside demo mode (it previously only checked `DEMO_ALLOW_RESET`).
|
||||
Failed password logins are throttled per client IP (`app/core/ratelimit.py`,
|
||||
`LOGIN_MAX_FAILURES`/`LOGIN_FAILURE_WINDOW_SECONDS`, 429 + `Retry-After`; only failures
|
||||
count, so tests are unaffected). OIDC email-based linking of an existing local account
|
||||
now requires `email_verified: true` (absent claim = unverified). n8n service tokens are
|
||||
compared with `hmac.compare_digest`; the return callback body is a bounded Pydantic model
|
||||
(`ReturnCallbackIn`, malformed `correlation_id` → 422 instead of 500).
|
||||
- **Correctness**: dashboard "today" buckets bookings by the Europe/Brussels calendar day
|
||||
instead of the UTC date; audit export accepts naive datetimes (were 500) and
|
||||
`correlation_id` is validated as UUID (was a DB error); paged booking lists for an
|
||||
unknown `vehicle_ref` keep the page shape; the data-quality scan skips anonymised
|
||||
customers (they were re-flagged as `missing_required_field` after every scan);
|
||||
`resolve_odometer_regression` locks booking→vehicle like every other flow (was the
|
||||
opposite order → deadlock risk); `merge_customers` locks both rows in a deterministic
|
||||
order, rejects already-merged customers (`CUSTOMER_ALREADY_MERGED`, localised in the
|
||||
three locales) and validates override lengths against the column sizes; a booking that
|
||||
was `blocked` at checkout can now be cancelled (it had no exit state); demo reset no
|
||||
longer wipes `revoked_sessions` (logged-out cookies were revived).
|
||||
- **Idempotency**: `idempotency_records.request_fingerprint` (migration `0a4c1d2e3f5b`);
|
||||
replaying an `Idempotency-Key` with a different body → 409 `IDEMPOTENCY_KEY_REUSED`.
|
||||
- **Observability**: unmatched paths are labelled `<unmatched>` in the HTTP metrics
|
||||
(404 probes no longer create unbounded Prometheus series). Integration status reads the
|
||||
latest heartbeat/failure per workflow with `DISTINCT ON` instead of loading every row.
|
||||
- **Frontend**: nginx hashed-asset regex never matched Vite's `name-HASH.js` output, so
|
||||
bundles were served `no-cache`; fixed (`Cache-Control: public, max-age=1y, immutable`),
|
||||
plus gzip and `server_tokens off`. Global search and the Vehicles/Bookings/Audit/DQ
|
||||
lists abort stale requests and use `replace` navigation (no history entry per
|
||||
keystroke). Bookings/Audit date filters use Brussels day boundaries via
|
||||
`brusselsDateTime.ts`. DQ "demo scenarios only" is a server-side filter (`demo_only`)
|
||||
so it spans all pages. `AbortSignal.any` fallback, `sessionStorage` parse guard,
|
||||
per-action error notices on booking detail, dead `.about-cta` guide target fixed.
|
||||
- **Build/CI**: root and frontend `.dockerignore`; backend image runs as non-root `app`;
|
||||
`VITE_API_BASE_URL` is a build arg (was a no-op runtime env); CI gains an `e2e` job that
|
||||
runs the full Playwright suite against the Compose stack; `npm audit --audit-level=high`.
|
||||
`httpx2` moved to dev extras (Starlette TestClient), unused `pytest-asyncio` removed.
|
||||
- **Tests**: `tests/test_migrations.py` upgrades an empty database through Alembic and
|
||||
asserts `compare_metadata` is empty (found and fixed a real drift: missing
|
||||
`index=True` on `Customer.anonymized_at`). `tests/test_hardening.py` covers the items
|
||||
above. `contracts/openapi.yaml` regenerated (it lacked `complete-requirements` and
|
||||
`schedule`).
|
||||
- **Docs**: `docs/18-visual-product-roadmap.md` → `docs/19-…` (duplicate number),
|
||||
`FILE_INDEX.md` regenerated from `git ls-files`, README points at `N8N_WEBHOOK_URL`
|
||||
and the definitive acceptance summary, `.gitignore` covers `.claude/settings.local.json`,
|
||||
`*.tgz`, `*.dump`, `backups/`.
|
||||
- Gates run from this checkout: 261 backend tests, Ruff, mypy (59 files), frontend
|
||||
`tsc -b && vite build`, and the complete Playwright suite (153 tests) against a local
|
||||
nginx + uvicorn + PostgreSQL 16 stack — all green.
|
||||
- **Lint**: ESLint 9 flat config (`frontend/eslint.config.js`) with typescript-eslint,
|
||||
`react-hooks` (`rules-of-hooks` + `exhaustive-deps` as errors; the React-Compiler-era
|
||||
`set-state-in-effect`/`purity`/`refs` rules are off because the app's "reset then fetch
|
||||
in an effect" pattern is deliberate) and `jsx-a11y`. `npm run lint` = `tsc -b --noEmit &&
|
||||
eslint .`; wired into `make lint` and CI. Fixed the real findings: missing `t`
|
||||
dependencies in `BookingDetail`/`Dashboard`/`DataQualityIssueDetail` loaders, missing
|
||||
`setCollapsedToChip` in `DemoGuide`, stale-object dependency in the reschedule
|
||||
pre-fill. `i18next`/`react-i18next` pinned exactly; `engines.node >= 22`.
|
||||
- README headline no longer links to the LAN-only demo (`192.168.10.150`); it gives the
|
||||
two-command local run and points to `deploy/unraid/README.md` for the hosted reference.
|
||||
- Left as-is on purpose: the bundled `n8n` service still starts with `make demo` (the
|
||||
runbook's local automation demo relies on it; production disables it via
|
||||
`compose.unraid.yaml`).
|
||||
- Exact next action: rebuild images (`docker compose build`) so the new migration
|
||||
applies on the next `up`; on Unraid confirm `.env` has a real `MOBILITYOPS_CALLBACK_TOKEN`
|
||||
and `APP_SECRET` before deploying, because production now refuses placeholders.
|
||||
|
||||
## Publication and Unraid deployment (2026-08-02)
|
||||
|
||||
- Unraid deployment is live at `http://192.168.10.150:1236` from
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**A recruiter-ready operations platform for vehicle rental and service teams.**
|
||||
|
||||
[Open the live demo](http://192.168.10.150:1236) · no password required · choose **Highlights in 90 seconds** for the shortest tour.
|
||||
**Try it in two commands** (`cp .env.example .env && make demo`, then open `http://localhost:1228`) · no password required · choose **Highlights in 90 seconds** for the shortest tour. The reference deployment runs on a private LAN (see [deploy/unraid/README.md](deploy/unraid/README.md)); ask for a link if you want the hosted version.
|
||||
|
||||
Fleet Ops turns fragmented vehicle, booking and procedure data into one controlled operational workspace. It is a complete synthetic-data product demo: the company and records are fictional, while the workflows, persistence, validation, authorization, audit trail and integration boundaries are implemented.
|
||||
|
||||
@@ -65,7 +65,7 @@ make demo
|
||||
|
||||
- Web: `http://localhost:1228`
|
||||
- API readiness: `http://localhost:8128/health/ready`
|
||||
- Existing n8n server: configure `N8N_BASE_URL`; Fleet Ops does not create a second n8n instance.
|
||||
- Existing n8n server: point `N8N_WEBHOOK_URL` at its return-processing webhook (see `.env.example`). The bundled `n8n` service in `compose.yaml` is a local fallback only; production reuses the server's central n8n (`compose.unraid.yaml` disables the bundled one).
|
||||
|
||||
The deterministic local knowledge provider supports clean-checkout acceptance without pretending to be the live RAGcore integration. Configuration is documented in `.env.example`; operations and recovery are in [docs/17-runbook.md](docs/17-runbook.md).
|
||||
|
||||
@@ -78,7 +78,7 @@ make e2e # complete Playwright browser acceptance
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Current verified results and production evidence are recorded in [artifacts/evidence/final-summary.md](artifacts/evidence/final-summary.md) and [PROJECT_STATE.md](PROJECT_STATE.md).
|
||||
Current verified results and production evidence are recorded in [artifacts/final-acceptance/summary.md](artifacts/final-acceptance/summary.md) (historical M7 evidence: [artifacts/evidence/final-summary.md](artifacts/evidence/final-summary.md)) and [PROJECT_STATE.md](PROJECT_STATE.md).
|
||||
|
||||
## Repository map
|
||||
|
||||
@@ -89,6 +89,6 @@ Current verified results and production evidence are recorded in [artifacts/evid
|
||||
- `n8n/` — importable workflow definitions for the existing server
|
||||
- `seed/` — deterministic synthetic dataset
|
||||
- `docs/` — architecture, security, UX, testing and runbooks
|
||||
- `artifacts/evidence/` — current acceptance evidence and screenshots
|
||||
- `artifacts/` — acceptance evidence and screenshots per release (`artifacts/final-acceptance/summary.md` is the definitive one)
|
||||
|
||||
“MobilityOps” remains the repository/deployment identifier; **Fleet Ops** is the product name shown to users.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Claude must use `PROJECT_STATE.md` as its compact memory between sessions. Do no
|
||||
- architecture and domain decisions;
|
||||
- API and event contracts;
|
||||
- realistic deterministic synthetic seed data;
|
||||
- ten fictitious procedures for RAGcore;
|
||||
- eleven fictitious procedures for RAGcore (nl-BE, en-GB, fr-BE);
|
||||
- an initial n8n workflow export;
|
||||
- MCP tool definitions for ITWorx MCP Hub;
|
||||
- a minimal bootable frontend/API scaffold;
|
||||
|
||||
+5
-1
@@ -11,6 +11,10 @@ COPY backend/tests ./tests
|
||||
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
|
||||
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
|
||||
# Run migrations and the API as an unprivileged user; nothing here needs root.
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
CMD ["./entrypoint.sh"]
|
||||
|
||||
@@ -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")
|
||||
@@ -44,6 +44,13 @@ _ROUTE_TEMPLATES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
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),
|
||||
@@ -51,8 +58,8 @@ def export_audit_csv(
|
||||
db: Session = Depends(get_db),
|
||||
actor: CurrentUser = Depends(require_operations_manager),
|
||||
) -> Response:
|
||||
end = occurred_to or datetime.now(UTC)
|
||||
start = occurred_from or end - timedelta(days=30)
|
||||
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(
|
||||
@@ -133,7 +140,7 @@ def list_audit_events(
|
||||
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: str | None = Query(default=None),
|
||||
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),
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -25,6 +26,25 @@ 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; honour the first hop of X-Forwarded-For when present.
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
oauth = OAuth()
|
||||
if settings.oidc_enabled and settings.oidc_issuer_url:
|
||||
oauth.register(
|
||||
@@ -139,6 +159,11 @@ def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
|
||||
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:
|
||||
@@ -223,6 +248,7 @@ async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Resp
|
||||
@router.post("/login", response_model=CurrentUser)
|
||||
def password_login(
|
||||
body: PasswordLoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> CurrentUser:
|
||||
@@ -231,9 +257,21 @@ def password_login(
|
||||
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,
|
||||
|
||||
@@ -70,7 +70,9 @@ def list_bookings(
|
||||
if vehicle_ref:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||
if vehicle is None:
|
||||
return []
|
||||
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)
|
||||
@@ -445,8 +447,13 @@ def cancel_booking(
|
||||
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 cancelled")
|
||||
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:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
@@ -30,10 +31,20 @@ 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.
|
||||
return datetime.now(UTC).date()
|
||||
# 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)
|
||||
@@ -97,7 +108,7 @@ def get_dashboard(
|
||||
for b in bookings:
|
||||
vehicle = vehicles_by_id.get(b.vehicle_id)
|
||||
vehicle_ref = vehicle.public_ref if vehicle else ""
|
||||
if b.starts_at.date() == today and b.status in ("reserved", "active"):
|
||||
if _local_date(b.starts_at) == today and b.status in ("reserved", "active"):
|
||||
today_items.append(
|
||||
TodayItem(
|
||||
kind="departure",
|
||||
@@ -106,7 +117,7 @@ def get_dashboard(
|
||||
scheduled_at=b.starts_at,
|
||||
)
|
||||
)
|
||||
if b.ends_at.date() == today and b.status in ("active", "returned"):
|
||||
if _local_date(b.ends_at) == today and b.status in ("active", "returned"):
|
||||
today_items.append(
|
||||
TodayItem(
|
||||
kind="return",
|
||||
|
||||
@@ -75,6 +75,7 @@ def list_issues(
|
||||
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),
|
||||
@@ -107,6 +108,10 @@ def list_issues(
|
||||
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(
|
||||
|
||||
@@ -111,6 +111,10 @@ def demo_reset(
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> dict:
|
||||
global _last_reset_monotonic
|
||||
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,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from sqlalchemy import select
|
||||
@@ -21,6 +21,7 @@ from app.schemas import (
|
||||
ProcedureListOut,
|
||||
ProcedureSyncResultIn,
|
||||
ProcedureSyncResultResult,
|
||||
ReturnCallbackIn,
|
||||
ScanResultOut,
|
||||
WorkflowErrorReportIn,
|
||||
WorkflowErrorReportResult,
|
||||
@@ -42,6 +43,14 @@ _CANONICAL_WORKFLOW_NAMES = frozenset(
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
|
||||
def workflow_heartbeat(
|
||||
body: N8nHeartbeatIn,
|
||||
@@ -49,8 +58,7 @@ def workflow_heartbeat(
|
||||
db: Session = Depends(get_db),
|
||||
) -> N8nHeartbeatResult:
|
||||
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_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)
|
||||
already_recorded = (
|
||||
@@ -87,13 +95,12 @@ def workflow_heartbeat(
|
||||
|
||||
@router.post("/return-callback")
|
||||
def return_callback(
|
||||
body: dict[str, Any],
|
||||
body: ReturnCallbackIn,
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
try:
|
||||
event_id = uuid.UUID(idempotency_key)
|
||||
@@ -124,10 +131,8 @@ def return_callback(
|
||||
actor_label="n8n",
|
||||
action="n8n_return_followup_recorded",
|
||||
entity_type="booking",
|
||||
correlation_id=uuid.UUID(body.get("correlation_id"))
|
||||
if body.get("correlation_id")
|
||||
else None,
|
||||
after={"follow_up": body.get("follow_up"), "summary": body.get("summary")},
|
||||
correlation_id=body.correlation_id,
|
||||
after={"follow_up": body.follow_up, "summary": body.summary},
|
||||
metadata={"event_id": str(event_id)},
|
||||
)
|
||||
db.commit()
|
||||
@@ -148,8 +153,7 @@ def scheduled_scan(
|
||||
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
|
||||
doesn't already have one open, so a duplicate or overlapping trigger does no
|
||||
duplicate domain work -- it just reports zero new issues for anything already known."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||
return ScanResultOut(created=result.created)
|
||||
@@ -165,8 +169,7 @@ def workflow_error(
|
||||
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."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
@@ -218,8 +221,7 @@ def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -
|
||||
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."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
documents = [
|
||||
ProcedureDocumentOut(
|
||||
@@ -245,8 +247,7 @@ def procedures_sync_result(
|
||||
"""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."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
|
||||
@@ -64,12 +64,52 @@ class Settings(BaseSettings):
|
||||
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
|
||||
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.
|
||||
|
||||
Only secrets that actually guard something in the given deployment are reported:
|
||||
``mcp_hub_service_token`` is irrelevant while MCP Hub registration is disabled.
|
||||
"""
|
||||
insecure: list[str] = []
|
||||
for name, placeholder in INSECURE_DEFAULT_SECRETS:
|
||||
if name == "mcp_hub_service_token" and not settings.mcp_hub_registration_enabled:
|
||||
continue
|
||||
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:
|
||||
return 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)."
|
||||
)
|
||||
return settings
|
||||
|
||||
@@ -74,10 +74,19 @@ def correlation_id_for(request: Request) -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
UNMATCHED_ROUTE_LABEL = "<unmatched>"
|
||||
|
||||
|
||||
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 or request.url.path)
|
||||
return str(path) if path else UNMATCHED_ROUTE_LABEL
|
||||
|
||||
|
||||
def request_started() -> float:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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)
|
||||
@@ -23,4 +23,4 @@ class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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))
|
||||
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
|
||||
@@ -15,5 +15,8 @@ class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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)
|
||||
|
||||
+16
-1
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
Role = Literal["operations_manager", "rental_employee"]
|
||||
|
||||
@@ -423,6 +424,20 @@ class N8nWorkflowEvidence(BaseModel):
|
||||
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")
|
||||
|
||||
correlation_id: uuid.UUID | None = None
|
||||
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)
|
||||
|
||||
@@ -19,7 +19,6 @@ 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.revoked_session import RevokedSession
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.services.audit import record_audit_event
|
||||
@@ -92,8 +91,9 @@ _PERSISTENT_TELEMETRY_ACTIONS = (
|
||||
|
||||
|
||||
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 (
|
||||
RevokedSession,
|
||||
OutboxEvent,
|
||||
IdempotencyRecord,
|
||||
DataQualityIssue,
|
||||
|
||||
@@ -130,7 +130,12 @@ def _open_issue(
|
||||
|
||||
def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
|
||||
customers = list(
|
||||
db.scalars(select(Customer).where(Customer.merged_into_customer_id.is_(None))).all()
|
||||
db.scalars(
|
||||
select(Customer).where(
|
||||
Customer.merged_into_customer_id.is_(None),
|
||||
Customer.anonymized_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
customers.sort(key=lambda c: c.public_ref)
|
||||
# The threshold cannot be reached without an exact email (60 points) or phone
|
||||
@@ -170,9 +175,7 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
|
||||
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)}}
|
||||
)
|
||||
signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
|
||||
summary_parts.append("similar name")
|
||||
|
||||
if score >= DUPLICATE_THRESHOLD:
|
||||
@@ -191,8 +194,13 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
|
||||
|
||||
|
||||
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))
|
||||
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:
|
||||
@@ -519,6 +527,30 @@ def resolve_odometer_regression(
|
||||
"This issue is not an odometer_regression issue.",
|
||||
status_code=409,
|
||||
)
|
||||
# Lock order is booking -> vehicle everywhere (checkout, return, reschedule); taking
|
||||
# the vehicle lock first here would be a deadlock waiting to happen under concurrency.
|
||||
booking: Booking | None = None
|
||||
if body.decision != "retain_canonical":
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
if body.booking_ref not in related_refs:
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's related bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
if body.corrected_odometer_km is None:
|
||||
raise AppError(
|
||||
"CORRECTED_VALUE_REQUIRED",
|
||||
"corrected_odometer_km is required when correcting a reading.",
|
||||
status_code=422,
|
||||
)
|
||||
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.entity_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
@@ -539,19 +571,7 @@ def resolve_odometer_regression(
|
||||
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
|
||||
)
|
||||
else:
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
if body.booking_ref not in related_refs:
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's related bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
if body.corrected_odometer_km is None:
|
||||
raise AppError(
|
||||
"CORRECTED_VALUE_REQUIRED",
|
||||
"corrected_odometer_km is required when correcting a reading.",
|
||||
status_code=422,
|
||||
)
|
||||
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:
|
||||
@@ -563,13 +583,6 @@ def resolve_odometer_regression(
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
|
||||
)
|
||||
if booking is None:
|
||||
raise AppError(
|
||||
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
|
||||
)
|
||||
|
||||
before = {
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
@@ -810,6 +823,16 @@ def apply_recommended_status(
|
||||
|
||||
|
||||
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(
|
||||
@@ -839,12 +862,26 @@ def merge_customers(
|
||||
)
|
||||
loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref)
|
||||
|
||||
survivor = db.scalar(select(Customer).where(Customer.public_ref == survivor_ref))
|
||||
loser = db.scalar(select(Customer).where(Customer.public_ref == loser_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},
|
||||
@@ -856,7 +893,15 @@ def merge_customers(
|
||||
raise AppError(
|
||||
"INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422
|
||||
)
|
||||
setattr(survivor, field_name, value)
|
||||
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)
|
||||
|
||||
@@ -2,11 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import Row, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -38,6 +39,22 @@ _STALE_AFTER = {
|
||||
}
|
||||
|
||||
|
||||
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(
|
||||
@@ -144,11 +161,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
|
||||
}
|
||||
heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {}
|
||||
heartbeat_rows = db.execute(
|
||||
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
|
||||
.where(AuditEvent.action == "n8n_workflow_heartbeat")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
).all()
|
||||
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:
|
||||
@@ -159,11 +172,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
)
|
||||
|
||||
failure_by_workflow: dict[str, tuple[datetime, str | None]] = {}
|
||||
failure_rows = db.execute(
|
||||
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
|
||||
.where(AuditEvent.action == "n8n_workflow_failure_registered")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
).all()
|
||||
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:
|
||||
|
||||
@@ -148,6 +148,8 @@ class RAGcoreKnowledgeProvider:
|
||||
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."
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -171,6 +173,33 @@ def preview_vehicle_return(
|
||||
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,
|
||||
@@ -178,18 +207,12 @@ def register_vehicle_return(
|
||||
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:
|
||||
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,
|
||||
)
|
||||
return existing.response_status, existing.response_body
|
||||
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||
|
||||
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
|
||||
|
||||
@@ -199,7 +222,7 @@ def register_vehicle_return(
|
||||
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing.response_status, existing.response_body
|
||||
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||
|
||||
if booking.status != "active":
|
||||
raise AppError(
|
||||
@@ -336,6 +359,7 @@ def register_vehicle_return(
|
||||
IdempotencyRecord(
|
||||
idempotency_key=idempotency_key,
|
||||
booking_id=booking.id,
|
||||
request_fingerprint=fingerprint,
|
||||
response_status=201,
|
||||
response_body=response_body,
|
||||
)
|
||||
@@ -349,7 +373,7 @@ def register_vehicle_return(
|
||||
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing.response_status, existing.response_body
|
||||
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||
raise
|
||||
|
||||
return 201, response_body
|
||||
|
||||
@@ -15,7 +15,6 @@ dependencies = [
|
||||
"psycopg[binary]>=3.2,<4",
|
||||
"alembic>=1.13,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"httpx2>=2.10,<3",
|
||||
"authlib>=1.6,<2",
|
||||
"itsdangerous>=2.2,<3",
|
||||
"prometheus-client>=0.24,<1"
|
||||
@@ -24,7 +23,8 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8,<9",
|
||||
"pytest-asyncio>=0.24,<1",
|
||||
# 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"
|
||||
]
|
||||
@@ -34,7 +34,6 @@ packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
@@ -95,10 +95,6 @@ pydantic-settings==2.14.2
|
||||
pygments==2.20.0
|
||||
# via pytest
|
||||
pytest==8.4.2
|
||||
# via
|
||||
# mobilityops-api (pyproject.toml)
|
||||
# pytest-asyncio
|
||||
pytest-asyncio==0.26.0
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
python-dotenv==1.2.2
|
||||
# via
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Regression tests for the hardening pass (security, robustness, data-quality edge cases)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.config import Settings, get_settings, insecure_default_secrets
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.observability import UNMATCHED_ROUTE_LABEL
|
||||
from app.core.ratelimit import FailedAttemptLimiter
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.services.data_quality import run_scan
|
||||
from tests.test_return import _activate_booking, _return_body
|
||||
|
||||
|
||||
def test_insecure_defaults_are_detected_only_for_relevant_secrets():
|
||||
defaults = Settings(_env_file=None)
|
||||
assert "app_secret" in insecure_default_secrets(defaults)
|
||||
assert "mcp_hub_service_token" not in insecure_default_secrets(defaults)
|
||||
hardened = Settings(
|
||||
_env_file=None,
|
||||
app_secret="x" * 32,
|
||||
n8n_callback_token="c" * 32,
|
||||
mcp_hub_registration_enabled=True,
|
||||
)
|
||||
assert insecure_default_secrets(hardened) == ["mcp_hub_service_token"]
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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_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):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={"follow_up": "cleaning", "correlation_id": "nope"},
|
||||
headers={
|
||||
"Idempotency-Key": str(uuid.uuid4()),
|
||||
"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"],
|
||||
"requirements_complete": True,
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
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()
|
||||
@@ -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)
|
||||
)
|
||||
+6
-3
@@ -76,9 +76,12 @@ services:
|
||||
networks: [mobilityops]
|
||||
|
||||
web:
|
||||
build: ./frontend
|
||||
environment:
|
||||
VITE_API_BASE_URL: ${MOBILITYOPS_API_URL:-http://localhost:8128}
|
||||
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:-}
|
||||
ports:
|
||||
- "1228:80"
|
||||
depends_on:
|
||||
|
||||
+139
-4
@@ -655,6 +655,70 @@ paths:
|
||||
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:
|
||||
@@ -883,6 +947,7 @@ paths:
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: uuid
|
||||
- type: 'null'
|
||||
title: Correlation Id
|
||||
- name: occurred_from
|
||||
@@ -986,6 +1051,14 @@ paths:
|
||||
- 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
|
||||
@@ -1441,9 +1514,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
$ref: '#/components/schemas/ReturnCallbackIn'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -2635,6 +2706,17 @@ components:
|
||||
- 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:
|
||||
@@ -2658,7 +2740,7 @@ components:
|
||||
requirements_complete:
|
||||
type: boolean
|
||||
title: Requirements Complete
|
||||
default: true
|
||||
default: false
|
||||
type: object
|
||||
required:
|
||||
- customer_ref
|
||||
@@ -3990,6 +4072,9 @@ components:
|
||||
workflow_event_id:
|
||||
type: string
|
||||
title: Workflow Event Id
|
||||
correlation_id:
|
||||
type: string
|
||||
title: Correlation Id
|
||||
next_booking_risk:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/NextBookingRisk'
|
||||
@@ -4003,6 +4088,7 @@ components:
|
||||
- odometer_regression
|
||||
- quality_issue_ref
|
||||
- workflow_event_id
|
||||
- correlation_id
|
||||
- next_booking_risk
|
||||
title: RegisterReturnResult
|
||||
ReleaseVehicleRequest:
|
||||
@@ -4016,6 +4102,27 @@ components:
|
||||
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:
|
||||
@@ -4060,6 +4167,34 @@ components:
|
||||
required:
|
||||
- booking_ref
|
||||
title: ResolveOverlapRequest
|
||||
ReturnCallbackIn:
|
||||
properties:
|
||||
correlation_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: uuid
|
||||
- type: 'null'
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -17,12 +17,12 @@ app_secret="$(openssl rand -hex 32)"
|
||||
n8n_key="$(openssl rand -hex 32)"
|
||||
n8n_password="$(openssl rand -hex 24)"
|
||||
callback_token="$(openssl rand -hex 32)"
|
||||
trigger_token="$(openssl rand -hex 32)"
|
||||
mcp_token="$(openssl rand -hex 32)"
|
||||
|
||||
sed -i \
|
||||
-e "s|^MOBILITYOPS_ENV=.*|MOBILITYOPS_ENV=production|" \
|
||||
-e "s|^MOBILITYOPS_PUBLIC_URL=.*|MOBILITYOPS_PUBLIC_URL=${public_url}|" \
|
||||
-e "s|^MOBILITYOPS_API_URL=.*|MOBILITYOPS_API_URL=${public_url}|" \
|
||||
-e "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+psycopg://mobilityops:${db_password}@db:5432/mobilityops|" \
|
||||
-e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \
|
||||
-e "s|^APP_SECRET=.*|APP_SECRET=${app_secret}|" \
|
||||
@@ -30,11 +30,12 @@ sed -i \
|
||||
-e "s|^N8N_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${n8n_key}|" \
|
||||
-e "s|^N8N_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \
|
||||
-e "s|^MOBILITYOPS_CALLBACK_TOKEN=.*|MOBILITYOPS_CALLBACK_TOKEN=${callback_token}|" \
|
||||
-e "s|^MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=.*|MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=${trigger_token}|" \
|
||||
-e "s|^KNOWLEDGE_PROVIDER=.*|KNOWLEDGE_PROVIDER=demo|" \
|
||||
-e "s|^MCP_HUB_REGISTRATION_ENABLED=.*|MCP_HUB_REGISTRATION_ENABLED=false|" \
|
||||
-e "s|^MCP_HUB_SERVICE_TOKEN=.*|MCP_HUB_SERVICE_TOKEN=${mcp_token}|" \
|
||||
.env
|
||||
|
||||
unset db_password app_secret n8n_key n8n_password callback_token mcp_token
|
||||
unset db_password app_secret n8n_key n8n_password callback_token trigger_token mcp_token
|
||||
|
||||
echo "Created server .env for ${public_url} using n8n webhook ${n8n_webhook_url}"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
playwright-report
|
||||
test-results
|
||||
e2e
|
||||
tsconfig.tsbuildinfo
|
||||
.env*
|
||||
@@ -4,6 +4,10 @@ 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.27-alpine
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
);
|
||||
+12
-2
@@ -7,6 +7,13 @@ server {
|
||||
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;
|
||||
@@ -44,8 +51,11 @@ server {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
location ~* ^/assets/.+\.[a-fA-F0-9_-]+\.(css|js|woff2|png|svg)$ {
|
||||
expires 1y;
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3796
-32
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -7,22 +7,31 @@
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"lint": "tsc -b --noEmit",
|
||||
"lint": "tsc -b --noEmit && eslint .",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^26.3.6",
|
||||
"i18next": "26.3.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^17.0.11",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export default defineConfig({
|
||||
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",
|
||||
},
|
||||
|
||||
@@ -14,11 +14,23 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
||||
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const timeoutController = new AbortController();
|
||||
const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS);
|
||||
const signal = init?.signal
|
||||
? AbortSignal.any([init.signal, timeoutController.signal])
|
||||
? combineSignals(init.signal, timeoutController.signal)
|
||||
: timeoutController.signal;
|
||||
let response: Response;
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ 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",
|
||||
|
||||
@@ -91,7 +91,7 @@ export function DemoGuide() {
|
||||
}
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
return () => document.removeEventListener("keydown", handleKeydown);
|
||||
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide]);
|
||||
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide, setCollapsedToChip]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTarget.current) return;
|
||||
|
||||
@@ -128,19 +128,31 @@ export function Layout() {
|
||||
}
|
||||
setSearchLoading(true);
|
||||
setSearchError(false);
|
||||
// Abort the in-flight request when the query changes so a slow response for "a"
|
||||
// can never overwrite the results for "ab" (or flip the loading state early).
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => {
|
||||
api
|
||||
.get<{ query: string; results: SearchResultItem[] }>(
|
||||
`/api/v1/search?q=${encodeURIComponent(query)}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((response) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setSearchResults(response.results);
|
||||
setActiveIndex(-1);
|
||||
})
|
||||
.catch(() => setSearchError(true))
|
||||
.finally(() => setSearchLoading(false));
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setSearchError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setSearchLoading(false);
|
||||
});
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,8 +18,14 @@ const AuthContext = createContext<AuthState | undefined>(undefined);
|
||||
const STORAGE_KEY = "mobilityops.demo-user";
|
||||
|
||||
function readCachedUser(): CurrentUser | null {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
||||
} catch {
|
||||
// A corrupted or blocked sessionStorage must never prevent the app from booting;
|
||||
// the session endpoint remains the source of truth.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheUser(user: CurrentUser | null) {
|
||||
|
||||
@@ -35,5 +35,5 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
|
||||
},
|
||||
{ id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" },
|
||||
{ id: "check-automation-audit", route: () => "/automation", target: ".integration-cards" },
|
||||
{ id: "review-real-vs-simulated", route: () => "/about", target: ".about-cta" },
|
||||
{ id: "review-real-vs-simulated", route: () => "/about", target: ".engineering-hero" },
|
||||
];
|
||||
|
||||
@@ -44,3 +44,15 @@ export function brusselsLocalToIso(value: string): string {
|
||||
}
|
||||
return new Date(candidate).toISOString();
|
||||
}
|
||||
|
||||
/** Start of the given Brussels calendar day (YYYY-MM-DD) as a UTC ISO instant. */
|
||||
export function brusselsDayStartIso(day: string): string {
|
||||
return brusselsLocalToIso(`${day}T00:00`);
|
||||
}
|
||||
|
||||
/** Start of the day *after* the given Brussels calendar day, i.e. an exclusive upper bound. */
|
||||
export function brusselsNextDayStartIso(day: string): string {
|
||||
const [year, month, date] = day.split("-").map(Number);
|
||||
const next = new Date(Date.UTC(year, month - 1, date + 1));
|
||||
return brusselsLocalToIso(`${next.toISOString().slice(0, 10)}T00:00`);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
"title": "Customer not found",
|
||||
"explanation": "This customer record could not be found."
|
||||
},
|
||||
"CUSTOMER_ALREADY_MERGED": {
|
||||
"title": "Customer already merged",
|
||||
"explanation": "One of these customer records has already been merged into another record.",
|
||||
"nextStep": "Reload the issue; it may already be resolved."
|
||||
},
|
||||
"ENTITY_NOT_FOUND": {
|
||||
"title": "Record not found",
|
||||
"explanation": "The underlying record for this action could not be found."
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
"title": "Client introuvable",
|
||||
"explanation": "Cette fiche client est introuvable."
|
||||
},
|
||||
"CUSTOMER_ALREADY_MERGED": {
|
||||
"title": "Client déjà fusionné",
|
||||
"explanation": "L'une de ces fiches client a déjà été fusionnée avec une autre fiche.",
|
||||
"nextStep": "Rechargez le problème ; il est peut-être déjà résolu."
|
||||
},
|
||||
"ENTITY_NOT_FOUND": {
|
||||
"title": "Fiche introuvable",
|
||||
"explanation": "La fiche sous-jacente à cette action est introuvable."
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
"title": "Klant niet gevonden",
|
||||
"explanation": "Dit klantrecord kon niet gevonden worden."
|
||||
},
|
||||
"CUSTOMER_ALREADY_MERGED": {
|
||||
"title": "Klant al samengevoegd",
|
||||
"explanation": "Eén van deze klantrecords is al samengevoegd met een ander record.",
|
||||
"nextStep": "Herlaad het probleem; mogelijk is het al opgelost."
|
||||
},
|
||||
"ENTITY_NOT_FOUND": {
|
||||
"title": "Record niet gevonden",
|
||||
"explanation": "Het onderliggende record voor deze actie kon niet gevonden worden."
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import type { AuditEvent, Page } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { brusselsDayStartIso, brusselsNextDayStartIso } from "../i18n/brusselsDateTime";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
@@ -78,7 +79,7 @@ export function Audit() {
|
||||
if (value === null || value === "") next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next);
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,9 +91,17 @@ export function Audit() {
|
||||
if (actor) params.set("actor_label", actor);
|
||||
if (entityRef) params.set("entity_ref", entityRef);
|
||||
if (correlationId) params.set("correlation_id", correlationId);
|
||||
if (from) params.set("occurred_from", `${from}T00:00:00Z`);
|
||||
if (to) params.set("occurred_to", `${to}T23:59:59Z`);
|
||||
api.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable")));
|
||||
// Date filters are Brussels calendar days, not UTC midnight boundaries.
|
||||
if (from) params.set("occurred_from", brusselsDayStartIso(from));
|
||||
if (to) params.set("occurred_to", brusselsNextDayStartIso(to));
|
||||
const controller = new AbortController();
|
||||
api
|
||||
.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`, { signal: controller.signal })
|
||||
.then(setEvents)
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setError(t("unavailable"));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [action, actor, correlationId, entityRef, from, page, t, to, user]);
|
||||
|
||||
const groups = useMemo<EventGroup[]>(() => {
|
||||
|
||||
@@ -27,6 +27,7 @@ export function BookingDetail() {
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
|
||||
const [actionErrorSource, setActionErrorSource] = useState<"cancel" | "requirements" | "reschedule" | null>(null);
|
||||
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
|
||||
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
|
||||
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
|
||||
@@ -41,7 +42,7 @@ export function BookingDetail() {
|
||||
.get<Booking>(`/api/v1/bookings/${publicRef}`)
|
||||
.then(setBooking)
|
||||
.catch(() => setError(t("detail.notFound")));
|
||||
}, [publicRef]);
|
||||
}, [publicRef, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setBooking(null);
|
||||
@@ -54,7 +55,7 @@ export function BookingDetail() {
|
||||
if (!booking) return;
|
||||
setScheduleStart(toBrusselsDateTimeLocal(new Date(booking.starts_at)));
|
||||
setScheduleEnd(toBrusselsDateTimeLocal(new Date(booking.ends_at)));
|
||||
}, [booking?.public_ref]);
|
||||
}, [booking]);
|
||||
|
||||
// The odometer-regression demo scenario supplies its own suspicious reading (per the
|
||||
// brief: never ask a demo visitor to invent one) -- only fetched for that one known
|
||||
@@ -87,6 +88,7 @@ export function BookingDetail() {
|
||||
setCancelReason("");
|
||||
} catch (err) {
|
||||
setActionError(describeApiError(t, err, "bookings:detail.cancelFailed"));
|
||||
setActionErrorSource("cancel");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
@@ -110,6 +112,7 @@ export function BookingDetail() {
|
||||
setRequirementsConfirmation("");
|
||||
} catch (err) {
|
||||
setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed"));
|
||||
setActionErrorSource("requirements");
|
||||
} finally {
|
||||
setConfirmingRequirements(false);
|
||||
}
|
||||
@@ -130,6 +133,7 @@ export function BookingDetail() {
|
||||
setScheduleReason("");
|
||||
} catch (err) {
|
||||
setActionError(describeApiError(t, err, "bookings:detail.rescheduleFailed"));
|
||||
setActionErrorSource("reschedule");
|
||||
} finally {
|
||||
setRescheduling(false);
|
||||
}
|
||||
@@ -158,7 +162,7 @@ export function BookingDetail() {
|
||||
<h2>{t("detail.requirementsAction")}</h2>
|
||||
<p>{t("detail.requirementsActionDetail")}</p>
|
||||
</div>
|
||||
<ApiErrorNotice error={actionError} />
|
||||
<ApiErrorNotice error={actionErrorSource === "requirements" ? actionError : null} />
|
||||
<label>{t("detail.requirementsConfirmation")}<textarea required minLength={3} maxLength={500} value={requirementsConfirmation} onChange={(event) => setRequirementsConfirmation(event.target.value)} placeholder={t("detail.requirementsConfirmationPlaceholder")} /></label>
|
||||
<div className="form-actions"><button className="button button-primary" type="submit" disabled={confirmingRequirements || requirementsConfirmation.trim().length < 3}>{confirmingRequirements ? t("detail.requirementsSaving") : t("detail.requirementsConfirm")}</button></div>
|
||||
</form>
|
||||
@@ -169,6 +173,7 @@ export function BookingDetail() {
|
||||
<summary>{t("detail.rescheduleAction")}</summary>
|
||||
<form onSubmit={reschedule}>
|
||||
<p>{t("detail.rescheduleDetail")}</p>
|
||||
<ApiErrorNotice error={actionErrorSource === "reschedule" ? actionError : null} />
|
||||
<div className="form-grid">
|
||||
<label>{t("create.startsAt")}<input type="datetime-local" required value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
|
||||
<label>{t("create.endsAt")}<input type="datetime-local" required min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
|
||||
@@ -179,9 +184,9 @@ export function BookingDetail() {
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
|
||||
{(booking.status === "reserved" || booking.status === "blocked") && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
|
||||
<h2>{t("detail.cancelAction")}</h2>
|
||||
<ApiErrorNotice error={actionError} />
|
||||
<ApiErrorNotice error={actionErrorSource === "cancel" ? actionError : null} />
|
||||
<label>{t("detail.cancelReason")}<textarea required minLength={3} maxLength={500} value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} placeholder={t("detail.cancelReasonPlaceholder")} /></label>
|
||||
<div className="form-actions"><button className="button button-danger" type="submit" disabled={cancelling || cancelReason.trim().length < 3}>{cancelling ? t("detail.cancelling") : t("detail.confirmCancel")}</button></div>
|
||||
</form>}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import type { Booking, Page } from "../api/types";
|
||||
import { brusselsDayStartIso, brusselsNextDayStartIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
@@ -10,15 +11,9 @@ import { Pagination } from "../components/Pagination";
|
||||
|
||||
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
|
||||
|
||||
function localDateString(date = new Date()): string {
|
||||
const offset = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nextLocalDay(value: string): string {
|
||||
const date = new Date(`${value}T12:00:00`);
|
||||
date.setDate(date.getDate() + 1);
|
||||
return localDateString(date);
|
||||
/** Today's Brussels calendar day (YYYY-MM-DD) -- the operational "today", not the browser's. */
|
||||
function brusselsToday(): string {
|
||||
return toBrusselsDateTimeLocal(new Date()).slice(0, 10);
|
||||
}
|
||||
|
||||
export function Bookings() {
|
||||
@@ -41,7 +36,7 @@ export function Bookings() {
|
||||
if (value === null || value === "") next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next);
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,14 +45,19 @@ export function Bookings() {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: "25" });
|
||||
if (status) params.set("status", status);
|
||||
if (query) params.set("query", query);
|
||||
if (startsFrom) params.set("starts_from", new Date(`${startsFrom}T00:00:00`).toISOString());
|
||||
if (startsTo) params.set("starts_to", new Date(`${nextLocalDay(startsTo)}T00:00:00`).toISOString());
|
||||
// Date filters are Brussels calendar days regardless of the browser's own time zone.
|
||||
if (startsFrom) params.set("starts_from", brusselsDayStartIso(startsFrom));
|
||||
if (startsTo) params.set("starts_to", brusselsNextDayStartIso(startsTo));
|
||||
if (location) params.set("location", location);
|
||||
params.set("sort", sort);
|
||||
const controller = new AbortController();
|
||||
api
|
||||
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`)
|
||||
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`, { signal: controller.signal })
|
||||
.then(setBookings)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setError(t("list.unavailable"));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [page, query, status, startsFrom, startsTo, location, sort, t]);
|
||||
|
||||
return (
|
||||
@@ -82,8 +82,8 @@ export function Bookings() {
|
||||
<label>{t("list.sortLabel")}<select value={sort} onChange={(event) => updateFilters({ sort: event.target.value, page: 1 })}><option value="operational">{t("list.sortOperational")}</option><option value="starts_asc">{t("list.sortAscending")}</option><option value="starts_desc">{t("list.sortDescending")}</option></select></label>
|
||||
</form>
|
||||
<div className="filter-presets" aria-label={t("list.presetsLabel")}>
|
||||
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: localDateString(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
|
||||
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
|
||||
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: brusselsToday(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
|
||||
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
|
||||
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export function Dashboard() {
|
||||
}
|
||||
// Only ever react to the initial `?guide=start` marker set by the login screen's
|
||||
// "Start begeleide demo" CTA, so this intentionally runs once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||
@@ -72,7 +73,7 @@ export function Dashboard() {
|
||||
useEffect(() => {
|
||||
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error")));
|
||||
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSeeAutomation) return;
|
||||
|
||||
@@ -51,12 +51,13 @@ export function DataQuality() {
|
||||
if (value === null || value === "" || value === false) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next);
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (user?.role !== "operations_manager") return;
|
||||
setIssues(null);
|
||||
// Keep the current rows visible while a filter change refetches (stale-while-revalidate);
|
||||
// the initial load still shows the loading state because `issues` starts as null.
|
||||
setError(null);
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
@@ -64,13 +65,14 @@ export function DataQuality() {
|
||||
if (severity) params.set("severity", severity);
|
||||
if (assignee) params.set("assigned_to_ref", assignee);
|
||||
if (overdueOnly) params.set("overdue", "true");
|
||||
if (demoScenariosOnly) params.set("demo_only", "true");
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", "25");
|
||||
api
|
||||
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
|
||||
.then(setIssues)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
}, [status, ruleType, severity, assignee, overdueOnly, page, user, t]);
|
||||
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -145,6 +147,8 @@ export function DataQuality() {
|
||||
}
|
||||
|
||||
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
|
||||
// The demo-scenario filter is applied server-side (so it spans all pages); the client-side
|
||||
// guard only bridges the moment between toggling the box and the filtered page arriving.
|
||||
const visibleIssues = issues
|
||||
? demoScenariosOnly
|
||||
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
|
||||
@@ -311,7 +315,7 @@ export function DataQuality() {
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>{issues && !demoScenariosOnly && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
|
||||
</table>{issues && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -727,7 +727,7 @@ export function DataQualityIssueDetail() {
|
||||
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
|
||||
.then(setIssue)
|
||||
.catch(() => setError(t("detail.notFound")));
|
||||
}, [publicRef]);
|
||||
}, [publicRef, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "operations_manager") return;
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Vehicles() {
|
||||
if (value === null || value === "" || value === false) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next);
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,10 +41,14 @@ export function Vehicles() {
|
||||
if (location) params.set("location", location);
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", "25");
|
||||
const controller = new AbortController();
|
||||
api
|
||||
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
|
||||
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`, { signal: controller.signal })
|
||||
.then(setVehicles)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setError(t("list.unavailable"));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [status, attentionOnly, query, location, page, t]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -283,9 +283,6 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; }
|
||||
.about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; }
|
||||
.about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; }
|
||||
.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--teal-pale); border-color: #bfe6df; }
|
||||
.about-cta strong { display: block; color: var(--ink); font-size: .85rem; }
|
||||
.about-cta p { margin: 2px 0 0; }
|
||||
.about-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
.about-grid .about-card { margin-bottom: 0; }
|
||||
.about-details summary { cursor: pointer; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; font-weight: 700; }
|
||||
|
||||
Reference in New Issue
Block a user