M39: harden application and acceptance gates
MobilityOps acceptance / backend (push) Failing after 45s
MobilityOps acceptance / frontend (push) Successful in 32s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-17 03:17:44 +02:00
parent a9f48d6880
commit ae39a8947f
62 changed files with 5277 additions and 202 deletions
+21
View File
@@ -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
View File
@@ -2,11 +2,15 @@ COMPOSE_PROJECT_NAME=mobilityops
MOBILITYOPS_ENV=development MOBILITYOPS_ENV=development
MOBILITYOPS_DEMO_MODE=true MOBILITYOPS_DEMO_MODE=true
MOBILITYOPS_PUBLIC_URL=http://localhost:1228 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 DATABASE_URL=postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops
POSTGRES_DB=mobilityops POSTGRES_DB=mobilityops
POSTGRES_USER=mobilityops POSTGRES_USER=mobilityops
POSTGRES_PASSWORD=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 APP_SECRET=replace-in-production
TZ=Europe/Brussels TZ=Europe/Brussels
# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the # Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the
+49 -1
View File
@@ -46,9 +46,57 @@ jobs:
- name: Install locked dependencies - name: Install locked dependencies
working-directory: frontend working-directory: frontend
run: npm ci --no-audit --no-fund 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 - name: Typecheck and production build
working-directory: frontend working-directory: frontend
run: npm run build run: npm run build
- name: Dependency audit - name: Dependency audit
working-directory: frontend 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
+6
View File
@@ -16,3 +16,9 @@ test-results/
*.tsbuildinfo *.tsbuildinfo
*.zip *.zip
*.tar.gz *.tar.gz
# Local Claude/Codex per-user settings and scratch archives
.claude/settings.local.json
*.tgz
*.dump
backups/
+305 -12
View File
@@ -1,25 +1,147 @@
# File index # 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` - `.env.example`
- `.gitattributes`
- `.gitea/workflows/ci.yml`
- `.gitignore` - `.gitignore`
- `AGENTS.md`
- `CLAUDE.md` - `CLAUDE.md`
- `FILE_INDEX.md`
- `MASTER_BUILD_PROMPT.md` - `MASTER_BUILD_PROMPT.md`
- `Makefile` - `Makefile`
- `PROJECT_STATE.md` - `PROJECT_STATE.md`
- `README.md` - `README.md`
- `START_HERE.md` - `START_HERE.md`
- `backend/Dockerfile` - `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/__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/__init__.py`
- `backend/app/core/config.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/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/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_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` - `compose.yaml`
- `contracts/events.schema.json` - `contracts/events.schema.json`
- `contracts/mcp-tools.json` - `contracts/mcp-tools.json`
- `contracts/openapi.yaml` - `contracts/openapi.yaml`
- `contracts/ragcore-contract-assumptions.md` - `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/00-product-brief.md`
- `docs/01-scope-and-non-goals.md` - `docs/01-scope-and-non-goals.md`
- `docs/02-user-stories.md` - `docs/02-user-stories.md`
@@ -38,32 +160,203 @@
- `docs/15-build-plan.md` - `docs/15-build-plan.md`
- `docs/16-portfolio-case-study.md` - `docs/16-portfolio-case-study.md`
- `docs/17-runbook.md` - `docs/17-runbook.md`
- `docs/18-privacy-governance.md`
- `docs/19-visual-product-roadmap.md`
- `docs/deferred.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/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/index.html`
- `frontend/nginx.conf` - `frontend/nginx.conf`
- `frontend/package-lock.json`
- `frontend/package.json` - `frontend/package.json`
- `frontend/playwright.config.ts`
- `frontend/public/favicon.svg`
- `frontend/public/og-fleet-ops.svg`
- `frontend/src/App.tsx` - `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/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/styles.css`
- `frontend/src/vite-env.d.ts`
- `frontend/tsconfig.json` - `frontend/tsconfig.json`
- `frontend/vite.config.ts` - `frontend/vite.config.ts`
- `knowledge/manifest.json` - `knowledge/manifest.json`
- `knowledge/procedures/01-vehicle-checkout.md` - `knowledge/procedures/en-GB/01-vehicle-checkout.md`
- `knowledge/procedures/02-vehicle-return.md` - `knowledge/procedures/en-GB/02-vehicle-return.md`
- `knowledge/procedures/03-damage-handling.md` - `knowledge/procedures/en-GB/03-damage-handling.md`
- `knowledge/procedures/04-odometer-anomalies.md` - `knowledge/procedures/en-GB/04-odometer-anomalies.md`
- `knowledge/procedures/05-cleaning-checklist.md` - `knowledge/procedures/en-GB/05-cleaning-checklist.md`
- `knowledge/procedures/06-maintenance-escalation.md` - `knowledge/procedures/en-GB/06-maintenance-escalation.md`
- `knowledge/procedures/07-customer-documents.md` - `knowledge/procedures/en-GB/07-customer-documents.md`
- `knowledge/procedures/08-privacy.md` - `knowledge/procedures/en-GB/08-privacy.md`
- `knowledge/procedures/09-booking-conflicts.md` - `knowledge/procedures/en-GB/09-booking-conflicts.md`
- `knowledge/procedures/10-roles-and-escalation.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/README.md`
- `n8n/workflows/MANIFEST.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/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/README.md`
- `seed/bookings.csv` - `seed/bookings.csv`
- `seed/customers.csv` - `seed/customers.csv`
+1
View File
@@ -15,6 +15,7 @@ test:
lint: lint:
docker compose run --rm api ruff check . docker compose run --rm api ruff check .
docker compose run --rm api mypy app docker compose run --rm api mypy app
cd frontend && npm run lint
seed: seed:
docker compose exec api python -m app.cli seed --reset docker compose exec api python -m app.cli seed --reset
+73
View File
@@ -1,5 +1,78 @@
# Project state # 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) ## Publication and Unraid deployment (2026-08-02)
- Unraid deployment is live at `http://192.168.10.150:1236` from - Unraid deployment is live at `http://192.168.10.150:1236` from
+4 -4
View File
@@ -2,7 +2,7 @@
**A recruiter-ready operations platform for vehicle rental and service teams.** **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. 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` - Web: `http://localhost:1228`
- API readiness: `http://localhost:8128/health/ready` - 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). 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 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 ## 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 - `n8n/` — importable workflow definitions for the existing server
- `seed/` — deterministic synthetic dataset - `seed/` — deterministic synthetic dataset
- `docs/` — architecture, security, UX, testing and runbooks - `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. “MobilityOps” remains the repository/deployment identifier; **Fleet Ops** is the product name shown to users.
+1 -1
View File
@@ -18,7 +18,7 @@ Claude must use `PROJECT_STATE.md` as its compact memory between sessions. Do no
- architecture and domain decisions; - architecture and domain decisions;
- API and event contracts; - API and event contracts;
- realistic deterministic synthetic seed data; - 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; - an initial n8n workflow export;
- MCP tool definitions for ITWorx MCP Hub; - MCP tool definitions for ITWorx MCP Hub;
- a minimal bootable frontend/API scaffold; - a minimal bootable frontend/API scaffold;
+5 -1
View File
@@ -11,6 +11,10 @@ COPY backend/tests ./tests
COPY seed ./seed COPY seed ./seed
COPY knowledge ./knowledge COPY knowledge ./knowledge
COPY backend/entrypoint.sh ./entrypoint.sh 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 EXPOSE 8000
CMD ["./entrypoint.sh"] 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")
+10 -3
View File
@@ -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") @router.get("/export.csv")
def export_audit_csv( def export_audit_csv(
occurred_from: datetime | None = Query(default=None), occurred_from: datetime | None = Query(default=None),
@@ -51,8 +58,8 @@ def export_audit_csv(
db: Session = Depends(get_db), db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager), actor: CurrentUser = Depends(require_operations_manager),
) -> Response: ) -> Response:
end = occurred_to or datetime.now(UTC) end = _as_utc(occurred_to) or datetime.now(UTC)
start = occurred_from or end - timedelta(days=30) start = _as_utc(occurred_from) or end - timedelta(days=30)
if end <= start or end - start > timedelta(days=90): if end <= start or end - start > timedelta(days=90):
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days") raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
events = db.scalars( events = db.scalars(
@@ -133,7 +140,7 @@ def list_audit_events(
action: str | None = Query(default=None), action: str | None = Query(default=None),
entity_type: 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), 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_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None), occurred_to: datetime | None = Query(default=None),
page: int | None = Query(default=None, ge=1), page: int | None = Query(default=None, ge=1),
+38
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.core.config import get_settings from app.core.config import get_settings
from app.core.ratelimit import FailedAttemptLimiter
from app.core.security import ( from app.core.security import (
SessionPayload, SessionPayload,
create_session_token, create_session_token,
@@ -25,6 +26,25 @@ from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings() 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() oauth = OAuth()
if settings.oidc_enabled and settings.oidc_issuer_url: if settings.oidc_enabled and settings.oidc_issuer_url:
oauth.register( 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)) user = db.scalar(select(User).where(User.email == email))
if user is not None and user.external_subject not in (None, subject): 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") 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 created = user is None
if created: if created:
if not settings.oidc_auto_provision: 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) @router.post("/login", response_model=CurrentUser)
def password_login( def password_login(
body: PasswordLoginRequest, body: PasswordLoginRequest,
request: Request,
response: Response, response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> CurrentUser: ) -> CurrentUser:
@@ -231,9 +257,21 @@ def password_login(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Password login is unavailable in demo mode", 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())) 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 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if _login_limiter:
_login_limiter.reset(limiter_key)
_set_session(response, user) _set_session(response, user)
record_audit_event( record_audit_event(
db, db,
+9 -2
View File
@@ -70,7 +70,9 @@ def list_bookings(
if vehicle_ref: if vehicle_ref:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None: if vehicle is None:
if page is None:
return [] return []
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
stmt = stmt.where(Booking.vehicle_id == vehicle.id) stmt = stmt.where(Booking.vehicle_id == vehicle.id)
if starts_from: if starts_from:
stmt = stmt.where(Booking.ends_at >= 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()) booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None: if booking is None:
raise HTTPException(status_code=404, detail="Booking not found") raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved": if booking.status not in ("reserved", "blocked"):
raise HTTPException(status_code=409, detail="Only a reserved booking can be cancelled") # 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) customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id) vehicle = db.get(Vehicle, booking.vehicle_id)
if customer is None or vehicle is None: if customer is None or vehicle is None:
+15 -4
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime from datetime import date, datetime
from typing import Literal from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
@@ -30,10 +31,20 @@ settings = get_settings()
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _local_tz() -> ZoneInfo:
return ZoneInfo(settings.demo_timezone)
def _today() -> date: def _today() -> date:
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor # 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. # 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) @router.get("", response_model=DashboardOut)
@@ -97,7 +108,7 @@ def get_dashboard(
for b in bookings: for b in bookings:
vehicle = vehicles_by_id.get(b.vehicle_id) vehicle = vehicles_by_id.get(b.vehicle_id)
vehicle_ref = vehicle.public_ref if vehicle else "" 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( today_items.append(
TodayItem( TodayItem(
kind="departure", kind="departure",
@@ -106,7 +117,7 @@ def get_dashboard(
scheduled_at=b.starts_at, 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( today_items.append(
TodayItem( TodayItem(
kind="return", kind="return",
+5
View File
@@ -75,6 +75,7 @@ def list_issues(
severity: str | None = Query(default=None), severity: str | None = Query(default=None),
assigned_to_ref: str | None = Query(default=None), assigned_to_ref: str | None = Query(default=None),
overdue: bool | 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: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25), page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -107,6 +108,10 @@ def list_issues(
DataQualityIssue.status == "open", DataQualityIssue.status == "open",
DataQualityIssue.due_at < datetime.now(UTC), 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 total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1 page_number = page or 1
issues = db.scalars( issues = db.scalars(
+4
View File
@@ -111,6 +111,10 @@ def demo_reset(
user: CurrentUser = Depends(require_operations_manager), user: CurrentUser = Depends(require_operations_manager),
) -> dict: ) -> dict:
global _last_reset_monotonic 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: if not settings.demo_allow_reset:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
+19 -18
View File
@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
import hmac
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Header from fastapi import APIRouter, Depends, Header
from sqlalchemy import select from sqlalchemy import select
@@ -21,6 +21,7 @@ from app.schemas import (
ProcedureListOut, ProcedureListOut,
ProcedureSyncResultIn, ProcedureSyncResultIn,
ProcedureSyncResultResult, ProcedureSyncResultResult,
ReturnCallbackIn,
ScanResultOut, ScanResultOut,
WorkflowErrorReportIn, WorkflowErrorReportIn,
WorkflowErrorReportResult, 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) @router.post("/heartbeat", response_model=N8nHeartbeatResult)
def workflow_heartbeat( def workflow_heartbeat(
body: N8nHeartbeatIn, body: N8nHeartbeatIn,
@@ -49,8 +58,7 @@ def workflow_heartbeat(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> N8nHeartbeatResult: ) -> N8nHeartbeatResult:
"""Authenticated, idempotent execution evidence from a canonical n8n workflow.""" """Authenticated, idempotent execution evidence from a canonical n8n workflow."""
if service_token != settings.n8n_callback_token: _require_service_token(service_token)
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES: if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422) raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
already_recorded = ( already_recorded = (
@@ -87,13 +95,12 @@ def workflow_heartbeat(
@router.post("/return-callback") @router.post("/return-callback")
def return_callback( def return_callback(
body: dict[str, Any], body: ReturnCallbackIn,
idempotency_key: str = Header(..., alias="Idempotency-Key"), idempotency_key: str = Header(..., alias="Idempotency-Key"),
service_token: str = Header(..., alias="X-Service-Token"), service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
if service_token != settings.n8n_callback_token: _require_service_token(service_token)
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
try: try:
event_id = uuid.UUID(idempotency_key) event_id = uuid.UUID(idempotency_key)
@@ -124,10 +131,8 @@ def return_callback(
actor_label="n8n", actor_label="n8n",
action="n8n_return_followup_recorded", action="n8n_return_followup_recorded",
entity_type="booking", entity_type="booking",
correlation_id=uuid.UUID(body.get("correlation_id")) correlation_id=body.correlation_id,
if body.get("correlation_id") after={"follow_up": body.follow_up, "summary": body.summary},
else None,
after={"follow_up": body.get("follow_up"), "summary": body.get("summary")},
metadata={"event_id": str(event_id)}, metadata={"event_id": str(event_id)},
) )
db.commit() db.commit()
@@ -148,8 +153,7 @@ def scheduled_scan(
safe to call repeatedly: run_scan() only ever creates an issue for a condition that 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 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.""" duplicate domain work -- it just reports zero new issues for anything already known."""
if service_token != settings.n8n_callback_token: _require_service_token(service_token)
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service") result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
return ScanResultOut(created=result.created) 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 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 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.""" error report (e.g. after a timed-out response), so this must not double-record."""
if service_token != settings.n8n_callback_token: _require_service_token(service_token)
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
already_recorded = ( already_recorded = (
db.scalar( 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 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 per-document id (source_id) and a content hash so the caller can detect changes
without re-fetching content it already has.""" without re-fetching content it already has."""
if service_token != settings.n8n_callback_token: _require_service_token(service_token)
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
documents = [ documents = [
ProcedureDocumentOut( ProcedureDocumentOut(
@@ -245,8 +247,7 @@ def procedures_sync_result(
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops -- """Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore. RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
Idempotent on execution_id, matching the workflow-error and return-callback pattern.""" Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
if service_token != settings.n8n_callback_token: _require_service_token(service_token)
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
already_recorded = ( already_recorded = (
db.scalar( db.scalar(
+41 -1
View File
@@ -64,12 +64,52 @@ class Settings(BaseSettings):
oidc_auto_provision: bool = True oidc_auto_provision: bool = True
oidc_default_role: str = "rental_employee" oidc_default_role: str = "rental_employee"
log_level: str = "INFO" 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 = "" metrics_bearer_token: str = ""
privacy_minimum_booking_retention_days: int = 30 privacy_minimum_booking_retention_days: int = 30
privacy_audit_retention_days: int = 2555 privacy_audit_retention_days: int = 2555
privacy_audit_export_max_rows: int = 10000 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 @lru_cache
def get_settings() -> Settings: 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
+10 -1
View File
@@ -74,10 +74,19 @@ def correlation_id_for(request: Request) -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())
UNMATCHED_ROUTE_LABEL = "<unmatched>"
def route_label(request: Request) -> str: 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") route = request.scope.get("route")
path = getattr(route, "path", None) 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: def request_started() -> float:
+49
View File
@@ -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)
+1 -1
View File
@@ -23,4 +23,4 @@ class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column( merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id") 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)
+3
View File
@@ -15,5 +15,8 @@ class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
booking_id: Mapped[uuid.UUID] = mapped_column( booking_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False 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_status: Mapped[int] = mapped_column(Integer, nullable=False)
response_body: Mapped[dict] = mapped_column(JSONB, nullable=False) response_body: Mapped[dict] = mapped_column(JSONB, nullable=False)
+16 -1
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime from datetime import datetime
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
Role = Literal["operations_manager", "rental_employee"] Role = Literal["operations_manager", "rental_employee"]
@@ -423,6 +424,20 @@ class N8nWorkflowEvidence(BaseModel):
last_execution_id: str | None = None last_execution_id: str | None = None
class ReturnCallbackIn(BaseModel):
"""Body of the n8n return follow-up callback.
n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are ignored;
only the fields we persist are validated and bounded.
"""
model_config = ConfigDict(extra="ignore")
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): class N8nHeartbeatIn(BaseModel):
workflow_id: str = Field(min_length=1, max_length=120) workflow_id: str = Field(min_length=1, max_length=120)
workflow_name: str = Field(min_length=1, max_length=200) workflow_name: str = Field(min_length=1, max_length=200)
+2 -2
View File
@@ -19,7 +19,6 @@ from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord from app.models.maintenance import MaintenanceRecord
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent 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.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event 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: 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 ( for model in (
RevokedSession,
OutboxEvent, OutboxEvent,
IdempotencyRecord, IdempotencyRecord,
DataQualityIssue, DataQualityIssue,
+73 -28
View File
@@ -130,7 +130,12 @@ def _open_issue(
def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None: def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
customers = list( 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) customers.sort(key=lambda c: c.public_ref)
# The threshold cannot be reached without an exact email (60 points) or phone # 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() ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5: if ratio >= 0.5:
score += round(ratio * 30) score += round(ratio * 30)
signals.append( signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name") summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD: 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: 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( 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(): ).all():
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)] missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)]
if not customer.email and not customer.phone: if not customer.email and not customer.phone:
@@ -519,6 +527,30 @@ def resolve_odometer_regression(
"This issue is not an odometer_regression issue.", "This issue is not an odometer_regression issue.",
status_code=409, 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()) vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None: if vehicle is None:
raise AppError( raise AppError(
@@ -539,19 +571,7 @@ def resolve_odometer_regression(
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km}, metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
) )
else: else:
related_refs = issue.evidence_json.get("related_refs", []) assert booking is not None and body.corrected_odometer_km is not None
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
# Never silently lower the canonical odometer: a correction must be at or above # 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. # the current canonical value, otherwise it would just create a new regression.
if body.corrected_odometer_km < vehicle.odometer_km: if body.corrected_odometer_km < vehicle.odometer_km:
@@ -563,13 +583,6 @@ def resolve_odometer_regression(
), ),
status_code=422, 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 = { before = {
"booking_end_odometer_km": booking.end_odometer_km, "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") 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( def merge_customers(
@@ -839,12 +862,26 @@ def merge_customers(
) )
loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref) loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref)
survivor = db.scalar(select(Customer).where(Customer.public_ref == survivor_ref)) # Lock both rows in a deterministic order (by public_ref) so two concurrent merges
loser = db.scalar(select(Customer).where(Customer.public_ref == loser_ref)) # 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: if survivor is None or loser is None:
raise AppError( raise AppError(
"CUSTOMER_NOT_FOUND", "One of the customers could not be found.", status_code=404 "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 = { before = {
"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS}, "survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS},
@@ -856,7 +893,15 @@ def merge_customers(
raise AppError( raise AppError(
"INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422 "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( rewired = db.execute(
update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id) update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id)
+21 -12
View File
@@ -2,11 +2,12 @@ from __future__ import annotations
import threading import threading
import time import time
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Literal from typing import Any, Literal
import httpx import httpx
from sqlalchemy import func, select from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings 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: def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
counts: dict[str, int] = dict( counts: dict[str, int] = dict(
db.execute( db.execute(
@@ -144,11 +161,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at, "Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
} }
heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {} heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {}
heartbeat_rows = db.execute( heartbeat_rows = _latest_rows_per_workflow(db, "n8n_workflow_heartbeat")
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
.where(AuditEvent.action == "n8n_workflow_heartbeat")
.order_by(AuditEvent.occurred_at.desc())
).all()
for occurred_at, after, metadata in heartbeat_rows: for occurred_at, after, metadata in heartbeat_rows:
workflow_name = (after or {}).get("workflow_name") workflow_name = (after or {}).get("workflow_name")
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in heartbeat_by_workflow: 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_by_workflow: dict[str, tuple[datetime, str | None]] = {}
failure_rows = db.execute( failure_rows = _latest_rows_per_workflow(db, "n8n_workflow_failure_registered")
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
.where(AuditEvent.action == "n8n_workflow_failure_registered")
.order_by(AuditEvent.occurred_at.desc())
).all()
for occurred_at, after, metadata in failure_rows: for occurred_at, after, metadata in failure_rows:
workflow_name = (after or {}).get("workflow_name") workflow_name = (after or {}).get("workflow_name")
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in failure_by_workflow: 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: with self._client() as client:
response = client.get("/health/ready") response = client.get("/health/ready")
body = response.json() 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" available = response.status_code == 200 and body.get("status") == "ok"
detail = ( detail = (
"RAGcore reachable and ready." "RAGcore reachable and ready."
+34 -10
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -171,17 +173,17 @@ def preview_vehicle_return(
return booking, vehicle, evaluation return booking, vehicle, evaluation
def register_vehicle_return( 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, db: Session,
existing: IdempotencyRecord,
booking_ref: str, booking_ref: str,
body: RegisterReturnRequest, fingerprint: str,
idempotency_key: str,
actor: CurrentUser,
) -> tuple[int, dict]: ) -> tuple[int, dict]:
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
booking = db.get(Booking, existing.booking_id) booking = db.get(Booking, existing.booking_id)
if booking is None or booking.public_ref != booking_ref: if booking is None or booking.public_ref != booking_ref:
raise AppError( raise AppError(
@@ -189,8 +191,29 @@ def register_vehicle_return(
"This idempotency key was already used for a different booking.", "This idempotency key was already used for a different booking.",
status_code=409, 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 return existing.response_status, existing.response_body
def register_vehicle_return(
db: Session,
booking_ref: str,
body: RegisterReturnRequest,
idempotency_key: str,
actor: CurrentUser,
) -> tuple[int, dict]:
fingerprint = request_fingerprint(body)
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
return _replay_or_reject(db, existing, booking_ref, fingerprint)
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True) booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
# Re-check after acquiring the row lock: a concurrent identical-key request may have # Re-check after acquiring the row lock: a concurrent identical-key request may have
@@ -199,7 +222,7 @@ def register_vehicle_return(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key) select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
) )
if existing is not None: 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": if booking.status != "active":
raise AppError( raise AppError(
@@ -336,6 +359,7 @@ def register_vehicle_return(
IdempotencyRecord( IdempotencyRecord(
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
booking_id=booking.id, booking_id=booking.id,
request_fingerprint=fingerprint,
response_status=201, response_status=201,
response_body=response_body, response_body=response_body,
) )
@@ -349,7 +373,7 @@ def register_vehicle_return(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key) select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
) )
if existing is not None: if existing is not None:
return existing.response_status, existing.response_body return _replay_or_reject(db, existing, booking_ref, fingerprint)
raise raise
return 201, response_body return 201, response_body
+2 -3
View File
@@ -15,7 +15,6 @@ dependencies = [
"psycopg[binary]>=3.2,<4", "psycopg[binary]>=3.2,<4",
"alembic>=1.13,<2", "alembic>=1.13,<2",
"httpx>=0.27,<1", "httpx>=0.27,<1",
"httpx2>=2.10,<3",
"authlib>=1.6,<2", "authlib>=1.6,<2",
"itsdangerous>=2.2,<3", "itsdangerous>=2.2,<3",
"prometheus-client>=0.24,<1" "prometheus-client>=0.24,<1"
@@ -24,7 +23,8 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8,<9", "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", "ruff>=0.8,<1",
"mypy>=1.13,<2" "mypy>=1.13,<2"
] ]
@@ -34,7 +34,6 @@ packages = ["app"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
-4
View File
@@ -95,10 +95,6 @@ pydantic-settings==2.14.2
pygments==2.20.0 pygments==2.20.0
# via pytest # via pytest
pytest==8.4.2 pytest==8.4.2
# via
# mobilityops-api (pyproject.toml)
# pytest-asyncio
pytest-asyncio==0.26.0
# via mobilityops-api (pyproject.toml) # via mobilityops-api (pyproject.toml)
python-dotenv==1.2.2 python-dotenv==1.2.2
# via # via
+196
View File
@@ -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()
+88
View File
@@ -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
View File
@@ -76,9 +76,12 @@ services:
networks: [mobilityops] networks: [mobilityops]
web: web:
build: ./frontend build:
environment: context: ./frontend
VITE_API_BASE_URL: ${MOBILITYOPS_API_URL:-http://localhost:8128} 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: ports:
- "1228:80" - "1228:80"
depends_on: depends_on:
+139 -4
View File
@@ -655,6 +655,70 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $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: /api/v1/bookings/{public_ref}/cancel:
post: post:
tags: tags:
@@ -883,6 +947,7 @@ paths:
schema: schema:
anyOf: anyOf:
- type: string - type: string
format: uuid
- type: 'null' - type: 'null'
title: Correlation Id title: Correlation Id
- name: occurred_from - name: occurred_from
@@ -986,6 +1051,14 @@ paths:
- type: boolean - type: boolean
- type: 'null' - type: 'null'
title: Overdue title: Overdue
- name: demo_only
in: query
required: false
schema:
anyOf:
- type: boolean
- type: 'null'
title: Demo Only
- name: page - name: page
in: query in: query
required: false required: false
@@ -1441,9 +1514,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: object $ref: '#/components/schemas/ReturnCallbackIn'
additionalProperties: true
title: Body
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -2635,6 +2706,17 @@ components:
- activated - activated
- attention_reasons - attention_reasons
title: CheckoutBookingResult title: CheckoutBookingResult
CompleteBookingRequirementsRequest:
properties:
confirmation:
type: string
maxLength: 500
minLength: 3
title: Confirmation
type: object
required:
- confirmation
title: CompleteBookingRequirementsRequest
CreateBookingRequest: CreateBookingRequest:
properties: properties:
customer_ref: customer_ref:
@@ -2658,7 +2740,7 @@ components:
requirements_complete: requirements_complete:
type: boolean type: boolean
title: Requirements Complete title: Requirements Complete
default: true default: false
type: object type: object
required: required:
- customer_ref - customer_ref
@@ -3990,6 +4072,9 @@ components:
workflow_event_id: workflow_event_id:
type: string type: string
title: Workflow Event Id title: Workflow Event Id
correlation_id:
type: string
title: Correlation Id
next_booking_risk: next_booking_risk:
anyOf: anyOf:
- $ref: '#/components/schemas/NextBookingRisk' - $ref: '#/components/schemas/NextBookingRisk'
@@ -4003,6 +4088,7 @@ components:
- odometer_regression - odometer_regression
- quality_issue_ref - quality_issue_ref
- workflow_event_id - workflow_event_id
- correlation_id
- next_booking_risk - next_booking_risk
title: RegisterReturnResult title: RegisterReturnResult
ReleaseVehicleRequest: ReleaseVehicleRequest:
@@ -4016,6 +4102,27 @@ components:
required: required:
- reason - reason
title: ReleaseVehicleRequest 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: ResolveOdometerRegressionRequest:
properties: properties:
decision: decision:
@@ -4060,6 +4167,34 @@ components:
required: required:
- booking_ref - booking_ref
title: ResolveOverlapRequest 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: ReturnPreviewResult:
properties: properties:
booking_ref: booking_ref:
+3 -2
View File
@@ -17,12 +17,12 @@ app_secret="$(openssl rand -hex 32)"
n8n_key="$(openssl rand -hex 32)" n8n_key="$(openssl rand -hex 32)"
n8n_password="$(openssl rand -hex 24)" n8n_password="$(openssl rand -hex 24)"
callback_token="$(openssl rand -hex 32)" callback_token="$(openssl rand -hex 32)"
trigger_token="$(openssl rand -hex 32)"
mcp_token="$(openssl rand -hex 32)" mcp_token="$(openssl rand -hex 32)"
sed -i \ sed -i \
-e "s|^MOBILITYOPS_ENV=.*|MOBILITYOPS_ENV=production|" \ -e "s|^MOBILITYOPS_ENV=.*|MOBILITYOPS_ENV=production|" \
-e "s|^MOBILITYOPS_PUBLIC_URL=.*|MOBILITYOPS_PUBLIC_URL=${public_url}|" \ -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|^DATABASE_URL=.*|DATABASE_URL=postgresql+psycopg://mobilityops:${db_password}@db:5432/mobilityops|" \
-e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \ -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \
-e "s|^APP_SECRET=.*|APP_SECRET=${app_secret}|" \ -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_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${n8n_key}|" \
-e "s|^N8N_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \ -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_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|^KNOWLEDGE_PROVIDER=.*|KNOWLEDGE_PROVIDER=demo|" \
-e "s|^MCP_HUB_REGISTRATION_ENABLED=.*|MCP_HUB_REGISTRATION_ENABLED=false|" \ -e "s|^MCP_HUB_REGISTRATION_ENABLED=.*|MCP_HUB_REGISTRATION_ENABLED=false|" \
-e "s|^MCP_HUB_SERVICE_TOKEN=.*|MCP_HUB_SERVICE_TOKEN=${mcp_token}|" \ -e "s|^MCP_HUB_SERVICE_TOKEN=.*|MCP_HUB_SERVICE_TOKEN=${mcp_token}|" \
.env .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}" echo "Created server .env for ${public_url} using n8n webhook ${n8n_webhook_url}"
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
playwright-report
test-results
e2e
tsconfig.tsbuildinfo
.env*
+4
View File
@@ -4,6 +4,10 @@ COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund
COPY public ./public COPY public ./public
COPY src ./src 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 RUN npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
+35
View File
@@ -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
View File
@@ -7,6 +7,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
@@ -44,8 +51,11 @@ server {
try_files $uri /index.html; try_files $uri /index.html;
} }
location ~* ^/assets/.+\.[a-fA-F0-9_-]+\.(css|js|woff2|png|svg)$ { # Vite emits content-hashed filenames like "Bookings-BXgkh2jX.js" (hyphen + 8 chars,
expires 1y; # 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; try_files $uri =404;
} }
} }
+3796 -32
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -7,22 +7,31 @@
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0", "preview": "vite preview --host 0.0.0.0",
"lint": "tsc -b --noEmit", "lint": "tsc -b --noEmit && eslint .",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"i18next": "^26.3.6", "i18next": "26.3.6",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "^17.0.11", "react-i18next": "17.0.11",
"react-router-dom": "7.18.2" "react-router-dom": "7.18.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.39.5",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.5", "@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": "5.6.3",
"typescript-eslint": "8.67.0",
"vite": "8.2.1" "vite": "8.2.1"
},
"engines": {
"node": ">=22"
} }
} }
+3
View File
@@ -11,6 +11,9 @@ export default defineConfig({
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
use: { use: {
baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228", 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", trace: "retain-on-failure",
screenshot: "only-on-failure", screenshot: "only-on-failure",
}, },
+13 -1
View File
@@ -14,11 +14,23 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
const DEFAULT_TIMEOUT_MS = 15_000; 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> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const timeoutController = new AbortController(); const timeoutController = new AbortController();
const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS); const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS);
const signal = init?.signal const signal = init?.signal
? AbortSignal.any([init.signal, timeoutController.signal]) ? combineSignals(init.signal, timeoutController.signal)
: timeoutController.signal; : timeoutController.signal;
let response: Response; let response: Response;
try { try {
+1
View File
@@ -19,6 +19,7 @@ export const KNOWN_CODES = new Set([
"VEHICLE_NOT_FOUND", "VEHICLE_NOT_FOUND",
"BOOKING_NOT_FOUND", "BOOKING_NOT_FOUND",
"CUSTOMER_NOT_FOUND", "CUSTOMER_NOT_FOUND",
"CUSTOMER_ALREADY_MERGED",
"ENTITY_NOT_FOUND", "ENTITY_NOT_FOUND",
"EVENT_NOT_FOUND", "EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND", "ISSUE_NOT_FOUND",
+1 -1
View File
@@ -91,7 +91,7 @@ export function DemoGuide() {
} }
document.addEventListener("keydown", handleKeydown); document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown); return () => document.removeEventListener("keydown", handleKeydown);
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide]); }, [open, tier, collapsedToChip, mobileSheetState, closeGuide, setCollapsedToChip]);
useEffect(() => { useEffect(() => {
if (!pendingTarget.current) return; if (!pendingTarget.current) return;
+15 -3
View File
@@ -128,19 +128,31 @@ export function Layout() {
} }
setSearchLoading(true); setSearchLoading(true);
setSearchError(false); 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(() => { const timeout = window.setTimeout(() => {
api api
.get<{ query: string; results: SearchResultItem[] }>( .get<{ query: string; results: SearchResultItem[] }>(
`/api/v1/search?q=${encodeURIComponent(query)}`, `/api/v1/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
) )
.then((response) => { .then((response) => {
if (controller.signal.aborted) return;
setSearchResults(response.results); setSearchResults(response.results);
setActiveIndex(-1); setActiveIndex(-1);
}) })
.catch(() => setSearchError(true)) .catch(() => {
.finally(() => setSearchLoading(false)); if (!controller.signal.aborted) setSearchError(true);
})
.finally(() => {
if (!controller.signal.aborted) setSearchLoading(false);
});
}, 250); }, 250);
return () => window.clearTimeout(timeout); return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [searchQuery]); }, [searchQuery]);
useEffect(() => { useEffect(() => {
+6
View File
@@ -18,8 +18,14 @@ const AuthContext = createContext<AuthState | undefined>(undefined);
const STORAGE_KEY = "mobilityops.demo-user"; const STORAGE_KEY = "mobilityops.demo-user";
function readCachedUser(): CurrentUser | null { function readCachedUser(): CurrentUser | null {
try {
const stored = sessionStorage.getItem(STORAGE_KEY); const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null; 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) { function cacheUser(user: CurrentUser | null) {
+1 -1
View File
@@ -35,5 +35,5 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
}, },
{ id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" }, { id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" },
{ id: "check-automation-audit", route: () => "/automation", target: ".integration-cards" }, { 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" },
]; ];
+12
View File
@@ -44,3 +44,15 @@ export function brusselsLocalToIso(value: string): string {
} }
return new Date(candidate).toISOString(); 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", "title": "Customer not found",
"explanation": "This customer record could not be 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": { "ENTITY_NOT_FOUND": {
"title": "Record not found", "title": "Record not found",
"explanation": "The underlying record for this action could not be found." "explanation": "The underlying record for this action could not be found."
@@ -16,6 +16,11 @@
"title": "Client introuvable", "title": "Client introuvable",
"explanation": "Cette fiche client est 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": { "ENTITY_NOT_FOUND": {
"title": "Fiche introuvable", "title": "Fiche introuvable",
"explanation": "La fiche sous-jacente à cette action est introuvable." "explanation": "La fiche sous-jacente à cette action est introuvable."
@@ -16,6 +16,11 @@
"title": "Klant niet gevonden", "title": "Klant niet gevonden",
"explanation": "Dit klantrecord kon niet gevonden worden." "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": { "ENTITY_NOT_FOUND": {
"title": "Record niet gevonden", "title": "Record niet gevonden",
"explanation": "Het onderliggende record voor deze actie kon niet gevonden worden." "explanation": "Het onderliggende record voor deze actie kon niet gevonden worden."
+13 -4
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { AuditEvent, Page } from "../api/types"; import type { AuditEvent, Page } from "../api/types";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { brusselsDayStartIso, brusselsNextDayStartIso } from "../i18n/brusselsDateTime";
import { useLocaleFormat } from "../i18n/format"; import { useLocaleFormat } from "../i18n/format";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination"; import { Pagination } from "../components/Pagination";
@@ -78,7 +79,7 @@ export function Audit() {
if (value === null || value === "") next.delete(key); if (value === null || value === "") next.delete(key);
else next.set(key, String(value)); else next.set(key, String(value));
}); });
setSearchParams(next); setSearchParams(next, { replace: true });
} }
useEffect(() => { useEffect(() => {
@@ -90,9 +91,17 @@ export function Audit() {
if (actor) params.set("actor_label", actor); if (actor) params.set("actor_label", actor);
if (entityRef) params.set("entity_ref", entityRef); if (entityRef) params.set("entity_ref", entityRef);
if (correlationId) params.set("correlation_id", correlationId); if (correlationId) params.set("correlation_id", correlationId);
if (from) params.set("occurred_from", `${from}T00:00:00Z`); // Date filters are Brussels calendar days, not UTC midnight boundaries.
if (to) params.set("occurred_to", `${to}T23:59:59Z`); if (from) params.set("occurred_from", brusselsDayStartIso(from));
api.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable"))); 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]); }, [action, actor, correlationId, entityRef, from, page, t, to, user]);
const groups = useMemo<EventGroup[]>(() => { const groups = useMemo<EventGroup[]>(() => {
+10 -5
View File
@@ -27,6 +27,7 @@ export function BookingDetail() {
const [cancelReason, setCancelReason] = useState(""); const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false); const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null); const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [actionErrorSource, setActionErrorSource] = useState<"cancel" | "requirements" | "reschedule" | null>(null);
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null); const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const [requirementsConfirmation, setRequirementsConfirmation] = useState(""); const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
const [confirmingRequirements, setConfirmingRequirements] = useState(false); const [confirmingRequirements, setConfirmingRequirements] = useState(false);
@@ -41,7 +42,7 @@ export function BookingDetail() {
.get<Booking>(`/api/v1/bookings/${publicRef}`) .get<Booking>(`/api/v1/bookings/${publicRef}`)
.then(setBooking) .then(setBooking)
.catch(() => setError(t("detail.notFound"))); .catch(() => setError(t("detail.notFound")));
}, [publicRef]); }, [publicRef, t]);
useEffect(() => { useEffect(() => {
setBooking(null); setBooking(null);
@@ -54,7 +55,7 @@ export function BookingDetail() {
if (!booking) return; if (!booking) return;
setScheduleStart(toBrusselsDateTimeLocal(new Date(booking.starts_at))); setScheduleStart(toBrusselsDateTimeLocal(new Date(booking.starts_at)));
setScheduleEnd(toBrusselsDateTimeLocal(new Date(booking.ends_at))); setScheduleEnd(toBrusselsDateTimeLocal(new Date(booking.ends_at)));
}, [booking?.public_ref]); }, [booking]);
// The odometer-regression demo scenario supplies its own suspicious reading (per the // 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 // brief: never ask a demo visitor to invent one) -- only fetched for that one known
@@ -87,6 +88,7 @@ export function BookingDetail() {
setCancelReason(""); setCancelReason("");
} catch (err) { } catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.cancelFailed")); setActionError(describeApiError(t, err, "bookings:detail.cancelFailed"));
setActionErrorSource("cancel");
} finally { } finally {
setCancelling(false); setCancelling(false);
} }
@@ -110,6 +112,7 @@ export function BookingDetail() {
setRequirementsConfirmation(""); setRequirementsConfirmation("");
} catch (err) { } catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed")); setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed"));
setActionErrorSource("requirements");
} finally { } finally {
setConfirmingRequirements(false); setConfirmingRequirements(false);
} }
@@ -130,6 +133,7 @@ export function BookingDetail() {
setScheduleReason(""); setScheduleReason("");
} catch (err) { } catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.rescheduleFailed")); setActionError(describeApiError(t, err, "bookings:detail.rescheduleFailed"));
setActionErrorSource("reschedule");
} finally { } finally {
setRescheduling(false); setRescheduling(false);
} }
@@ -158,7 +162,7 @@ export function BookingDetail() {
<h2>{t("detail.requirementsAction")}</h2> <h2>{t("detail.requirementsAction")}</h2>
<p>{t("detail.requirementsActionDetail")}</p> <p>{t("detail.requirementsActionDetail")}</p>
</div> </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> <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> <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> </form>
@@ -169,6 +173,7 @@ export function BookingDetail() {
<summary>{t("detail.rescheduleAction")}</summary> <summary>{t("detail.rescheduleAction")}</summary>
<form onSubmit={reschedule}> <form onSubmit={reschedule}>
<p>{t("detail.rescheduleDetail")}</p> <p>{t("detail.rescheduleDetail")}</p>
<ApiErrorNotice error={actionErrorSource === "reschedule" ? actionError : null} />
<div className="form-grid"> <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.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> <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> </details>
) : null} ) : 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> <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> <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> <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>} </form>}
+16 -16
View File
@@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Booking, Page } from "../api/types"; import type { Booking, Page } from "../api/types";
import { brusselsDayStartIso, brusselsNextDayStartIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
import { useLocaleFormat } from "../i18n/format"; import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge"; import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; 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"]; const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
function localDateString(date = new Date()): string { /** Today's Brussels calendar day (YYYY-MM-DD) -- the operational "today", not the browser's. */
const offset = date.getTimezoneOffset() * 60_000; function brusselsToday(): string {
return new Date(date.getTime() - offset).toISOString().slice(0, 10); return toBrusselsDateTimeLocal(new Date()).slice(0, 10);
}
function nextLocalDay(value: string): string {
const date = new Date(`${value}T12:00:00`);
date.setDate(date.getDate() + 1);
return localDateString(date);
} }
export function Bookings() { export function Bookings() {
@@ -41,7 +36,7 @@ export function Bookings() {
if (value === null || value === "") next.delete(key); if (value === null || value === "") next.delete(key);
else next.set(key, String(value)); else next.set(key, String(value));
}); });
setSearchParams(next); setSearchParams(next, { replace: true });
} }
useEffect(() => { useEffect(() => {
@@ -50,14 +45,19 @@ export function Bookings() {
const params = new URLSearchParams({ page: String(page), page_size: "25" }); const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (status) params.set("status", status); if (status) params.set("status", status);
if (query) params.set("query", query); if (query) params.set("query", query);
if (startsFrom) params.set("starts_from", new Date(`${startsFrom}T00:00:00`).toISOString()); // Date filters are Brussels calendar days regardless of the browser's own time zone.
if (startsTo) params.set("starts_to", new Date(`${nextLocalDay(startsTo)}T00:00:00`).toISOString()); if (startsFrom) params.set("starts_from", brusselsDayStartIso(startsFrom));
if (startsTo) params.set("starts_to", brusselsNextDayStartIso(startsTo));
if (location) params.set("location", location); if (location) params.set("location", location);
params.set("sort", sort); params.set("sort", sort);
const controller = new AbortController();
api api
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`) .get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`, { signal: controller.signal })
.then(setBookings) .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]); }, [page, query, status, startsFrom, startsTo, location, sort, t]);
return ( 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> <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> </form>
<div className="filter-presets" aria-label={t("list.presetsLabel")}> <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: brusselsToday(), to: brusselsToday(), 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: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button> <button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
</div> </div>
+2 -1
View File
@@ -61,6 +61,7 @@ export function Dashboard() {
} }
// Only ever react to the initial `?guide=start` marker set by the login screen's // 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. // "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 [data, setData] = useState<DashboardData | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null); const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
@@ -72,7 +73,7 @@ export function Dashboard() {
useEffect(() => { useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error"))); 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)); api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, []); }, [t]);
useEffect(() => { useEffect(() => {
if (!canSeeAutomation) return; if (!canSeeAutomation) return;
+8 -4
View File
@@ -51,12 +51,13 @@ export function DataQuality() {
if (value === null || value === "" || value === false) next.delete(key); if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value)); else next.set(key, String(value));
}); });
setSearchParams(next); setSearchParams(next, { replace: true });
} }
const load = useCallback(() => { const load = useCallback(() => {
if (user?.role !== "operations_manager") return; 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); setError(null);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (status) params.set("status", status); if (status) params.set("status", status);
@@ -64,13 +65,14 @@ export function DataQuality() {
if (severity) params.set("severity", severity); if (severity) params.set("severity", severity);
if (assignee) params.set("assigned_to_ref", assignee); if (assignee) params.set("assigned_to_ref", assignee);
if (overdueOnly) params.set("overdue", "true"); if (overdueOnly) params.set("overdue", "true");
if (demoScenariosOnly) params.set("demo_only", "true");
params.set("page", String(page)); params.set("page", String(page));
params.set("page_size", "25"); params.set("page_size", "25");
api api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`) .get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues) .then(setIssues)
.catch(() => setError(t("list.unavailable"))); .catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, assignee, overdueOnly, page, user, t]); }, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user, t]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -145,6 +147,8 @@ export function DataQuality() {
} }
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0; 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 const visibleIssues = issues
? demoScenariosOnly ? demoScenariosOnly
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-")) ? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
@@ -311,7 +315,7 @@ export function DataQuality() {
</tr> </tr>
))} ))}
</tbody> </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> </div>
); );
@@ -727,7 +727,7 @@ export function DataQualityIssueDetail() {
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`) .get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
.then(setIssue) .then(setIssue)
.catch(() => setError(t("detail.notFound"))); .catch(() => setError(t("detail.notFound")));
}, [publicRef]); }, [publicRef, t]);
useEffect(() => { useEffect(() => {
if (user?.role !== "operations_manager") return; if (user?.role !== "operations_manager") return;
+7 -3
View File
@@ -28,7 +28,7 @@ export function Vehicles() {
if (value === null || value === "" || value === false) next.delete(key); if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value)); else next.set(key, String(value));
}); });
setSearchParams(next); setSearchParams(next, { replace: true });
} }
useEffect(() => { useEffect(() => {
@@ -41,10 +41,14 @@ export function Vehicles() {
if (location) params.set("location", location); if (location) params.set("location", location);
params.set("page", String(page)); params.set("page", String(page));
params.set("page_size", "25"); params.set("page_size", "25");
const controller = new AbortController();
api api
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`) .get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`, { signal: controller.signal })
.then(setVehicles) .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]); }, [status, attentionOnly, query, location, page, t]);
return ( return (
-3
View File
@@ -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 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 { 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-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 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 18px; }
.about-grid .about-card { margin-bottom: 0; } .about-grid .about-card { margin-bottom: 0; }
.about-details summary { cursor: pointer; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; font-weight: 700; } .about-details summary { cursor: pointer; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; font-weight: 700; }