M39: harden application and acceptance gates
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
# Backend image build context is the repository root (see compose.yaml); keep it small.
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.state
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.pytest_cache
|
||||||
|
**/__pycache__
|
||||||
|
**/.venv
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/playwright-report
|
||||||
|
**/test-results
|
||||||
|
artifacts
|
||||||
|
docs
|
||||||
|
deploy
|
||||||
|
n8n
|
||||||
|
frontend
|
||||||
|
*.tgz
|
||||||
|
*.tar.gz
|
||||||
|
.env
|
||||||
+5
-1
@@ -2,11 +2,15 @@ COMPOSE_PROJECT_NAME=mobilityops
|
|||||||
MOBILITYOPS_ENV=development
|
MOBILITYOPS_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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
return []
|
if page is None:
|
||||||
|
return []
|
||||||
|
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||||
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Small in-process failed-attempt limiter for credential endpoints.
|
||||||
|
|
||||||
|
Fleet Ops runs as a single API process per deployment, so an in-memory sliding window
|
||||||
|
is sufficient to blunt online password guessing (and the scrypt CPU amplification that
|
||||||
|
comes with it) without adding Redis. Only *failed* attempts count, so legitimate users
|
||||||
|
and the automated test suite are never throttled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
|
||||||
|
class FailedAttemptLimiter:
|
||||||
|
def __init__(self, *, max_failures: int, window_seconds: float) -> None:
|
||||||
|
self.max_failures = max_failures
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self._failures: dict[str, deque[float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _prune(self, key: str, now: float) -> deque[float]:
|
||||||
|
bucket = self._failures.setdefault(key, deque())
|
||||||
|
cutoff = now - self.window_seconds
|
||||||
|
while bucket and bucket[0] <= cutoff:
|
||||||
|
bucket.popleft()
|
||||||
|
if not bucket:
|
||||||
|
self._failures.pop(key, None)
|
||||||
|
return bucket
|
||||||
|
|
||||||
|
def retry_after_seconds(self, key: str) -> int:
|
||||||
|
"""Return >0 seconds to wait when the key is currently blocked, else 0."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
bucket = self._prune(key, now)
|
||||||
|
if len(bucket) < self.max_failures:
|
||||||
|
return 0
|
||||||
|
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
|
||||||
|
|
||||||
|
def record_failure(self, key: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
self._prune(key, now)
|
||||||
|
self._failures.setdefault(key, deque()).append(now)
|
||||||
|
|
||||||
|
def reset(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._failures.pop(key, None)
|
||||||
@@ -23,4 +23,4 @@ class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
|
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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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,6 +173,33 @@ def preview_vehicle_return(
|
|||||||
return booking, vehicle, evaluation
|
return booking, vehicle, evaluation
|
||||||
|
|
||||||
|
|
||||||
|
def request_fingerprint(body: RegisterReturnRequest) -> str:
|
||||||
|
canonical = json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_or_reject(
|
||||||
|
db: Session,
|
||||||
|
existing: IdempotencyRecord,
|
||||||
|
booking_ref: str,
|
||||||
|
fingerprint: str,
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
booking = db.get(Booking, existing.booking_id)
|
||||||
|
if booking is None or booking.public_ref != booking_ref:
|
||||||
|
raise AppError(
|
||||||
|
"IDEMPOTENCY_KEY_REUSED",
|
||||||
|
"This idempotency key was already used for a different booking.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if existing.request_fingerprint is not None and existing.request_fingerprint != fingerprint:
|
||||||
|
raise AppError(
|
||||||
|
"IDEMPOTENCY_KEY_REUSED",
|
||||||
|
"This idempotency key was already used with a different request body.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
return existing.response_status, existing.response_body
|
||||||
|
|
||||||
|
|
||||||
def register_vehicle_return(
|
def register_vehicle_return(
|
||||||
db: Session,
|
db: Session,
|
||||||
booking_ref: str,
|
booking_ref: str,
|
||||||
@@ -178,18 +207,12 @@ def register_vehicle_return(
|
|||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
actor: CurrentUser,
|
actor: CurrentUser,
|
||||||
) -> tuple[int, dict]:
|
) -> tuple[int, dict]:
|
||||||
|
fingerprint = request_fingerprint(body)
|
||||||
existing = db.scalar(
|
existing = db.scalar(
|
||||||
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:
|
||||||
booking = db.get(Booking, existing.booking_id)
|
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||||
if booking is None or booking.public_ref != booking_ref:
|
|
||||||
raise AppError(
|
|
||||||
"IDEMPOTENCY_KEY_REUSED",
|
|
||||||
"This idempotency key was already used for a different booking.",
|
|
||||||
status_code=409,
|
|
||||||
)
|
|
||||||
return existing.response_status, existing.response_body
|
|
||||||
|
|
||||||
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
|
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
|
||||||
|
|
||||||
@@ -199,7 +222,7 @@ def register_vehicle_return(
|
|||||||
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Regression tests for the hardening pass (security, robustness, data-quality edge cases)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings, insecure_default_secrets
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.core.observability import UNMATCHED_ROUTE_LABEL
|
||||||
|
from app.core.ratelimit import FailedAttemptLimiter
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.services.data_quality import run_scan
|
||||||
|
from tests.test_return import _activate_booking, _return_body
|
||||||
|
|
||||||
|
|
||||||
|
def test_insecure_defaults_are_detected_only_for_relevant_secrets():
|
||||||
|
defaults = Settings(_env_file=None)
|
||||||
|
assert "app_secret" in insecure_default_secrets(defaults)
|
||||||
|
assert "mcp_hub_service_token" not in insecure_default_secrets(defaults)
|
||||||
|
hardened = Settings(
|
||||||
|
_env_file=None,
|
||||||
|
app_secret="x" * 32,
|
||||||
|
n8n_callback_token="c" * 32,
|
||||||
|
mcp_hub_registration_enabled=True,
|
||||||
|
)
|
||||||
|
assert insecure_default_secrets(hardened) == ["mcp_hub_service_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
|
||||||
|
monkeypatch.setenv("MOBILITYOPS_ENV", "production")
|
||||||
|
monkeypatch.setenv("APP_SECRET", "replace-in-production")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
try:
|
||||||
|
with pytest.raises(RuntimeError, match="placeholder secrets"):
|
||||||
|
get_settings()
|
||||||
|
finally:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
monkeypatch.undo()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
# The cached settings the running app relies on must be intact afterwards.
|
||||||
|
assert get_settings().mobilityops_env == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
|
||||||
|
limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60)
|
||||||
|
for _ in range(3):
|
||||||
|
assert limiter.retry_after_seconds("1.2.3.4") == 0
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
assert limiter.retry_after_seconds("1.2.3.4") > 0
|
||||||
|
assert limiter.retry_after_seconds("5.6.7.8") == 0
|
||||||
|
limiter.reset("1.2.3.4")
|
||||||
|
assert limiter.retry_after_seconds("1.2.3.4") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_export_accepts_naive_datetimes(ops_client):
|
||||||
|
response = ops_client.get(
|
||||||
|
"/api/v1/audit/export.csv",
|
||||||
|
params={"occurred_from": "2026-01-01T00:00:00", "occurred_to": "2026-01-15T00:00:00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_list_rejects_non_uuid_correlation_id(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/audit", params={"correlation_id": "not-a-uuid"})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmatched_routes_do_not_create_metric_series(client):
|
||||||
|
probe = f"/api/v1/does-not-exist-{uuid.uuid4().hex}"
|
||||||
|
assert client.get(probe).status_code == 404
|
||||||
|
metrics = client.get("/metrics").text
|
||||||
|
assert probe not in metrics
|
||||||
|
assert UNMATCHED_ROUTE_LABEL in metrics
|
||||||
|
|
||||||
|
|
||||||
|
def test_bookings_page_for_unknown_vehicle_keeps_page_shape(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-NOPE", "page": 1})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"items": [],
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 25,
|
||||||
|
"total": 0,
|
||||||
|
"total_pages": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_idempotency_key_rejects_different_body(ops_client):
|
||||||
|
booking_ref = _activate_booking("MO-011", start_odometer_km=30000)
|
||||||
|
key = "test-return-fingerprint-001"
|
||||||
|
first = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return",
|
||||||
|
json=_return_body(end_odometer_km=30500),
|
||||||
|
headers={"Idempotency-Key": key},
|
||||||
|
)
|
||||||
|
assert first.status_code == 201
|
||||||
|
replay = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return",
|
||||||
|
json=_return_body(end_odometer_km=30500),
|
||||||
|
headers={"Idempotency-Key": key},
|
||||||
|
)
|
||||||
|
assert replay.status_code == 201
|
||||||
|
mismatch = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return",
|
||||||
|
json=_return_body(end_odometer_km=30999),
|
||||||
|
headers={"Idempotency-Key": key},
|
||||||
|
)
|
||||||
|
assert mismatch.status_code == 409
|
||||||
|
assert mismatch.json()["error"]["code"] == "IDEMPOTENCY_KEY_REUSED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_callback_rejects_malformed_correlation_id(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/return-callback",
|
||||||
|
json={"follow_up": "cleaning", "correlation_id": "nope"},
|
||||||
|
headers={
|
||||||
|
"Idempotency-Key": str(uuid.uuid4()),
|
||||||
|
"X-Service-Token": get_settings().n8n_callback_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_checkout_booking_can_be_cancelled(ops_client):
|
||||||
|
window = {"starts_at": "2051-03-01T10:00:00Z", "ends_at": "2051-03-02T12:00:00Z"}
|
||||||
|
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||||
|
vehicle_option = next(item for item in available if item["operational_status"] == "available")
|
||||||
|
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||||
|
booking = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={
|
||||||
|
"customer_ref": "CUS-0002",
|
||||||
|
"vehicle_ref": vehicle["public_ref"],
|
||||||
|
"requirements_complete": True,
|
||||||
|
**window,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
checkout = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||||
|
json={
|
||||||
|
"start_odometer_km": vehicle["odometer_km"],
|
||||||
|
"fuel_level_percent": 80,
|
||||||
|
"cleanliness_ok": True,
|
||||||
|
"damage_reported": True,
|
||||||
|
"technical_warning": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert checkout.status_code == 200
|
||||||
|
assert checkout.json()["booking_status"] == "blocked"
|
||||||
|
cancelled = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/cancel",
|
||||||
|
json={"reason": "Vehicle damaged at departure inspection"},
|
||||||
|
)
|
||||||
|
assert cancelled.status_code == 200
|
||||||
|
assert cancelled.json()["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_does_not_flag_anonymised_customers_as_missing_fields(ops_client):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
customer = Customer(
|
||||||
|
public_ref="CUS-ANON-SCAN",
|
||||||
|
first_name="Anoniem",
|
||||||
|
last_name="Klant",
|
||||||
|
email=None,
|
||||||
|
phone=None,
|
||||||
|
postal_code=None,
|
||||||
|
city=None,
|
||||||
|
date_of_birth=None,
|
||||||
|
anonymized_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(customer)
|
||||||
|
db.commit()
|
||||||
|
customer_id = customer.id
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
run_scan(db)
|
||||||
|
db.commit()
|
||||||
|
flagged = db.scalar(
|
||||||
|
select(DataQualityIssue.id).where(
|
||||||
|
DataQualityIssue.entity_type == "customer",
|
||||||
|
DataQualityIssue.entity_id == customer_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert flagged is None
|
||||||
|
finally:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == customer_id))
|
||||||
|
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
|
||||||
|
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Guard against model/migration drift.
|
||||||
|
|
||||||
|
The functional suite builds its schema with ``Base.metadata.create_all`` for speed, so a
|
||||||
|
column or index added to a model but never written into an Alembic migration would only
|
||||||
|
surface on the first real deployment. This test runs the migration chain from an empty
|
||||||
|
database and asserts that Alembic's autogenerate sees nothing left to do.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic.autogenerate import compare_metadata
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
SCRATCH_DB = "mobilityops_migration_check"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def migrated_database_url() -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
base_url = make_url(settings.database_url)
|
||||||
|
admin_engine = create_engine(
|
||||||
|
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
|
||||||
|
)
|
||||||
|
with admin_engine.connect() as conn:
|
||||||
|
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}"'))
|
||||||
|
conn.execute(text(f'CREATE DATABASE "{SCRATCH_DB}"'))
|
||||||
|
scratch_url = base_url.set(database=SCRATCH_DB).render_as_string(hide_password=False)
|
||||||
|
try:
|
||||||
|
yield scratch_url
|
||||||
|
finally:
|
||||||
|
admin_engine.dispose()
|
||||||
|
admin_engine = create_engine(
|
||||||
|
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
|
||||||
|
)
|
||||||
|
with admin_engine.connect() as conn:
|
||||||
|
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)'))
|
||||||
|
admin_engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _alembic_config(database_url: str) -> Config:
|
||||||
|
config = Config(str(BACKEND_DIR / "alembic.ini"))
|
||||||
|
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrations_upgrade_from_empty_and_match_models(migrated_database_url: str) -> None:
|
||||||
|
config = _alembic_config(migrated_database_url)
|
||||||
|
# env.py reads DATABASE_URL from settings; override it for the scratch database.
|
||||||
|
import os
|
||||||
|
|
||||||
|
previous = os.environ.get("DATABASE_URL")
|
||||||
|
os.environ["DATABASE_URL"] = migrated_database_url
|
||||||
|
get_settings.cache_clear()
|
||||||
|
try:
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
finally:
|
||||||
|
if previous is None:
|
||||||
|
os.environ.pop("DATABASE_URL", None)
|
||||||
|
else:
|
||||||
|
os.environ["DATABASE_URL"] = previous
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
engine = create_engine(migrated_database_url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
context = MigrationContext.configure(
|
||||||
|
conn, opts={"compare_type": True, "compare_server_default": False}
|
||||||
|
)
|
||||||
|
diff = compare_metadata(context, Base.metadata)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
assert diff == [], (
|
||||||
|
"Models and Alembic migrations have drifted; write a migration for:\n"
|
||||||
|
+ "\n".join(repr(entry) for entry in diff)
|
||||||
|
)
|
||||||
+6
-3
@@ -76,9 +76,12 @@ services:
|
|||||||
networks: [mobilityops]
|
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
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
e2e
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
.env*
|
||||||
@@ -4,6 +4,10 @@ COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
|||||||
RUN npm ci --no-audit --no-fund
|
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
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist/", "playwright-report/", "test-results/", "node_modules/"] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
jsxA11y.flatConfigs.recommended,
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
|
languageOptions: { globals: { ...globals.browser } },
|
||||||
|
rules: {
|
||||||
|
// Hook dependency mistakes are real bugs in this codebase's data-loading effects.
|
||||||
|
"react-hooks/exhaustive-deps": "error",
|
||||||
|
// The React Compiler-oriented rules in react-hooks v7 flag the classic
|
||||||
|
// "reset state, then fetch in an effect" data-loading pattern this React 18 app
|
||||||
|
// uses deliberately; they are not bugs here.
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
"react-hooks/purity": "off",
|
||||||
|
"react-hooks/refs": "off",
|
||||||
|
// Choice cards wrap the input and put the visible text two spans deep.
|
||||||
|
"jsx-a11y/label-has-associated-control": ["error", { depth: 3 }],
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["e2e/**/*.ts", "*.config.ts"],
|
||||||
|
languageOptions: { globals: { ...globals.node } },
|
||||||
|
},
|
||||||
|
);
|
||||||
+12
-2
@@ -7,6 +7,13 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3796
-32
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -7,22 +7,31 @@
|
|||||||
"dev": "vite --host 0.0.0.0",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
try {
|
||||||
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
||||||
|
} catch {
|
||||||
|
// A corrupted or blocked sessionStorage must never prevent the app from booting;
|
||||||
|
// the session endpoint remains the source of truth.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheUser(user: CurrentUser | null) {
|
function cacheUser(user: CurrentUser | null) {
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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[]>(() => {
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user