Author SHA1 Message Date
NuklearRabbit 51b0ade7e7 M51: record zero-error rollout acceptance
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 30s
MobilityOps acceptance / e2e (push) Skipped
MobilityOps release evidence / release-evidence (push) Failing after 2m18s
2026-08-21 22:33:41 +02:00
NuklearRabbit cea0825d60 M50: preserve rollout API compatibility alias
MobilityOps acceptance / backend (push) Failing after 17s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 22:29:55 +02:00
NuklearRabbit dd3acd872c M49: record resilience production acceptance
MobilityOps acceptance / backend (push) Failing after 20s
MobilityOps acceptance / frontend (push) Successful in 26s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 22:25:48 +02:00
NuklearRabbit 00191e9b54 M48: harden demo operations and offsite recovery
MobilityOps acceptance / backend (push) Failing after 20s
MobilityOps acceptance / frontend (push) Successful in 28s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 22:17:49 +02:00
NuklearRabbit a24098c583 M47: record final production acceptance
MobilityOps acceptance / backend (push) Failing after 15s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
MobilityOps release evidence / release-evidence (push) Failing after 1m10s
2026-08-21 18:52:23 +02:00
NuklearRabbit 95c91797fa M46: refresh web runtime base
MobilityOps acceptance / backend (push) Failing after 18s
MobilityOps acceptance / frontend (push) Successful in 27s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 18:46:10 +02:00
NuklearRabbit ec02aca0fd M45: authenticate Prometheus scraping
MobilityOps acceptance / backend (push) Failing after 17s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 18:33:23 +02:00
NuklearRabbit acd8b82b09 M44: harden release integrity and assurance
MobilityOps acceptance / backend (push) Failing after 20s
MobilityOps acceptance / frontend (push) Successful in 26s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 18:32:02 +02:00
NuklearRabbit 9e4fca5708 M43: record production deployment evidence
MobilityOps acceptance / backend (push) Failing after 15s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 17:25:04 +02:00
NuklearRabbit 0045778dbb M42: calibrate grounded RAGcore fallback
MobilityOps acceptance / backend (push) Failing after 17s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 17:22:17 +02:00
NuklearRabbit 24dcb3494c M41: harden trust boundaries and delivery
MobilityOps acceptance / backend (push) Failing after 47s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 17:06:59 +02:00
JensandClaude Fable 5 a830e8a2d0 ci(security): pin trivy-action to an existing tag (v0.30.0) so the gate can resolve on Gitea Actions
MobilityOps acceptance / backend (push) Failing after 33s
MobilityOps acceptance / frontend (push) Successful in 33s
MobilityOps acceptance / e2e (push) Skipped
The first real platform runs (2026-08-17, new instance runner) failed at
'Unable to resolve 0.30.0: reference not found' - the tag exists only as v0.30.0.
Where present, the trufflehog GitHub Action (which fails under the act runner) is
replaced by the pinned trufflehog binary in filesystem mode next to gitleaks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 14:13:18 +02:00
NuklearRabbit ca66083c8b M40: record hardening deployment evidence
MobilityOps acceptance / backend (push) Failing after 14s
MobilityOps acceptance / frontend (push) Successful in 37s
MobilityOps acceptance / e2e (push) Skipped
2026-08-17 03:31:10 +02:00
NuklearRabbit ae39a8947f M39: harden application and acceptance gates
MobilityOps acceptance / backend (push) Failing after 45s
MobilityOps acceptance / frontend (push) Successful in 32s
MobilityOps acceptance / e2e (push) Skipped
2026-08-17 03:17:44 +02:00
NuklearRabbit a9f48d6880 fix(security): add secret-scan step to backend CI job
Backend job had static checks (ruff/mypy) and a dependency-vulnerability
gate but no secret scan; frontend had a dependency audit but no secret
scan either. Adds trufflehog once, on the backend job's full checkout,
covering the whole repository - the last gap for this repo to count as
fully-authored.
2026-08-16 14:54:44 +02:00
NuklearRabbit 6859249570 fix(security): add backend dependency vulnerability gate to CI
Backend CI had ruff/mypy static checks and a real pip-audit-equivalent
was missing; frontend already had npm audit, so backend was the only
side of this repo without any dependency-vulnerability gate (partial
coverage). Adds trivy-action fs scoped to backend/, HIGH/CRITICAL only,
consistent with this security programme's gate convention elsewhere.
Verified clean (0 HIGH/CRITICAL) against backend/pyproject.toml before
wiring in, so the gate does not immediately fail CI.
2026-08-16 04:41:07 +02:00
NuklearRabbit 1ca70187a2 M38: record production acceptance evidence
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 23:29:10 +02:00
NuklearRabbit 26819354ee M37: complete production acceptance hardening
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 23:19:26 +02:00
NuklearRabbit 9dfbd7c4bf M36: deepen operational and mobile UX 2026-08-10 22:53:45 +02:00
NuklearRabbit 809ba0ddcc M35: harden the shared public demo 2026-08-10 22:32:58 +02:00
NuklearRabbit c7492bf6ad M34: enforce domain integrity in PostgreSQL 2026-08-10 22:28:38 +02:00
NuklearRabbit 82a933f6cd M33: enforce booking readiness workflow 2026-08-10 22:23:11 +02:00
NuklearRabbit be33b46228 M32: clarify deployment source evidence
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 21:19:27 +02:00
NuklearRabbit b44915ff35 M32: record production integration acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 21:18:50 +02:00
NuklearRabbit eecfcb4b79 M31: cover both knowledge provider title contracts
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 21:10:48 +02:00
NuklearRabbit cb7edb0b84 M31: align RAG verification with provider contract
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 21:02:47 +02:00
NuklearRabbit efab8d816f M31: verify RAG inventory and polish attention queue
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 20:58:26 +02:00
NuklearRabbit cfffb1ce54 M30: record production recruiter acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 18:32:30 +02:00
NuklearRabbit 29325b6c27 M29: polish recruiter engineering story
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 18:13:18 +02:00
NuklearRabbit 6365586e82 M28: complete production acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 16:34:56 +02:00
NuklearRabbit e1b700b10e M27: make backup checksums portable
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 16:19:18 +02:00
NuklearRabbit b00d33af11 M26: harden clean observability acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 16:15:23 +02:00
NuklearRabbit 90cc3cf378 M25: expose provenance-aware knowledge statistics 2026-08-10 16:04:39 +02:00
NuklearRabbit 0935901f11 M24: implement privacy governance 2026-08-10 15:56:03 +02:00
NuklearRabbit f0f1be83ae M23: automate verified database backups 2026-08-10 15:46:06 +02:00
NuklearRabbit 689e499634 M22: implement operational observability 2026-08-10 15:42:26 +02:00
NuklearRabbit c3f1cfc699 M21: add optional organisation identity 2026-08-10 15:35:25 +02:00
NuklearRabbit 509cb95110 M20: complete production acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 13:22:55 +02:00
NuklearRabbit 5dda5742e4 M20: verify reordered work queue by identity
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 13:12:07 +02:00
NuklearRabbit 152d847a26 M20: target guided issue semantically
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 13:06:28 +02:00
NuklearRabbit f715085f65 M20: stabilize production acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 13:02:03 +02:00
NuklearRabbit 5d7a5e7359 M19: harden performance and recovery
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 12:51:41 +02:00
NuklearRabbit fe06ff75a1 M18: implement operational workspaces 2026-08-10 12:41:28 +02:00
NuklearRabbit 8030753dbc M17: ground knowledge and integration evidence 2026-08-10 12:27:17 +02:00
NuklearRabbit 686795a452 M16: isolate acceptance and harden readiness 2026-08-10 12:08:42 +02:00
NuklearRabbit 2ee8b2d82b M15: synchronize contracts and acceptance 2026-08-10 05:39:19 +02:00
NuklearRabbit 58fb515337 M14: add n8n execution health telemetry 2026-08-10 03:41:06 +02:00
NuklearRabbit 218599af7d M13: harden MCP trust boundary 2026-08-10 03:24:17 +02:00
NuklearRabbit 15bdbe40ac M12: complete daily operations cycle 2026-08-10 03:15:46 +02:00
NuklearRabbit 4a3c3bd0a9 M11: implement operational booking lifecycle 2026-08-10 03:06:30 +02:00
NuklearRabbit 3f13912739 test: select an available vehicle for booking creation 2026-08-10 02:54:50 +02:00
NuklearRabbit b2e1ae7f17 test: make booking overlap assertion deterministic 2026-08-10 02:53:40 +02:00
NuklearRabbit de151914b6 M10: harden knowledge trust and telemetry 2026-08-10 02:51:07 +02:00
NuklearRabbit e577c16db5 M9: create validated internal bookings 2026-08-10 02:20:28 +02:00
NuklearRabbit c194c18ca9 docs: record operational mode validation 2026-08-10 02:11:26 +02:00
NuklearRabbit 948d5eb6a6 fix: normalize return risk timestamps 2026-08-10 02:10:02 +02:00
NuklearRabbit 3cd9ddfa66 fix: retain demo sessions across seed resets 2026-08-10 02:08:40 +02:00
NuklearRabbit 0bfcf71ff7 M8: add operational authentication mode 2026-08-10 02:06:50 +02:00
NuklearRabbit 4cdf667dc1 docs: record booking scale-up verification 2026-08-10 01:49:49 +02:00
NuklearRabbit 0ef4a6fa98 UX: paginate booking operations 2026-08-10 01:47:35 +02:00
NuklearRabbit 2648cef8e3 docs: record final roadmap verification 2026-08-10 01:15:16 +02:00
NuklearRabbit aacf0e04bc fix: preserve mobile record status 2026-08-10 01:14:20 +02:00
262 changed files with 21525 additions and 2968 deletions
+21
View File
@@ -0,0 +1,21 @@
# Backend image build context is the repository root (see compose.yaml); keep it small.
.git
.gitea
.state
.mypy_cache
.ruff_cache
.pytest_cache
**/__pycache__
**/.venv
**/node_modules
**/dist
**/playwright-report
**/test-results
artifacts
docs
deploy
n8n
frontend
*.tgz
*.tar.gz
.env
+65 -4
View File
@@ -2,24 +2,82 @@ 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. Development on localhost may use false; production startup
# current Unraid review environment); set true only once MobilityOps is served over HTTPS, # requires both an HTTPS public URL and this value set to true.
# otherwise browsers will silently drop the cookie and no one can log in.
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
# Optional OpenID Connect login. Public demo role buttons remain available when enabled.
OIDC_ENABLED=false
OIDC_PROVIDER_NAME=Organisatieaccount
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=
OIDC_ALLOWED_EMAIL_DOMAINS=
OIDC_AUTO_PROVISION=true
OIDC_DEFAULT_ROLE=rental_employee
# Observability: JSON logs are always enabled. Set a token only if /metrics is exposed
# outside the private Compose network; Prometheus can send it as a bearer token.
LOG_LEVEL=INFO
# Required when the observability profile is enabled. Keep private and high entropy.
METRICS_BEARER_TOKEN=replace-me-private-metrics-token
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=change-me-before-start
# Alertmanager sends every firing/resolved alert and the continuous watchdog to this
# owner-managed receiver. Production must route it to a channel that is actually watched.
ALERTMANAGER_WEBHOOK_URL=https://n8n.itworx.tech/webhook/mobilityops-alerts
# Verified scheduled PostgreSQL backups (Unraid override).
BACKUP_INTERVAL_SECONDS=86400
BACKUP_RETENTION_DAYS=30
BACKUP_MINIMUM_COPIES=7
# Restore the newest dump into a disposable database at least weekly. Backup health also
# requires a successful drill within eight days.
BACKUP_RESTORE_DRILL_INTERVAL_SECONDS=604800
# Set both values to copy every verified backup to an independently mounted path.
BACKUP_SECONDARY_DESTINATION=
MOBILITYOPS_BACKUP_DIR=./backups/postgres
MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite
# Optional real off-site copy through the official rclone OneDrive adapter. OAuth state
# lives only in MOBILITYOPS_RCLONE_CONFIG_DIR and must never be committed.
BACKUP_OFFSITE_INTERVAL_SECONDS=900
RCLONE_ONEDRIVE_REMOTE=onedrive
RCLONE_ONEDRIVE_PATH=FleetOps/backups
MOBILITYOPS_RCLONE_CONFIG_DIR=./.secrets/rclone
MOBILITYOPS_OFFSITE_VERIFY_DIR=./backups/offsite-verify
# Privacy governance defaults.
PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS=30
PRIVACY_AUDIT_RETENTION_DAYS=2555
PRIVACY_AUDIT_EXPORT_MAX_ROWS=10000
# Demo presentation (fictional org identity, badge/manifest, reset safety valve). # Demo presentation (fictional org identity, badge/manifest, reset safety valve).
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent # DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
# of role -- a safety valve for any environment where the dataset must not be rebuildable. # of role -- a safety valve for any environment where the dataset must not be rebuildable.
DEMO_ORGANIZATION_NAME=Northstar Mobility DEMO_ORGANIZATION_NAME=Northstar Mobility
DEMO_TIMEZONE=Europe/Brussels DEMO_TIMEZONE=Europe/Brussels
DEMO_ALLOW_RESET=true DEMO_ALLOW_RESET=true
# Prevent public visitors from repeatedly rebuilding the shared dataset. Concurrent
# resets are always rejected using both process and PostgreSQL advisory locks.
DEMO_RESET_COOLDOWN_SECONDS=60
# Operational mode: set MOBILITYOPS_DEMO_MODE=false and provide the first manager.
# Keep these values in a secret store or an untracked production .env file.
INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_DISPLAY_NAME=Operations Manager
# n8n # n8n
N8N_BASE_URL=http://n8n:5678 N8N_BASE_URL=http://n8n:5678
@@ -44,6 +102,9 @@ RAGCORE_COLLECTION=internal-procedures
RAGCORE_API_TOKEN= RAGCORE_API_TOKEN=
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3). # UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
RAGCORE_SPACE_ID= RAGCORE_SPACE_ID=
# Skip the slower generation endpoint temporarily after a timeout/non-2xx response and
# use the still-grounded extractive search fallback immediately.
RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS=60
# ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own # ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a # side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
+135
View File
@@ -0,0 +1,135 @@
name: MobilityOps acceptance
on:
push:
branches: [master]
pull_request:
schedule:
- cron: "17 3 * * 1"
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Secret scan
uses: trufflesecurity/trufflehog@b9dd330365132cd2d01dd5dc8a857a056a2544e1 # v3.79.0
with:
path: ./
extra_args: --only-verified
- name: Backend tests in isolated PostgreSQL stack
run: sh scripts/run-isolated-tests.sh
- name: Backend static checks
run: |
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm api mypy app
- name: Contract drift gate
run: |
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm \
-v "$PWD:/repo:ro" api python /repo/scripts/check-contracts.py
python scripts/check-source-budgets.py
- name: Build production API image for vulnerability scan
run: |
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" \
--tag mobilityops-api-ci --file backend/Dockerfile .
- name: Production API image vulnerability scan (HIGH/CRITICAL)
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-api-ci
format: table
severity: HIGH,CRITICAL
exit-code: "1"
ignore-unfixed: true
- name: Build production web image for vulnerability scan
run: |
docker build --build-arg VCS_REF="$GITHUB_SHA" \
--tag mobilityops-web-ci frontend
- name: Production web image vulnerability scan (HIGH/CRITICAL)
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-web-ci
format: table
severity: HIGH,CRITICAL
exit-code: "1"
ignore-unfixed: true
- name: Remove CI stack
if: always()
run: docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml down -v --remove-orphans
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install locked dependencies
working-directory: frontend
run: npm ci --no-audit --no-fund
- name: Lint (tsc + ESLint with react-hooks and jsx-a11y)
working-directory: frontend
run: npm run lint
- name: Typecheck and production build
working-directory: frontend
run: npm run build && npm run budget
- name: Dependency audit
working-directory: frontend
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@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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 firefox
- name: Run browser acceptance suite
working-directory: frontend
env:
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
run: npx playwright test
- name: Run non-destructive Chromium and Firefox smoke suite
working-directory: frontend
env:
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
run: npx playwright test --config=playwright.live.config.ts
- name: Run concurrent persisted-read smoke
run: python scripts/run-readonly-load-smoke.py --base-url http://localhost:1228
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: playwright-report
path: |
frontend/playwright-report
frontend/playwright-live-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
+47
View File
@@ -0,0 +1,47 @@
name: MobilityOps live canary
on:
schedule:
- cron: "7 * * * *"
workflow_dispatch:
jobs:
public-demo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Verify HTTPS readiness and certificate horizon
run: |
curl --fail --silent --show-error --retry 3 https://fleetops.itworx.tech/health/ready
openssl s_client -servername fleetops.itworx.tech -connect fleetops.itworx.tech:443 </dev/null 2>/dev/null \
| openssl x509 -checkend 1209600 -noout
- name: Install locked Playwright runtime
working-directory: frontend
run: |
npm ci --no-audit --no-fund
npx playwright install --with-deps chromium firefox
- name: Run non-destructive cross-browser production canary
working-directory: frontend
env:
MOBILITYOPS_PUBLIC_URL: https://fleetops.itworx.tech
run: npx playwright test --config=playwright.live.config.ts
- name: Report successful external heartbeat
env:
HEARTBEAT_URL: ${{ secrets.LIVE_CANARY_HEARTBEAT_URL }}
run: |
if [ -n "$HEARTBEAT_URL" ]; then
curl --fail --silent --show-error --retry 3 "$HEARTBEAT_URL"
fi
- name: Upload failure evidence
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: live-canary-failure
path: |
frontend/playwright-live-report
frontend/test-results
+62
View File
@@ -0,0 +1,62 @@
name: MobilityOps release evidence
on:
push:
tags: ["v*"]
jobs:
release-evidence:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Build commit-labelled release images
run: |
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-api-release --file backend/Dockerfile .
docker build --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-web-release frontend
docker build --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-backup-tools-release --file deploy/unraid/Dockerfile.backup-tools .
- name: Scan all release images
run: |
for image in mobilityops-api-release mobilityops-web-release mobilityops-backup-tools-release; do
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v mobilityops-release-trivy:/root/.cache/ \
aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 \
image --scanners vuln --severity HIGH,CRITICAL \
--ignore-unfixed --exit-code 1 "$image"
done
- name: Generate API CycloneDX SBOM
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-api-release
format: cyclonedx
output: mobilityops-api-sbom.cdx.json
- name: Generate web CycloneDX SBOM
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-web-release
format: cyclonedx
output: mobilityops-web-sbom.cdx.json
- name: Generate backup-tools CycloneDX SBOM
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-backup-tools-release
format: cyclonedx
output: mobilityops-backup-tools-sbom.cdx.json
- name: Record immutable image metadata
run: |
docker image inspect mobilityops-api-release > mobilityops-api-image.json
docker image inspect mobilityops-web-release > mobilityops-web-image.json
docker image inspect mobilityops-backup-tools-release > mobilityops-backup-tools-image.json
python scripts/generate-release-provenance.py
sha256sum mobilityops-*-sbom.cdx.json mobilityops-*-image.json release-provenance.json > SHA256SUMS
- name: Upload release evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: mobilityops-${{ github.ref_name }}-evidence
path: |
mobilityops-*-sbom.cdx.json
mobilityops-*-image.json
release-provenance.json
SHA256SUMS
+7
View File
@@ -8,6 +8,7 @@ node_modules/
dist/ dist/
coverage/ coverage/
playwright-report/ playwright-report/
playwright-live-report/
test-results/ test-results/
*.pyc *.pyc
.DS_Store .DS_Store
@@ -16,3 +17,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/
+18
View File
@@ -0,0 +1,18 @@
# Changelog
All notable changes are documented here. The project follows semantic release tags for
the deployable PoC; detailed validation evidence remains in `PROJECT_STATE.md`.
## [Unreleased]
- Added contract-drift, accessibility, Firefox smoke and frontend asset-budget gates.
- Added immutable commit-labelled deployment with automatic application rollback.
- Added real PostgreSQL restore drills and routed Alertmanager notifications.
- Added RAGcore generation circuit breaking and retrieval telemetry.
- Added scheduled dependency maintenance, dual-image vulnerability scans and release SBOMs.
## [1.0.0-poc] - 2026-08-21
- Completed the locked Fleet Ops proof of concept: operational core, transactional returns,
data quality, n8n orchestration, grounded RAGcore knowledge, read-only MCP integration,
privacy governance, observability, backup/recovery and full browser acceptance.
+305 -12
View File
@@ -1,25 +1,147 @@
# File index # File index
Tracked source, contract, documentation and configuration files. Release evidence and
screenshots live under `artifacts/<release>/` and are omitted here for brevity.
Regenerate with `git ls-files` when the tree changes.
- `.env.example` - `.env.example`
- `.gitattributes`
- `.gitea/workflows/ci.yml`
- `.gitignore` - `.gitignore`
- `AGENTS.md`
- `CLAUDE.md` - `CLAUDE.md`
- `FILE_INDEX.md`
- `MASTER_BUILD_PROMPT.md` - `MASTER_BUILD_PROMPT.md`
- `Makefile` - `Makefile`
- `PROJECT_STATE.md` - `PROJECT_STATE.md`
- `README.md` - `README.md`
- `START_HERE.md` - `START_HERE.md`
- `backend/Dockerfile` - `backend/Dockerfile`
- `backend/alembic.ini`
- `backend/alembic/env.py`
- `backend/alembic/script.py.mako`
- `backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py`
- `backend/alembic/versions/799d8800e241_outbox_last_error_code.py`
- `backend/alembic/versions/a81d0ce9f662_oidc_identity.py`
- `backend/alembic/versions/b7c7b536df85_operational_user_credentials.py`
- `backend/alembic/versions/b913a72e8c14_customer_privacy_state.py`
- `backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py`
- `backend/alembic/versions/c9498525abb5_initial_schema.py`
- `backend/alembic/versions/d1f83bc64170_revoked_sessions.py`
- `backend/alembic/versions/e7b08389f47f_idempotency_records.py`
- `backend/alembic/versions/f43d829ab610_quality_work_queue.py`
- `backend/app/__init__.py` - `backend/app/__init__.py`
- `backend/app/api/__init__.py`
- `backend/app/api/deps.py`
- `backend/app/api/routers/__init__.py`
- `backend/app/api/routers/audit.py`
- `backend/app/api/routers/auth.py`
- `backend/app/api/routers/bookings.py`
- `backend/app/api/routers/customers.py`
- `backend/app/api/routers/dashboard.py`
- `backend/app/api/routers/data_quality.py`
- `backend/app/api/routers/demo.py`
- `backend/app/api/routers/integration_status.py`
- `backend/app/api/routers/integrations.py`
- `backend/app/api/routers/knowledge.py`
- `backend/app/api/routers/mcp_integrations.py`
- `backend/app/api/routers/observability.py`
- `backend/app/api/routers/privacy.py`
- `backend/app/api/routers/search.py`
- `backend/app/api/routers/users.py`
- `backend/app/api/routers/vehicles.py`
- `backend/app/api/routers/workflows.py`
- `backend/app/cli.py`
- `backend/app/core/__init__.py` - `backend/app/core/__init__.py`
- `backend/app/core/config.py` - `backend/app/core/config.py`
- `backend/app/core/db.py`
- `backend/app/core/errors.py`
- `backend/app/core/observability.py`
- `backend/app/core/ratelimit.py`
- `backend/app/core/security.py`
- `backend/app/main.py` - `backend/app/main.py`
- `backend/app/models/__init__.py`
- `backend/app/models/audit.py`
- `backend/app/models/booking.py`
- `backend/app/models/customer.py`
- `backend/app/models/data_quality.py`
- `backend/app/models/idempotency.py`
- `backend/app/models/inspection.py`
- `backend/app/models/maintenance.py`
- `backend/app/models/mixins.py`
- `backend/app/models/outbox.py`
- `backend/app/models/revoked_session.py`
- `backend/app/models/user.py`
- `backend/app/models/vehicle.py`
- `backend/app/schemas.py`
- `backend/app/seed_loader.py`
- `backend/app/services/__init__.py`
- `backend/app/services/audit.py`
- `backend/app/services/data_quality.py`
- `backend/app/services/demo_manifest.py`
- `backend/app/services/dispatcher.py`
- `backend/app/services/integration_status.py`
- `backend/app/services/knowledge/__init__.py`
- `backend/app/services/knowledge/demo.py`
- `backend/app/services/knowledge/procedures.py`
- `backend/app/services/knowledge/ragcore.py`
- `backend/app/services/operations.py`
- `backend/app/services/returns.py`
- `backend/app/services/sessions.py`
- `backend/app/services/vehicle_status.py`
- `backend/entrypoint.sh`
- `backend/pyproject.toml` - `backend/pyproject.toml`
- `backend/requirements.lock`
- `backend/scripts/generate_openapi.py`
- `backend/tests/conftest.py`
- `backend/tests/test_audit.py`
- `backend/tests/test_auth.py`
- `backend/tests/test_bookings.py`
- `backend/tests/test_dashboard.py`
- `backend/tests/test_data_quality.py`
- `backend/tests/test_database_constraints.py`
- `backend/tests/test_demo_manifest.py`
- `backend/tests/test_dispatcher.py`
- `backend/tests/test_hardening.py`
- `backend/tests/test_health.py` - `backend/tests/test_health.py`
- `backend/tests/test_integration_status.py`
- `backend/tests/test_integrations.py`
- `backend/tests/test_knowledge.py`
- `backend/tests/test_mcp_integrations.py`
- `backend/tests/test_migrations.py`
- `backend/tests/test_observability.py`
- `backend/tests/test_operational_auth.py`
- `backend/tests/test_privacy.py`
- `backend/tests/test_return.py`
- `backend/tests/test_search.py`
- `backend/tests/test_seed.py`
- `backend/tests/test_users.py`
- `backend/tests/test_vehicle_status.py`
- `backend/tests/test_vehicles.py`
- `backend/tests/test_workflows.py`
- `compose.observability.yaml`
- `compose.test.yaml`
- `compose.unraid.yaml`
- `compose.yaml` - `compose.yaml`
- `contracts/events.schema.json` - `contracts/events.schema.json`
- `contracts/mcp-tools.json` - `contracts/mcp-tools.json`
- `contracts/openapi.yaml` - `contracts/openapi.yaml`
- `contracts/ragcore-contract-assumptions.md` - `contracts/ragcore-contract-assumptions.md`
- `deploy/observability/alerts.yml`
- `deploy/observability/grafana/dashboards/mobilityops-overview.json`
- `deploy/observability/grafana/provisioning/dashboards/mobilityops.yml`
- `deploy/observability/grafana/provisioning/datasources/prometheus.yml`
- `deploy/observability/prometheus.yml`
- `deploy/unraid/README.md`
- `deploy/unraid/backup-postgres.sh`
- `deploy/unraid/configure-env.sh`
- `deploy/unraid/prune-postgres-backups.sh`
- `deploy/unraid/restore-postgres.sh`
- `deploy/unraid/scheduled-backup.sh`
- `deploy/unraid/setup-existing-n8n.sh`
- `deploy/unraid/setup-n8n.sh`
- `deploy/unraid/setup-scheduled-scan.sh`
- `deploy/unraid/verify-postgres-backups.sh`
- `docs/00-product-brief.md` - `docs/00-product-brief.md`
- `docs/01-scope-and-non-goals.md` - `docs/01-scope-and-non-goals.md`
- `docs/02-user-stories.md` - `docs/02-user-stories.md`
@@ -38,32 +160,203 @@
- `docs/15-build-plan.md` - `docs/15-build-plan.md`
- `docs/16-portfolio-case-study.md` - `docs/16-portfolio-case-study.md`
- `docs/17-runbook.md` - `docs/17-runbook.md`
- `docs/18-privacy-governance.md`
- `docs/19-visual-product-roadmap.md`
- `docs/deferred.md` - `docs/deferred.md`
- `docs/demo-release/current-demo-gap-audit.md`
- `docs/demo-release/demo-concept.md`
- `docs/demo-release/demo-data.md`
- `docs/demo-release/demo-guide.md`
- `docs/demo-release/demo-runbook.md`
- `docs/demo-release/demo-scenarios.md`
- `docs/design/current-ux-audit.md`
- `docs/design/design-directions.md`
- `docs/design/design-system.md`
- `docs/design/implementation-validation.md`
- `docs/design/stitch-manifest.md`
- `docs/final-integrations/ai-operations-brief-runbook.md`
- `docs/final-integrations/current-state-audit.md`
- `docs/final-product-polish/audit.md`
- `docs/fleet-ops-correction/current-gap-audit.md`
- `docs/fleet-ops-correction/i18n-inventory.md`
- `docs/fleet-ops-correction/vehicle-status-decision-table.md`
- `docs/fleet-ops-final-localization/audit.md`
- `docs/functional-completion/current-functional-audit.md`
- `docs/functional-completion/server-baseline.md`
- `docs/live-ai-integration/n8n-current-state.md`
- `frontend/Dockerfile` - `frontend/Dockerfile`
- `frontend/e2e/_capture-demo-screenshots.spec.ts`
- `frontend/e2e/_capture-recruiter-screenshots.spec.ts`
- `frontend/e2e/_capture-screenshots.spec.ts`
- `frontend/e2e/clickable-rows.spec.ts`
- `frontend/e2e/demo-accessibility.spec.ts`
- `frontend/e2e/demo-entry.spec.ts`
- `frontend/e2e/demo-guide.spec.ts`
- `frontend/e2e/demo-legibility.spec.ts`
- `frontend/e2e/demo.spec.ts`
- `frontend/e2e/error-messages.spec.ts`
- `frontend/e2e/fleet-ops-correction.spec.ts`
- `frontend/e2e/greeting-live.spec.ts`
- `frontend/e2e/greeting.spec.ts`
- `frontend/e2e/guided-demo-full.spec.ts`
- `frontend/e2e/i18n-coverage.spec.ts`
- `frontend/e2e/interactive-elements.spec.ts`
- `frontend/e2e/operational-workflows.spec.ts`
- `frontend/e2e/privacy.spec.ts`
- `frontend/e2e/recruiter-polish.spec.ts`
- `frontend/e2e/responsive-i18n.spec.ts`
- `frontend/e2e/roadmap-regression.spec.ts`
- `frontend/e2e/ui-redesign.spec.ts`
- `frontend/index.html` - `frontend/index.html`
- `frontend/nginx.conf` - `frontend/nginx.conf`
- `frontend/package-lock.json`
- `frontend/package.json` - `frontend/package.json`
- `frontend/playwright.config.ts`
- `frontend/public/favicon.svg`
- `frontend/public/og-fleet-ops.svg`
- `frontend/src/App.tsx` - `frontend/src/App.tsx`
- `frontend/src/api/apiError.ts`
- `frontend/src/api/client.ts`
- `frontend/src/api/errorMessages.ts`
- `frontend/src/api/types.ts`
- `frontend/src/components/Badge.tsx`
- `frontend/src/components/CheckoutForm.tsx`
- `frontend/src/components/DemoBadge.tsx`
- `frontend/src/components/DemoGuide.tsx`
- `frontend/src/components/Icons.tsx`
- `frontend/src/components/LanguageSwitcher.tsx`
- `frontend/src/components/Layout.tsx`
- `frontend/src/components/PageChrome.tsx`
- `frontend/src/components/Pagination.tsx`
- `frontend/src/components/RequireAuth.tsx`
- `frontend/src/components/ReturnForm.tsx`
- `frontend/src/components/VehicleMaintenanceActions.tsx`
- `frontend/src/context/AuthContext.tsx`
- `frontend/src/context/DemoGuideContext.tsx`
- `frontend/src/context/DemoManifestContext.tsx`
- `frontend/src/data/demoGuideSteps.ts`
- `frontend/src/data/evidenceSignals.ts`
- `frontend/src/data/integrationLabels.ts`
- `frontend/src/hooks/useViewportTier.ts`
- `frontend/src/i18n/brusselsDateTime.ts`
- `frontend/src/i18n/config.ts`
- `frontend/src/i18n/format.ts`
- `frontend/src/i18n/greeting.ts`
- `frontend/src/i18n/locales/en-GB/accessibility.json`
- `frontend/src/i18n/locales/en-GB/audit.json`
- `frontend/src/i18n/locales/en-GB/auth.json`
- `frontend/src/i18n/locales/en-GB/bookings.json`
- `frontend/src/i18n/locales/en-GB/common.json`
- `frontend/src/i18n/locales/en-GB/dashboard.json`
- `frontend/src/i18n/locales/en-GB/demo.json`
- `frontend/src/i18n/locales/en-GB/errors.json`
- `frontend/src/i18n/locales/en-GB/fleet.json`
- `frontend/src/i18n/locales/en-GB/integrations.json`
- `frontend/src/i18n/locales/en-GB/knowledge.json`
- `frontend/src/i18n/locales/en-GB/navigation.json`
- `frontend/src/i18n/locales/en-GB/operations.json`
- `frontend/src/i18n/locales/en-GB/privacy.json`
- `frontend/src/i18n/locales/en-GB/quality.json`
- `frontend/src/i18n/locales/en-GB/returns.json`
- `frontend/src/i18n/locales/fr-BE/accessibility.json`
- `frontend/src/i18n/locales/fr-BE/audit.json`
- `frontend/src/i18n/locales/fr-BE/auth.json`
- `frontend/src/i18n/locales/fr-BE/bookings.json`
- `frontend/src/i18n/locales/fr-BE/common.json`
- `frontend/src/i18n/locales/fr-BE/dashboard.json`
- `frontend/src/i18n/locales/fr-BE/demo.json`
- `frontend/src/i18n/locales/fr-BE/errors.json`
- `frontend/src/i18n/locales/fr-BE/fleet.json`
- `frontend/src/i18n/locales/fr-BE/integrations.json`
- `frontend/src/i18n/locales/fr-BE/knowledge.json`
- `frontend/src/i18n/locales/fr-BE/navigation.json`
- `frontend/src/i18n/locales/fr-BE/operations.json`
- `frontend/src/i18n/locales/fr-BE/privacy.json`
- `frontend/src/i18n/locales/fr-BE/quality.json`
- `frontend/src/i18n/locales/fr-BE/returns.json`
- `frontend/src/i18n/locales/nl-BE/accessibility.json`
- `frontend/src/i18n/locales/nl-BE/audit.json`
- `frontend/src/i18n/locales/nl-BE/auth.json`
- `frontend/src/i18n/locales/nl-BE/bookings.json`
- `frontend/src/i18n/locales/nl-BE/common.json`
- `frontend/src/i18n/locales/nl-BE/dashboard.json`
- `frontend/src/i18n/locales/nl-BE/demo.json`
- `frontend/src/i18n/locales/nl-BE/errors.json`
- `frontend/src/i18n/locales/nl-BE/fleet.json`
- `frontend/src/i18n/locales/nl-BE/integrations.json`
- `frontend/src/i18n/locales/nl-BE/knowledge.json`
- `frontend/src/i18n/locales/nl-BE/navigation.json`
- `frontend/src/i18n/locales/nl-BE/operations.json`
- `frontend/src/i18n/locales/nl-BE/privacy.json`
- `frontend/src/i18n/locales/nl-BE/quality.json`
- `frontend/src/i18n/locales/nl-BE/returns.json`
- `frontend/src/i18n/useGreetingPeriod.ts`
- `frontend/src/main.tsx` - `frontend/src/main.tsx`
- `frontend/src/pages/AboutDemo.tsx`
- `frontend/src/pages/Audit.tsx`
- `frontend/src/pages/Automation.tsx`
- `frontend/src/pages/BookingCreate.tsx`
- `frontend/src/pages/BookingDetail.tsx`
- `frontend/src/pages/Bookings.tsx`
- `frontend/src/pages/Dashboard.tsx`
- `frontend/src/pages/DataQuality.tsx`
- `frontend/src/pages/DataQualityIssueDetail.tsx`
- `frontend/src/pages/Highlights.tsx`
- `frontend/src/pages/Knowledge.tsx`
- `frontend/src/pages/Login.tsx`
- `frontend/src/pages/Privacy.tsx`
- `frontend/src/pages/Scenarios.tsx`
- `frontend/src/pages/Users.tsx`
- `frontend/src/pages/VehicleDetail.tsx`
- `frontend/src/pages/Vehicles.tsx`
- `frontend/src/product.ts`
- `frontend/src/styles.css` - `frontend/src/styles.css`
- `frontend/src/vite-env.d.ts`
- `frontend/tsconfig.json` - `frontend/tsconfig.json`
- `frontend/vite.config.ts` - `frontend/vite.config.ts`
- `knowledge/manifest.json` - `knowledge/manifest.json`
- `knowledge/procedures/01-vehicle-checkout.md` - `knowledge/procedures/en-GB/01-vehicle-checkout.md`
- `knowledge/procedures/02-vehicle-return.md` - `knowledge/procedures/en-GB/02-vehicle-return.md`
- `knowledge/procedures/03-damage-handling.md` - `knowledge/procedures/en-GB/03-damage-handling.md`
- `knowledge/procedures/04-odometer-anomalies.md` - `knowledge/procedures/en-GB/04-odometer-anomalies.md`
- `knowledge/procedures/05-cleaning-checklist.md` - `knowledge/procedures/en-GB/05-cleaning-checklist.md`
- `knowledge/procedures/06-maintenance-escalation.md` - `knowledge/procedures/en-GB/06-maintenance-escalation.md`
- `knowledge/procedures/07-customer-documents.md` - `knowledge/procedures/en-GB/07-customer-documents.md`
- `knowledge/procedures/08-privacy.md` - `knowledge/procedures/en-GB/08-privacy.md`
- `knowledge/procedures/09-booking-conflicts.md` - `knowledge/procedures/en-GB/09-booking-conflicts.md`
- `knowledge/procedures/10-roles-and-escalation.md` - `knowledge/procedures/en-GB/10-roles-and-escalation.md`
- `knowledge/procedures/en-GB/11-vehicle-availability.md`
- `knowledge/procedures/fr-BE/01-vehicle-checkout.md`
- `knowledge/procedures/fr-BE/02-vehicle-return.md`
- `knowledge/procedures/fr-BE/03-damage-handling.md`
- `knowledge/procedures/fr-BE/04-odometer-anomalies.md`
- `knowledge/procedures/fr-BE/05-cleaning-checklist.md`
- `knowledge/procedures/fr-BE/06-maintenance-escalation.md`
- `knowledge/procedures/fr-BE/07-customer-documents.md`
- `knowledge/procedures/fr-BE/08-privacy.md`
- `knowledge/procedures/fr-BE/09-booking-conflicts.md`
- `knowledge/procedures/fr-BE/10-roles-and-escalation.md`
- `knowledge/procedures/fr-BE/11-vehicle-availability.md`
- `knowledge/procedures/nl-BE/01-vehicle-checkout.md`
- `knowledge/procedures/nl-BE/02-vehicle-return.md`
- `knowledge/procedures/nl-BE/03-damage-handling.md`
- `knowledge/procedures/nl-BE/04-odometer-anomalies.md`
- `knowledge/procedures/nl-BE/05-cleaning-checklist.md`
- `knowledge/procedures/nl-BE/06-maintenance-escalation.md`
- `knowledge/procedures/nl-BE/07-customer-documents.md`
- `knowledge/procedures/nl-BE/08-privacy.md`
- `knowledge/procedures/nl-BE/09-booking-conflicts.md`
- `knowledge/procedures/nl-BE/10-roles-and-escalation.md`
- `knowledge/procedures/nl-BE/11-vehicle-availability.md`
- `n8n/README.md` - `n8n/README.md`
- `n8n/workflows/MANIFEST.md` - `n8n/workflows/MANIFEST.md`
- `n8n/workflows/fleet-ops-vehicle-return.json`
- `n8n/workflows/fleet-ops-data-quality-scan.json`
- `n8n/workflows/check_drift.py` - `n8n/workflows/check_drift.py`
- `n8n/workflows/fleet-ops-data-quality-scan.json`
- `n8n/workflows/fleet-ops-error-handler.json`
- `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`
- `n8n/workflows/fleet-ops-vehicle-return.json`
- `n8n/workflows/merge_credential_refs.py`
- `scripts/run-isolated-tests.sh`
- `seed/README.md` - `seed/README.md`
- `seed/bookings.csv` - `seed/bookings.csv`
- `seed/customers.csv` - `seed/customers.csv`
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jens Caers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13 -4
View File
@@ -1,4 +1,4 @@
.PHONY: up down logs test lint seed reset n8n-setup n8n-setup-scan demo e2e .PHONY: up down logs test lint contracts seed reset n8n-setup n8n-setup-scan demo e2e live-smoke
up: up:
docker compose up --build -d docker compose up --build -d
@@ -10,11 +10,17 @@ logs:
docker compose logs -f --tail=200 docker compose logs -f --tail=200
test: test:
docker compose run --rm api pytest sh scripts/run-isolated-tests.sh
lint: lint:
docker compose run --rm api ruff check . docker compose -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
docker compose run --rm api mypy app docker compose -f compose.yaml -f compose.test.yaml run --rm api mypy app
cd frontend && npm run lint
contracts:
docker compose -f compose.yaml -f compose.test.yaml run --build --rm \
-v "$(CURDIR):/repo:ro" api python /repo/scripts/check-contracts.py
python scripts/check-source-budgets.py
seed: seed:
docker compose exec api python -m app.cli seed --reset docker compose exec api python -m app.cli seed --reset
@@ -48,3 +54,6 @@ demo: up
e2e: e2e:
cd frontend && npx playwright test cd frontend && npx playwright test
live-smoke:
cd frontend && npx playwright test --config=playwright.live.config.ts
+974 -2
View File
@@ -1,5 +1,346 @@
# Project state # Project state
## M51 — zero-error production rollout evidence (2026-08-21)
- The rejected M49 candidate proved rollback isolation: production remained on M48 and
**500/500** external probes succeeded. M50 revision
`cea0825d6094e2981452fd179dd5d9f8724766d0` then promoted successfully from archive
SHA-256 `6148de2ea7966d1527b28d769048b4b6cf8a7cfc699f2e5cb568e7504efc4693`.
- The established gateway stayed online throughout M50 promotion: **700/700** external
probes passed with zero interruption. Exactly two M50 API and two M50 web replicas serve
behind it; no rejected M49 candidate remains. Database, backup, Prometheus, Alertmanager,
Grafana and gateway all remain healthy.
- Post-promotion live acceptance passed **4/4** in Chromium/Firefox, including the grounded
production knowledge path. The 360-request authenticated read gate again had zero errors
at concurrency 18, p95 **116.4 ms** and max **144.6 ms**.
- Exact next action: commit/push this evidence-only M51 revision, deploy it through the same
verified gateway path, confirm exact revision/readiness and tag `v1.1.0-poc`. OneDrive is
fully prepared but remains disabled until the owner completes Microsoft OAuth once.
## M50 — preserve web/API compatibility alias during rollout (2026-08-21)
- The first steady-gateway M49 rollout correctly kept M48 serving when both candidate web
containers failed health. Their static Nginx configuration resolves `api` at startup,
while the rollout initially published only the revision-specific API alias used by the
gateway. The rejection removed every candidate and left the recorded production revision,
four old replicas and public readiness unchanged.
- Added the compatibility `api` alias alongside the revision-specific alias. The stable
gateway continues to route `/api` only to the revision-specific upstream, so the generic
name cannot weaken atomic promotion; it solely lets the independently usable web image
validate its existing proxy configuration.
- A continuous external probe recorded **500/500** successes throughout the rejected
candidate rollout and rollback.
- Exact next action: validate the compatibility alias in isolation, commit/push M50 and
deploy its exact archive while repeating the zero-error steady-gateway probe.
## M49 — M48 production acceptance evidence (2026-08-21)
- Pushed M48 revision `00191e9b54ee6b961648a6e02abbb3a57957dba0` and promoted its
checksum-verified archive (`9421429ccfec8a91cef482472edf9e7f2047df447f4b612c90d247ca1c21a669`)
after creating and verifying `mobilityops-20260821T201820Z.dump`.
- Production now has a healthy stable gateway, exactly two revision-labelled API replicas
and two web replicas. The database stayed running; backup, Prometheus, Alertmanager and
Grafana were explicitly refreshed and all report healthy. Alembic is
`4f2b9c8d7e61 (head)`, Prometheus sees its API target and backup/restore markers exist.
- The one-time hand-off of host port 1236 from the former web container to the new gateway
caused 14 failures in 1,200 rapid probes. This bounded migration interruption cannot recur:
subsequent releases keep the gateway running and atomically reload versioned upstreams.
- Live non-destructive acceptance passed **4/4** in Chromium and Firefox, including the
real grounded knowledge path. The authenticated production read-load gate passed
**360/360** at concurrency 18 with p95 **137.2 ms** and max **211.4 ms**.
- Trivy 0.74 reports zero fixed HIGH/CRITICAL findings for the exact production API, web
and gateway images. TLS is valid through 2026-11-03 and a 14-day horizon check passes;
all MobilityOps containers had zero traceback/uncaught/panic/fatal/emergency log hits.
- Exact next action: commit/push this evidence-only M49 revision, deploy it through the now
established start-first gateway while probing continuously, verify exact revision and
tag `v1.1.0-poc`. OneDrive remains intentionally disabled until the owner completes the
one-time interactive Microsoft OAuth authorization.
## M48 — resilient synthetic-demo operations (2026-08-21)
- Replaced routine Compose recreation with a stable Nginx gateway and a two-API/two-web
start-first promotion. The gateway atomically reloads revision-specific upstream aliases;
old replicas drain only after public readiness. A real rolling test sustained **300/300**
concurrent probes with zero failures. Database, backup and observability containers now
refresh only through an explicit infrastructure command.
- Added an opt-in OneDrive off-site worker using rclone 1.75.0 rebuilt reproducibly with
patched Go 1.26.6. Every upload is downloaded, checksum/list verified and weekly restored
into a disposable database; retention and health markers are enforced. OAuth state stays
outside Git. The synthetic-only scope is explicit; introducing personal data remains out
of scope.
- Added hourly external HTTPS/TLS and Chromium/Firefox canaries, an authenticated read-only
concurrency gate, healthchecks for all monitoring services, and tag evidence for the
API/web/backup images (CycloneDX, immutable metadata, hashes and provenance).
- Split the two remaining source hotspots into bounded backend duplicate-scan and frontend
comparison/CSS modules, then tightened growth budgets around all extracted files.
- Validation: backend **271/271**, Playwright **155/155**, frontend lint/build/audit,
Ruff, strict mypy, OpenAPI/event/MCP/n8n contracts, Compose rendering, ShellCheck,
actionlint, source budgets and shell/Python parsing passed. Read load sustained **360/360**
requests at concurrency 18 with p95 **292.6 ms**. Trivy 0.74 reports zero fixed
HIGH/CRITICAL findings for API and web; the first backup-tools scan caught stale Go
binaries, which were removed/rebuilt and then also scanned clean.
- Exact next action: rebuild the final labelled backup-tools image, commit/push M48, create
and verify a production backup, deploy the exact archive, explicitly refresh monitoring,
run live acceptance and image scans, then record final evidence. OneDrive activation
remains a one-time interactive Microsoft OAuth action after deployment.
## M47 — final production acceptance evidence (2026-08-21)
- Promoted immutable M46 revision `95c91797fa2c599443d69d9c96d83a85ee0711f7`
from checksum-verified archive
`559b035b4f563d7580926c1193a579f3bd43ba791fcad2110d241dbc68b1126f`
after creating production backup `mobilityops-20260821T164628Z.dump`.
- The API and web OCI labels plus `.deploy/source-revision` all matched M46. API, web,
PostgreSQL, backup and Alertmanager were healthy; Prometheus and Grafana were running;
public readiness reported `ready/database up`; Alembic reported `4f2b9c8d7e61 (head)`.
- Trivy 0.70 scanned the exact production API and web images with fixed findings enabled:
both reported zero HIGH/CRITICAL vulnerabilities. Prometheus scraped the protected API
target successfully with an empty error and Alertmanager exposed the active watchdog.
- The first post-promotion Firefox pass encountered one transient module fetch error while
every asset and Chromium request was returning HTTP 200. Direct asset verification was
HTTP 200 with immutable caching; an immediate clean rerun passed **4/4** in Chromium and
Firefox, including HTTPS readiness, all operator routes and a real grounded RAGcore answer.
- Exact next action: commit/push this evidence-only milestone, deploy that exact revision
(application bytes are unchanged from accepted M46), verify revision/readiness, tag the
accepted PoC release and leave the repository synchronized and clean.
## M46 — refresh vulnerable web runtime base (2026-08-21)
- The production image gate found fixed HIGH/CRITICAL Alpine vulnerabilities in the
previously pinned Nginx 1.27 runtime, after all functional production checks passed.
- Refreshed the official runtime to Nginx 1.30.4 on Alpine 3.24.1 and pinned its immutable
multi-platform digest `sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46`.
Trivy 0.70 reports zero fixed HIGH/CRITICAL findings for that base.
- Exact next action: build and scan the complete web image, commit and push M46, deploy
the exact revision, then repeat production image and browser acceptance gates.
## M45 — authenticate production metrics scraping (2026-08-21)
- Pre-deployment inspection confirmed production protects `/metrics` with a non-empty
bearer token. Prometheus now renders that token into its private scrape authorization
config at container start instead of silently receiving HTTP 401.
- Validation: the real pinned Prometheus and Alertmanager images started with rendered
configs; `promtool` accepted the configuration and all seven alert rules.
- Exact next action: push M45, then execute the M44/M45 production release procedure.
## M44 — release integrity and assurance hardening (2026-08-21)
- Replaced mutable archive overlays with checksum-verified, commit-named release staging,
OCI revision-labelled API/web images, health-gated promotion and automatic application
rollback. Routine deployment no longer resets persisted demo data.
- Scheduled backups now execute a weekly real restore into a disposable database and gate
health on both backup and restore-drill freshness. The isolated drill passed at Alembic
`4f2b9c8d7e61` with restored counts `2 users / 50 vehicles / 254 bookings / 1 audit event`.
- Added Alertmanager routing with a continuous watchdog and an authenticated fifth n8n
workflow targeting the existing watched M365 owner mailbox. Added weekly Renovate,
API/web image vulnerability scans, tag-triggered CycloneDX SBOM evidence and immutable
image metadata.
- Contract drift is now executable for OpenAPI, events, MCP endpoints and all five n8n
workflows. RAGcore generation failures open a bounded circuit breaker so grounded search
fallback avoids repeated five-second delays; provider-stage outcomes and retrieval scores
are measurable.
- Added Axe accessibility, platform-independent visual regression, non-destructive
Chromium/Firefox canaries, frontend asset budgets and source-growth budgets. The checks
found and fixed two real WCAG contrast defects.
- Validation: backend **271/271**, Playwright **155/155** in 4.6 minutes, live-safe canary
**4/4**, focused knowledge/observability **42/42**, frontend lint/build/audit/budgets,
contract gate, Compose rendering, Prometheus/Alertmanager validation and shell parsing
passed. The user approved the repository security policy before it was written.
- Exact next action: commit and push M44, create a production backup, publish/test the alert
receiver, deploy the exact archive, run the non-destructive production canary and record
M45 live evidence.
## M43 — publish and redeploy review remediation (2026-08-21)
- Published M41 hardening commit `24dcb3494c522fadf536fa9d6826450227aeff4e`
and M42 RAGcore calibration commit `0045778dbbd2255f8ed35e7be22978a4d4497341`
to `origin/master`; fetch/revision checks matched before and after deployment.
- Created and verified the pre-deployment PostgreSQL backup
`backups/postgres/mobilityops-20260821T150738Z.dump`. The M41 deployment archive
verified at SHA-256 `5b1868b5f2d8800a3a8678bea8c55f58d35605c54234eb26b022560bbca8ebfb`;
the M42 hotfix archive verified at
`dbbaf19678aba15dc52e10186c9c784ca748a236819a5c543d597a92c87792a9`.
- Production now records M42's exact source revision, uses
`https://fleetops.itworx.tech` with Secure session cookies, returns HTTPS 200 with HSTS
and redirects HTTP to HTTPS. API, web, PostgreSQL and backup are healthy; Alembic is
`4f2b9c8d7e61 (head)`; Grafana and Prometheus stayed running. The API runtime contains
neither tests nor pytest and the post-deploy API/web critical-error scan is clean.
- Exported all 30 central n8n workflows before import to
`.deploy/n8n-backups/pre-m41-20260821T171139Z.json` (SHA-256
`9622a1345607624f4254f87ad219fc9e93de98981b93f029f946cfb3e97bd746`).
Re-imported, published and restarted the four Fleet Ops workflows; n8n is healthy,
all four are `active:true`, and every Fleet Ops callback is HTTPS.
- The full live browser run recorded **92 passed**, **8 failed** and **53 skipped** because
the acceptance suite requests repeated resets while production intentionally enforces
a 60-second reset cooldown (confirmed 429 plus `Retry-After`). Local acceptance remains
**153/153**. A production-compatible isolated five-minute run then exposed the RRF
threshold issue fixed in M42; its final rerun passed live in **13.5 s**.
- Final hand-off: a final audited demo reset returned 200 with all five scenarios ready
and canonical synthetic counts (2 users, 180 customers, 50 vehicles, 254 bookings,
75 inspections, 40 maintenance records, 33 quality issues and 20 workflow runs).
- Exact next action: no implementation or deployment work remains; monitor the next
scheduled n8n heartbeats and normal production telemetry.
## M42 — calibrate grounded RAGcore fallback (2026-08-21)
- The first live five-minute acceptance run exposed a real calibration error in M41's
new fallback threshold: `/v1/answers` timed out, while `/v1/search` correctly returned
`damage-procedure.md` at rank one with fused score `0.0163934426`. RAGcore uses
reciprocal-rank fusion (roughly `1 / (60 + rank)`), so the normalized-looking `0.05`
threshold could never accept a legitimate result.
- Set the default minimum to `0.016`: this accepts the first two normal RRF ranks while
still rejecting missing scores and the existing low-score `0.01` abuse case. Damage
questions continue to require explicit damage evidence and out-of-domain questions
continue to return `insufficient`; no generated answer is trusted when retrieval is
absent or weak.
- Added a regression using RAGcore's observed rank-one fused score. Focused knowledge
tests **36 passed**; complete isolated PostgreSQL backend suite **270 passed**; Ruff and
mypy are clean and `git diff --check` passes.
- Exact next action: commit and push M42, deploy that exact archive over the healthy M41
production deployment, then repeat the live five-minute demo and final health checks.
## M41 — full review remediation and hardening (2026-08-21)
- Closed all findings from the repository-wide review without expanding the locked PoC:
production now refuses placeholder MCP credentials, cleartext public URLs and insecure
session cookies; OIDC requires an explicit verified-email claim; nginx overwrites the
forwarded client address and the API uses the proxy-appended hop for rate limiting.
- Added bounded per-IP/per-session knowledge requests and an explicit minimum RAGcore
retrieval score. Weak or concept-mismatched search fallback evidence is returned as
`insufficient`, never `grounded`. MCP audit attribution now authenticates the fixed Hub
service identity and stores the Hub-reported caller only as non-authoritative metadata.
- Serialised data-quality scans with a PostgreSQL transaction advisory lock, added a
partial unique index for one open issue per condition, and locked issue rows for every
mutating resolution. Concurrent scan and concurrent-resolution regression tests pass.
- Split the backend production/test image stages and locks: the runtime contains no test
suite, pytest, Ruff or mypy. All container bases and CI actions are digest/SHA pinned.
CI now builds and scans the real runtime image. The initial Debian 13 base exposed 36
fixable HIGH findings; switching to the pinned Python 3.12 Bookworm image reduced the
final Trivy result to **0 HIGH/CRITICAL** across OS and Python packages.
- Moved every central n8n callback/source URL to the existing HTTPS endpoint
`fleetops.itworx.tech`, refreshed workflow checksums, corrected stale workflow status/
node-count documentation, fixed the return-odometer documentation and made the Unraid
bootstrap enforce HTTPS plus Secure cookies. Makefile lint now always builds the test
target and cannot silently inspect a stale runtime image.
- Validation evidence: focused security/integration/data-quality suite **116 passed**;
final isolated PostgreSQL backend suite **270 passed**; Ruff and mypy clean; frontend
lint/build clean; npm audit **0 vulnerabilities**; production runtime contains no dev
dependencies/tests; Trivy runtime scan **0 HIGH/CRITICAL**; full Playwright acceptance
**153/153 passed in 5.1 minutes**; Compose test/Unraid configs and `git diff --check`
clean. Existing public TLS returns 200 with HSTS and HTTP redirects to HTTPS.
- Exact next action: commit and push M41, take a verified production backup, update the
deployment's public URL/Secure-cookie settings, deploy the committed archive and
migration `4f2b9c8d7e61`, safely republish the four HTTPS n8n definitions, then repeat
live health, migration, security-header and browser acceptance checks.
## M40 — publish and redeploy M39 (2026-08-17)
- Published three validated commits to Gitea `master`: the backend dependency and secret
CI gates (`6859249`, `a9f48d6`) plus the complete M39 hardening milestone (`ae39a89`).
A fresh fetch confirmed local `HEAD` and `origin/master` both resolved to
`ae39a8947fff1ec1b60116556ed411567b11f0d2` before deployment.
- Local validation used freshly rebuilt API/web images: isolated PostgreSQL backend suite
**261 passed** (one Alembic configuration deprecation warning), Ruff clean, mypy clean
across 59 source files, frontend lint and production build clean, full and production
npm audits at **0 vulnerabilities**, Trivy/secret scan clean, and Playwright
**153/153 passed in 3.1 minutes**.
- Created and verified the pre-deployment custom-format backup
`backups/postgres/mobilityops-20260817T011857Z.dump`; checksum verification and
`pg_restore --list` both passed. The committed source archive matched locally and on
Unraid at SHA-256 `473293921717b7f1363689f4a152493797778ceb91f284cc21b5b68600803de6`.
- Deployed the exact committed archive to `/mnt/user/appdata/mobilityops`, preserving
`.env`, `.deploy`, volumes and the central n8n. API/web were rebuilt and recreated;
PostgreSQL data remained on its named volume. Production secrets were verified as
non-empty/non-placeholder without exposing their values. Readiness is `ready`, API,
database, web and backup are healthy, and Alembic is `0a4c1d2e3f5b (head)`.
- Live Playwright acceptance completed **152/153** on the first pass. The sole failure was
the expected honest degradation path: one RAGcore request exhausted both bounded 5 s
calls (`/v1/answers` then `/v1/search`) and rendered “knowledge service unavailable”
while returning HTTP 200 and leaving operations unaffected. The complete guided-demo
test passed on immediate focused rerun in **11.3 s**. The final API/web critical-log
scan is clean.
- Final hand-off state: all five synthetic scenarios are ready; reset cooldown is restored
to 60 seconds; RAGcore and MCP Hub report operational. Central n8n is healthy and all
four Fleet Ops workflows are active. Its aggregate state is temporarily `degraded`
solely because the hourly quality-scan heartbeat is stale; there are zero unexpected
delivery failures and the one failed outbox row is the labelled demo scenario.
- Exact next action: no code or deployment work remains. Confirm the next scheduled
quality-scan heartbeat clears the temporary stale n8n state; investigate the central
workflow scheduler only if it does not.
## 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
@@ -2226,5 +2567,636 @@ evidence yet."
Docker Desktop is unavailable and the configured `db` hostname cannot resolve; run the Docker Desktop is unavailable and the configured `db` hostname cannot resolve; run the
complete suite inside the Unraid Compose API container after deployment. complete suite inside the Unraid Compose API container after deployment.
- **Commit**: `f2cdad194ccafd0e9aa59e91ddd7dbe0c79278af` (`UX: implement visual product roadmap`). - **Commit**: `f2cdad194ccafd0e9aa59e91ddd7dbe0c79278af` (`UX: implement visual product roadmap`).
- **Next action**: push, fast-forward `master`, redeploy API/web to Unraid, run the - **Deployment and final verification**: feature branch was pushed, fast-forward merged
container test suite and live responsive verification at 390/768/1440px. and pushed to `master` at `ad1182582d87f0911cc97af88924fd0dee927ac8`; Unraid rebuilt
the API/web services from `/mnt/user/appdata/mobilityops/.deploy/source-ad11825.tar.gz`
and recorded that revision in `.deploy/source-revision`. The isolated Compose test run
with the explicit demo contract (`KNOWLEDGE_PROVIDER=demo`, MCP Hub registration off)
passed **192 tests**; `ruff check .` and `mypy app` passed. The default production
environment intentionally exposes the live RAGcore/MCP settings, so its five old
demo-only assertions are not a production regression.
- **Live visual evidence**: at 1440px Vehicles renders 25 bounded rows and “Pagina 1 van
2”; at 390px the return workflow has no horizontal overflow and the dashboard attention
queue is capped at six with a direct continuation link. A cached pre-roadmap CSS asset
was found in one browser session still hiding `.page-actions`; the final live asset
hash explicitly keeps the mobile status/action row visible.
## Booking list scale-up follow-up (2026-08-10)
- **Improvement found and completed**: Bookings was the last operations list that fetched
and searched every booking in the browser. It now uses the same bounded server-side
query contract as Vehicles, Data Quality and Audit: optional `query`, `page` and
`page_size <= 25`, with a total and total-page count. Unpaged API calls retain their
original list response for existing consumers.
- **Usability**: booking search, status and page are URL-backed (`q`, `status`, `page`),
so an operator can reload, share or navigate back to an exact queue view.
- **Evidence**: local web build, ruff and mypy passed. On Unraid the isolated demo-contract
suite passed **193 tests**; ruff and mypy passed. Live at 1440px, 254 bookings render as
25 rows over 11 pages, and `?q=BK-DEMO&page=1` returns four records with the filter
visibly retained. The synthetic demo seed was reset after the test run (2 users, 180
customers, 50 vehicles, 254 bookings, 33 quality issues and 20 workflow runs).
- **Deployment**: committed and pushed as `0ef4a6fa98e6e4b4f73b0bcaa2581ecc82fbbc51`
(`UX: paginate booking operations`), deployed to Unraid; `.deploy/source-revision`
matches that commit.
## Operational-mode foundation (2026-08-10)
- Added a non-demo operational mode with email/password login, scrypt password hashes,
an explicitly configured first Operations Manager and database-backed active-user
revalidation on every operational request. Demo mode remains the default and retains
its deterministic reset semantics; outside demo mode, demo login/manifest/reset are
unavailable and demo-only controls are hidden in the UI.
- Added migration `b7c7b536df85`, production environment guidance in `.env.example` and
`docs/17-runbook.md`, and focused authentication contract tests. Also normalized the
return response through its declared schema so preview and committed return risks use
identical UTC serialization.
- Evidence: local web build, ruff and mypy clean; deployed Unraid Compose test suite
**195 passed**. API/web/db healthy, deterministic demo seed restored. Live source
revision: `948d5eb6a60a138dcfc539fd9e36f885200190de`.
## Knowledge trust and persistent integration telemetry (2026-08-10)
- RAGcore fallback now refuses unrelated questions, ranks multilingual domain evidence
before answering and returns `insufficient` with no answer when no MobilityOps concept
is present. A live damage question is grounded in `damage-procedure.md`; an unrelated
football question is explicitly insufficient.
- Demo reset preserves operational MCP, n8n and knowledge telemetry while CLI/test reset
remains fully deterministic by default.
- Evidence: deployed Unraid suite **199 passed**; ruff and mypy clean. Committed as
`de15191`, with follow-up deterministic test corrections through `3f13912`.
## Operational booking lifecycle (2026-08-10)
- Added authenticated canonical-customer search, interval-aware vehicle availability,
booking creation and audited cancellation. The web app now provides a localized,
responsive creation flow and cancellation action instead of requiring direct API use.
- Booking creation obtains a PostgreSQL row lock on the selected vehicle before checking
overlap. The concurrent contract test proves two simultaneous requests yield exactly
one reservation and one conflict.
- Evidence: production web build passed; ruff and mypy clean; the modified code passed
the full Unraid Compose suite: **203 passed, 1 warning**.
- Exact next action: implement audited checkout/activation, maintenance capture and
operations-manager user administration, then repeat the complete validation gate.
## Complete daily operations cycle (2026-08-10)
- Reserved bookings now have an audited checkout inspection. A safe inspection atomically
activates the booking and marks the vehicle rented; odometer regression, dirt, damage
or a technical warning blocks the booking and routes the vehicle to cleaning or
maintenance without an unsafe activation.
- Operations Managers can register persisted maintenance evidence, advance service and
odometer values, explicitly release a vehicle only when no active rental or open
high-severity vehicle issue remains, and create/activate/deactivate operational users.
Self-deactivation and self-demotion are prevented. Rental employees receive 403 for
manager actions.
- Added localized web workflows for checkout, maintenance/release and user access
administration. All actions use persisted API state and expose actionable errors.
- Evidence: frontend lint and production build passed; ruff, mypy and diff check passed;
focused Unraid contracts **19 passed** and the full suite **208 passed, 1 warning**.
- Exact next action: harden MCP per-client authorization and evidence completeness, then
replace inferred n8n status with explicit heartbeat/execution telemetry.
## MCP trust boundary and trace completeness (2026-08-10)
- The MCP API now validates the ITWorx Hub delegated client-id shape in addition to the
shared service secret, supports an explicit tenant assertion and rejects cross-tenant
calls. Readiness and project-bound client identities remain compatible with the Hub's
documented connector contract; arbitrary/spoofed labels no longer enter the audit log.
- Every successful tool response returns `X-Correlation-Id`, `X-Tenant-Id` and
`Cache-Control: no-store`. Knowledge calls additionally expose available versus
returned source counts and persist tenant, locale and source coverage in their audit
metadata. `contracts/mcp-tools.json` is versioned to 1.2.0 with this trust contract.
- Evidence: ruff and mypy passed; focused MCP tests **14 passed** and full Unraid suite
**210 passed, 1 warning**.
- Exact next action: add authenticated n8n workflow heartbeats and execution results,
surface stale/healthy workflow state, then update generated contracts and E2E coverage.
## n8n execution health telemetry (2026-08-10)
- Added an authenticated, canonical-workflow-only, idempotent heartbeat contract. Status
now records each workflow's last execution ID/result and classifies it as healthy,
stale, failed or without evidence. Scheduled scan evidence expires after 2h30 and the
daily RAGcore sync after 30h; event-driven workflows are not falsely marked stale just
because no business event arrived.
- Registered target-workflow failures override older success evidence until a newer
successful execution arrives. The Automation UI renders these explicit states and
execution IDs instead of treating any historical timestamp as permanently healthy.
- All four versioned n8n definitions now report a successful execution heartbeat with
bounded retries; the central error handler continues to register failed target runs.
- Evidence: JSON validation for all four definitions, frontend lint, ruff and mypy passed;
focused integration tests **26 passed** and full Unraid suite **212 passed, 1 warning**.
- Live deployment: API/web deployed at `c9a8609`; all five pre-existing n8n workflows
were exported to the recoverable appdata backup
`backups/mobilityops-pre-heartbeat-20260810.json` before the four definitions were
imported and published. The import initially exposed n8n CLI's unsafe name-only
credential resolution (both Header Auth nodes resolved to the service credential);
credential IDs were restored from the backup before republishing. A real return then
completed through outbox → server n8n → callback → heartbeat as execution `337`;
its first 403 delivery remained safely retryable and succeeded after the credential
correction. Live n8n state is operational and Vehicle Return is `healthy` with its
execution ID visible.
- Exact next action: regenerate the checked-in OpenAPI contract, add E2E coverage for the
new operator workflows, document credential-safe n8n upgrades and run acceptance.
## Contract and acceptance synchronization (2026-08-10)
- Replaced the obsolete hand-maintained OpenAPI baseline with a deterministic snapshot
generated directly from the FastAPI application. The committed contract now describes
all 53 paths and 75 schemas, including booking lifecycle, operational users, MCP trust
headers and n8n execution telemetry.
- Added browser acceptance coverage for booking creation/cancellation, user
creation/deactivation and vehicle maintenance/release. Existing acceptance journeys
now reset their own state, tolerate the configured honest knowledge provider and allow
the provider's bounded response window instead of depending on suite order or a demo
provider that is not active in production.
- Added a credential-reference merge utility and a runbook procedure that preserves live
n8n credential IDs during workflow upgrades without exporting or committing secrets.
- Hardened logout beyond browser cookie deletion: signed sessions now carry a unique
nonce, logout persists a token-hash denylist, expired revocations are pruned, and both
demo and operational authentication reject retained or copied cookies server-side.
Repeated live refresh/logout coverage passed **40/40**.
- Removed a return-form initialization race: the return form now mounts only after the
persisted demo manifest is ready, so operator input cannot be overwritten by a late
manifest response. The final mobile row interaction is represented by actual link
semantics rather than a nested interactive table row.
- Split frontend dependency installation from source compilation in the Docker build so
dependency layers are cached and reproducible. Upgraded the build toolchain to pinned
Vite **8.2.1** and `@vitejs/plugin-react` **6.0.5**; both the production-only and full
npm audits report **0 vulnerabilities**.
- **Final acceptance evidence**: generated OpenAPI output is byte-for-byte deterministic;
frontend TypeScript/production build, ruff, mypy and diff checks pass; Alembic reports
`d1f83bc64170 (head)`; the complete backend suite passes **213 tests**; the complete
Playwright suite passes **144 tests in 4.8 minutes** against the deployed production
bundle.
- **Live integration evidence**: a real vehicle return completed through local commit →
outbox → the existing central n8n → callback → heartbeat. After the final browser run,
Vehicle Return execution **376** is healthy, n8n is operational with zero pending
events, MCP Hub is reachable and operational with a real audited
`fleet_ops_get_operations_summary` call, and RAGcore reports available/ready.
- **Restored hand-off state**: the synthetic reset reports all scenarios ready with 2
users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance
records, 33 data-quality issues and 20 workflow runs; `BK-DEMO-RETURN` is active again.
- **Exact next action**: none for the locked PoC. All acceptance criteria are satisfied;
subsequent work is routine production operation, monitoring and explicitly approved
scope beyond this build.
## M16 — reliability boundary and truthful delivery foundation (2026-08-10)
- Added `compose.test.yaml` and `scripts/run-isolated-tests.sh`: backend acceptance now
runs in a fixed, disposable `mobilityops-test` Compose project with its own PostgreSQL
database/volume. The script cleans up on success, failure or interruption. The live
deployment database is no longer an acceptable test target.
- Added a Gitea Actions workflow for isolated backend tests, ruff/mypy, the locked
frontend build and npm audit. `make test` now routes through the isolated test runner.
- Split process liveness from database-backed readiness (`/health/live` and
`/health/ready`) while retaining `/health` compatibility.
- Replaced count-derived return-inspection and scan-issue references with prefixed UUID
references, eliminating collisions between independent concurrent transactions.
- Updated the README/runbook where they still claimed demo RAGcore, two n8n workflows or
unsafe in-place pytest execution.
- Evidence: Compose merge validated; ruff and mypy clean; full isolated PostgreSQL suite
**218 passed** and the disposable database/network/volume were removed automatically.
- Exact next action: implement honest loading states and RAG/source deduplication, then
revalidate live Knowledge and Integration flows.
## M17 — grounded knowledge and integration evidence UX (2026-08-10)
- Replaced transient false demo/unavailable/not-configured labels with explicit loading,
settled-unavailable and provider-aware states on Knowledge and Automation.
- Deduplicated RAGcore citations by their human-visible identity instead of volatile
document/version UUIDs and capped each answer at three concise, collapsible source
cards. Re-uploaded copies can no longer dominate an answer.
- Added answer latency and authenticated helpful/not-helpful feedback. Feedback is
correlation-bound to the requesting user, auditable and safely updateable without
creating duplicate audit events.
- Explained the expected cadence of all four central n8n workflows so event-driven and
scheduled no-event states are understandable rather than looking broken.
- Regenerated the OpenAPI contract. Evidence: frontend production build passed; focused
backend knowledge suite **31 passed**; targeted ruff and mypy checks passed. The E2E
journey now verifies the three-source limit, unique source titles and persisted
feedback.
- Exact next action: turn the data-quality queue, booking planning, fleet overview and
user administration into complete daily operational workspaces.
## M18 — daily operational workspaces (2026-08-10)
- Turned data quality into an owned work queue: every newly detected open issue receives
a severity-based SLA deadline (4h high, 1d medium, 3d low), managers can filter by
assignee/overdue state and assign or reschedule up to 25 selected issues atomically.
Every change is row-locked, validated against an active user and independently audited.
- Added the PostgreSQL ownership/deadline migration with indexed nullable assignment,
`ON DELETE SET NULL`, live-data backfill and deterministic demo-reset deadlines.
- Upgraded booking planning with operational-priority ordering, inclusive date-window,
location and explicit sort filters plus Today/Upcoming presets. The default no longer
leads with the furthest-future booking.
- Upgraded the fleet register with exact location filtering, next-booking context,
remaining service distance and explicit attention reasons (blocked, service due or
open quality issue) instead of one unexplained warning label.
- Completed user administration: managers can now edit names/roles, reset passwords and
activate/deactivate accounts from the UI; existing self-demotion/deactivation guards
and auditing remain authoritative in the API.
- Evidence: frontend TypeScript production build passed; ruff and mypy passed; focused
PostgreSQL suites **58 passed**; migration applied in the isolated stack; OpenAPI was
regenerated. E2E coverage now includes bulk queue assignment and full user editing.
- Exact next action: split the frontend bundle, harden mobile layout and operational
backup/deployment controls, then run clean full acceptance and redeploy.
## M19 — performance and recoverable operations (2026-08-10)
- Route-level React lazy loading reduced the initial production JavaScript chunk from
about **572 kB to 212 kB**; every operational page now ships as a separate bounded
chunk and the previous Vite large-chunk warning is gone. Both npm audits report zero
vulnerabilities.
- Reflowed the five dashboard readiness metrics into a readable 3+2 mobile grid instead
of an overflowing horizontal strip at 390 px.
- Compose now gates dependants on database-backed `/health/ready`. The optional bundled
n8n fallback is pinned to `n8nio/n8n:2.33.7`, matching the central server n8n version;
Unraid still starts no second n8n instance.
- Added guarded Unraid PostgreSQL backup/restore scripts. Backups use custom format and
are structurally verified; restore requires an explicit confirmation, makes a safety
backup, stops API writes, recreates only the configured database and checks Alembic.
A disposable dump/restore drill recovered all **50 vehicles** into a second database.
- Evidence: merged Compose configurations and shell syntax pass; frontend production
build and audits pass; full isolated PostgreSQL suite **225 passed, 1 warning**; ruff
and mypy are clean.
- Exact next action: commit and deploy this milestone, run complete Playwright and visual
acceptance against Unraid, refresh final evidence, push and verify the live hash.
## M20 — final production acceptance and hand-off (2026-08-10)
- Tightened RAGcore citation identity to title plus named section. Multiple chunks from
the same unsectioned document now render as one source card, while distinct named
sections remain independently citable.
- Stabilized acceptance selectors around persistent business identity instead of table
position: the guided demo filters for the generated MO-024 odometer issue, vehicle
attention checks use semantic reason markers, and reassigned work verifies the exact
captured issue reference after the queue reorders.
- Validation evidence: targeted knowledge **31 passed**; complete isolated PostgreSQL
backend **225 passed, 1 dependency warning**; complete live production Playwright
**145 passed in 4.4 minutes**. The live visual audit covered desktop dashboard, mobile
data-quality, Integration Management and a grounded RAGcore exchange without overlap
or horizontal page overflow.
- Live hand-off: readiness is `ready`; migration is `f43d829ab610 (head)`; API, database
and web containers are healthy. Reset restored all scenarios with the canonical 2 / 180
/ 50 / 254 / 75 / 40 / 33 / 20 entity counts. Central n8n, MCP Hub and RAGcore all
report operational/available; no separate n8n was started.
- Recovery evidence: verified live backup
`/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T111913Z.dump`.
- Exact next action: none for the locked PoC. Routine operation, monitoring and any scope
expansion require a separate approved milestone.
## M21 — optional organisation identity alongside the public demo (2026-08-10)
- Added standards-based OpenID Connect login while preserving both public demo roles and
the guided demo. Provider discovery, authorization-code exchange, state/nonce checks
and ID-token validation are delegated to Authlib's OIDC client.
- External identities bind uniquely to issuer plus subject. A verified email is required;
deployments can enforce an email-domain allowlist and disable auto-provisioning. New
users receive the least-privileged rental role and every provision/link/login is
audited. Deactivated users remain blocked by the canonical user record.
- Added the nullable external-identity migration `a81d0ce9f662`, configuration contract,
trilingual login action and runbook. OIDC secrets stay deployment-only.
- Added Starlette's supported `httpx2` test transport, removing the prior suite-wide
deprecation warning rather than suppressing it.
- Evidence: focused authentication **13 passed with zero warnings**; frontend production
build passed; ruff clean. Exact next action: implement structured request logging,
correlation, metrics, dashboards and alerts.
## M22 — operational observability (2026-08-10)
- Added UUID request correlation propagated through response headers, structured API
errors and machine-readable JSON request logs. Logs include UTC time, route, method,
status, latency and client IP; Docker rotates bounded 10 MB files.
- Added Prometheus metrics for request rate/status, duration buckets, in-flight requests,
database readiness and persisted outbox state separated into real and synthetic
scenarios. `/metrics` supports constant-time Bearer protection if exposed beyond the
private Compose network.
- Added an optional pinned Prometheus/Grafana Compose profile, provisioned datasource,
six-panel operational dashboard and six validated alert rules. Real failures and
backlogs alert; the deliberate demo retry does not.
- Evidence: focused observability **5 passed without warnings**; ruff/mypy clean;
Prometheus `promtool` accepted the scrape config and all six rules; merged Compose and
Grafana dashboard JSON validate. Exact next action: automate verified backups,
retention and restore-readiness checks.
## M23 — scheduled and retained recovery points (2026-08-10)
- Added a continuously running Unraid backup service that waits for healthy PostgreSQL,
creates an immediate custom-format dump and repeats at a configurable interval without
access to the Docker socket. The existing on-demand path remains supported.
- Every backup must pass `pg_restore --list` before publication and receives a SHA-256
sidecar. Retention defaults to 30 days while always preserving at least seven newest
restore points. An optional independently mounted secondary destination receives the
same verified artifacts.
- Added a latest-success marker and container healthcheck that detects a missed 26-hour
recovery point, a standalone verification command, guarded destinations and bounded
backup/log storage.
- Evidence: all shell scripts pass Alpine `sh -n`; merged Unraid Compose validates; a
disposable PostgreSQL instance produced a real dump, checksum verification passed and
`pg_restore --list` accepted the artifact. Exact next action: implement privacy export,
anonymisation safeguards, retention reporting and governance documentation.
## M24 — executable privacy governance (2026-08-10)
- Added a manager-only Privacy workspace with persisted policy metrics, customer dossier
export, bounded audit CSV export and irreversible customer anonymisation. Privacy is
localized in all three supported languages, searchable and hidden from rental staff.
- Anonymisation is row-locked and requires the exact stable customer reference plus a
reason. Reserved/active bookings and bookings inside the configurable minimum retention
window block the action. PII is cleared while stable references and operational history
remain valid; repeated requests are idempotent.
- Every export and anonymisation is audited. The anonymisation audit records state and
justification but deliberately never copies erased PII. Audit CSV ranges are capped at
90 days and a configurable maximum row count.
- Added explicit customer anonymisation state/migration `b913a72e8c14`, a governance
runbook covering inventory, retention, data-subject requests, access review and incident
handling, plus regenerated OpenAPI.
- Evidence: privacy API **6 passed without warnings**; ruff/mypy clean; React review led
to stable callback/effect dependencies and a lazy route chunk; TypeScript, lint and
production build pass. Exact next action: extend RAGcore corpus statistics and health
evidence, then run complete acceptance and deploy all production-readiness milestones.
## M25 — provenance-aware knowledge statistics (2026-08-10)
- Expanded knowledge health with separate counts for authoritative local source
documents, the latest persisted n8n sync report and documents independently verified
as indexed. The API includes failed sync count, report time and an explicit statistics
provenance state.
- Confirmed against RAGcore's checked-in OpenAPI and route implementation that the
service intentionally exposes identity-based single-document lookup but no corpus-size
or space-browse endpoint. Fleet Ops therefore keeps `document_count=null` for RAGcore
and never mislabels an accepted upload as proven indexing/publication.
- Knowledge, dashboard and Integration Management now present the available source and
sync evidence in all three locales. The deterministic demo provider continues to
report its directly verified per-language corpus count.
- Regenerated the Fleet Ops OpenAPI contract and documented the provenance rules.
- Evidence: focused knowledge/demo suite **36 passed with zero warnings** from a rebuilt
image; ruff/mypy passed; frontend TypeScript lint and production build passed. The
prior Starlette/httpx warning is confirmed absent in the rebuilt environment.
- Exact next action: run complete clean acceptance, push all five milestone commits,
create a verified live backup, redeploy and execute live browser acceptance.
## M26 — clean acceptance hardening (2026-08-10)
- The clean full-suite run exposed and fixed two observability regressions that targeted
runs against a stale image had missed: readiness now returns immediately with 503 only
on database failure and reaches the healthy 200 branch correctly; Prometheus outbox
series now use the documented label order.
- All synthetic/operational and pending/delivering/succeeded/failed outbox combinations
are initialized to zero before persisted counts are applied. Fresh installs, restores
and demo resets therefore produce stable zero-valued metrics instead of misleading
`no data` panels and absent alert inputs.
- Evidence from the final rebuilt isolated stack: complete backend **239 passed with zero
warnings**, ruff passed and mypy passed across 58 source files. Frontend TypeScript
lint and production build pass; full and production-only npm audits both report zero
vulnerabilities.
- Exact next action: commit/push, take a verified pre-deployment backup, redeploy to
Unraid, then run migration, health, browser and full Playwright acceptance.
## M27 — portable backup evidence (2026-08-10)
- Live restore-readiness validation found that scheduled checksum files named their dump
with the container-only `/backups` path. Dumps were structurally valid, but host-side
`sha256sum -c` could not resolve that path.
- Scheduled and on-demand backups now write basename-only checksum entries, so dump plus
sidecar remains verifiable after copying to the host or an independent secondary
destination.
- Evidence: Alpine shell syntax passed; the recreated live backup service produced
`mobilityops-20260810T141944Z.dump`; checksum validation and `pg_restore --list` both
passed through the documented host verification command.
## M28 — final production acceptance and hand-off (2026-08-10)
- Pushed milestones M21M27 to Gitea `master`, deployed committed source archives to
`/mnt/user/appdata/mobilityops`, and applied migration `b913a72e8c14 (head)`. Public
passwordless demo access remains enabled; OIDC remains an optional disabled addition.
- Activated the pinned internal Prometheus/Grafana profile with a server-generated
Grafana password and loopback-only ports. Prometheus reports the API target up; all six
alert rules evaluate healthy; Grafana 12.2.0 reports database `ok`. API, database, web,
scheduled backup, Prometheus and Grafana are running, while the existing shared n8n
remains the only n8n instance used by Fleet Ops.
- Live visual acceptance covered desktop dashboard and knowledge statistics plus Privacy
at desktop and a fresh 390 × 844 load. There was no horizontal overflow; the mobile
shell starts closed and the irreversible privacy action remains guarded.
- Complete live Playwright acceptance is **147 passed in 4.6 minutes**. A race in the new
privacy test login helper was removed by waiting for the authenticated dashboard; the
privacy pair also passed three consecutive repetitions before the full green run.
- Final demo reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
inspections / 40 maintenance records / 33 quality issues / 20 workflow runs**. API
readiness is `ready`, RAGcore is available with 11 authoritative NL sources and an
honestly unknown provider index count, and the latest backup verifies. No API/backup
error or traceback appears in post-deployment logs.
- Exact next action: none for the selected scope. Optional secret rotation and new n8n
execution evidence remain intentionally deferred by the user.
## M29 — recruiter-facing product polish and engineering evidence (2026-08-10)
- Added a passwordless **90-second Highlights** entry that presents three directly
verifiable engineering stories: transactional vehicle returns, human-controlled data
correction and citation-bound knowledge. Rebuilt the Engineering page around domain
ownership, commit-before-orchestration, AI evidence, architecture and honest live/demo
integration states.
- Return completion now carries its persisted correlation ID into a dedicated audit
processing trace, linking the return, vehicle state, transactional outbox and delivery
evidence. Data-quality detail pages lead with the human rule name instead of a raw
identifier; Knowledge Hub source titles, staged progress and provider diagnostics are
clearer without overstating RAGcore index evidence.
- Improved recruiter entry metadata and social preview, route skeletons, 360 px touch
targets, reduced-motion behaviour, trilingual content and deterministic guide fallbacks.
Added automated recruiter and mobile-overflow coverage plus reproducible evidence
screenshot tooling. Refocused README, architecture and portfolio documentation on
inspectable product and reliability claims.
- Validation: isolated backend **239 passed**; Ruff clean; mypy clean across 58 files;
frontend lint/build passed; full Playwright **150 passed in 5.2 minutes**; full and
production npm audits report zero vulnerabilities; screenshot capture **1 passed** and
desktop/mobile output was visually inspected.
- Exact next action: commit and push M29, create and verify a live database backup,
deploy the committed archive to Unraid, then run live health, migration, browser and
acceptance checks before recording the final production evidence.
## M30 — recruiter polish production acceptance (2026-08-10)
- Pushed M29 to Gitea `master` as `29325b6`, created and verified pre-deployment backup
`mobilityops-20260810T161659Z.dump`, and deployed that exact source archive while
preserving `.env`, volumes and the existing central n8n. Deployment revision is
`29325b6c2775806080dc40e1988634d6ceb300a0`; Alembic is `b913a72e8c14 (head)`.
- API, web, PostgreSQL, scheduled backup, Prometheus and Grafana are healthy/current.
Live readiness is `ready`; post-deployment logs contain no traceback or critical error.
- The initial production browser run exposed two stale assertions that expected raw
RAGcore filenames. Fleet Ops now intentionally presents localized source titles. The
tests were tightened around localized titles plus language-specific evidence; the
focused live matrix passed 4/4 and the complete live suite then passed **150/150 in
4.2 minutes**.
- Final live visual audit covered login, Highlights and Engineering. Desktop overflow is
zero and the 360/390 px layouts are automated. The integration story is evidence-based:
n8n operational (4/4 workflows, 19 successful, zero pending/unexpected failures),
RAGcore operational (11 managed sources, provider index count honestly unknown), and
MCP labelled not connected because the final reset clears recent tool-call evidence.
- Final reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
inspections / 40 maintenance records / 33 quality issues / 20 workflow runs** with
all five scenarios ready. Live evidence screenshots and the canonical acceptance
summary were refreshed in `artifacts/evidence/`.
- Exact next action: commit/push this evidence-only hand-off update and refresh the
server's source archive/revision marker; no runtime rebuild or database change is needed.
## M31 — verified RAG inventory and dashboard attention polish (2026-08-10)
- RAGcore health now verifies every language-specific managed source through the
documented exact `/v1/documents` identity lookup. Only an active document with a
published active version is counted. RAGcore's provider-owned `content_sha256` hashes
its canonical parsed artifact rather than the raw upload, so it is deliberately not
compared with Fleet Ops's source-file hash.
Lookups are bounded, concurrent and cached for five minutes; an unavailable verifier
remains explicitly unknown instead of being presented as zero or as a reported count.
- Persisted n8n sync provenance no longer downgrades the stronger provider-verified
statistics state. The Knowledge Hub shows the verified count as a separate evidence
fact alongside source, sync and recency evidence, in all three supported languages.
- The dashboard's remaining-attention action is now a compact 52 px evidence-backed row
with count badge, legible title/hint and a 14 px directional icon. The attention panel
no longer stretches to the neighbouring full-day timeline; desktop and responsive
layouts remain overflow-free.
- Validation: isolated backend **241 passed**; Ruff clean; mypy clean across 58 files in
the locked container; frontend lint/build passed; focused visual regressions **5 passed**;
localized knowledge regressions **3 passed**; complete Playwright **152 passed in 5.4
minutes**; full and production npm audits report zero vulnerabilities. In-app visual
inspection confirmed the new action dimensions and presentation.
- Production RAGcore presents curated localized citation titles while the deterministic
provider presents the Markdown frontmatter title. Acceptance now permits both explicit
provider contracts while still requiring the language-specific evidence excerpt.
- Exact next action: commit and push M31, create and verify a live database backup,
redeploy the committed archive, then verify live RAG inventory, create a real post-reset
MCP Hub tool-call audit record, and complete production browser/acceptance evidence.
## M32 — M31 production acceptance and integration proof (2026-08-10)
- Pushed M31 to Gitea `master`, deployed runtime commit `cb7edb0`, and refreshed the
server source marker to the current evidence HEAD on `master`. The verified
pre-deployment custom-format backup is
`backups/postgres/mobilityops-20260810T185833Z.dump`; its SHA-256 and
`pg_restore --list` both passed. Alembic remains `b913a72e8c14 (head)`.
- Live RAGcore exact lookups report **11/11 verified active published documents** for
each of NL/EN/FR. The Knowledge Hub visibly separates that index proof from the 33/0
n8n sync report and labels the provider operational.
- A real production MCP Hub `mobilityops.operations.summary` call reached Fleet Ops under
the tenant-bound Hub client identity. The final short-lived demo bearer returned HTTP
200 with JSON and HTTP 401 after immediate revocation; Fleet Ops now reports MCP Hub
operational and retains three real verification audit events.
- Complete live Playwright acceptance passed **152/152 in 4.7 minutes** after the focused
three-language RAG title/evidence matrix passed 3/3. Final in-app visual inspection
confirmed the compact dashboard attention action, the four-fact RAG evidence strip and
the operational MCP card. Post-deployment API logs contain no traceback or critical
error.
- The final reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
inspections / 40 maintenance records / 33 quality issues / 20 workflow runs**, with
all five scenarios ready; the last action after reset was the read-only Hub proof.
- Exact next action: none for the requested scope; keep the public synthetic demo online
and monitor its existing health, backup and integration evidence surfaces.
## M33 — explicit booking readiness and Brussels-safe planning (2026-08-10)
- New reservations now default to incomplete requirements. Checkout remains unavailable
until an operator records a deliberate requirements confirmation; that transition and
its bounded evidence are persisted in the audit trail.
- Booking creation and availability use Europe/Brussels wall-clock conversion independent
of the visitor's browser timezone. Customer and vehicle searches cancel stale requests,
use a bounded timeout and expose server-side vehicle filtering up to 50 results.
- Validation: isolated booking API suite **12 passed**; backend Ruff clean; frontend
TypeScript lint and production build passed.
- Exact next action: add database invariants/indexes and harden the shared public demo reset.
## M34 — database-enforced domain integrity (2026-08-10)
- Added named PostgreSQL checks for booking windows/status/odometers, vehicle state and
non-negative counters, quality rule/severity/lifecycle values, outbox state/attempts and
audit actor types. Model metadata and migration `c24f6a9d013e` stay aligned.
- Added workload indexes for vehicle availability windows, quality work queues, outbox
retries and audit filtering/entity traces.
- Validation: migration upgraded from an empty PostgreSQL database to head; the complete
deterministic seed loaded with the expected 2/180/50/254/75/40/33/20 counts; four
direct invalid-state writes were rejected by their named constraints; Ruff passed.
- Exact next action: serialize and rate-limit shared demo reset, add production web guards
and cache MCP reachability evidence.
## M35 — shared public demo and edge hardening (2026-08-10)
- Demo resets now use a non-blocking process guard plus a PostgreSQL transaction advisory
lock, and enforce a configurable post-success cooldown with a standards-based
`Retry-After`. Test deployments explicitly disable only the cooldown, never locking.
- Nginx rate-limits public demo login/reset endpoints and adds CSP, anti-framing, MIME,
referrer and browser capability headers. Hashed assets receive long-lived caching while
the application shell is revalidated. Production FastAPI deployments no longer expose
Swagger, ReDoc or OpenAPI routes.
- MCP Hub reachability probes are synchronized and cached for 60 seconds, removing a
remote network call from every Integration Management page load while retaining honest
failure evidence.
- Validation: authentication/reset suite **15 passed**; Ruff and mypy passed across 58
source files; production web image built and `nginx -t` passed; production API docs-off
assertion passed.
- Exact next action: improve mobile Data Quality operations, technical evidence labels,
RAG scope clarity and sticky resolution actions.
## M36 — operational UX depth and scalable quality review (2026-08-10)
- Reserved bookings can now be moved through an audited, overlap-safe schedule command.
The detail page uses Brussels wall-clock input and explains/rechecks availability.
- Mobile Data Quality starts with a compact filter trigger, exposes removable active
filters, supports select/deselect-visible, aligns checkboxes with record cards and keeps
bulk/resolution controls reachable above the mobile navigation. The row link now has one
accessible reference instead of duplicate screen-reader text.
- MCP evidence leads with the friendly ITWorx Hub connector and keeps the raw client
identity in technical disclosure. RAGcore statistics explicitly distinguish the current
language from the all-language n8n report.
- Duplicate-customer scanning now blocks on the exact identifiers required to reach its
score threshold before running name similarity. This replaces quadratic all-pairs work
without changing detection semantics. Booking list hydration also fetches only related
customer and vehicle rows.
- React review confirmed primitive effect dependencies, aborted request handling, semantic
controls, keyboard names and no new render waterfalls. Validation: focused booking/data
quality API **50 passed**; Ruff/mypy and frontend lint/build passed; focused Playwright
booking and mobile quality flows passed. Visual inspection at 390 px and 1440 px found
no overflow or console warnings; the only logged error was the intentional anonymous
session probe 401.
- Exact next action: run complete clean backend/frontend acceptance and the full five-minute
browser suite, then commit/push, back up and deploy.
## M37 — complete local acceptance and production guard calibration (2026-08-10)
- The complete backend suite passed **249/249** against an isolated clean PostgreSQL
database. Ruff is clean, mypy reports no issues across 58 source files and the final
migration/seed path remains reproducible.
- The complete Playwright matrix passed **153/153 in 5.7 minutes** with one worker. This
covers the five-minute scenario, recruiter entry, booking readiness/rescheduling,
operational CRUD, Data Quality resolution, privacy, RAG/MCP evidence, three locales,
keyboard behavior and responsive layouts from 360 px through 1440 px.
- Frontend TypeScript lint and production build passed; the production dependency audit
reports zero vulnerabilities. The rebuilt Nginx configuration passes `nginx -t`.
- Demo reset cooldown is now explicitly passed into the API container. Acceptance can set
it to zero without weakening the advisory/process locks, while production defaults to
60 seconds. Login throttling remains active at ten requests/second with a 100-request
burst so a public demo is protected without rejecting the deterministic acceptance run.
- Exact next action: commit and push M37, create and verify a live PostgreSQL backup,
deploy the committed archive, migrate to `c24f6a9d013e`, then repeat live acceptance and
restore the public reset cooldown to 60 seconds.
## M38 — production deployment and final acceptance (2026-08-10)
- Pushed M33M37 to Gitea `master` and deployed runtime commit `2681935` to Unraid at
`http://192.168.10.150:1236`. The verified pre-deployment custom-format backup is
`backups/postgres/mobilityops-20260810T211951Z.dump`; SHA-256 verification and
`pg_restore --list` both passed.
- All application, database, backup and observability containers are healthy. Alembic is
`c24f6a9d013e (head)`, the production API returns 404 for Swagger/OpenAPI internally,
readiness returns 200 and the final ten-minute traceback/critical-error scan is clean.
- Complete live Playwright acceptance passed **153/153 in 5.6 minutes** against the public
Unraid URL. Nginx exposes the CSP, anti-framing, MIME and referrer guards and revalidates
the app shell.
- The final reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
inspections / 40 maintenance records / 33 quality issues / 20 workflow runs** and all
five demo scenarios report ready. The public reset cooldown was restored to 60 seconds.
- The shared n8n at `192.168.10.150:5678` returns health 200; no bundled n8n container was
started. Fleet Ops reports 19 successful deliveries and zero unexpected failures; its
one expected failure is the labelled error-handling demo scenario. MCP Hub is operational
with three retained real tool calls. RAGcore is available with 11/11 verified sources in
the active language collection and 33/0 all-language sync evidence.
- Exact next action: none for the locked scope; keep the public synthetic demo online and
monitor the existing health, backup and integration evidence surfaces.
+63 -106
View File
@@ -1,138 +1,95 @@
# Fleet Ops # Fleet Ops
**Connected operations for vehicle rental and service teams.** **A recruiter-ready operations platform for vehicle rental and service teams.**
Fleet Ops is a working proof of concept for a fictitious mobility company. It combines vehicle and booking operations, a controlled vehicle-return workflow, data-quality review, RAGcore-backed internal knowledge, n8n orchestration and read-only tools published through ITWorx MCP Hub. **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.
**Naming:** "Fleet Ops" is the product's visible name everywhere in the UI, the demo 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.
knowledge base, and this documentation. "MobilityOps" remains the technical
identifier only — the repository name, local directory, package/module names, Docker
Compose project, deployment directory, and database names. The UI is fully trilingual
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
architecture, the vehicle-status decision table, and the correction evidence, and
`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining
NL/FR translation gaps, centralized API-error localization, the time-dependent
Europe/Brussels dashboard greeting).
The web application uses the premium responsive **Control Rail** interface: a compact ![Fleet Ops engineering overview](frontend/public/og-fleet-ops.svg)
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
review-before-commit return handling and mobile navigation designed down to 390 px. See
`docs/design/design-directions.md` and `docs/design/implementation-validation.md` for the
design decision and visual evidence.
All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real. ## The 90-second tour
## Demo 1. Open **Highlights** from the login screen.
2. Follow a vehicle return from review to atomic commit, quality issue, outbox and correlated audit trace.
3. Compare and merge a duplicate customer with explicit human confirmation.
4. Ask the Knowledge Hub a damage question and inspect its cited procedure evidence.
5. Open **Engineering** for the architecture, reliability guarantees, test evidence and honest scope boundary.
The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van ## What makes it more than a mock-up
rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app
guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page
all make the fictional context, synthetic-data status, and real-vs-simulated boundaries
explicit without any verbal explanation. See `docs/demo-release/` for the full demo
concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour
design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy,
rollback).
## Scope - **Transactional operations:** a return writes the inspection, vehicle/booking state, audit events and outbox record atomically. n8n downtime never rolls back the local business transaction.
- **Explainable data quality:** five persisted rule types, SLA deadlines, assignment, bulk queue controls and bounded resolution flows—not decorative warning cards.
- **Grounded knowledge:** the live deployment uses RAGcore; insufficient or unavailable evidence produces no invented answer. Citations and provider provenance remain inspectable.
- **Safe AI exposure:** four tenant-bound, service-authenticated, read-only Fleet Ops tools are published through ITWorx MCP Hub and audited with correlation IDs.
- **Operational reliability:** bounded retries, delivery leases, health/readiness, Prometheus metrics, Grafana, scheduled verified backups and graceful external-dependency degradation.
- **Real product ergonomics:** nl-BE, en-GB and fr-BE; responsive from 360 px; keyboard-accessible navigation; role-aware global search; route-level lazy loading; server-enforced permissions.
The PoC implements: ## Architecture
- operations dashboard with a truthful aggregate n8n/MCP integration-status card; ```mermaid
- vehicle and booking views with working search, filters and pagination; flowchart LR
- server-backed session lifecycle (refresh-safe, central 401 handling); UI["React + TypeScript\nresponsive operations UI"] -->|session cookie| API["FastAPI\nbusiness rules + RBAC"]
- a role matrix enforced server-side and mirrored in the UI (see API --> DB[(PostgreSQL)]
`docs/12-security-and-audit.md`); API -->|grounded retrieval| RAG[RAGcore]
- vehicle return capture → authoritative server-evaluated review → commit → result; DB --> OUT["Transactional outbox"]
- five deterministic data-quality checks, each with a bounded resolution flow, plus a OUT -->|bounded retry| N8N["Existing central n8n"]
manual scan action; N8N -->|authenticated callback| API
- human review and customer merge; HUB["ITWorx MCP Hub"] -->|4 read-only tools| API
- audit trail with human-readable before/after evidence and safe entity links; ```
- role-aware global search across vehicles, bookings and (Operations Manager) issues;
- safe, confirmed demo reset;
- RAGcore-backed knowledge assistant with citations;
- two n8n workflows: return processing, and a scheduled data-quality scan with
crash-recoverable outbox delivery leases;
- four read-only MCP tools through ITWorx MCP Hub;
- deterministic demo reset and five-minute showcase.
It is not an ERP, CRM, accounting package, public booking site, payment system or autonomous agent. Fleet Ops owns operational truth. RAGcore owns retrieval, n8n performs post-commit orchestration, and MCP Hub owns tool transport/publication. Neither RAGcore nor MCP Hub accesses the Fleet Ops database directly. See [the as-built architecture](artifacts/evidence/architecture.md).
## Integration status ## Demonstrable scope
- **n8n**: fully implemented and verified against a real n8n instance, including - dashboard, vehicle fleet, booking lifecycle and controlled returns;
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending` - data-quality queue, assignment, review, merge and resolution;
with backoff, self-heals once n8n returns), the failed-delivery manual-retry path, - correlated human-readable audit history;
stale-delivery-lease recovery after a simulated crash, and a second (scheduled - cited Knowledge Hub with honest provider state;
quality-scan) workflow live-verified end to end against a real n8n instance. - n8n delivery monitoring and manual retry;
`GET /api/v1/integrations/status` reports a truthful aggregate state from outbox - user administration, privacy export/anonymisation and retention guards;
delivery counts, not just the most recent event. - deterministic reset with 2 users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance records, 33 quality issues and 20 workflow runs.
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
over the local procedure documents) is what satisfies the knowledge-assistant
acceptance criteria and is what's active in production (`KNOWLEDGE_PROVIDER=demo`). A
`RAGcoreKnowledgeProvider` HTTP adapter is implemented, unit-tested, and has been
exercised live against the deployed RAGcore instance: a real filesystem-permission bug
that caused every live retrieval to return zero candidates was found and fixed
(`docs/final-integrations/current-state-audit.md`), but a second, deeper gap — RAGcore's
reranker adapter calls an Ollama HTTP route (`/api/rerank`) that does not exist on the
deployed Ollama version — still blocks real grounded answers. `KNOWLEDGE_PROVIDER` stays
`demo` until that is resolved on the RAGcore side.
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
directly `curl`-verified with correct auth enforcement and audit logging.
`MCP_HUB_REGISTRATION_ENABLED` is actually wired into `Settings` and reported honestly
by the integration-status endpoint (evidence-based: real tool-call audit history, not
just the flag). The Fleet Ops connector is confirmed live in the ITWorx MCP Hub's own
production deployment (Tower), with a real contract fix already applied there
(`vehicle.get`'s wire parameter normalized to `vehicleRef`).
See `artifacts/functional-completion/final-summary.md` for the functional-completion This is deliberately not accounting, payments, a public reservation site, generic CRM, inventory, HR or an autonomous write agent.
audit evidence (supersedes the design-validation summary below for integration status),
and `artifacts/final-acceptance/summary.md` for the original M0M7 acceptance evidence.
## Repository map ## Stack
- `CLAUDE.md` — binding implementation rules. React, TypeScript, Vite, FastAPI, SQLAlchemy 2, PostgreSQL, Alembic, n8n, RAGcore, ITWorx MCP Hub, Docker Compose, Prometheus, Grafana and Playwright.
- `MASTER_BUILD_PROMPT.md` — prompt to start an autonomous Claude run.
- `PROJECT_STATE.md` — short persistent project memory.
- `docs/` — product, architecture, UX and acceptance specification.
- `contracts/` — OpenAPI, event and MCP contracts.
- `knowledge/` — fictitious source documents for the MobilityOps RAGcore workspace.
- `seed/` — deterministic synthetic dataset and generator.
- `n8n/` — importable workflow definitions.
- `backend/` — FastAPI/SQLAlchemy/Alembic API.
- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`).
- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`).
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures.
- `docs/functional-completion/` — the functional-completion audit and pre-work server baseline.
- `artifacts/functional-completion/` — functional-completion acceptance evidence.
- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided
tour, and runbook.
- `artifacts/demo-release/` — demo-productization acceptance evidence.
## Quickstart ## Run locally
```bash ```bash
cp .env.example .env cp .env.example .env
make demo make demo
``` ```
This builds and starts the full stack (migrations run automatically) and loads the
deterministic demo dataset. See `docs/17-runbook.md` for the one-time n8n workflow setup
required for the automation demo, and the full operational runbook.
Endpoints:
- Web: `http://localhost:1228` - Web: `http://localhost:1228`
- API health: `http://localhost:8128/health` - API readiness: `http://localhost:8128/health/ready`
- n8n: `http://localhost:5678` - 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).
All defaults are configurable via `.env` (see `.env.example`). 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).
## Quality gates ## Quality gates
```bash ```bash
make test # backend: pytest (151 tests) make test # isolated PostgreSQL backend suite
make lint # backend: ruff + mypy (strict, zero errors) make lint # Ruff + strict mypy
make e2e # frontend: Playwright end-to-end (138 tests, live stack required) make e2e # complete Playwright browser acceptance
cd frontend && npm run build
python scripts/run-readonly-load-smoke.py # while the demo stack is running
``` ```
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`). Release-scoped results and production evidence are recorded in [artifacts/final-acceptance/summary.md](artifacts/final-acceptance/summary.md); older milestone evidence remains explicitly historical. [PROJECT_STATE.md](PROJECT_STATE.md) records the commands and exact deployment revision.
## Repository map
- `backend/` — FastAPI domain, API, migrations and tests
- `frontend/` — React app and Playwright acceptance suite
- `contracts/` — OpenAPI, event and MCP contracts
- `knowledge/` — versioned fictional procedures
- `n8n/` — importable workflow definitions for the existing server
- `seed/` — deterministic synthetic dataset
- `docs/` — architecture, security, UX, testing and runbooks
- `artifacts/` — dated, release-scoped acceptance evidence and screenshots
“MobilityOps” remains the repository/deployment identifier; **Fleet Ops** is the product name shown to users.
+56
View File
@@ -0,0 +1,56 @@
# Security Policy
## Supported versions
| Version | Security support |
|---|---|
| Latest tagged PoC release and current `master` | Supported |
| Older commits, branches and untagged deployments | Not supported |
MobilityOps is a synthetic-data proof of concept, not a production identity,
payments or public reservation platform. Security fixes target the current
release line only.
## Reporting a vulnerability
Do not disclose suspected vulnerabilities through a public issue.
Report them privately to `jens@itworx.tech` with:
- the affected revision, endpoint or component;
- reproduction steps and prerequisites;
- the observed and expected behaviour;
- the security impact;
- a minimal proof of concept, without unnecessary personal or secret data.
Receipt should be acknowledged within three business days. An initial
assessment or request for additional evidence should follow within ten
business days. Remediation timing depends on severity and reproducibility.
## Scope
In scope:
- MobilityOps backend, frontend, container and deployment code;
- authentication, authorization, tenant boundaries and audit integrity;
- database, outbox, backup and restore behaviour;
- MobilityOps-owned n8n workflow definitions;
- RAGcore and MCP Hub integration boundaries implemented in this repository.
Out of scope:
- denial-of-service or destructive testing against the hosted demo;
- social engineering, credential stuffing or physical attacks;
- synthetic demo-data exposure without a security-boundary failure;
- vulnerabilities solely inside RAGcore, ITWorx MCP Hub, n8n or another
third-party service. Report those to their respective owners.
Do not access data beyond what is required to demonstrate the issue, modify
shared infrastructure, interrupt other services or retain obtained secrets.
## Coordinated disclosure
Good-faith research that respects this policy and applicable law will be
handled constructively. Allow a reasonable remediation period before public
disclosure. Submitted reports and evidence are used only for investigation,
remediation and verification.
+1 -1
View File
@@ -18,7 +18,7 @@ Claude must use `PROJECT_STATE.md` as its compact memory between sessions. Do no
- architecture and domain decisions; - architecture and domain decisions;
- API and event contracts; - API and event contracts;
- realistic deterministic synthetic seed data; - realistic deterministic synthetic seed data;
- ten fictitious procedures for RAGcore; - eleven fictitious procedures for RAGcore (nl-BE, en-GB, fr-BE);
- an initial n8n workflow export; - an initial n8n workflow export;
- MCP tool definitions for ITWorx MCP Hub; - MCP tool definitions for ITWorx MCP Hub;
- a minimal bootable frontend/API scaffold; - a minimal bootable frontend/API scaffold;
+52 -47
View File
@@ -1,63 +1,68 @@
# MobilityOps — as-built architecture # Fleet Ops — as-built architecture
```mermaid ```mermaid
flowchart TB flowchart TB
subgraph Browser UI["Fleet Ops Web\nReact + TypeScript + Vite"]
UI["MobilityOps Web<br/>React + TypeScript"]
end
subgraph MobilityOps["MobilityOps (this repo)"] subgraph CORE["Fleet Ops this repository"]
API["FastAPI backend<br/>/api/v1/*"] API["FastAPI /api/v1\nRBAC + domain rules"]
DISPATCH["Outbox dispatcher<br/>background thread"] OUT["Outbox dispatcher\nleases + bounded retry"]
DB[(PostgreSQL)] DB[(PostgreSQL)]
OBS["Prometheus metrics\nGrafana dashboards"]
API --> DB API --> DB
DISPATCH --> DB OUT --> DB
API --> OBS
end end
subgraph External["External central services"] subgraph EXT["Existing external platforms"]
N8N["n8n<br/>return-processing workflow"] N8N["Central n8n\nsecondary orchestration"]
RAGDEMO["Demo KnowledgeProvider<br/>TF-IDF extractive, local files"] RAG["RAGcore\ngrounded procedure retrieval"]
RAGCORE["RAGcore<br/>(adapter built, no live instance)"] HUB["ITWorx MCP Hub\ntool transport + publication"]
HUB["ITWorx MCP Hub<br/>(endpoints built, no live instance)"]
end end
UI -->|session cookie| API UI -->|secure session cookie| API
API -->|GroundedAnswer| RAGDEMO API -->|tenant/workspace adapter| RAG
API -.->|configurable, unavailable-safe| RAGCORE OUT -->|vehicle.returned.v1| N8N
DISPATCH -->|POST vehicle.returned.v1| N8N N8N -->|service-authenticated callback| API
N8N -->|callback, X-Service-Token| API HUB -->|service-authenticated read-only tools| API
HUB -.->|X-Service-Token, read-only| API
classDef unverified stroke-dasharray: 5 5;
class RAGCORE,HUB unverified;
``` ```
Dashed boxes/arrows are implemented and unit/contract-tested but were never exercised ## Ownership and trust boundaries
against a live instance in this environment (no reachable RAGcore or ITWorx MCP Hub).
Solid boxes were verified end-to-end, including a real n8n instance.
## Component responsibility (unchanged from `docs/03-architecture.md`) | Component | Owns | Explicitly does not own |
|---|---|---|
| Fleet Ops | vehicles, customers, bookings, inspections, quality issues, audit, permissions, outbox state | external workflow execution or procedure retrieval |
| RAGcore | indexing/retrieval and grounded procedure evidence | Fleet Ops database or business state |
| ITWorx MCP Hub | MCP transport, connector publication and central tool-call audit | Fleet Ops database or write actions |
| n8n | post-commit workflow orchestration | critical business rules or the source-of-truth transaction |
| Component | Owns | ## End-to-end return trace
|---|---|
| MobilityOps | vehicles, customers, bookings, inspections, data-quality issues, audit, outbox/delivery state |
| RAGcore | procedure retrieval and grounded answers (demo provider substitutes locally) |
| ITWorx MCP Hub | MCP transport, tool publication, central tool-call audit |
| n8n | post-commit secondary orchestration only — never the source of truth for vehicle state |
## Reliability boundaries verified in this build ```mermaid
sequenceDiagram
actor Operator
participant Web
participant API
participant DB
participant n8n
Operator->>Web: Review and confirm return
Web->>API: POST return with idempotency key
API->>DB: Lock booking and validate invariants
API->>DB: Commit inspection, state, audit and outbox atomically
API-->>Web: Result + correlation ID
Web-->>Operator: Human result and full processing trace
API->>n8n: Deliver persisted outbox event
n8n->>API: Authenticated status callback
API->>DB: Persist delivery/audit evidence
```
1. **Return commits atomically with its outbox event**`app/services/returns.py`, one ## Verified reliability properties
transaction; verified by `test_concurrent_returns_only_one_succeeds` (real Postgres row
locking, not mocked). 1. Concurrent returns serialize through PostgreSQL row locking; only one can commit.
2. **Outbox delivery is at-least-once, idempotent by event ID** — verified live: the n8n 2. Local return success is independent of n8n availability. Pending delivery remains persisted and retryable.
callback checks for an existing `AuditEvent` by event ID before recording a second time. 3. Outbox delivery is at-least-once and idempotent by event ID, with crash-recoverable leases and bounded backoff.
3. **RAGcore failure disables knowledge answers only**`RAGcoreKnowledgeProvider` degrades 4. RAGcore failure affects knowledge answers only. The UI reports unavailable/insufficient evidence and does not invent an answer.
to `unavailable`; the rest of the app is unaffected because the knowledge router is the 5. MCP endpoints are a separate tenant-bound, client-identity-validated, read-only surface; every call is audited with a correlation ID.
only consumer. 6. Browser authorization is enforced again on the API. Hiding a navigation item is never the security boundary.
4. **MCP Hub failure does not affect the web application** — the four MCP provider
endpoints are a separate authenticated surface (`X-Service-Token`), invisible to the The live deployment has exercised all three external boundaries. Local clean-checkout acceptance can use the deterministic extractive knowledge provider while reporting that mode honestly.
browser-facing API/UI.
5. **n8n failure leaves events pending with bounded retries** — verified live: a seeded
`failed` event, retried through the UI, was picked up by the background dispatcher and
delivered through the real n8n instance within one poll cycle.
+53 -156
View File
@@ -1,177 +1,74 @@
# MobilityOps — final acceptance evidence # Fleet Ops — final acceptance evidence
## Commit ## Accepted implementation
Built on top of commit `c5b7e21f81694f0339ad31e3bf044db952d0fbe0` (M6, "implement ITWorx - Deployment source marker: current `master`; the application image was built from runtime commit `cb7edb0` and the following commits contain acceptance/evidence only.
MCP Hub publication"). This evidence file and the rest of M7's polish are committed as - Live demo: `http://192.168.10.150:1236` — public passwordless synthetic demo.
`M7: portfolio polish and final acceptance` — run `git log --oneline` for the exact hash. - Deployment: Unraid `/mnt/user/appdata/mobilityops`, Compose project `mobilityops`.
- Database migration: `b913a72e8c14 (head)`.
- Product name: Fleet Ops; MobilityOps remains the technical repository/deployment ID.
## Exact commands (clean checkout) ## Clean acceptance — 2026-08-10
```bash ```text
git clone <repo> && cd MobilityOps Backend, isolated PostgreSQL: 241 passed, zero warnings
cp .env.example .env Playwright, fresh local build: 152 passed (5.4 min)
make demo # docker compose up --build -d ; migrations run automatically ; seed --reset Playwright, live production: 152 passed (4.7 min)
Ruff: clean
Mypy: clean across 58 source files
Frontend lint/build: passed
npm audit, full and prod: 0 vulnerabilities
Alembic: b913a72e8c14 (head)
``` ```
One-time n8n setup (see `docs/17-runbook.md` for full detail — this cannot be scripted The browser suite covers both roles, protected routes, booking/return/maintenance/user/privacy workflows, five data-quality resolution types, correlated audit, retryable orchestration, grounded knowledge in three languages, the complete guided demo, the recruiter highlights, keyboard behaviour and responsive layouts from 360 to 1440 px.
end-to-end because it requires a one-time owner account created through n8n's web UI):
```bash The provider matrix explicitly covers both valid title contracts: the deterministic provider uses Markdown frontmatter titles, while production RAGcore returns curated localized presentation titles. Both must include the language-specific source fragment. The focused live matrix passed 3/3, followed by the complete 152/152 green production run.
# open http://localhost:5678/setup in a browser, create any owner account
make n8n-setup ## Production hand-off state
The final reset completed immediately before the verified Hub call at `2026-08-10T19:16Z` and restored:
```text
users 2 · customers 180 · vehicles 50 · bookings 254 · inspections 75
maintenance 40 · data-quality issues 33 · workflow runs 20
scenario_integrity.all_ready = true (5/5 scenarios)
``` ```
Verification: API readiness is `ready`, PostgreSQL is `up`, and API, web, database, scheduled backup, Prometheus and Grafana are running healthy/current. No traceback or critical error appears in post-deployment API/backup logs.
```bash A pre-deployment custom-format backup was created and independently verified by SHA-256 plus `pg_restore --list`:
docker compose run --rm api pytest -q # 66 passed
docker compose run --rm api ruff check . # All checks passed
cd frontend && npm run build # clean tsc + vite build
cd frontend && npx playwright test # 1 passed (full 5-minute demo script)
```
## Test counts `/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T185833Z.dump`
- **Backend**: 66 tests passing (`pytest`), 0 skipped, 0 failed. Ruff clean. Coverage by ## External integration evidence
area: seed determinism (2), auth/roles (4), dashboard (3), vehicles (4), bookings (3),
return workflow incl. real concurrent-submission test (9), data quality incl. S2/S4
scenarios (10), audit (2), n8n dispatcher incl. malformed-payload regression (6),
n8n callback idempotency (3), workflows/retry (4), knowledge incl. S6 scenario (7),
MCP provider endpoints (8), health (1).
- **Frontend**: `npm run build` — clean TypeScript + Vite build, zero errors.
- **End-to-end**: 1 Playwright test (`frontend/e2e/demo.spec.ts`) automating the full
documented 5-minute demo script (login → dashboard → S1 return → S2 merge → S6 knowledge
question → audit → 360px responsive check) — **passing** against the live stack.
## Screenshots of the seven main pages - **Existing central n8n:** configured, dispatch enabled and operational. All four expected workflows have recent healthy heartbeats. The reset state contains 19 successful runs, zero pending, zero unexpected failures and one explicitly synthetic failed-retry scenario.
- **RAGcore:** reachable and ready for tenant `northstar-mobility-demo`, workspace `mobilityops`, collection `internal-procedures`. Exact identity lookups independently confirm 11/11 active published documents in each of NL/EN/FR; the latest n8n report separately records 33 synchronized documents and zero failures. The canonical parsed-artifact hash is not misrepresented as a raw-source hash.
- **ITWorx MCP Hub:** registration is enabled, the Hub is reachable and Fleet Ops is **operational**. Three real `mobilityops.operations.summary` verification calls are audited under the Hub's tenant-bound client identity; the final short-lived bearer produced HTTP 200/JSON and HTTP 401 after immediate revocation. All exposed Fleet Ops tools remain read-only.
Captured live against the deterministic seed (`artifacts/evidence/screenshots/`, ## Recruiter and visual acceptance
via `frontend/e2e/_capture-screenshots.spec.ts`):
| # | Page | File | The public entry now offers a direct **90-second Highlights** route. It links three product actions to their engineering proof, while the **Engineering** workspace explains domain ownership, commit-before-orchestration, citation-bound AI, security and explicit scope.
|---|---|---|
| 1 | Login | `1-login.png` |
| 2 | Dashboard | `2-dashboard.png` |
| 3 | Vehicles | `3-vehicles.png` |
| 4 | Bookings | `4-bookings.png` |
| 5 | Data Quality | `5-data-quality.png` |
| 6 | Knowledge (grounded S6 answer) | `6-knowledge.png` |
| 7 | Automation | `7-automation.png` |
| — | Audit (bonus, 8th nav item) | `8-audit.png` |
| — | Dashboard at 360px (responsive proof) | `9-mobile-dashboard.png` |
## RAGcore evidence Interactive live inspection confirmed:
**Success (demo provider, the one actually satisfying acceptance in this environment)** - no horizontal overflow on Highlights or Engineering at desktop; automated coverage confirms 360/390 px mobile layouts;
S6 question against the real `/api/v1/knowledge/questions` endpoint: - clear hierarchy, consistent primary actions and readable proof cards;
- compact remaining-attention action (count badge, two-line label, small directional icon) without the former stretched empty panel;
- truthful live n8n/RAGcore/MCP labels after reset, including 11 verified RAG documents and the real Hub client/tool evidence;
- no dead links, placeholder numbers or unexplained raw data-quality references;
- complete synthetic-data disclosure on login and in the persistent shell.
```json Current evidence images are in `artifacts/evidence/screenshots/`:
{
"answer": "Per \"Vehicle return procedure\" (v2.0), section \"1. Register the return\": Open the active booking and record the ending odometer, fuel level, cleanliness, visible damage, technical warnings and relevant notes.",
"evidence_state": "grounded",
"sources": [
{"document_id": "vehicle-return-procedure", "title": "Vehicle return procedure", "version": "2.0", "section": "1. Register the return", "excerpt": "..."},
{"document_id": "vehicle-return-procedure", "title": "Vehicle return procedure", "version": "2.0", "section": "3. Determine next state", "excerpt": "..."},
{"document_id": "damage-procedure", "title": "Damage handling procedure", "version": "1.3", "section": "1. Immediate actions", "excerpt": "..."}
],
"provider": "demo",
"correlation_id": "b50094b7-1c84-4e39-9055-1dc03e8fd1f8"
}
```
**Unavailable (RAGcore adapter, live-demonstrated against an unreachable host)** - `1-login.png`
`KNOWLEDGE_PROVIDER=ragcore`, `RAGCORE_BASE_URL=http://ragcore-not-reachable:9999`: - `2-highlights.png`
- `3-engineering-story.png`
- `4-dashboard.png`
- `5-knowledge-evidence.png`
- `6-highlights-mobile.png`
``` ## Deliberate boundary
health: {'provider': 'ragcore', 'available': False, 'detail': 'RAGcore unavailable: ConnectError: ...', 'document_count': 0}
ask: {'answer': '', 'evidence_state': 'unavailable', 'sources': [], 'provider': 'ragcore', 'correlation_id': 'demo-correlation'}
```
No live RAGcore instance was reachable in this environment, so the adapter's actual This is a completed, production-shaped public demo—not claimed customer adoption and not a general ERP. Accounting, payments, public reservations, CRM, inventory, HR, a second RAG stack, a separate MCP server and autonomous write agents remain intentionally excluded. A real personal-data rollout would additionally require the adopting organisation's identity provider, retention approvals, secrets lifecycle, alert ownership and disaster-recovery governance.
request/response contract against a real RAGcore is unverified beyond this
degrade-safely behavior — see `contracts/ragcore-contract-assumptions.md` and
`PROJECT_STATE.md`'s M5 notes.
## n8n evidence
**Success** — a real return registered on `BK-DEMO-RETURN`, delivered through the actual
n8n instance (not mocked), confirmed via `GET /api/v1/workflows`:
```json
{"event_id": "aa5dfeee-90ca-452a-bdd1-0a0b6d3dd63f", "event_type": "vehicle.returned.v1", "aggregate_ref": "BK-DEMO-RETURN", "status": "succeeded", "attempts": 2, "last_error": null}
```
(`attempts: 2` because the first delivery attempt landed while n8n was mid-restart from
the one-time workflow-activation step — the dispatcher's backoff-and-retry handled it
without any manual intervention, which is itself evidence of the retry behavior working.)
**Retry (S5 scenario)** — seeded `BK-H-0020` (event `00000000-...-0020`), initially
`failed` after 3 attempts with `"Synthetic connection timeout to n8n"`:
1. Before: `{"status": "failed", "attempts": 3, "last_error": "Synthetic connection timeout to n8n"}`
2. Operations Manager clicks Retry on `/automation`.
3. Within one ~3s dispatcher poll cycle, delivered through the live n8n instance.
4. After: `{"status": "succeeded", "attempts": 4, "last_error": null}`
## MCP tool sample calls
All four provider endpoints, authenticated with `X-Service-Token`:
```
$ curl -H "X-Service-Token: <token>" http://localhost:8128/api/v1/integrations/mcp/operations-summary
{"tenant":"northstar-mobility-demo","metrics":{"available":21,"rented":11,"cleaning":6,"maintenance":5,"blocked":7,"open_quality_issues":22,"pending_or_failed_workflows":1}}
$ curl -H "X-Service-Token: <token>" "http://localhost:8128/api/v1/integrations/mcp/attention-vehicles?minimum_severity=high&limit=3"
[{"vehicle_ref":"MO-016","severity":"high","rule_type":"booking_overlap",...},
{"vehicle_ref":"MO-016","severity":"high","rule_type":"vehicle_status_conflict",...},
{"vehicle_ref":"MO-031","severity":"high","rule_type":"missing_required_field",...}]
$ curl -H "X-Service-Token: <token>" http://localhost:8128/api/v1/integrations/mcp/vehicles/MO-016
{"public_ref":"MO-016","make":"Hymer","model":"Exsis","model_year":2021,"location":"Geel","operational_status":"available","odometer_km":30497,"next_service_km":40000,"open_quality_issue_count":2,"current_booking_ref":null}
$ curl -H "X-Service-Token: <token>" -X POST -d '{"question":"What must I do when a vehicle returns with damage?","max_sources":2}' http://localhost:8128/api/v1/integrations/mcp/search-knowledge
{"answer":"Per \"Vehicle return procedure\" ...","evidence_state":"grounded","sources":[...2 items...],"provider":"demo",...}
```
Auth verified: missing header → `422`; wrong token → `401`. All four calls confirmed
recorded in `GET /api/v1/audit?action=mcp_tool_request` with `actor_type: "service"`.
No live ITWorx MCP Hub instance was reachable in this environment — these are direct
calls to MobilityOps's own provider endpoints, not a Hub round trip.
## Known PoC limitations
- **RAGcore and ITWorx MCP Hub were never reachable in this build environment.** Both
integrations are implemented against best-effort/documented contracts and are
unit/contract-tested (including their failure-degradation paths), but neither was
verified against a real counterpart service. The demo `KnowledgeProvider` is what
actually satisfies the knowledge-assistant acceptance criteria here.
- **n8n requires a one-time manual owner-account setup** per fresh environment
(`docker compose down -v` wipes it) — this is a property of the n8n 2.x image itself
(`N8N_BASIC_AUTH_ACTIVE` no longer gates the UI), not something MobilityOps can bypass.
Documented precisely in `docs/17-runbook.md`; the workflow import/activation itself
*is* scripted (`make n8n-setup`).
- **Inspection public refs are a simple `count+1` sequence**, not gap-safe under true
concurrent writers — acceptable for this single-tenant demo, would need a DB sequence
for a multi-writer production system.
- **The five data-quality rules use simplified idempotency** — `(rule_type, entity_type,
entity_id)` while open, rather than the doc's literal evidence-fingerprint scheme — see
`PROJECT_STATE.md`'s M3 notes for the reasoning (the fingerprint scheme would have let
the scan double-report issues already present in the seeded CSV).
- **No production authentication** — demo login is an HMAC-signed session cookie tied to
two fixed seeded users, appropriate for a PoC, not a real identity provider.
## Portfolio wording (truthful)
MobilityOps is a working proof of concept, not a production system and not deployed for
any real company. All customers, vehicles, bookings, and documents are synthetic
(deterministically generated). The application logic it demonstrates is real: a
transactional vehicle-return workflow with idempotency and concurrency control tested
against real concurrent database transactions; five explainable, deterministic
data-quality rules with a working customer-merge UI; a background outbox dispatcher
verified end-to-end against a real n8n instance including failure/retry; a
TF-IDF-weighted extractive knowledge assistant that never fabricates answers; and four
read-only, audited, service-authenticated integration endpoints. RAGcore and the ITWorx
MCP Hub integrations are implemented and tested in isolation but were not verified
against live instances of those systems in this environment.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+66 -254
View File
@@ -1,265 +1,77 @@
# MobilityOps — final acceptance audit summary # Fleet Ops release acceptance
This audit was run after M0M7 had already been implemented and committed, specifically This file is release-scoped evidence, not a timeless claim. Older evidence under
to independently re-verify the finished system end to end rather than trust the `artifacts/evidence/` is historical. Exact commands and production revisions are recorded
milestone-by-milestone build log. It found and fixed one real category of defect in `PROJECT_STATE.md`.
(`mypy` had never been run across the whole build) and confirmed everything else — every
user journey, every button/filter/form, both external-dependency degraded modes, secret
hygiene, and the clean-checkout path — works as documented.
## Final commit ## 2026-08-21 release candidate
This audit's fixes are committed as the commit immediately following - Backend: **271/271** tests passed against an isolated clean PostgreSQL database.
`108b5d04fc6f7c5ff9c47009032d6469df29cf3c` ("M7: portfolio polish and final acceptance"). - Browser acceptance: **155/155** Chromium tests passed in 6.0 minutes.
Run `git log -1 --format="%H %s"` for the exact hash. - Live-safe browser canary: **4/4** passed across Chromium and Firefox against the local
deployed stack; unlike the acceptance suite, it never resets or mutates demo records.
- Accessibility: the principal login, dashboard, data-quality, knowledge, automation and
audit routes have no automated critical/serious WCAG 2 A/AA/2.1 AA violations.
- Frontend: TypeScript, ESLint, production build, dependency audit and per-asset JS/CSS
budgets passed; committed visual baselines cover the public entry and engineering story.
- Contracts: committed OpenAPI, event schema, MCP tools and all five n8n definitions match
their code/manifest sources.
- Recovery: a custom-format PostgreSQL dump was restored into a disposable database; the
Alembic revision and non-zero canonical table counts matched the source database.
- Operations: Prometheus/Alertmanager configuration validation passed, including the
watchdog and authenticated n8n receiver route.
## Exact commands executed ## 2026-08-21 production verification
Clean-checkout drill (run twice during this audit, most recently against fully wiped - Immutable application revision `95c91797fa2c599443d69d9c96d83a85ee0711f7` was promoted
Docker volumes): from a checksum-verified source archive after a fresh production backup.
- Source revision and both OCI revision labels matched. Public readiness was green,
Alembic was at head, all health-gated services were healthy and persisted demo data was
retained without a deployment reset.
- Trivy found zero fixed HIGH/CRITICAL vulnerabilities in each exact production image.
- Prometheus successfully scraped the bearer-protected API target, Alertmanager carried
the active delivery watchdog, and the authenticated n8n alert receiver remained active.
- The final non-destructive HTTPS canary passed **4/4** across Chromium and Firefox,
including the core operator routes and a grounded answer from the real knowledge stack.
```bash ## 2026-08-21 resilience upgrade verification
git status # working tree clean before starting
docker compose down -v # wipe all volumes — genuinely clean state
cp .env.example .env
docker compose up --build -d # migrations run automatically (backend/entrypoint.sh)
docker compose exec api python -m app.cli seed --reset
docker compose run --rm api pytest -q
docker compose run --rm api ruff check .
docker compose run --rm api mypy app
cd frontend && npm run build
cd frontend && npx playwright test
```
n8n one-time setup (owner account via browser at `http://localhost:5678/setup`, then): - Immutable revision `00191e9b54ee6b961648a6e02abbb3a57957dba0` was promoted from a
checksum-verified archive after a verified production dump. A stable gateway now routes
to two revision-specific API and two web replicas; stateful services are no longer
restarted by routine application releases.
- Moving host port 1236 from the legacy web container to the gateway was a one-time
migration hand-off and produced 14 failures across 1,200 rapid probes. Future releases
do not move that port; their acceptance gate is the zero-error versioned gateway reload.
- The subsequent M50 release exercised that steady-state path: the gateway remained online,
atomically switched revision-specific API/web aliases and sustained **700/700** external
readiness probes without interruption. Post-promotion Chromium/Firefox acceptance passed
**4/4** and 360 concurrent authenticated reads had zero errors at p95 **116.4 ms**.
- The versioned gateway switch sustained **300/300** local rollout probes without an error.
Production's non-destructive Chromium/Firefox canary passed **4/4**, and 360 authenticated
concurrent reads returned zero errors at p95 **137.2 ms**.
- PostgreSQL, backup, Prometheus, Alertmanager, Grafana and the gateway all reported healthy;
Alembic was at head, the protected Prometheus target was present, and backup plus real
restore-drill evidence remained current.
- Trivy 0.74 found zero fixed HIGH/CRITICAL vulnerabilities in the exact production API,
web and gateway images. The separately built rclone/PostgreSQL backup-tools image is also
clean after rebuilding rclone 1.75.0 with Go 1.26.6.
- The OneDrive worker is deployed as an opt-in profile but is not represented as active:
it requires the owner's one-time interactive Microsoft OAuth authorization. Until that
happens, verified local backups remain the active recovery source.
```bash ## Evidence boundary
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n
```
Degraded-mode drills: The complete local suite uses the deterministic provider and an isolated database so it is
repeatable and safely destructive. Production verification is deliberately smaller and
non-destructive; it verifies the real RAGcore/MCP/n8n health surfaces without resetting the
shared demo. A successful local result is never presented as proof that an external service
was live. The production subsection is added only after the exact committed release is
deployed and observed.
```bash ## Remaining product boundary
docker compose stop n8n # then register a return via the API — commits, event stays pending
docker compose start n8n # dispatcher self-heals, no manual intervention
docker compose run --rm -e KNOWLEDGE_PROVIDER=ragcore -e RAGCORE_BASE_URL=http://ragcore-not-reachable:9999 \
api python -c "from app.services.knowledge import get_knowledge_provider; ..."
```
## Test and validation results Fleet Ops remains a synthetic single-tenant PoC. It is not a production identity provider,
payment system, accounting package or public reservation platform. External RAGcore, MCP
| Check | Command | Result | Hub and n8n services remain independently operated dependencies and are accessed only
|---|---|---| through their documented adapters.
| Backend unit/integration tests | `docker compose run --rm api pytest -q` | **66 passed**, 0 failed, 0 skipped |
| Backend lint | `docker compose run --rm api ruff check .` | **All checks passed** |
| Backend type check | `docker compose run --rm api mypy app` | **Success: no issues found in 44 source files** (found and fixed 43 pre-existing errors this audit — see below) |
| Frontend build + typecheck | `cd frontend && npm run build` | Clean (`tsc -b && vite build`, zero errors) |
| End-to-end (Playwright) | `cd frontend && npx playwright test` | **12 passed** (`demo.spec.ts` — full 9-step demo script; `interactive-elements.spec.ts` — 11 tests covering every nav item, filter, tab, and role boundary) |
| Clean-checkout migrations | `docker compose down -v && docker compose up --build -d` | 11 tables created automatically, `alembic current``e7b08389f47f (head)`, zero manual step |
| Deterministic seed | `docker compose exec api python -m app.cli seed --reset` | `users:2 customers:180 vehicles:50 bookings:246 inspections:75 maintenance:40 data_quality_issues:26 workflow_runs:20` — identical across every reseed this session |
| Secret scan | `git ls-files \| grep -x .env`; `git log --all -p -- '*.env'`; history grep for AWS/private-key/`sk-` patterns | No `.env` ever committed; no secrets found in history |
### mypy defects found and fixed (the one real gap this audit uncovered)
`mypy` is a declared dev dependency (`backend/pyproject.toml`) but was never added to any
milestone's validation loop — only `ruff` was run throughout M0M7. Running it cold
surfaced 43 errors across 10 files. All were triaged and fixed (not suppressed):
- **Two genuine defensive-programming gaps**, not just type-annotation issues:
- `app/services/returns.py`: the vehicle lookup after acquiring the row lock had no
`None` guard; a dangling FK would have crashed with an unhandled 500 instead of a
clean `404 VEHICLE_NOT_FOUND`. Fixed.
- `app/api/routers/bookings.py`: same pattern in `get_booking` for the customer/vehicle
lookups — now returns a clean `500` with a message instead of an `AttributeError`.
- `app/api/deps.py` / `app/api/routers/demo.py`: `CurrentUser.role` is validated by
Pydantic at runtime already, but `get_current_user` now explicitly checks role
membership before construction, turning a would-be unhandled `ValidationError` into a
clean `401` for a corrupted/tampered session cookie.
- Two instances of reusing one variable name for both a `Vehicle` and a `Customer` across
branches (`dashboard.py`, `data_quality.py`) — renamed for clarity, not just to satisfy
mypy.
- `Booking.__table__.update()` / `Customer.__table__.update()` switched to the idiomatic
`sqlalchemy.update(Model)` construct (also fixes the type error).
- Remainder: deprecated `conint()``Annotated[int, Field(...)]`, a `Sequence` vs `list`
`.sort()` call, an `assert`-guarded None-narrow after a `WHERE ... IS NOT NULL` filter
mypy can't see through, and a couple of narrowly-scoped `# type: ignore[...]` comments
for known SQLAlchemy stub gaps (`Result.rowcount`).
`make lint` now runs `ruff check .` **and** `mypy app`.
## Application URLs and ports
| Service | URL | Notes |
|---|---|---|
| Web (React SPA) | `http://localhost:1228` | nginx-served static build |
| API | `http://localhost:8128` | FastAPI, `/health` for liveness |
| API docs | `http://localhost:8128/docs` | auto-generated OpenAPI/Swagger UI |
| n8n | `http://localhost:5678` | requires one-time owner setup, see below |
| PostgreSQL | `localhost:5432` (container-internal only, no host port published) | |
## Demo users and access method
No passwords. Two demo-role buttons on `http://localhost:1228/login`:
- **Open as Operations Manager** → `USR-OPS`, "Amelie De Ridder". Full access: dashboard,
data-quality resolution/merge, automation retry, demo reset, MCP/service-token routes
are separate (not user-facing).
- **Open as Rental Employee** → `USR-EMP`, "Karim Boujaddaine". Can register returns and
browse vehicles/bookings/knowledge; Automation page is visible but shows a
role-restricted message instead of the delivery table (enforced both in the UI and by
the backend's `require_operations_manager` dependency — verified by
`test_retry_requires_operations_manager` and the e2e role-restriction test).
Session is an HMAC-signed, `HttpOnly` cookie (`app/core/security.py`) — a demo mechanism,
not a real identity provider (documented as a known limitation).
## Implemented functionality
- Operations dashboard with 100% database-backed metrics, attention items linking to the
underlying data-quality issue, "today" departures/returns, and recent automation runs.
- Vehicle and booking list/detail pages with working filters (status, attention-only) and
a tabbed vehicle detail view (overview/bookings/inspections/maintenance/quality).
- Full transactional vehicle-return workflow: row-locked, idempotent by
`Idempotency-Key`, canonical-odometer regression handling (never silently lowers the
canonical value), vehicle status derivation, two audit events, and a schema-compliant
outbox event — verified against real concurrent submissions (1×201 + 2×409).
- Invalid-mileage rejection: negative values and non-numeric input both correctly
rejected with `422` and a precise Pydantic validation message.
- Data Quality Workbench: five deterministic rules (duplicate customer via TF-IDF-style
weighted signal scoring, missing required field, odometer regression, booking overlap,
vehicle status conflict), issue list/detail/defer/reject, and a two-column
duplicate-customer compare-and-merge UI with an inline (non-native-dialog) confirmation
step, transactional booking rewiring, and audit logging.
- Full audit trail: every significant action (login, return, vehicle status change,
data-quality issue lifecycle, customer merge, workflow retry, demo reset, n8n
callback, MCP tool request, knowledge question) is recorded with actor, correlation ID,
and before/after state; filterable by action.
- Knowledge Assistant: deterministic TF-IDF-weighted extractive retrieval over the 10
procedure documents — never generative, always cites real excerpts, and honestly
reports `insufficient`/`unavailable` states rather than fabricating an answer.
- n8n automation: background outbox dispatcher (`FOR UPDATE SKIP LOCKED` claim,
exponential backoff, no DB transaction held during the HTTP call), a live-verified
round trip through an actual n8n workflow, manual retry for failed deliveries, and an
Automation page (Operations Manager only) showing all runs with filtering.
- Four read-only, service-token-authenticated MCP Hub provider endpoints, each recording
its own service-request audit event, with zero write/mutation endpoints anywhere in
that namespace.
- Responsive UI verified down to 360px width (nav wraps, tables become cards, metric
tiles reflow to a 2-column grid, no horizontal overflow) — both by an automated
Playwright viewport/overflow assertion and by a captured screenshot.
## RAGcore integration status: implemented, not live-verified
The active `KnowledgeProvider` in this environment is `DemoKnowledgeProvider` — fully
implemented, fully tested, fully live-verified, and what actually satisfies the
knowledge-assistant acceptance criteria. A `RAGcoreKnowledgeProvider` HTTP adapter also
exists (`app/services/knowledge/ragcore.py`), targeting a best-effort contract inferred
from `contracts/ragcore-contract-assumptions.md` (no live RAGcore API spec was available).
Its **unavailable-degradation path is live-verified this audit**: pointed at an
unreachable host, it returns `{"evidence_state": "unavailable", "answer": "", "sources":
[]}` with no fabrication, exactly as required — but an actual successful round trip
against a real RAGcore instance has never been performed, because no such instance was
reachable in this environment.
## MCP Hub integration status: implemented, not live-verified
All four contracted read-only tools (`mobilityops_get_operations_summary`,
`mobilityops_list_attention_vehicles`, `mobilityops_get_vehicle_details`,
`mobilityops_search_knowledge`) are implemented as service-token-protected endpoints under
`/api/v1/integrations/mcp/`, directly `curl`-verified this audit (auth enforcement,
correct data shape, no write methods, service-request audit logging). No live ITWorx MCP
Hub instance was reachable in this environment, so an actual Hub-mediated tool call was
never performed — only direct calls to MobilityOps's own provider API.
## n8n integration status: implemented and fully live-verified
The only external integration with a real, running counterpart service available in this
environment. Fully verified this audit, including both success and degraded paths:
- **Success**: a real return registered via the API was delivered by the background
dispatcher to an actual n8n instance (owner account + imported/activated workflow),
which called back into MobilityOps and was recorded `succeeded`.
- **Degraded mode**: `docker compose stop n8n`, then a return was registered — it
**committed successfully** (`201`, booking `status: returned` persisted) exactly as
required by the architecture's reliability boundary ("a return command and its outbox
event commit in one transaction" and "n8n failure leaves events pending with bounded
retries"). The outbox event stayed `pending` with two real `ConnectError`s logged and
exponential backoff.
- **Self-healing**: restarting n8n required no manual intervention — the background
dispatcher picked the pending event back up on its next poll cycle and delivered it to
`succeeded` (5 total attempts across the outage).
- **Manual retry (S5 scenario)**: a seeded `failed` delivery, retried from the Automation
page, moved to `pending` and was delivered to `succeeded` by the live dispatcher within
one poll cycle.
## Known limitations
- RAGcore and the ITWorx MCP Hub were never reachable in this build/audit environment;
both integrations are implemented and tested against inferred/documented contracts but
not verified against real instances of those systems (see above).
- n8n requires a one-time, per-fresh-environment manual owner-account setup through its
own web UI (`http://localhost:5678/setup`) — a property of the n8n 2.x image itself
(`N8N_BASIC_AUTH_ACTIVE` no longer gates the UI), not something MobilityOps can bypass.
The workflow import/activation itself *is* scripted (`make n8n-setup`).
- Demo authentication is an HMAC-signed session cookie tied to two fixed seeded users —
appropriate for a PoC, not a production identity provider.
- Inspection public references are assigned via a simple `count + 1` sequence, not
gap-safe under true concurrent writers (acceptable for this single-tenant demo).
- The five data-quality rules use a simplified idempotency key
(`rule_type, entity_type, entity_id` while open) rather than the spec's literal
evidence-fingerprint scheme — documented rationale in `PROJECT_STATE.md`'s M3 notes.
- `npm audit` reports one residual moderate `esbuild`/Vite-8 dev-server-only advisory
(fixable only by a Vite major version bump) and one high `react-router` RSC-mode
advisory that does not apply to this app (it never uses React Router's RSC/SSR mode).
## Clean deployment instructions
```bash
git clone <repo> && cd MobilityOps
cp .env.example .env
make demo # build, start, migrate (automatic), seed
```
One-time n8n setup (only needed for the automation demo path; everything else works
without it):
```bash
# open http://localhost:5678/setup in a browser, create any owner account
# (8+ chars, 1 number, 1 capital letter — no email verification required)
make n8n-setup
```
Verify:
```bash
curl http://localhost:8128/health # {"status":"ok",...}
curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200
make test # 66 backend tests
make lint # ruff + mypy, zero errors
make e2e # 12 Playwright tests (stack must be running)
```
Full detail, recovery expectations, and required operational checks: `docs/17-runbook.md`.
## Five-minute demonstration flow
1. Open `http://localhost:1228`**Open as Operations Manager**.
2. **Dashboard**: point out the metrics are live counts (available/rented/cleaning/
maintenance/blocked vehicles, open quality issues, pending/failed workflows), and the
Attention Required list linking straight to the underlying issues.
3. **Vehicles → MO-024** → open the active booking `BK-DEMO-RETURN`, register a return
with an odometer reading below MO-024's canonical value → the result panel shows the
inspection, the derived vehicle status, the automatically-created data-quality issue,
and the queued automation event — canonical odometer is confirmed unchanged.
4. **Data Quality → DQ-DEMO-DUPLICATE**: the two-column CUS-0012/CUS-0178 comparison,
merge with the inline confirmation step, issue flips to `resolved`.
5. **Knowledge**: ask "What must I do when a vehicle returns with damage?" → grounded
answer citing both the return and damage-handling procedures with real excerpts.
6. **Automation**: filter to `failed`, retry the seeded delivery, watch it succeed within
a few seconds via the live n8n instance.
7. **Audit**: filter by `return_registered` or `customer_merged` to show every action from
this walkthrough is recorded with actor, timestamp, and correlation ID.
8. Resize the browser to 360px width to show the responsive layout (nav wraps, tables
become cards) — or run `make e2e` and point at the passing responsive assertion.
+22 -5
View File
@@ -1,16 +1,33 @@
FROM python:3.12-slim FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base
ARG VCS_REF=development
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.title="Fleet Ops API" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.source="https://fleetops.itworx.tech"
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY backend/requirements.lock ./ COPY backend/requirements-prod.lock ./
RUN pip install --no-cache-dir -r requirements.lock RUN pip install --no-cache-dir -r requirements-prod.lock
COPY backend/pyproject.toml ./ COPY backend/pyproject.toml ./
COPY backend/app ./app COPY backend/app ./app
COPY backend/alembic ./alembic COPY backend/alembic ./alembic
COPY backend/alembic.ini ./ COPY backend/alembic.ini ./
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
FROM runtime-base AS test
COPY backend/requirements.lock ./requirements.lock
RUN pip install --no-cache-dir -r requirements.lock
COPY backend/tests ./tests
USER app
FROM runtime-base AS runtime
# 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"]
+1 -1
View File
@@ -1,7 +1,7 @@
[alembic] [alembic]
script_location = alembic script_location = alembic
prepend_sys_path = . prepend_sys_path = .
version_path_separator = os path_separator = os
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic
@@ -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")
@@ -0,0 +1,27 @@
"""enforce one open issue per detected condition
Revision ID: 4f2b9c8d7e61
Revises: 0a4c1d2e3f5b
"""
from alembic import op
import sqlalchemy as sa
revision = "4f2b9c8d7e61"
down_revision = "0a4c1d2e3f5b"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"uq_data_quality_one_open_condition",
"data_quality_issues",
["rule_type", "entity_type", "entity_id"],
unique=True,
postgresql_where=sa.text("status = 'open'"),
)
def downgrade() -> None:
op.drop_index("uq_data_quality_one_open_condition", table_name="data_quality_issues")
@@ -0,0 +1,28 @@
"""add optional external OIDC identity
Revision ID: a81d0ce9f662
Revises: f43d829ab610
"""
import sqlalchemy as sa
from alembic import op
revision = "a81d0ce9f662"
down_revision = "f43d829ab610"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("identity_provider", sa.String(80), nullable=True))
op.add_column("users", sa.Column("external_subject", sa.String(255), nullable=True))
op.create_unique_constraint(
"uq_user_external_identity", "users", ["identity_provider", "external_subject"]
)
def downgrade() -> None:
op.drop_constraint("uq_user_external_identity", "users", type_="unique")
op.drop_column("users", "external_subject")
op.drop_column("users", "identity_provider")
@@ -0,0 +1,25 @@
"""operational user credentials
Revision ID: b7c7b536df85
Revises: 799d8800e241
"""
from alembic import op
import sqlalchemy as sa
revision = "b7c7b536df85"
down_revision = "799d8800e241"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("email", sa.String(length=320), nullable=True))
op.add_column("users", sa.Column("password_hash", sa.String(length=512), nullable=True))
op.create_unique_constraint("uq_users_email", "users", ["email"])
def downgrade() -> None:
op.drop_constraint("uq_users_email", "users", type_="unique")
op.drop_column("users", "password_hash")
op.drop_column("users", "email")
@@ -0,0 +1,24 @@
"""add explicit customer anonymisation state
Revision ID: b913a72e8c14
Revises: a81d0ce9f662
"""
import sqlalchemy as sa
from alembic import op
revision = "b913a72e8c14"
down_revision = "a81d0ce9f662"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("customers", sa.Column("anonymized_at", sa.DateTime(timezone=True)))
op.create_index("ix_customers_anonymized_at", "customers", ["anonymized_at"])
def downgrade() -> None:
op.drop_index("ix_customers_anonymized_at", table_name="customers")
op.drop_column("customers", "anonymized_at")
@@ -0,0 +1,68 @@
"""add domain constraints and operational indexes
Revision ID: c24f6a9d013e
Revises: b913a72e8c14
"""
import sqlalchemy as sa
from alembic import op
revision = "c24f6a9d013e"
down_revision = "b913a72e8c14"
branch_labels = None
depends_on = None
def upgrade() -> None:
checks = (
("bookings", "ck_bookings_status", "status IN ('reserved','active','returned','cancelled','blocked')"),
("bookings", "ck_bookings_time_window", "ends_at > starts_at"),
("bookings", "ck_bookings_start_odometer", "start_odometer_km IS NULL OR start_odometer_km >= 0"),
("bookings", "ck_bookings_end_odometer", "end_odometer_km IS NULL OR end_odometer_km >= 0"),
("vehicles", "ck_vehicles_operational_status", "operational_status IN ('available','rented','cleaning','maintenance','blocked')"),
("vehicles", "ck_vehicles_model_year", "model_year BETWEEN 1900 AND 2100"),
("vehicles", "ck_vehicles_odometer", "odometer_km >= 0"),
("vehicles", "ck_vehicles_next_service", "next_service_km >= 0"),
("vehicles", "ck_vehicles_version", "version >= 1"),
("data_quality_issues", "ck_data_quality_rule_type", "rule_type IN ('possible_duplicate_customer','missing_required_field','odometer_regression','booking_overlap','vehicle_status_conflict')"),
("data_quality_issues", "ck_data_quality_severity", "severity IN ('low','medium','high')"),
("data_quality_issues", "ck_data_quality_status", "status IN ('open','deferred','resolved','rejected')"),
("outbox_events", "ck_outbox_delivery_status", "delivery_status IN ('pending','delivering','succeeded','failed')"),
("outbox_events", "ck_outbox_attempts", "attempts >= 0"),
("audit_events", "ck_audit_actor_type", "actor_type IN ('user','service','system')"),
)
for table, name, condition in checks:
op.create_check_constraint(name, table, condition)
op.create_index("ix_bookings_vehicle_status_window", "bookings", ["vehicle_id", "status", "starts_at", "ends_at"])
op.create_index("ix_data_quality_work_queue", "data_quality_issues", ["status", "due_at", "severity"])
op.create_index("ix_outbox_delivery_next_attempt", "outbox_events", ["delivery_status", "next_attempt_at"])
op.create_index("ix_audit_action_occurred", "audit_events", ["action", "occurred_at"])
op.create_index("ix_audit_entity", "audit_events", ["entity_type", "entity_id"])
def downgrade() -> None:
op.drop_index("ix_audit_entity", table_name="audit_events")
op.drop_index("ix_audit_action_occurred", table_name="audit_events")
op.drop_index("ix_outbox_delivery_next_attempt", table_name="outbox_events")
op.drop_index("ix_data_quality_work_queue", table_name="data_quality_issues")
op.drop_index("ix_bookings_vehicle_status_window", table_name="bookings")
for table, name in (
("audit_events", "ck_audit_actor_type"),
("outbox_events", "ck_outbox_attempts"),
("outbox_events", "ck_outbox_delivery_status"),
("data_quality_issues", "ck_data_quality_status"),
("data_quality_issues", "ck_data_quality_severity"),
("data_quality_issues", "ck_data_quality_rule_type"),
("vehicles", "ck_vehicles_version"),
("vehicles", "ck_vehicles_next_service"),
("vehicles", "ck_vehicles_odometer"),
("vehicles", "ck_vehicles_model_year"),
("vehicles", "ck_vehicles_operational_status"),
("bookings", "ck_bookings_end_odometer"),
("bookings", "ck_bookings_start_odometer"),
("bookings", "ck_bookings_time_window"),
("bookings", "ck_bookings_status"),
):
op.drop_constraint(name, table, type_="check")
@@ -0,0 +1,49 @@
"""persist revoked sessions
Revision ID: d1f83bc64170
Revises: b7c7b536df85
"""
import sqlalchemy as sa
from alembic import op
revision = "d1f83bc64170"
down_revision = "b7c7b536df85"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"revoked_sessions",
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_revoked_sessions_expires_at", "revoked_sessions", ["expires_at"])
op.create_index(
"ix_revoked_sessions_token_hash",
"revoked_sessions",
["token_hash"],
unique=True,
)
def downgrade() -> None:
op.drop_index("ix_revoked_sessions_token_hash", table_name="revoked_sessions")
op.drop_index("ix_revoked_sessions_expires_at", table_name="revoked_sessions")
op.drop_table("revoked_sessions")
@@ -0,0 +1,65 @@
"""add ownership and SLA deadlines to data quality issues
Revision ID: f43d829ab610
Revises: d1f83bc64170
"""
import sqlalchemy as sa
from alembic import op
revision = "f43d829ab610"
down_revision = "d1f83bc64170"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"data_quality_issues",
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"data_quality_issues",
sa.Column("assigned_to_user_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
"fk_data_quality_issues_assigned_user",
"data_quality_issues",
"users",
["assigned_to_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_data_quality_issues_due_at", "data_quality_issues", ["due_at"])
op.create_index(
"ix_data_quality_issues_assigned_to_user_id",
"data_quality_issues",
["assigned_to_user_id"],
)
op.execute(
"""
UPDATE data_quality_issues
SET due_at = detected_at + CASE severity
WHEN 'high' THEN interval '4 hours'
WHEN 'low' THEN interval '3 days'
ELSE interval '1 day'
END
WHERE status = 'open' AND due_at IS NULL
"""
)
def downgrade() -> None:
op.drop_index(
"ix_data_quality_issues_assigned_to_user_id",
table_name="data_quality_issues",
)
op.drop_index("ix_data_quality_issues_due_at", table_name="data_quality_issues")
op.drop_constraint(
"fk_data_quality_issues_assigned_user",
"data_quality_issues",
type_="foreignkey",
)
op.drop_column("data_quality_issues", "assigned_to_user_id")
op.drop_column("data_quality_issues", "due_at")
+50 -8
View File
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import hmac
import re
from collections.abc import Generator from collections.abc import Generator
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -8,7 +11,9 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.core.security import SessionPayload, read_session_token from app.core.security import SessionPayload, read_session_token
from app.models.user import User
from app.schemas import CurrentUser, Role from app.schemas import CurrentUser, Role
from app.services.sessions import is_session_revoked
settings = get_settings() settings = get_settings()
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined] _VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
@@ -22,13 +27,32 @@ def get_db() -> Generator[Session, None, None]:
db.close() db.close()
def get_current_user(request: Request) -> CurrentUser: def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
token = request.cookies.get(settings.session_cookie_name) token = request.cookies.get(settings.session_cookie_name)
payload: SessionPayload | None = read_session_token(token) if token else None payload: SessionPayload | None = read_session_token(token) if token else None
if payload is None or payload.role not in _VALID_ROLES: revoked = token is not None and is_session_revoked(db, token)
if payload is None or payload.role not in _VALID_ROLES or revoked:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
role: Role = payload.role # type: ignore[assignment] # Demo reset deliberately rebuilds the deterministic users table. Retaining the
return CurrentUser(public_ref=payload.public_ref, display_name=payload.display_name, role=role) # signed demo session until the reset endpoint clears its cookie keeps existing demo
# workflows stable; operational sessions are always checked against the live record.
if settings.mobilityops_demo_mode:
demo_role: Role = payload.role # type: ignore[assignment]
return CurrentUser(
public_ref=payload.public_ref,
display_name=payload.display_name,
role=demo_role,
)
user = db.get(User, payload.user_id)
if (
user is None
or not user.active
or user.public_ref != payload.public_ref
or user.role not in _VALID_ROLES
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
role: Role = user.role # type: ignore[assignment]
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=role)
def require_operations_manager( def require_operations_manager(
@@ -41,12 +65,30 @@ def require_operations_manager(
return user return user
@dataclass(frozen=True)
class McpClientContext:
reported_client_id: str
tenant: str
_MCP_CLIENT_ID = re.compile(
r"^itworx-mcp-hub:(?:readiness|mobilityops:[A-Za-z0-9][A-Za-z0-9._:-]{0,127})$"
)
def require_mcp_service_token( def require_mcp_service_token(
x_service_token: str = Header(..., alias="X-Service-Token"), x_service_token: str = Header(..., alias="X-Service-Token"),
x_client_id: str = Header(default="unknown-mcp-client", alias="X-Client-Id"), x_client_id: str = Header(..., alias="X-Client-Id", min_length=1, max_length=180),
) -> str: x_tenant_id: str | None = Header(default=None, alias="X-Tenant-Id", max_length=120),
if x_service_token != settings.mcp_hub_service_token: ) -> McpClientContext:
if not hmac.compare_digest(x_service_token, settings.mcp_hub_service_token):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token" status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token"
) )
return x_client_id if not _MCP_CLIENT_ID.fullmatch(x_client_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Untrusted MCP client identity"
)
if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch")
return McpClientContext(reported_client_id=x_client_id, tenant=settings.ragcore_tenant)
+89 -7
View File
@@ -1,23 +1,30 @@
from __future__ import annotations from __future__ import annotations
import csv
import io
import json
import uuid import uuid
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings
from app.models.audit import AuditEvent from app.models.audit import AuditEvent
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
settings = get_settings()
# Only entity types with a stable public reference and (optionally) a real frontend route # Only entity types with a stable public reference and (optionally) a real frontend route
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable # are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
@@ -37,6 +44,81 @@ _ROUTE_TEMPLATES: dict[str, str] = {
} }
def _as_utc(value: datetime | None) -> datetime | None:
"""Treat naive query datetimes as UTC so they compare safely with aware values."""
if value is None:
return None
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
@router.get("/export.csv")
def export_audit_csv(
occurred_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None),
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> Response:
end = _as_utc(occurred_to) or datetime.now(UTC)
start = _as_utc(occurred_from) or end - timedelta(days=30)
if end <= start or end - start > timedelta(days=90):
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
events = db.scalars(
select(AuditEvent)
.where(AuditEvent.occurred_at >= start, AuditEvent.occurred_at <= end)
.order_by(AuditEvent.occurred_at)
.limit(settings.privacy_audit_export_max_rows + 1)
).all()
if len(events) > settings.privacy_audit_export_max_rows:
raise HTTPException(status_code=413, detail="Audit export exceeds configured row limit")
output = io.StringIO(newline="")
writer = csv.writer(output)
writer.writerow(
(
"id",
"occurred_at",
"actor_type",
"actor_label",
"action",
"entity_type",
"entity_id",
"correlation_id",
"before",
"after",
"metadata",
)
)
for event in events:
writer.writerow(
(
event.id,
event.occurred_at.isoformat(),
event.actor_type,
event.actor_label,
event.action,
event.entity_type,
event.entity_id or "",
event.correlation_id,
json.dumps(event.before_json, separators=(",", ":"), default=str),
json.dumps(event.after_json, separators=(",", ":"), default=str),
json.dumps(event.metadata_json, separators=(",", ":"), default=str),
)
)
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="audit_exported",
entity_type="audit",
metadata={"from": start.isoformat(), "to": end.isoformat(), "rows": len(events)},
)
db.commit()
return Response(
output.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="mobilityops-audit.csv"'},
)
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]: def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
ids_by_type: dict[str, set[uuid.UUID]] = {} ids_by_type: dict[str, set[uuid.UUID]] = {}
for event in events: for event in events:
@@ -58,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),
@@ -77,14 +159,14 @@ def list_audit_events(
matched_ids: set[uuid.UUID] = set() matched_ids: set[uuid.UUID] = set()
for model in _ENTITY_MODELS.values(): for model in _ENTITY_MODELS.values():
matched_ids.update( matched_ids.update(
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all() db.scalars(
select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))
).all()
) )
if not matched_ids: if not matched_ids:
if page is None: if page is None:
return [] return []
return AuditEventPageOut( return AuditEventPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
items=[], page=1, page_size=page_size, total=0, total_pages=1
)
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids)) stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
if correlation_id: if correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id) stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import time
import uuid
from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped]
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.core.ratelimit import FailedAttemptLimiter
from app.core.security import (
SessionPayload,
create_session_token,
hash_password,
read_session_token,
verify_password,
)
from app.models.user import User
from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest
from app.services.audit import record_audit_event
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
_login_limiter = (
FailedAttemptLimiter(
max_failures=settings.login_max_failures,
window_seconds=settings.login_failure_window_seconds,
)
if settings.login_max_failures > 0
else None
)
def _client_key(request: Request) -> str:
# The API sits behind the web container's reverse proxy in every documented
# deployment. The proxy appends/overwrites the socket peer as the final hop, so an
# attacker-controlled leading value must never select a fresh limiter bucket.
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[-1].strip()
return request.client.host if request.client else "unknown"
oauth = OAuth()
if settings.oidc_enabled and settings.oidc_issuer_url:
oauth.register(
name="oidc",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
def _current_user_out(user: User) -> CurrentUser:
return CurrentUser(
public_ref=user.public_ref,
display_name=user.display_name,
role=user.role, # type: ignore[arg-type]
)
def _set_session(response: Response, user: User) -> None:
token = create_session_token(
SessionPayload(
user_id=str(user.id),
public_ref=user.public_ref,
role=user.role,
display_name=user.display_name,
issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
)
)
response.set_cookie(
settings.session_cookie_name,
token,
httponly=True,
samesite="lax",
secure=settings.session_cookie_secure,
max_age=settings.session_ttl_seconds,
)
def bootstrap_initial_admin(db: Session) -> None:
"""Create or rotate the explicitly configured first manager in operational mode."""
if (
settings.mobilityops_demo_mode
or not settings.initial_admin_email
or not settings.initial_admin_password
):
return
email = settings.initial_admin_email.strip().lower()
user = db.scalar(select(User).where(User.email == email))
if user is None:
user = User(
public_ref="USR-ADMIN",
email=email,
password_hash=hash_password(settings.initial_admin_password),
display_name=settings.initial_admin_display_name,
role="operations_manager",
active=True,
)
db.add(user)
record_audit_event(
db,
actor_type="system",
actor_label="bootstrap",
action="operational_admin_created",
entity_type="user",
entity_id=user.id,
)
db.commit()
def _oidc_configured() -> bool:
return bool(
settings.oidc_enabled
and settings.oidc_issuer_url
and settings.oidc_client_id
and settings.oidc_client_secret
)
def _oidc_redirect_uri() -> str:
return settings.oidc_redirect_uri or (
f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback"
)
def _allowed_oidc_email(email: str) -> bool:
domains = {
value.strip().casefold()
for value in settings.oidc_allowed_email_domains.split(",")
if value.strip()
}
return not domains or email.rsplit("@", 1)[-1].casefold() in domains
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
subject = str(claims.get("sub") or "").strip()
email = str(claims.get("email") or "").strip().lower()
if not subject or not email or claims.get("email_verified") is not True:
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
if not _allowed_oidc_email(email):
raise HTTPException(status_code=403, detail="Email domain is not allowed")
provider = settings.oidc_issuer_url.rstrip("/")
user = db.scalar(
select(User).where(
User.identity_provider == provider,
User.external_subject == subject,
)
)
if user is None:
user = db.scalar(select(User).where(User.email == email))
if user is not None and user.external_subject not in (None, subject):
raise HTTPException(status_code=409, detail="Email is linked to another identity")
if user is not None and claims.get("email_verified") is not True:
# Linking an existing local account (possibly the bootstrap admin) purely on an
# email match requires the IdP to explicitly assert the address is verified;
# an absent claim is treated as unverified.
raise HTTPException(status_code=401, detail="Verified OIDC email is required")
created = user is None
if created:
if not settings.oidc_auto_provision:
raise HTTPException(status_code=403, detail="OIDC user is not provisioned")
role = settings.oidc_default_role
if role not in {"operations_manager", "rental_employee"}:
role = "rental_employee"
user = User(
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
email=email,
password_hash=None,
display_name=str(claims.get("name") or email),
role=role,
active=True,
)
db.add(user)
db.flush()
assert user is not None
if not user.active:
raise HTTPException(status_code=403, detail="User is inactive")
user.identity_provider = provider
user.external_subject = subject
record_audit_event(
db,
actor_type="system" if created else "user",
actor_id=None if created else user.id,
actor_label=settings.oidc_provider_name,
action="oidc_user_provisioned" if created else "oidc_identity_linked",
entity_type="user",
entity_id=user.id,
metadata={"provider": provider},
)
return user
@router.get("/oidc/status", response_model=OidcStatusOut)
def oidc_status() -> OidcStatusOut:
return OidcStatusOut(
enabled=_oidc_configured(),
provider_name=settings.oidc_provider_name if _oidc_configured() else None,
)
@router.get("/oidc/login")
async def oidc_login(request: Request) -> Response:
if not _oidc_configured():
raise HTTPException(status_code=404, detail="OIDC login is not configured")
client = oauth.create_client("oidc")
if client is None:
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
return await client.authorize_redirect(request, _oidc_redirect_uri())
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response:
if not _oidc_configured():
raise HTTPException(status_code=404, detail="OIDC login is not configured")
client = oauth.create_client("oidc")
if client is None:
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
try:
token = await client.authorize_access_token(request)
except OAuthError as exc:
raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc
user = _resolve_oidc_user(db, dict(token.get("userinfo") or {}))
response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard")
_set_session(response, user)
record_audit_event(
db,
actor_type="user",
actor_id=user.id,
actor_label=user.display_name,
action="oidc_login",
entity_type="user",
entity_id=user.id,
metadata={"provider": settings.oidc_issuer_url.rstrip("/")},
)
db.commit()
return response
@router.post("/login", response_model=CurrentUser)
def password_login(
body: PasswordLoginRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
) -> CurrentUser:
if settings.mobilityops_demo_mode:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Password login is unavailable in demo mode",
)
limiter_key = _client_key(request)
retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0
if retry_after:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many failed login attempts. Try again later.",
headers={"Retry-After": str(retry_after)},
)
user = db.scalar(select(User).where(User.email == body.email.strip().lower()))
if user is None or not user.active or not verify_password(body.password, user.password_hash):
if _login_limiter:
_login_limiter.record_failure(limiter_key)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if _login_limiter:
_login_limiter.reset(limiter_key)
_set_session(response, user)
record_audit_event(
db,
actor_type="user",
actor_id=user.id,
actor_label=user.display_name,
action="password_login",
entity_type="user",
entity_id=user.id,
)
db.commit()
return _current_user_out(user)
@router.get("/session", response_model=CurrentUser)
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
response.headers["Cache-Control"] = "no-store"
return user
@router.post("/logout")
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None
if payload is not None and token is not None:
revoke_session(db, token, payload)
record_audit_event(
db,
actor_type="user",
actor_id=uuid.UUID(payload.user_id),
actor_label=payload.display_name,
action="logout",
entity_type="user",
)
db.commit()
response.delete_cookie(settings.session_cookie_name)
return {"status": "logged_out"}
+413 -11
View File
@@ -1,20 +1,35 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy import select from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session 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.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.inspection import Inspection
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
AvailableVehicleOut,
BookingOut, BookingOut,
BookingPageOut,
CancelBookingRequest,
CheckoutBookingRequest,
CheckoutBookingResult,
CompleteBookingRequirementsRequest,
CreateBookingRequest,
CurrentUser, CurrentUser,
NextBookingRisk, NextBookingRisk,
RegisterReturnRequest, RegisterReturnRequest,
RegisterReturnResult,
RescheduleBookingRequest,
ReturnPreviewResult, ReturnPreviewResult,
) )
from app.services.audit import record_audit_event
from app.services.returns import preview_vehicle_return, register_vehicle_return from app.services.returns import preview_vehicle_return, register_vehicle_return
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"]) router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
@@ -35,25 +50,292 @@ def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOu
) )
@router.get("", response_model=list[BookingOut]) @router.get("", response_model=list[BookingOut] | BookingPageOut)
def list_bookings( def list_bookings(
status: str | None = Query(default=None), status: str | None = Query(default=None),
vehicle_ref: str | None = Query(default=None), vehicle_ref: str | None = Query(default=None),
query: str | None = Query(default=None, min_length=1, max_length=100),
starts_from: datetime | None = Query(default=None),
starts_to: datetime | None = Query(default=None),
location: str | None = Query(default=None, min_length=1, max_length=120),
sort: Literal["operational", "starts_asc", "starts_desc"] = Query(default="operational"),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
) -> list[BookingOut]: ) -> list[BookingOut] | BookingPageOut:
stmt = select(Booking).order_by(Booking.starts_at.desc()) stmt = select(Booking)
if status: if status:
stmt = stmt.where(Booking.status == status) stmt = stmt.where(Booking.status == status)
if vehicle_ref: if vehicle_ref:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None: if vehicle is None:
if page is None:
return [] return []
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
stmt = stmt.where(Booking.vehicle_id == vehicle.id) stmt = stmt.where(Booking.vehicle_id == vehicle.id)
bookings = db.scalars(stmt).all() if starts_from:
customers = {c.id: c for c in db.scalars(select(Customer)).all()} stmt = stmt.where(Booking.ends_at >= starts_from)
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()} if starts_to:
return [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings] stmt = stmt.where(Booking.starts_at < starts_to)
if query or location:
stmt = stmt.join(Customer, Booking.customer_id == Customer.id).join(
Vehicle, Booking.vehicle_id == Vehicle.id
)
if location:
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
if query:
term = f"%{query.strip()}%"
stmt = stmt.where(
or_(
Booking.public_ref.ilike(term),
Customer.first_name.ilike(term),
Customer.last_name.ilike(term),
Vehicle.public_ref.ilike(term),
)
)
if sort == "starts_asc":
stmt = stmt.order_by(Booking.starts_at.asc())
elif sort == "starts_desc":
stmt = stmt.order_by(Booking.starts_at.desc())
else:
now = datetime.now(UTC)
operational_bucket = case(
(Booking.status == "active", 0),
(Booking.starts_at >= now, 1),
else_=2,
)
stmt = stmt.order_by(
operational_bucket,
case((Booking.starts_at >= now, Booking.starts_at)).asc().nulls_last(),
Booking.starts_at.desc(),
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
bookings = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
customer_ids = {booking.customer_id for booking in bookings}
vehicle_ids = {booking.vehicle_id for booking in bookings}
customers = {
customer.id: customer
for customer in db.scalars(select(Customer).where(Customer.id.in_(customer_ids))).all()
}
vehicles = {
vehicle.id: vehicle
for vehicle in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
}
items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
if page is None:
return items
total_pages = max(1, (total + page_size - 1) // page_size)
return BookingPageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
@router.post("", response_model=BookingOut, status_code=201)
def create_booking(
body: CreateBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
if body.ends_at <= body.starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
customer = db.scalar(select(Customer).where(Customer.public_ref == body.customer_ref))
if customer is None or customer.merged_into_customer_id is not None:
raise HTTPException(status_code=422, detail="Customer is unavailable for booking")
# Serialise booking creation per vehicle. The overlap check must run after
# acquiring this lock, otherwise two concurrent requests can both pass it.
vehicle = db.scalar(
select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref).with_for_update()
)
if (
vehicle is None
or not vehicle.active
or vehicle.operational_status in {"maintenance", "blocked"}
):
raise HTTPException(status_code=422, detail="Vehicle is unavailable for booking")
overlap = db.scalar(
select(Booking.id).where(
Booking.vehicle_id == vehicle.id,
Booking.status.in_(("reserved", "active")),
Booking.starts_at < body.ends_at,
Booking.ends_at > body.starts_at,
)
)
if overlap is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
booking = Booking(
public_ref=f"BK-{uuid.uuid4().hex[:10].upper()}",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=body.starts_at,
ends_at=body.ends_at,
status="reserved",
start_odometer_km=None,
end_odometer_km=None,
requirements_complete=body.requirements_complete,
)
db.add(booking)
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_created",
entity_type="booking",
entity_id=booking.id,
after={"public_ref": booking.public_ref, "vehicle_ref": vehicle.public_ref},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult)
def checkout_booking(
public_ref: str,
body: CheckoutBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> CheckoutBookingResult:
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
raise HTTPException(status_code=409, detail="Only a reserved booking can be checked out")
if not booking.requirements_complete:
raise HTTPException(status_code=409, detail="Booking requirements are incomplete")
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
if vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing vehicle")
if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}:
raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout")
active_conflict = db.scalar(
select(Booking.id).where(
Booking.vehicle_id == vehicle.id,
Booking.status == "active",
Booking.id != booking.id,
)
)
if active_conflict is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an active booking")
attention_reasons: list[str] = []
if body.start_odometer_km < vehicle.odometer_km:
attention_reasons.append("odometer_regression")
if not body.cleanliness_ok:
attention_reasons.append("cleanliness")
if body.damage_reported:
attention_reasons.append("damage")
if body.technical_warning:
attention_reasons.append("technical_warning")
inspection = Inspection(
public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}",
booking_id=booking.id,
vehicle_id=vehicle.id,
type="checkout",
fuel_level_percent=body.fuel_level_percent,
cleanliness_ok=body.cleanliness_ok,
damage_reported=body.damage_reported,
technical_warning=body.technical_warning,
notes=body.notes,
odometer_km=body.start_odometer_km,
completed_at=datetime.now(UTC),
completed_by=user.display_name,
)
db.add(inspection)
if attention_reasons:
booking.status = "blocked"
vehicle.operational_status = (
"maintenance" if body.damage_reported or body.technical_warning else "cleaning"
)
else:
booking.status = "active"
booking.start_odometer_km = body.start_odometer_km
vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km)
vehicle.operational_status = "rented"
vehicle.version += 1
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_checkout_recorded",
entity_type="booking",
entity_id=booking.id,
after={
"inspection_ref": inspection.public_ref,
"booking_status": booking.status,
"vehicle_status": vehicle.operational_status,
"attention_reasons": attention_reasons,
},
)
db.commit()
return CheckoutBookingResult(
booking_ref=booking.public_ref,
vehicle_ref=vehicle.public_ref,
inspection_ref=inspection.public_ref,
booking_status=booking.status,
resulting_vehicle_status=vehicle.operational_status,
activated=booking.status == "active",
attention_reasons=attention_reasons,
)
@router.get("/availability", response_model=list[AvailableVehicleOut])
def list_available_vehicles(
starts_at: datetime,
ends_at: datetime,
query: str | None = Query(default=None, max_length=100),
limit: int = Query(default=25, ge=1, le=50),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[AvailableVehicleOut]:
if ends_at <= starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
overlapping_vehicle_ids = select(Booking.vehicle_id).where(
Booking.status.in_(("reserved", "active")),
Booking.starts_at < ends_at,
Booking.ends_at > starts_at,
)
stmt = (
select(Vehicle)
.where(
Vehicle.active.is_(True),
Vehicle.operational_status.not_in(("maintenance", "blocked")),
Vehicle.id.not_in(overlapping_vehicle_ids),
)
.order_by(Vehicle.location, Vehicle.public_ref)
.limit(limit)
)
if query and query.strip():
term = f"%{query.strip()}%"
stmt = stmt.where(
or_(
Vehicle.public_ref.ilike(term),
Vehicle.make.ilike(term),
Vehicle.model.ilike(term),
Vehicle.registration_number.ilike(term),
Vehicle.location.ilike(term),
)
)
return [
AvailableVehicleOut(
public_ref=vehicle.public_ref,
make=vehicle.make,
model=vehicle.model,
registration_number=vehicle.registration_number,
location=vehicle.location,
operational_status=vehicle.operational_status,
)
for vehicle in db.scalars(stmt).all()
]
@router.get("/{public_ref}", response_model=BookingOut) @router.get("/{public_ref}", response_model=BookingOut)
@@ -72,6 +354,126 @@ def get_booking(
return _to_out(booking, customer, vehicle) return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/complete-requirements", response_model=BookingOut)
def complete_booking_requirements(
public_ref: str,
body: CompleteBookingRequirementsRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
raise HTTPException(
status_code=409,
detail="Requirements can only be confirmed for a reserved booking",
)
customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id)
if customer is None or vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing record")
if not booking.requirements_complete:
booking.requirements_complete = True
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_requirements_completed",
entity_type="booking",
entity_id=booking.id,
before={"requirements_complete": False},
after={"requirements_complete": True},
metadata={"confirmation": body.confirmation.strip()},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.patch("/{public_ref}/schedule", response_model=BookingOut)
def reschedule_booking(
public_ref: str,
body: RescheduleBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
if body.ends_at <= body.starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
raise HTTPException(status_code=409, detail="Only a reserved booking can be rescheduled")
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
customer = db.get(Customer, booking.customer_id)
if customer is None or vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing record")
overlap = db.scalar(
select(Booking.id).where(
Booking.vehicle_id == booking.vehicle_id,
Booking.id != booking.id,
Booking.status.in_(("reserved", "active")),
Booking.starts_at < body.ends_at,
Booking.ends_at > body.starts_at,
)
)
if overlap is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
before = {"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()}
booking.starts_at = body.starts_at
booking.ends_at = body.ends_at
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_rescheduled",
entity_type="booking",
entity_id=booking.id,
before=before,
after={"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()},
metadata={"reason": body.reason.strip()},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/cancel", response_model=BookingOut)
def cancel_booking(
public_ref: str,
body: CancelBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status not in ("reserved", "blocked"):
# A booking blocked at checkout (damage, technical warning, ...) has no other exit:
# it never became active, so it can neither be returned nor completed. Cancelling
# it (audited, with a reason) is the only way to close the file.
raise HTTPException(
status_code=409, detail="Only a reserved or blocked booking can be cancelled"
)
customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id)
if customer is None or vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing record")
before = {"status": booking.status}
booking.status = "cancelled"
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_cancelled",
entity_type="booking",
entity_id=booking.id,
before=before,
after={"status": booking.status, "reason": body.reason.strip()},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult) @router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult)
def preview_return( def preview_return(
public_ref: str, public_ref: str,
@@ -101,7 +503,7 @@ def preview_return(
) )
@router.post("/{public_ref}/return") @router.post("/{public_ref}/return", response_model=RegisterReturnResult)
def register_return( def register_return(
public_ref: str, public_ref: str,
body: RegisterReturnRequest, body: RegisterReturnRequest,
@@ -109,7 +511,7 @@ def register_return(
idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128), idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> dict: ) -> RegisterReturnResult:
status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user) status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user)
response.status_code = status_code response.status_code = status_code
return result return RegisterReturnResult(**result)
+41
View File
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.customer import Customer
from app.schemas import CurrentUser, CustomerOptionOut
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
@router.get("", response_model=list[CustomerOptionOut])
def search_customers(
query: str = Query(min_length=2, max_length=100),
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[CustomerOptionOut]:
term = f"%{query.strip()}%"
customers = db.scalars(
select(Customer)
.where(
Customer.merged_into_customer_id.is_(None),
or_(
Customer.public_ref.ilike(term),
Customer.first_name.ilike(term),
Customer.last_name.ilike(term),
Customer.email.ilike(term),
),
)
.order_by(Customer.last_name, Customer.first_name)
.limit(limit)
).all()
return [
CustomerOptionOut(
public_ref=customer.public_ref,
display_name=f"{customer.first_name} {customer.last_name}",
email=customer.email,
)
for customer in customers
]
+22 -9
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime from datetime import date, datetime
from typing import Literal from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
@@ -30,10 +31,20 @@ settings = get_settings()
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _local_tz() -> ZoneInfo:
return ZoneInfo(settings.demo_timezone)
def _today() -> date: def _today() -> date:
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor # Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting. # shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
return datetime.now(UTC).date() # Timestamps are stored in UTC but the operational day is the local (Europe/Brussels)
# calendar day, so a 23:30Z departure belongs to tomorrow's schedule in summer.
return datetime.now(_local_tz()).date()
def _local_date(value: datetime) -> date:
return value.astimezone(_local_tz()).date()
@router.get("", response_model=DashboardOut) @router.get("", response_model=DashboardOut)
@@ -97,25 +108,27 @@ 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", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, kind="departure",
booking_ref=b.public_ref,
vehicle_ref=vehicle_ref,
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", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, kind="return",
booking_ref=b.public_ref,
vehicle_ref=vehicle_ref,
scheduled_at=b.ends_at, scheduled_at=b.ends_at,
) )
) )
today_items.sort(key=lambda item: item.scheduled_at) today_items.sort(key=lambda item: item.scheduled_at)
recent = db.scalars( recent = db.scalars(select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)).all()
select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)
).all()
recent_automation = [ recent_automation = [
AutomationRunOut( AutomationRunOut(
event_id=str(r.event_id), event_id=str(r.event_id),
+128 -8
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import case, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
@@ -9,10 +11,13 @@ from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
ApplyRecommendedStatusRequest, ApplyRecommendedStatusRequest,
ApplyRecommendedStatusResult, ApplyRecommendedStatusResult,
BulkDataQualityWorkRequest,
BulkDataQualityWorkResult,
CurrentUser, CurrentUser,
DataQualityIssueDetailOut, DataQualityIssueDetailOut,
DataQualityIssueOut, DataQualityIssueOut,
@@ -26,6 +31,7 @@ from app.schemas import (
StatusRecommendationOut, StatusRecommendationOut,
VehicleStatusFactsOut, VehicleStatusFactsOut,
) )
from app.services.audit import record_audit_event
from app.services.data_quality import ( from app.services.data_quality import (
apply_recommended_status, apply_recommended_status,
defer_issue, defer_issue,
@@ -42,6 +48,7 @@ router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut: def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
assignee = issue.assigned_to_user
return DataQualityIssueOut( return DataQualityIssueOut(
public_ref=issue.public_ref, public_ref=issue.public_ref,
rule_type=issue.rule_type, rule_type=issue.rule_type,
@@ -51,6 +58,12 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
status=issue.status, status=issue.status,
evidence=issue.evidence_json, evidence=issue.evidence_json,
detected_at=issue.detected_at, detected_at=issue.detected_at,
due_at=issue.due_at,
assigned_to_ref=assignee.public_ref if assignee else None,
assigned_to_name=assignee.display_name if assignee else None,
overdue=(
issue.status == "open" and issue.due_at is not None and issue.due_at < datetime.now(UTC)
),
resolved_at=issue.resolved_at, resolved_at=issue.resolved_at,
) )
@@ -60,18 +73,45 @@ def list_issues(
status: str | None = Query(default=None), status: str | None = Query(default=None),
rule_type: str | None = Query(default=None), rule_type: str | None = Query(default=None),
severity: str | None = Query(default=None), severity: str | None = Query(default=None),
assigned_to_ref: str | None = Query(default=None),
overdue: bool | None = Query(default=None),
demo_only: bool | None = Query(default=None),
page: int | None = Query(default=None, ge=1), page: 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),
_user: CurrentUser = Depends(require_operations_manager), _user: CurrentUser = Depends(require_operations_manager),
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut: ) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc()) severity_order = case(
(DataQualityIssue.severity == "high", 0),
(DataQualityIssue.severity == "medium", 1),
else_=2,
)
stmt = select(DataQualityIssue).order_by(
DataQualityIssue.due_at.asc().nulls_last(),
severity_order,
DataQualityIssue.detected_at.desc(),
)
if status: if status:
stmt = stmt.where(DataQualityIssue.status == status) stmt = stmt.where(DataQualityIssue.status == status)
if rule_type: if rule_type:
stmt = stmt.where(DataQualityIssue.rule_type == rule_type) stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
if severity: if severity:
stmt = stmt.where(DataQualityIssue.severity == severity) stmt = stmt.where(DataQualityIssue.severity == severity)
if assigned_to_ref == "unassigned":
stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None))
elif assigned_to_ref:
stmt = stmt.join(DataQualityIssue.assigned_to_user).where(
User.public_ref == assigned_to_ref
)
if overdue is True:
stmt = stmt.where(
DataQualityIssue.status == "open",
DataQualityIssue.due_at < datetime.now(UTC),
)
if demo_only is True:
# Server-side so the guided demo scenarios are found on any page, not only the
# 25 rows currently loaded in the browser.
stmt = stmt.where(DataQualityIssue.public_ref.like("DQ-DEMO-%"))
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 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(
@@ -90,6 +130,90 @@ def list_issues(
) )
@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult)
def update_issue_work_queue(
body: BulkDataQualityWorkRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> BulkDataQualityWorkResult:
refs = list(dict.fromkeys(body.issue_refs))
if (
body.assigned_to_ref is None
and not body.clear_assignment
and body.due_at is None
and not body.clear_due_at
):
raise HTTPException(status_code=422, detail="No work queue change was requested")
if body.assigned_to_ref is not None and body.clear_assignment:
raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment")
if body.due_at is not None and body.clear_due_at:
raise HTTPException(status_code=422, detail="Choose a due date or clear the due date")
if body.due_at is not None and body.due_at.tzinfo is None:
raise HTTPException(status_code=422, detail="Due date must include a timezone")
assignee = None
if body.assigned_to_ref is not None:
assignee = db.scalar(
select(User).where(
User.public_ref == body.assigned_to_ref,
User.active.is_(True),
)
)
if assignee is None:
raise HTTPException(status_code=422, detail="Active assignee not found")
issues = list(
db.scalars(
select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update()
).all()
)
if len(issues) != len(refs):
found = {issue.public_ref for issue in issues}
missing = next(ref for ref in refs if ref not in found)
raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found")
for issue in issues:
if issue.status != "open":
raise HTTPException(
status_code=409,
detail=f"Data quality issue {issue.public_ref} is not open",
)
before = {
"assigned_to_ref": issue.assigned_to_user.public_ref
if issue.assigned_to_user
else None,
"due_at": issue.due_at.isoformat() if issue.due_at else None,
}
if body.assigned_to_ref is not None:
issue.assigned_to_user = assignee
elif body.clear_assignment:
issue.assigned_to_user = None
if body.due_at is not None:
issue.due_at = body.due_at
elif body.clear_due_at:
issue.due_at = None
after = {
"assigned_to_ref": assignee.public_ref
if body.assigned_to_ref is not None and assignee
else (None if body.clear_assignment else before["assigned_to_ref"]),
"due_at": issue.due_at.isoformat() if issue.due_at else None,
}
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="data_quality_work_updated",
entity_type="data_quality_issue",
entity_id=issue.id,
before=before,
after=after,
)
db.commit()
for issue in issues:
db.refresh(issue)
return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue in issues])
# Every public reference in this system carries its entity type in its own prefix # Every public reference in this system carries its entity type in its own prefix
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself, # (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are # not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
@@ -239,9 +363,7 @@ def provide_fields(
return _to_out(issue) return _to_out(issue)
@router.post( @router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
)
def resolve_odometer( def resolve_odometer(
public_ref: str, public_ref: str,
body: ResolveOdometerRegressionRequest, body: ResolveOdometerRegressionRequest,
@@ -263,9 +385,7 @@ def resolve_overlap(
return _to_out(issue) return _to_out(issue)
@router.post( @router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
)
def status_recommendation( def status_recommendation(
public_ref: str, public_ref: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
+37 -6
View File
@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import threading
import time import time
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db, require_operations_manager from app.api.deps import get_current_user, get_db, require_operations_manager
@@ -15,13 +16,19 @@ from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
from app.seed_loader import reset_and_seed from app.seed_loader import reset_and_seed
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings() settings = get_settings()
_reset_guard = threading.Lock()
_last_reset_monotonic = 0.0
_RESET_ADVISORY_LOCK_ID = 706_533_149
@router.get("/manifest", response_model=DemoManifestOut) @router.get("/manifest", response_model=DemoManifestOut)
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut: def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
if not settings.mobilityops_demo_mode:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge # Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
# both need this before any session exists. Nothing here is sensitive — it's the same # both need this before any session exists. Nothing here is sensitive — it's the same
# honest "what is this demo" summary a logged-in user would see. # honest "what is this demo" summary a logged-in user would see.
@@ -32,6 +39,8 @@ def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
def demo_login( def demo_login(
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
) -> CurrentUser: ) -> CurrentUser:
if not settings.mobilityops_demo_mode:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP" public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP"
user = db.scalar(select(User).where(User.public_ref == public_ref)) user = db.scalar(select(User).where(User.public_ref == public_ref))
if user is None: if user is None:
@@ -44,6 +53,7 @@ def demo_login(
role=user.role, role=user.role,
display_name=user.display_name, display_name=user.display_name,
issued_at=int(time.time()), issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
) )
) )
response.set_cookie( response.set_cookie(
@@ -68,9 +78,7 @@ def demo_login(
@router.get("/session", response_model=CurrentUser) @router.get("/session", response_model=CurrentUser)
def get_session( def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
response: Response, user: CurrentUser = Depends(get_current_user)
) -> CurrentUser:
# Never let the browser (or an intermediary) cache an authentication check — a stale # Never let the browser (or an intermediary) cache an authentication check — a stale
# cached 200 here would keep showing a logged-out browser as authenticated. # cached 200 here would keep showing a logged-out browser as authenticated.
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
@@ -81,7 +89,8 @@ def get_session(
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict: def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name) token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None payload = read_session_token(token) if token else None
if payload is not None: if payload is not None and token is not None:
revoke_session(db, token, payload)
record_audit_event( record_audit_event(
db, db,
actor_type="user", actor_type="user",
@@ -101,12 +110,31 @@ def demo_reset(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager), user: CurrentUser = Depends(require_operations_manager),
) -> dict: ) -> dict:
global _last_reset_monotonic
if not settings.mobilityops_demo_mode:
# Outside demo mode the reset endpoint must not exist at all: it wipes
# operational data and replaces it with synthetic records.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
if not settings.demo_allow_reset: if not settings.demo_allow_reset:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Demo reset is disabled on this deployment.", detail="Demo reset is disabled on this deployment.",
) )
result = reset_and_seed(db) if not _reset_guard.acquire(blocking=False):
raise HTTPException(status_code=409, detail="A demo reset is already running.")
try:
elapsed = time.monotonic() - _last_reset_monotonic
if _last_reset_monotonic and elapsed < settings.demo_reset_cooldown_seconds:
retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999))
raise HTTPException(
status_code=429,
detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.",
headers={"Retry-After": str(retry_after)},
)
locked = db.scalar(select(func.pg_try_advisory_xact_lock(_RESET_ADVISORY_LOCK_ID)))
if not locked:
raise HTTPException(status_code=409, detail="A demo reset is already running.")
result = reset_and_seed(db, preserve_integration_telemetry=True)
integrity = scenario_integrity_report(db) integrity = scenario_integrity_report(db)
record_audit_event( record_audit_event(
db, db,
@@ -121,6 +149,9 @@ def demo_reset(
}, },
) )
db.commit() db.commit()
_last_reset_monotonic = time.monotonic()
finally:
_reset_guard.release()
response.delete_cookie(settings.session_cookie_name) response.delete_cookie(settings.session_cookie_name)
return { return {
"status": "reset", "status": "reset",
+71 -16
View File
@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
import hmac
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Header from fastapi import APIRouter, Depends, Header
from sqlalchemy import select from sqlalchemy import select
@@ -15,10 +15,13 @@ from app.core.errors import AppError
from app.models.audit import AuditEvent from app.models.audit import AuditEvent
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent
from app.schemas import ( from app.schemas import (
N8nHeartbeatIn,
N8nHeartbeatResult,
ProcedureDocumentOut, ProcedureDocumentOut,
ProcedureListOut, ProcedureListOut,
ProcedureSyncResultIn, ProcedureSyncResultIn,
ProcedureSyncResultResult, ProcedureSyncResultResult,
ReturnCallbackIn,
ScanResultOut, ScanResultOut,
WorkflowErrorReportIn, WorkflowErrorReportIn,
WorkflowErrorReportResult, WorkflowErrorReportResult,
@@ -30,16 +33,74 @@ from app.services.knowledge.procedures import iter_procedure_documents
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"]) router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
settings = get_settings() settings = get_settings()
_CANONICAL_WORKFLOW_NAMES = frozenset(
{
"Fleet Ops — Vehicle Return Orchestration",
"Fleet Ops — Scheduled Data Quality Scan",
"Fleet Ops — RAGcore Procedure Sync",
"Fleet Ops — Workflow Error Handler",
}
)
def _require_service_token(service_token: str) -> None:
# Constant-time comparison: a plain ``!=`` leaks how many leading bytes matched.
if not hmac.compare_digest(
service_token.encode("utf-8"), settings.n8n_callback_token.encode("utf-8")
):
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
def workflow_heartbeat(
body: N8nHeartbeatIn,
service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db),
) -> N8nHeartbeatResult:
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
_require_service_token(service_token)
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
already_recorded = (
db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "n8n_workflow_heartbeat",
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
AuditEvent.after_json["status"].astext == body.status,
)
)
is not None
)
if not already_recorded:
record_audit_event(
db,
actor_type="service",
actor_label="n8n workflow heartbeat",
action="n8n_workflow_heartbeat",
entity_type="automation",
after={
"workflow_id": body.workflow_id,
"workflow_name": body.workflow_name,
"status": body.status,
},
metadata={"execution_id": body.execution_id},
)
db.commit()
return N8nHeartbeatResult(
status="already_registered" if already_recorded else "registered",
execution_id=body.execution_id,
occurred_at=datetime.now(UTC),
)
@router.post("/return-callback") @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)
@@ -70,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()
@@ -94,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)
@@ -111,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(
@@ -164,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(
@@ -191,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(
+110 -2
View File
@@ -1,18 +1,32 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import uuid import uuid
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session 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.ratelimit import SlidingWindowLimiter
from app.models.audit import AuditEvent
from app.schemas import CurrentUser from app.schemas import CurrentUser
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
settings = get_settings()
_question_limiter = (
SlidingWindowLimiter(
max_requests=settings.knowledge_max_requests,
window_seconds=settings.knowledge_rate_limit_window_seconds,
)
if settings.knowledge_max_requests > 0
else None
)
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"] SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
@@ -22,12 +36,37 @@ class AskQuestionRequest(BaseModel):
language: SupportedLanguage = "en-GB" language: SupportedLanguage = "en-GB"
class KnowledgeFeedbackRequest(BaseModel):
correlation_id: uuid.UUID
helpful: bool
@router.post("/questions", response_model=GroundedAnswer) @router.post("/questions", response_model=GroundedAnswer)
def ask_question( def ask_question(
body: AskQuestionRequest, body: AskQuestionRequest,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> GroundedAnswer: ) -> GroundedAnswer:
if _question_limiter is not None:
forwarded = request.headers.get("x-forwarded-for", "")
client_ip = (
forwarded.split(",")[-1].strip()
if forwarded
else request.client.host if request.client else "unknown"
)
token = request.cookies.get(settings.session_cookie_name, "")
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
retry_after = max(
_question_limiter.consume(f"ip:{client_ip}"),
_question_limiter.consume(f"session:{session_key}"),
)
if retry_after:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many knowledge questions. Try again later.",
headers={"Retry-After": str(retry_after)},
)
correlation_id = str(uuid.uuid4()) correlation_id = str(uuid.uuid4())
provider = get_knowledge_provider() provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id, body.language) answer = provider.ask(body.question, correlation_id, body.language)
@@ -51,9 +90,78 @@ def ask_question(
return answer return answer
@router.post("/feedback")
def record_feedback(
body: KnowledgeFeedbackRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict[str, str]:
question_event = db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "knowledge_question_asked",
AuditEvent.correlation_id == body.correlation_id,
AuditEvent.actor_label == user.display_name,
)
)
if question_event is None:
raise HTTPException(status_code=404, detail="Knowledge exchange not found")
existing = db.scalar(
select(AuditEvent).where(
AuditEvent.action == "knowledge_feedback_recorded",
AuditEvent.correlation_id == body.correlation_id,
AuditEvent.actor_label == user.display_name,
)
)
if existing is not None:
existing.metadata_json = {"helpful": body.helpful}
else:
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="knowledge_feedback_recorded",
entity_type="knowledge",
correlation_id=body.correlation_id,
metadata={"helpful": body.helpful},
)
db.commit()
return {"status": "recorded"}
@router.get("/status", response_model=KnowledgeHealth) @router.get("/status", response_model=KnowledgeHealth)
def knowledge_status( def knowledge_status(
language: SupportedLanguage = "en-GB", language: SupportedLanguage = "en-GB",
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
) -> KnowledgeHealth: ) -> KnowledgeHealth:
return get_knowledge_provider().health(language) health = get_knowledge_provider().health(language)
if health.provider != "ragcore":
return health
latest_sync = db.scalar(
select(AuditEvent)
.where(AuditEvent.action == "n8n_procedures_synced")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
)
if latest_sync is None:
return health
reported = latest_sync.after_json or {}
synced = reported.get("synced")
failed = reported.get("failed")
return health.model_copy(
update={
"reported_synced_document_count": synced if isinstance(synced, int) else None,
"reported_failed_document_count": failed if isinstance(failed, int) else None,
"last_sync_at": latest_sync.occurred_at,
# A persisted sync callback is useful additional provenance, but must not
# downgrade stronger provider-side verification to merely "reported".
"statistics_state": (
health.statistics_state
if health.statistics_state == "verified"
else "sync_reported"
),
}
)
+52 -15
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
import uuid import uuid
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, Header, Query from fastapi import APIRouter, Depends, Header, Query, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_mcp_service_token from app.api.deps import McpClientContext, get_db, require_mcp_service_token
from app.core.config import get_settings from app.core.config import get_settings
from app.core.errors import AppError from app.core.errors import AppError
from app.models.booking import Booking from app.models.booking import Booking
@@ -42,52 +42,76 @@ def get_correlation_id(
def _audit_service_request( def _audit_service_request(
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str db: Session,
*,
reported_client_id: str,
tool: str,
status_label: str,
correlation_id: str,
metadata: dict[str, object] | None = None,
) -> None: ) -> None:
record_audit_event( record_audit_event(
db, db,
actor_type="service", actor_type="service",
actor_label=client_id, # The shared service token authenticates the Hub, not the caller identity that
# the Hub reports in a header. Keep attribution authoritative and retain the
# reported value only as explicitly non-authenticated diagnostic metadata.
actor_label="itworx-mcp-hub",
action="mcp_tool_request", action="mcp_tool_request",
entity_type="mcp_tool", entity_type="mcp_tool",
correlation_id=uuid.UUID(correlation_id), correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label}, metadata={
"tool": tool,
"status": status_label,
"reported_client_id": reported_client_id,
**(metadata or {}),
},
) )
db.commit() db.commit()
def _set_trace_headers(response: Response, correlation_id: str, tenant: str) -> None:
response.headers["X-Correlation-Id"] = correlation_id
response.headers["X-Tenant-Id"] = tenant
response.headers["Cache-Control"] = "no-store"
@router.get("/operations-summary", response_model=OperationsSummaryOut) @router.get("/operations-summary", response_model=OperationsSummaryOut)
def operations_summary( def operations_summary(
response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client: McpClientContext = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id), correlation_id: str = Depends(get_correlation_id),
) -> OperationsSummaryOut: ) -> OperationsSummaryOut:
metrics = compute_metrics(db) metrics = compute_metrics(db)
_set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_get_operations_summary", tool="fleet_ops_get_operations_summary",
status_label="ok", status_label="ok",
correlation_id=correlation_id, correlation_id=correlation_id,
) )
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics) return OperationsSummaryOut(tenant=client.tenant, metrics=metrics)
@router.get("/attention-vehicles", response_model=list[AttentionVehicleOut]) @router.get("/attention-vehicles", response_model=list[AttentionVehicleOut])
def attention_vehicles( def attention_vehicles(
response: Response,
minimum_severity: str = Query(default="medium", pattern="^(low|medium|high)$"), minimum_severity: str = Query(default="medium", pattern="^(low|medium|high)$"),
date_filter: date | None = Query(default=None, alias="date"), date_filter: date | None = Query(default=None, alias="date"),
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client: McpClientContext = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id), correlation_id: str = Depends(get_correlation_id),
) -> list[AttentionVehicleOut]: ) -> list[AttentionVehicleOut]:
results = list_attention_vehicles( results = list_attention_vehicles(
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
) )
_set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_list_attention_vehicles", tool="fleet_ops_list_attention_vehicles",
status_label="ok", status_label="ok",
correlation_id=correlation_id, correlation_id=correlation_id,
@@ -98,15 +122,17 @@ def attention_vehicles(
@router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut) @router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut)
def vehicle_details( def vehicle_details(
vehicle_ref: str, vehicle_ref: str,
response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client: McpClientContext = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id), correlation_id: str = Depends(get_correlation_id),
) -> McpVehicleDetailOut: ) -> McpVehicleDetailOut:
_set_trace_headers(response, correlation_id, client.tenant)
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:
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details", tool="fleet_ops_get_vehicle_details",
status_label="not_found", status_label="not_found",
correlation_id=correlation_id, correlation_id=correlation_id,
@@ -128,7 +154,7 @@ def vehicle_details(
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details", tool="fleet_ops_get_vehicle_details",
status_label="ok", status_label="ok",
correlation_id=correlation_id, correlation_id=correlation_id,
@@ -150,18 +176,29 @@ def vehicle_details(
@router.post("/search-knowledge", response_model=GroundedAnswer) @router.post("/search-knowledge", response_model=GroundedAnswer)
def search_knowledge( def search_knowledge(
body: McpKnowledgeSearchRequest, body: McpKnowledgeSearchRequest,
response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client: McpClientContext = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id), correlation_id: str = Depends(get_correlation_id),
) -> GroundedAnswer: ) -> GroundedAnswer:
provider = get_knowledge_provider() provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id, language=body.locale) answer = provider.ask(body.question, correlation_id, language=body.locale)
source_count_available = len(answer.sources)
answer.sources = answer.sources[: body.max_sources] answer.sources = answer.sources[: body.max_sources]
_set_trace_headers(response, correlation_id, client.tenant)
response.headers["X-Sources-Available"] = str(source_count_available)
response.headers["X-Sources-Returned"] = str(len(answer.sources))
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, reported_client_id=client.reported_client_id,
tool="fleet_ops_search_knowledge", tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state, status_label=answer.evidence_state,
correlation_id=correlation_id, correlation_id=correlation_id,
metadata={
"tenant": client.tenant,
"locale": body.locale,
"sources_available": source_count_available,
"sources_returned": len(answer.sources),
},
) )
return answer return answer
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import hmac
from fastapi import APIRouter, Header, HTTPException
from fastapi.responses import Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from sqlalchemy import func, select
from app.core.config import get_settings
from app.core.db import SessionLocal
from app.core.observability import OUTBOX_EVENTS
from app.models.outbox import DELIVERY_STATUSES, DEMO_SCENARIO_ERROR_CODE, OutboxEvent
router = APIRouter(tags=["observability"])
settings = get_settings()
def _refresh_database_metrics() -> None:
with SessionLocal() as db:
rows = db.execute(
select(
OutboxEvent.delivery_status,
(OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE).label("demo"),
func.count(),
).group_by(OutboxEvent.delivery_status, "demo")
).all()
OUTBOX_EVENTS.clear()
# Keep every time series present even when a state currently contains no rows.
# Stable zero-valued series make dashboards and alerts deterministic after resets,
# restores and fresh installations instead of turning "zero" into "no data".
for scenario in ("synthetic", "operational"):
for status in DELIVERY_STATUSES:
OUTBOX_EVENTS.labels(scenario, status).set(0)
for status, is_demo, count in rows:
OUTBOX_EVENTS.labels("synthetic" if is_demo else "operational", str(status)).set(count)
@router.get("/metrics", include_in_schema=False)
def metrics(authorization: str | None = Header(default=None)) -> Response:
if settings.metrics_bearer_token:
supplied = authorization.removeprefix("Bearer ") if authorization else ""
if not hmac.compare_digest(supplied, settings.metrics_bearer_token):
raise HTTPException(status_code=401, detail="Metrics token required")
_refresh_database_metrics()
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings
from app.models.booking import Booking
from app.models.customer import Customer
from app.schemas import (
CurrentUser,
CustomerAnonymizeRequest,
CustomerAnonymizeResult,
PrivacyRetentionOut,
)
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/privacy", tags=["privacy"])
settings = get_settings()
def _retention_cutoff() -> datetime:
return datetime.now(UTC) - timedelta(days=settings.privacy_minimum_booking_retention_days)
def _customer_is_eligible(db: Session, customer_id) -> bool:
blocking = db.scalar(
select(func.count())
.select_from(Booking)
.where(
Booking.customer_id == customer_id,
or_(
Booking.status.in_(("reserved", "active")),
Booking.ends_at > _retention_cutoff(),
),
)
)
return not blocking
@router.get("/retention", response_model=PrivacyRetentionOut)
def retention_status(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
) -> PrivacyRetentionOut:
customers = db.scalars(select(Customer)).all()
return PrivacyRetentionOut(
minimum_booking_retention_days=settings.privacy_minimum_booking_retention_days,
audit_retention_days=settings.privacy_audit_retention_days,
customers_total=len(customers),
customers_anonymized=sum(customer.anonymized_at is not None for customer in customers),
customers_eligible=sum(
customer.anonymized_at is None and _customer_is_eligible(db, customer.id)
for customer in customers
),
)
@router.get("/customers/{public_ref}/export")
def export_customer_data(
public_ref: str,
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> JSONResponse:
customer = db.scalar(select(Customer).where(Customer.public_ref == public_ref))
if customer is None:
raise HTTPException(status_code=404, detail="Customer not found")
bookings = db.scalars(
select(Booking).where(Booking.customer_id == customer.id).order_by(Booking.starts_at)
).all()
payload = {
"generated_at": datetime.now(UTC).isoformat(),
"customer": {
"public_ref": customer.public_ref,
"first_name": customer.first_name,
"last_name": customer.last_name,
"email": customer.email,
"phone": customer.phone,
"postal_code": customer.postal_code,
"city": customer.city,
"date_of_birth": customer.date_of_birth.isoformat() if customer.date_of_birth else None,
"anonymized_at": customer.anonymized_at.isoformat() if customer.anonymized_at else None,
},
"bookings": [
{
"public_ref": booking.public_ref,
"starts_at": booking.starts_at.isoformat(),
"ends_at": booking.ends_at.isoformat(),
"status": booking.status,
}
for booking in bookings
],
}
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="privacy_customer_exported",
entity_type="customer",
entity_id=customer.id,
metadata={"customer_ref": customer.public_ref, "booking_count": len(bookings)},
)
db.commit()
return JSONResponse(
payload,
headers={"Content-Disposition": f'attachment; filename="{public_ref}-privacy.json"'},
)
@router.post("/customers/{public_ref}/anonymize", response_model=CustomerAnonymizeResult)
def anonymize_customer(
public_ref: str,
body: CustomerAnonymizeRequest,
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> CustomerAnonymizeResult:
customer = db.scalar(
select(Customer).where(Customer.public_ref == public_ref).with_for_update()
)
if customer is None:
raise HTTPException(status_code=404, detail="Customer not found")
if body.confirmation != public_ref:
raise HTTPException(
status_code=422, detail="Customer reference confirmation does not match"
)
if customer.anonymized_at is not None:
return CustomerAnonymizeResult(
public_ref=public_ref,
anonymized_at=customer.anonymized_at,
status="already_anonymized",
)
if not _customer_is_eligible(db, customer.id):
raise HTTPException(
status_code=409,
detail="Customer has an active/recent booking within the minimum retention period",
)
anonymized_at = datetime.now(UTC)
customer.first_name = "Anoniem"
customer.last_name = public_ref
customer.email = None
customer.phone = None
customer.postal_code = None
customer.city = None
customer.date_of_birth = None
customer.anonymized_at = anonymized_at
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="privacy_customer_anonymized",
entity_type="customer",
entity_id=customer.id,
before={"anonymized": False},
after={"anonymized": True},
metadata={"reason": body.reason, "customer_ref": public_ref},
)
db.commit()
return CustomerAnonymizeResult(
public_ref=public_ref,
anonymized_at=anonymized_at,
status="anonymized",
)
+10 -1
View File
@@ -89,6 +89,12 @@ _SECTIONS: list[dict] = [
"terms": ["audit", "history", "geschiedenis", "historique"], "terms": ["audit", "history", "geschiedenis", "historique"],
"role": "operations_manager", "role": "operations_manager",
}, },
{
"id": "privacy",
"link": "/privacy",
"terms": ["privacy", "retention", "anonymise", "anonimiseren", "confidentialité"],
"role": "operations_manager",
},
] ]
@@ -143,7 +149,10 @@ def search(
) )
for b in db.scalars( for b in db.scalars(
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5) select(Booking)
.where(Booking.public_ref.ilike(like))
.order_by(Booking.starts_at.desc())
.limit(5)
).all(): ).all():
results.append( results.append(
SearchResultItem( SearchResultItem(
+100
View File
@@ -0,0 +1,100 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.security import hash_password
from app.models.user import User
from app.schemas import CreateUserRequest, CurrentUser, UpdateUserRequest, UserOut
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/users", tags=["users"])
def _to_out(user: User) -> UserOut:
return UserOut(
public_ref=user.public_ref,
email=user.email,
display_name=user.display_name,
role=user.role, # type: ignore[arg-type]
active=user.active,
)
@router.get("", response_model=list[UserOut])
def list_users(
db: Session = Depends(get_db),
_manager: CurrentUser = Depends(require_operations_manager),
) -> list[UserOut]:
return [_to_out(user) for user in db.scalars(select(User).order_by(User.display_name)).all()]
@router.post("", response_model=UserOut, status_code=201)
def create_user(
body: CreateUserRequest,
db: Session = Depends(get_db),
manager: CurrentUser = Depends(require_operations_manager),
) -> UserOut:
email = body.email.strip().lower()
if db.scalar(select(User.id).where(User.email == email)) is not None:
raise HTTPException(status_code=409, detail="A user with this email already exists")
user = User(
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
email=email,
password_hash=hash_password(body.password),
display_name=body.display_name.strip(),
role=body.role,
active=True,
)
db.add(user)
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=manager.display_name,
action="user_created",
entity_type="user",
entity_id=user.id,
after={"public_ref": user.public_ref, "role": user.role, "active": user.active},
)
db.commit()
return _to_out(user)
@router.patch("/{public_ref}", response_model=UserOut)
def update_user(
public_ref: str,
body: UpdateUserRequest,
db: Session = Depends(get_db),
manager: CurrentUser = Depends(require_operations_manager),
) -> UserOut:
user = db.scalar(select(User).where(User.public_ref == public_ref).with_for_update())
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if user.public_ref == manager.public_ref and body.active is False:
raise HTTPException(status_code=409, detail="You cannot deactivate your own account")
if user.public_ref == manager.public_ref and body.role not in (None, "operations_manager"):
raise HTTPException(status_code=409, detail="You cannot remove your own manager role")
before = {"display_name": user.display_name, "role": user.role, "active": user.active}
if body.display_name is not None:
user.display_name = body.display_name.strip()
if body.role is not None:
user.role = body.role
if body.active is not None:
user.active = body.active
if body.password is not None:
user.password_hash = hash_password(body.password)
record_audit_event(
db,
actor_type="user",
actor_label=manager.display_name,
action="user_updated",
entity_type="user",
entity_id=user.id,
before=before,
after={"display_name": user.display_name, "role": user.role, "active": user.active},
)
db.commit()
return _to_out(user)
+183 -4
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session 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, require_operations_manager
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
@@ -13,14 +16,17 @@ from app.models.maintenance import MaintenanceRecord
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
BookingSummaryOut, BookingSummaryOut,
CreateMaintenanceRequest,
CurrentUser, CurrentUser,
DataQualityIssueOut, DataQualityIssueOut,
InspectionOut, InspectionOut,
MaintenanceOut, MaintenanceOut,
ReleaseVehicleRequest,
VehicleDetailOut, VehicleDetailOut,
VehicleOut, VehicleOut,
VehiclePageOut, VehiclePageOut,
) )
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
@@ -39,6 +45,7 @@ def _attention_vehicle_ids(db: Session) -> set:
def list_vehicles( def list_vehicles(
status: str | None = Query(default=None), status: str | None = Query(default=None),
attention_only: bool = Query(default=False), attention_only: bool = Query(default=False),
location: str | None = Query(default=None, min_length=1, max_length=120),
query: str | None = Query(default=None, min_length=1, max_length=100), query: str | None = Query(default=None, min_length=1, max_length=100),
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),
@@ -48,6 +55,8 @@ def list_vehicles(
stmt = select(Vehicle).order_by(Vehicle.public_ref) stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status: if status:
stmt = stmt.where(Vehicle.operational_status == status) stmt = stmt.where(Vehicle.operational_status == status)
if location:
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
if query: if query:
term = f"%{query.strip()}%" term = f"%{query.strip()}%"
stmt = stmt.where( stmt = stmt.where(
@@ -62,13 +71,30 @@ def list_vehicles(
attention_ids = _attention_vehicle_ids(db) attention_ids = _attention_vehicle_ids(db)
if attention_only: if attention_only:
stmt = stmt.where( stmt = stmt.where(
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked") or_(
Vehicle.id.in_(attention_ids),
Vehicle.operational_status == "blocked",
Vehicle.next_service_km <= Vehicle.odometer_km,
)
) )
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1 page_number = page or 1
vehicles = db.scalars( vehicles = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all() ).all()
vehicle_ids = [vehicle.id for vehicle in vehicles]
next_bookings: dict[uuid.UUID, Booking] = {}
if vehicle_ids:
for booking in db.scalars(
select(Booking)
.where(
Booking.vehicle_id.in_(vehicle_ids),
Booking.status == "reserved",
Booking.starts_at >= datetime.now(UTC),
)
.order_by(Booking.starts_at.asc())
).all():
next_bookings.setdefault(booking.vehicle_id, booking)
items = [ items = [
VehicleOut( VehicleOut(
public_ref=v.public_ref, public_ref=v.public_ref,
@@ -81,7 +107,23 @@ def list_vehicles(
odometer_km=v.odometer_km, odometer_km=v.odometer_km,
next_service_km=v.next_service_km, next_service_km=v.next_service_km,
active=v.active, active=v.active,
attention=v.id in attention_ids or v.operational_status == "blocked", attention=(
v.id in attention_ids
or v.operational_status == "blocked"
or v.next_service_km <= v.odometer_km
),
attention_reason=(
"blocked_status"
if v.operational_status == "blocked"
else "service_due"
if v.next_service_km <= v.odometer_km
else "data_quality"
if v.id in attention_ids
else None
),
service_remaining_km=v.next_service_km - v.odometer_km,
next_booking_ref=(next_bookings[v.id].public_ref if v.id in next_bookings else None),
next_booking_at=(next_bookings[v.id].starts_at if v.id in next_bookings else None),
) )
for v in vehicles for v in vehicles
] ]
@@ -128,6 +170,14 @@ def get_vehicle(
).all() ).all()
booking_by_id = {b.id: b.public_ref for b in bookings} booking_by_id = {b.id: b.public_ref for b in bookings}
next_booking = next(
(
booking
for booking in sorted(bookings, key=lambda item: item.starts_at)
if booking.status == "reserved" and booking.starts_at >= datetime.now(UTC)
),
None,
)
attention_ids = _attention_vehicle_ids(db) attention_ids = _attention_vehicle_ids(db)
return VehicleDetailOut( return VehicleDetailOut(
@@ -141,7 +191,23 @@ def get_vehicle(
odometer_km=vehicle.odometer_km, odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km, next_service_km=vehicle.next_service_km,
active=vehicle.active, active=vehicle.active,
attention=vehicle.id in attention_ids or vehicle.operational_status == "blocked", attention=(
vehicle.id in attention_ids
or vehicle.operational_status == "blocked"
or vehicle.next_service_km <= vehicle.odometer_km
),
attention_reason=(
"blocked_status"
if vehicle.operational_status == "blocked"
else "service_due"
if vehicle.next_service_km <= vehicle.odometer_km
else "data_quality"
if vehicle.id in attention_ids
else None
),
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
next_booking_ref=next_booking.public_ref if next_booking else None,
next_booking_at=next_booking.starts_at if next_booking else None,
bookings=[ bookings=[
BookingSummaryOut( BookingSummaryOut(
public_ref=b.public_ref, public_ref=b.public_ref,
@@ -187,8 +253,121 @@ def get_vehicle(
status=q.status, status=q.status,
evidence=q.evidence_json, evidence=q.evidence_json,
detected_at=q.detected_at, detected_at=q.detected_at,
due_at=q.due_at,
assigned_to_ref=(q.assigned_to_user.public_ref if q.assigned_to_user else None),
assigned_to_name=(q.assigned_to_user.display_name if q.assigned_to_user else None),
overdue=(
q.status == "open" and q.due_at is not None and q.due_at < datetime.now(UTC)
),
resolved_at=q.resolved_at, resolved_at=q.resolved_at,
) )
for q in issues for q in issues
], ],
) )
@router.post("/{public_ref}/maintenance", response_model=MaintenanceOut, status_code=201)
def create_maintenance_record(
public_ref: str,
body: CreateMaintenanceRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> MaintenanceOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
record = MaintenanceRecord(
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
vehicle_id=vehicle.id,
occurred_at=body.occurred_at,
odometer_km=body.odometer_km,
category=body.category,
summary=body.summary.strip(),
)
db.add(record)
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
if body.next_service_km is not None:
if body.next_service_km < vehicle.odometer_km:
raise HTTPException(status_code=422, detail="Next service must not be below odometer")
vehicle.next_service_km = body.next_service_km
if body.mark_maintenance:
vehicle.operational_status = "maintenance"
vehicle.version += 1
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="maintenance_record_created",
entity_type="vehicle",
entity_id=vehicle.id,
after={"maintenance_ref": record.public_ref, "status": vehicle.operational_status},
)
db.commit()
return MaintenanceOut(
public_ref=record.public_ref,
occurred_at=record.occurred_at,
odometer_km=record.odometer_km,
category=record.category,
summary=record.summary,
)
@router.post("/{public_ref}/release", response_model=VehicleOut)
def release_vehicle(
public_ref: str,
body: ReleaseVehicleRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> VehicleOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}:
raise HTTPException(status_code=409, detail="Vehicle does not require release")
active_booking = db.scalar(
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
)
open_high_issue = db.scalar(
select(DataQualityIssue.id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.severity == "high",
)
)
if active_booking is not None or open_high_issue is not None:
raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition")
before = {"status": vehicle.operational_status}
vehicle.operational_status = "available"
vehicle.version += 1
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="vehicle_released",
entity_type="vehicle",
entity_id=vehicle.id,
before=before,
after={"status": "available", "reason": body.reason.strip()},
)
db.commit()
return VehicleOut(
public_ref=vehicle.public_ref,
make=vehicle.make,
model=vehicle.model,
model_year=vehicle.model_year,
registration_number=vehicle.registration_number,
location=vehicle.location,
operational_status=vehicle.operational_status,
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
active=vehicle.active,
attention=vehicle.next_service_km <= vehicle.odometer_km,
attention_reason=(
"service_due" if vehicle.next_service_km <= vehicle.odometer_km else None
),
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
next_booking_ref=None,
next_booking_at=None,
)
+70 -1
View File
@@ -23,6 +23,11 @@ class Settings(BaseSettings):
ragcore_api_token: str = "" ragcore_api_token: str = ""
ragcore_space_id: str = "" ragcore_space_id: str = ""
ragcore_http_timeout_seconds: float = 5.0 ragcore_http_timeout_seconds: float = 5.0
ragcore_answers_circuit_breaker_seconds: float = 60.0
# Search fallback is only labelled grounded above this explicit retrieval threshold.
# RAGcore's fused score is reciprocal-rank based (top ranks are ~1/61), so this
# accepts only leading results while still rejecting absent and low-ranked evidence.
ragcore_min_search_score: float = 0.016
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return" n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token" n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
n8n_callback_token: str = "replace-me-n8n-callback-token" n8n_callback_token: str = "replace-me-n8n-callback-token"
@@ -48,8 +53,72 @@ class Settings(BaseSettings):
demo_organization_name: str = "Northstar Mobility" demo_organization_name: str = "Northstar Mobility"
demo_timezone: str = "Europe/Brussels" demo_timezone: str = "Europe/Brussels"
demo_allow_reset: bool = True demo_allow_reset: bool = True
demo_reset_cooldown_seconds: int = 60
mcp_hub_health_cache_seconds: int = 60
initial_admin_email: str = ""
initial_admin_password: str = ""
initial_admin_display_name: str = "Operations Manager"
mobilityops_public_url: str = "http://localhost:1228"
oidc_enabled: bool = False
oidc_provider_name: str = "Organisatieaccount"
oidc_issuer_url: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_redirect_uri: str = ""
oidc_allowed_email_domains: str = ""
oidc_auto_provision: bool = True
oidc_default_role: str = "rental_employee"
log_level: str = "INFO"
# Failed password logins per client IP before a temporary 429 (0 disables).
login_max_failures: int = 10
login_failure_window_seconds: int = 900
knowledge_max_requests: int = 30
knowledge_rate_limit_window_seconds: int = 60
metrics_bearer_token: str = ""
privacy_minimum_booking_retention_days: int = 30
privacy_audit_retention_days: int = 2555
privacy_audit_export_max_rows: int = 10000
# Secrets that guard *inbound* trust (session cookies, service callbacks). Running
# production with any of these at their placeholder value means forged sessions or
# unauthenticated writes, so startup refuses.
INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = (
("app_secret", "replace-in-production"),
("n8n_callback_token", "replace-me-n8n-callback-token"),
("mcp_hub_service_token", "replace-me-mcp-hub-token"),
)
def insecure_default_secrets(settings: "Settings") -> list[str]:
"""Return the names of secret settings that still carry their placeholder value.
MCP routes are always mounted, independently of the Hub reachability-status flag, so
their inbound token must always be non-placeholder in production.
"""
insecure: list[str] = []
for name, placeholder in INSECURE_DEFAULT_SECRETS:
value = getattr(settings, name)
if not value or value == placeholder or value.startswith("replace-me"):
insecure.append(name)
return insecure
@lru_cache @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)."
)
if not settings.mobilityops_public_url.lower().startswith("https://"):
raise RuntimeError("Production MOBILITYOPS_PUBLIC_URL must use HTTPS.")
if not settings.session_cookie_secure:
raise RuntimeError("Production SESSION_COOKIE_SECURE must be true.")
return settings
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import json
import logging
import time
import uuid
from contextvars import ContextVar
from datetime import UTC, datetime
from fastapi import Request
from prometheus_client import Counter, Gauge, Histogram
correlation_id_context: ContextVar[str] = ContextVar("correlation_id", default="")
HTTP_REQUESTS = Counter(
"mobilityops_http_requests_total",
"Completed MobilityOps HTTP requests.",
("method", "route", "status"),
)
HTTP_DURATION = Histogram(
"mobilityops_http_request_duration_seconds",
"MobilityOps HTTP request duration.",
("method", "route"),
buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
)
HTTP_IN_PROGRESS = Gauge(
"mobilityops_http_requests_in_progress",
"MobilityOps HTTP requests currently executing.",
)
OUTBOX_EVENTS = Gauge(
"mobilityops_outbox_events",
"Persisted outbox events by state and scenario type.",
("scenario", "status"),
)
DATABASE_READY = Gauge(
"mobilityops_database_ready",
"Whether the canonical PostgreSQL database answered the most recent readiness probe.",
)
KNOWLEDGE_PROVIDER_REQUESTS = Counter(
"mobilityops_knowledge_provider_requests_total",
"RAGcore adapter requests by stage and outcome.",
("stage", "outcome"),
)
KNOWLEDGE_RETRIEVAL_SCORE = Histogram(
"mobilityops_knowledge_retrieval_score",
"Observed RAGcore fused/rerank retrieval scores.",
buckets=(0.005, 0.01, 0.015, 0.016, 0.0162, 0.0164, 0.02, 0.05, 0.1, 0.5, 1.0),
)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, object] = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname.lower(),
"logger": record.name,
"message": record.getMessage(),
}
correlation_id = correlation_id_context.get()
if correlation_id:
payload["correlation_id"] = correlation_id
for key in ("method", "path", "status_code", "duration_ms", "client_ip"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, separators=(",", ":"), default=str)
def configure_logging(level: str) -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(level.upper())
def correlation_id_for(request: Request) -> str:
candidate = request.headers.get("X-Correlation-Id", "").strip()
try:
return str(uuid.UUID(candidate)) if candidate else str(uuid.uuid4())
except ValueError:
return str(uuid.uuid4())
UNMATCHED_ROUTE_LABEL = "<unmatched>"
def route_label(request: Request) -> str:
"""Return the route *template* for metrics labels.
Unmatched paths (404 probes, scanners) must not become their own label value:
every distinct URL would otherwise create a new Prometheus time series and the
metric cardinality would grow without bound.
"""
route = request.scope.get("route")
path = getattr(route, "path", None)
return str(path) if path else UNMATCHED_ROUTE_LABEL
def request_started() -> float:
HTTP_IN_PROGRESS.inc()
return time.perf_counter()
def request_finished(request: Request, status_code: int, started_at: float) -> float:
duration = time.perf_counter() - started_at
route = route_label(request)
HTTP_REQUESTS.labels(request.method, route, str(status_code)).inc()
HTTP_DURATION.labels(request.method, route).observe(duration)
HTTP_IN_PROGRESS.dec()
return duration
+72
View File
@@ -0,0 +1,72 @@
"""Small in-process failed-attempt limiter for credential endpoints.
Fleet Ops runs as a single API process per deployment, so an in-memory sliding window
is sufficient to blunt online password guessing (and the scrypt CPU amplification that
comes with it) without adding Redis. Only *failed* attempts count, so legitimate users
and the automated test suite are never throttled.
"""
from __future__ import annotations
import threading
import time
from collections import deque
class FailedAttemptLimiter:
def __init__(self, *, max_failures: int, window_seconds: float) -> None:
self.max_failures = max_failures
self.window_seconds = window_seconds
self._failures: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def _prune(self, key: str, now: float) -> deque[float]:
bucket = self._failures.setdefault(key, deque())
cutoff = now - self.window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if not bucket:
self._failures.pop(key, None)
return bucket
def retry_after_seconds(self, key: str) -> int:
"""Return >0 seconds to wait when the key is currently blocked, else 0."""
now = time.monotonic()
with self._lock:
bucket = self._prune(key, now)
if len(bucket) < self.max_failures:
return 0
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
def record_failure(self, key: str) -> None:
now = time.monotonic()
with self._lock:
self._prune(key, now)
self._failures.setdefault(key, deque()).append(now)
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
class SlidingWindowLimiter:
"""Thread-safe request limiter where every accepted request consumes capacity."""
def __init__(self, *, max_requests: int, window_seconds: float) -> None:
self.max_requests = max_requests
self.window_seconds = window_seconds
self._requests: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def consume(self, key: str) -> int:
"""Record an accepted request, or return the seconds until capacity is available."""
now = time.monotonic()
with self._lock:
bucket = self._requests.setdefault(key, deque())
cutoff = now - self.window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if len(bucket) >= self.max_requests:
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
bucket.append(now)
return 0
+35
View File
@@ -4,6 +4,7 @@ import base64
import hashlib import hashlib
import hmac import hmac
import json import json
import os
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -19,6 +20,7 @@ class SessionPayload:
role: str role: str
display_name: str display_name: str
issued_at: int issued_at: int
session_id: str = ""
def _sign(data: bytes) -> str: def _sign(data: bytes) -> str:
@@ -26,6 +28,11 @@ def _sign(data: bytes) -> str:
return base64.urlsafe_b64encode(digest).decode().rstrip("=") return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def session_token_hash(token: str) -> str:
"""Return a non-reversible identifier safe to persist for token revocation."""
return hashlib.sha256(token.encode()).hexdigest()
def create_session_token(payload: SessionPayload) -> str: def create_session_token(payload: SessionPayload) -> str:
body = json.dumps(payload.__dict__, separators=(",", ":")).encode() body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=") encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
@@ -50,3 +57,31 @@ def read_session_token(token: str) -> SessionPayload | None:
if time.time() - payload.issued_at > settings.session_ttl_seconds: if time.time() - payload.issued_at > settings.session_ttl_seconds:
return None return None
return payload return payload
def hash_password(password: str) -> str:
salt = os.urandom(16)
derived = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32)
encoded_salt = base64.urlsafe_b64encode(salt).decode()
encoded_hash = base64.urlsafe_b64encode(derived).decode()
return f"scrypt$16384$8$1${encoded_salt}${encoded_hash}"
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded:
return False
try:
algorithm, n, r, p, salt, expected = encoded.split("$")
if algorithm != "scrypt":
return False
derived = hashlib.scrypt(
password.encode(),
salt=base64.urlsafe_b64decode(salt.encode()),
n=int(n),
r=int(r),
p=int(p),
dklen=32,
)
return hmac.compare_digest(derived, base64.urlsafe_b64decode(expected.encode()))
except (ValueError, TypeError):
return False
+112 -5
View File
@@ -1,13 +1,18 @@
import logging
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import text
from starlette.middleware.sessions import SessionMiddleware
from app.api.routers import ( from app.api.routers import (
audit, audit,
auth,
bookings, bookings,
customers,
dashboard, dashboard,
data_quality, data_quality,
demo, demo,
@@ -15,25 +20,92 @@ from app.api.routers import (
integrations, integrations,
knowledge, knowledge,
mcp_integrations, mcp_integrations,
observability,
privacy,
search, search,
users,
vehicles, vehicles,
workflows, workflows,
) )
from app.api.routers.auth import bootstrap_initial_admin
from app.core.config import PRODUCT_NAME, get_settings from app.core.config import PRODUCT_NAME, get_settings
from app.core.db import SessionLocal
from app.core.errors import AppError, error_body from app.core.errors import AppError, error_body
from app.core.observability import (
DATABASE_READY,
configure_logging,
correlation_id_context,
correlation_id_for,
request_finished,
request_started,
)
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
settings = get_settings() settings = get_settings()
configure_logging(settings.log_level)
request_logger = logging.getLogger("mobilityops.request")
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
with SessionLocal() as db:
bootstrap_initial_admin(db)
start_background_dispatcher() start_background_dispatcher()
yield yield
stop_background_dispatcher() stop_background_dispatcher()
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan) production = settings.mobilityops_env.lower() == "production"
app = FastAPI(
title=f"{PRODUCT_NAME} API",
version="0.1.0",
description=(
"Generated contract for Fleet Ops. The visible product name is Fleet Ops; "
"MobilityOps remains the technical repository and service identifier."
),
servers=[{"url": "http://localhost:8128"}],
lifespan=lifespan,
docs_url=None if production else "/docs",
redoc_url=None if production else "/redoc",
openapi_url=None if production else "/openapi.json",
)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app_secret,
session_cookie="mobilityops_oidc_state",
max_age=600,
same_site="lax",
https_only=settings.session_cookie_secure,
)
@app.middleware("http")
async def request_observability(request: Request, call_next):
correlation_id = correlation_id_for(request)
request.state.correlation_id = correlation_id
token = correlation_id_context.set(correlation_id)
started_at = request_started()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Correlation-Id"] = correlation_id
return response
finally:
duration = request_finished(request, status_code, started_at)
request_logger.info(
"request_completed",
extra={
"method": request.method,
"path": request.url.path,
"status_code": status_code,
"duration_ms": round(duration * 1000, 2),
"client_ip": request.client.host if request.client else None,
},
)
correlation_id_context.reset(token)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -45,23 +117,29 @@ app.add_middleware(
@app.exception_handler(AppError) @app.exception_handler(AppError)
def handle_app_error(_request: Request, exc: AppError) -> JSONResponse: def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content=error_body(exc.code, exc.message, exc.correlation_id, exc.details), content=error_body(
exc.code,
exc.message,
request.state.correlation_id,
exc.details,
),
) )
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse: def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content=error_body( content=error_body(
code=str(exc.status_code), code=str(exc.status_code),
message=str(exc.detail), message=str(exc.detail),
correlation_id=str(uuid.uuid4()), correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())),
details={}, details={},
), ),
headers=exc.headers,
) )
@@ -70,6 +148,28 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "mobilityops-api"} return {"status": "ok", "service": "mobilityops-api"}
@app.get("/health/live")
def liveness() -> dict[str, str]:
"""Process liveness only; external dependencies deliberately do not affect it."""
return {"status": "ok", "service": "mobilityops-api"}
@app.get("/health/ready")
def readiness() -> JSONResponse:
"""Traffic readiness: the API is useful only while its canonical database responds."""
try:
with SessionLocal() as db:
db.execute(text("SELECT 1"))
except Exception: # noqa: BLE001 -- readiness must convert infrastructure errors to 503
DATABASE_READY.set(0)
return JSONResponse(
status_code=503,
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
)
DATABASE_READY.set(1)
return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"})
@app.get("/api/v1/system/status") @app.get("/api/v1/system/status")
def system_status() -> dict[str, object]: def system_status() -> dict[str, object]:
return { return {
@@ -77,13 +177,17 @@ def system_status() -> dict[str, object]:
"environment": settings.mobilityops_env, "environment": settings.mobilityops_env,
"demo_mode": settings.mobilityops_demo_mode, "demo_mode": settings.mobilityops_demo_mode,
"knowledge_provider": settings.knowledge_provider, "knowledge_provider": settings.knowledge_provider,
"oidc_enabled": auth.oidc_status().enabled,
"oidc_provider_name": auth.oidc_status().provider_name,
} }
app.include_router(demo.router) app.include_router(demo.router)
app.include_router(auth.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(vehicles.router) app.include_router(vehicles.router)
app.include_router(bookings.router) app.include_router(bookings.router)
app.include_router(customers.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(data_quality.router) app.include_router(data_quality.router)
app.include_router(workflows.router) app.include_router(workflows.router)
@@ -92,3 +196,6 @@ app.include_router(knowledge.router)
app.include_router(mcp_integrations.router) app.include_router(mcp_integrations.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(integration_status.router) app.include_router(integration_status.router)
app.include_router(users.router)
app.include_router(observability.router)
app.include_router(privacy.router)
+2
View File
@@ -7,6 +7,7 @@ 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 OutboxEvent from app.models.outbox import 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
@@ -20,6 +21,7 @@ __all__ = [
"Inspection", "Inspection",
"MaintenanceRecord", "MaintenanceRecord",
"OutboxEvent", "OutboxEvent",
"RevokedSession",
"User", "User",
"Vehicle", "Vehicle",
] ]
+6 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import CheckConstraint, DateTime, Index, String
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -13,6 +13,11 @@ ACTOR_TYPES = ("user", "service", "system")
class AuditEvent(UUIDPrimaryKeyMixin, Base): class AuditEvent(UUIDPrimaryKeyMixin, Base):
__tablename__ = "audit_events" __tablename__ = "audit_events"
__table_args__ = (
CheckConstraint("actor_type IN ('user','service','system')", name="ck_audit_actor_type"),
Index("ix_audit_action_occurred", "action", "occurred_at"),
Index("ix_audit_entity", "entity_type", "entity_id"),
)
actor_type: Mapped[str] = mapped_column(String(20), nullable=False) actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
+18 -2
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -13,6 +13,22 @@ BOOKING_STATUSES = ("reserved", "active", "returned", "cancelled", "blocked")
class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base): class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "bookings" __tablename__ = "bookings"
__table_args__ = (
CheckConstraint(
"status IN ('reserved','active','returned','cancelled','blocked')",
name="ck_bookings_status",
),
CheckConstraint("ends_at > starts_at", name="ck_bookings_time_window"),
CheckConstraint(
"start_odometer_km IS NULL OR start_odometer_km >= 0",
name="ck_bookings_start_odometer",
),
CheckConstraint(
"end_odometer_km IS NULL OR end_odometer_km >= 0",
name="ck_bookings_end_odometer",
),
Index("ix_bookings_vehicle_status_window", "vehicle_id", "status", "starts_at", "ends_at"),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
customer_id: Mapped[uuid.UUID] = mapped_column( customer_id: Mapped[uuid.UUID] = mapped_column(
@@ -26,4 +42,4 @@ class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
status: Mapped[str] = mapped_column(String(20), nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False)
start_odometer_km: Mapped[int | None] = mapped_column(Integer) start_odometer_km: Mapped[int | None] = mapped_column(Integer)
end_odometer_km: Mapped[int | None] = mapped_column(Integer) end_odometer_km: Mapped[int | None] = mapped_column(Integer)
requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+3 -2
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import date from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, String from sqlalchemy import Date, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -23,3 +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), index=True)
+32 -2
View File
@@ -1,13 +1,17 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, String from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from app.models.user import User
RULE_TYPES = ( RULE_TYPES = (
"possible_duplicate_customer", "possible_duplicate_customer",
"missing_required_field", "missing_required_field",
@@ -21,6 +25,27 @@ ISSUE_STATUSES = ("open", "deferred", "resolved", "rejected")
class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base): class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "data_quality_issues" __tablename__ = "data_quality_issues"
__table_args__ = (
CheckConstraint(
"rule_type IN ('possible_duplicate_customer','missing_required_field',"
"'odometer_regression','booking_overlap','vehicle_status_conflict')",
name="ck_data_quality_rule_type",
),
CheckConstraint("severity IN ('low','medium','high')", name="ck_data_quality_severity"),
CheckConstraint(
"status IN ('open','deferred','resolved','rejected')",
name="ck_data_quality_status",
),
Index("ix_data_quality_work_queue", "status", "due_at", "severity"),
Index(
"uq_data_quality_one_open_condition",
"rule_type",
"entity_type",
"entity_id",
unique=True,
postgresql_where=text("status = 'open'"),
),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
rule_type: Mapped[str] = mapped_column(String(40), nullable=False) rule_type: Mapped[str] = mapped_column(String(40), nullable=False)
@@ -31,5 +56,10 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
assigned_to_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True
)
assigned_to_user: Mapped["User | None"] = relationship(lazy="selectin")
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_by: Mapped[str | None] = mapped_column(String(120)) resolved_by: Mapped[str | None] = mapped_column(String(120))
+3
View File
@@ -15,5 +15,8 @@ class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
booking_id: Mapped[uuid.UUID] = mapped_column( booking_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False
) )
# SHA-256 of the canonical request body. Replaying a key with a *different* body is
# a client bug and must be rejected instead of silently answered with the old result.
request_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
response_status: Mapped[int] = mapped_column(Integer, nullable=False) response_status: Mapped[int] = mapped_column(Integer, nullable=False)
response_body: Mapped[dict] = mapped_column(JSONB, nullable=False) response_body: Mapped[dict] = mapped_column(JSONB, nullable=False)
+1 -3
View File
@@ -7,9 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
class UUIDPrimaryKeyMixin: class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
class TimestampMixin: class TimestampMixin:
+9 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text from sqlalchemy import CheckConstraint, DateTime, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -32,6 +32,14 @@ def is_demo_scenario_failure(event: "OutboxEvent") -> bool:
class OutboxEvent(TimestampMixin, Base): class OutboxEvent(TimestampMixin, Base):
__tablename__ = "outbox_events" __tablename__ = "outbox_events"
__table_args__ = (
CheckConstraint(
"delivery_status IN ('pending','delivering','succeeded','failed')",
name="ck_outbox_delivery_status",
),
CheckConstraint("attempts >= 0", name="ck_outbox_attempts"),
Index("ix_outbox_delivery_next_attempt", "delivery_status", "next_attempt_at"),
)
event_id: Mapped[uuid.UUID] = mapped_column( event_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class RevokedSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "revoked_sessions"
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
+8 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, String from sqlalchemy import Boolean, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base from app.core.db import Base
@@ -9,8 +9,15 @@ ROLES = ("operations_manager", "rental_employee")
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users" __tablename__ = "users"
__table_args__ = (
UniqueConstraint("identity_provider", "external_subject", name="uq_user_external_identity"),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(512), nullable=True)
display_name: Mapped[str] = mapped_column(String(120), nullable=False) display_name: Mapped[str] = mapped_column(String(120), nullable=False)
role: Mapped[str] = mapped_column(String(30), nullable=False) role: Mapped[str] = mapped_column(String(30), nullable=False)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
identity_provider: Mapped[str | None] = mapped_column(String(80), nullable=True)
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True)
+11 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Integer, String from sqlalchemy import Boolean, CheckConstraint, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base from app.core.db import Base
@@ -9,6 +9,16 @@ OPERATIONAL_STATUSES = ("available", "rented", "cleaning", "maintenance", "block
class Vehicle(UUIDPrimaryKeyMixin, TimestampMixin, Base): class Vehicle(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "vehicles" __tablename__ = "vehicles"
__table_args__ = (
CheckConstraint(
"operational_status IN ('available','rented','cleaning','maintenance','blocked')",
name="ck_vehicles_operational_status",
),
CheckConstraint("model_year BETWEEN 1900 AND 2100", name="ck_vehicles_model_year"),
CheckConstraint("odometer_km >= 0", name="ck_vehicles_odometer"),
CheckConstraint("next_service_km >= 0", name="ck_vehicles_next_service"),
CheckConstraint("version >= 1", name="ck_vehicles_version"),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
make: Mapped[str] = mapped_column(String(80), nullable=False) make: Mapped[str] = mapped_column(String(80), nullable=False)
+181 -1
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime from datetime import datetime
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
Role = Literal["operations_manager", "rental_employee"] Role = Literal["operations_manager", "rental_employee"]
@@ -12,6 +13,33 @@ class DemoLoginRequest(BaseModel):
role: Role role: Role
class PasswordLoginRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
password: str = Field(min_length=8, max_length=256)
class UserOut(BaseModel):
public_ref: str
email: str | None
display_name: str
role: Role
active: bool
class CreateUserRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
display_name: str = Field(min_length=2, max_length=120)
role: Role
password: str = Field(min_length=8, max_length=256)
class UpdateUserRequest(BaseModel):
display_name: str | None = Field(default=None, min_length=2, max_length=120)
role: Role | None = None
active: bool | None = None
password: str | None = Field(default=None, min_length=8, max_length=256)
class CurrentUser(BaseModel): class CurrentUser(BaseModel):
public_ref: str public_ref: str
display_name: str display_name: str
@@ -30,6 +58,10 @@ class VehicleOut(BaseModel):
next_service_km: int next_service_km: int
active: bool active: bool
attention: bool = False attention: bool = False
attention_reason: str | None = None
service_remaining_km: int
next_booking_ref: str | None = None
next_booking_at: datetime | None = None
class VehiclePageOut(BaseModel): class VehiclePageOut(BaseModel):
@@ -56,6 +88,70 @@ class BookingOut(BookingSummaryOut):
customer_name: str customer_name: str
class CreateBookingRequest(BaseModel):
customer_ref: str = Field(min_length=3, max_length=20)
vehicle_ref: str = Field(min_length=3, max_length=20)
starts_at: datetime
ends_at: datetime
requirements_complete: bool = False
class CompleteBookingRequirementsRequest(BaseModel):
confirmation: str = Field(min_length=3, max_length=500)
class RescheduleBookingRequest(BaseModel):
starts_at: datetime
ends_at: datetime
reason: str = Field(min_length=3, max_length=500)
class CustomerOptionOut(BaseModel):
public_ref: str
display_name: str
email: str | None
class AvailableVehicleOut(BaseModel):
public_ref: str
make: str
model: str
registration_number: str
location: str
operational_status: str
class CancelBookingRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500)
class CheckoutBookingRequest(BaseModel):
start_odometer_km: Annotated[int, Field(ge=0)]
fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
cleanliness_ok: bool
damage_reported: bool = False
technical_warning: bool = False
notes: str | None = Field(default=None, max_length=2000)
class CheckoutBookingResult(BaseModel):
booking_ref: str
vehicle_ref: str
inspection_ref: str
booking_status: str
resulting_vehicle_status: str
activated: bool
attention_reasons: list[str]
class BookingPageOut(BaseModel):
items: list[BookingOut]
page: int
page_size: int
total: int
total_pages: int
class RegisterReturnRequest(BaseModel): class RegisterReturnRequest(BaseModel):
end_odometer_km: Annotated[int, Field(ge=0)] end_odometer_km: Annotated[int, Field(ge=0)]
fuel_level_percent: Annotated[int, Field(ge=0, le=100)] fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
@@ -79,6 +175,7 @@ class RegisterReturnResult(BaseModel):
odometer_regression: bool odometer_regression: bool
quality_issue_ref: str | None quality_issue_ref: str | None
workflow_event_id: str workflow_event_id: str
correlation_id: str
next_booking_risk: NextBookingRisk | None next_booking_risk: NextBookingRisk | None
@@ -118,6 +215,19 @@ class MaintenanceOut(BaseModel):
summary: str summary: str
class CreateMaintenanceRequest(BaseModel):
occurred_at: datetime
odometer_km: Annotated[int, Field(ge=0)]
category: Literal["periodic_service", "repair", "inspection", "tyres", "other"]
summary: str = Field(min_length=3, max_length=2000)
next_service_km: Annotated[int | None, Field(default=None, ge=0)]
mark_maintenance: bool = True
class ReleaseVehicleRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500)
class DataQualityIssueOut(BaseModel): class DataQualityIssueOut(BaseModel):
public_ref: str public_ref: str
rule_type: str rule_type: str
@@ -127,6 +237,10 @@ class DataQualityIssueOut(BaseModel):
status: str status: str
evidence: dict[str, Any] evidence: dict[str, Any]
detected_at: datetime detected_at: datetime
due_at: datetime | None = None
assigned_to_ref: str | None = None
assigned_to_name: str | None = None
overdue: bool = False
resolved_at: datetime | None = None resolved_at: datetime | None = None
@@ -143,6 +257,18 @@ class DataQualityIssueDetailOut(DataQualityIssueOut):
related_snapshots: list[dict[str, Any]] = Field(default_factory=list) related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
class BulkDataQualityWorkRequest(BaseModel):
issue_refs: list[str] = Field(min_length=1, max_length=25)
assigned_to_ref: str | None = Field(default=None, min_length=3, max_length=20)
clear_assignment: bool = False
due_at: datetime | None = None
clear_due_at: bool = False
class BulkDataQualityWorkResult(BaseModel):
updated: list[DataQualityIssueOut]
class MergeCustomersRequest(BaseModel): class MergeCustomersRequest(BaseModel):
survivor_ref: str survivor_ref: str
field_overrides: dict[str, str] | None = None field_overrides: dict[str, str] | None = None
@@ -265,10 +391,64 @@ class SearchResponse(BaseModel):
results: list[SearchResultItem] results: list[SearchResultItem]
class OidcStatusOut(BaseModel):
enabled: bool
provider_name: str | None = None
class PrivacyRetentionOut(BaseModel):
minimum_booking_retention_days: int
audit_retention_days: int
customers_total: int
customers_anonymized: int
customers_eligible: int
class CustomerAnonymizeRequest(BaseModel):
confirmation: str = Field(min_length=1, max_length=20)
reason: str = Field(min_length=8, max_length=500)
class CustomerAnonymizeResult(BaseModel):
public_ref: str
anonymized_at: datetime
status: Literal["anonymized", "already_anonymized"]
class N8nWorkflowEvidence(BaseModel): class N8nWorkflowEvidence(BaseModel):
name: str name: str
built: bool built: bool
last_seen_at: datetime | None last_seen_at: datetime | None
state: Literal["no_evidence", "healthy", "stale", "failed"] = "no_evidence"
last_status: Literal["succeeded", "failed"] | None = None
last_execution_id: str | None = None
class ReturnCallbackIn(BaseModel):
"""Body of the n8n return follow-up callback.
n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are ignored;
only the fields we persist are validated and bounded.
"""
model_config = ConfigDict(extra="ignore")
correlation_id: uuid.UUID | None = None
follow_up: str | None = Field(default=None, max_length=200)
summary: str | None = Field(default=None, max_length=2000)
class N8nHeartbeatIn(BaseModel):
workflow_id: str = Field(min_length=1, max_length=120)
workflow_name: str = Field(min_length=1, max_length=200)
execution_id: str = Field(min_length=1, max_length=120)
status: Literal["succeeded", "failed"]
class N8nHeartbeatResult(BaseModel):
status: Literal["registered", "already_registered"]
execution_id: str
occurred_at: datetime
class N8nErrorHandlerStatus(BaseModel): class N8nErrorHandlerStatus(BaseModel):
+28 -7
View File
@@ -80,9 +80,20 @@ def _read_csv(name: str) -> list[dict[str, str]]:
return list(csv.DictReader(handle)) return list(csv.DictReader(handle))
def clear_all(db: Session) -> None: _PERSISTENT_TELEMETRY_ACTIONS = (
"mcp_tool_request",
"n8n_return_followup_recorded",
"n8n_workflow_failure_registered",
"n8n_procedures_synced",
"n8n_workflow_heartbeat",
"knowledge_question_asked",
)
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
# RevokedSession is intentionally NOT cleared: it has no FK to users and wiping it
# would silently re-validate cookies that were logged out before the reset.
for model in ( for model in (
AuditEvent,
OutboxEvent, OutboxEvent,
IdempotencyRecord, IdempotencyRecord,
DataQualityIssue, DataQualityIssue,
@@ -94,6 +105,12 @@ def clear_all(db: Session) -> None:
User, User,
): ):
db.execute(delete(model)) db.execute(delete(model))
if preserve_integration_telemetry:
db.execute(
delete(AuditEvent).where(AuditEvent.action.not_in(_PERSISTENT_TELEMETRY_ACTIONS))
)
else:
db.execute(delete(AuditEvent))
def load_seed(db: Session) -> SeedResult: def load_seed(db: Session) -> SeedResult:
@@ -101,9 +118,7 @@ def load_seed(db: Session) -> SeedResult:
today = datetime.now(UTC).date() today = datetime.now(UTC).date()
shift = _seed_anchor_shift(today) shift = _seed_anchor_shift(today)
user_rows = [ user_rows = [{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS]
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
]
db.execute(insert(User), user_rows) db.execute(insert(User), user_rows)
counts["users"] = len(user_rows) counts["users"] = len(user_rows)
@@ -339,6 +354,11 @@ def load_seed(db: Session) -> SeedResult:
entity_type, entity_id = resolve_entity(row["entity_ref"]) entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or "" related_ref = row.get("related_ref") or ""
related_refs = related_ref.split("|") if related_ref else [] related_refs = related_ref.split("|") if related_ref else []
severity_due_delta = {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
"low": timedelta(days=3),
}.get(row["severity"], timedelta(days=1))
dq_rows.append( dq_rows.append(
{ {
"id": uuid.uuid4(), "id": uuid.uuid4(),
@@ -356,6 +376,7 @@ def load_seed(db: Session) -> SeedResult:
}, },
"proposed_action_json": {}, "proposed_action_json": {},
"detected_at": now, "detected_at": now,
"due_at": now + severity_due_delta if row["status"] == "open" else None,
"resolved_at": now if row["status"] == "resolved" else None, "resolved_at": now if row["status"] == "resolved" else None,
"resolved_by": "USR-OPS" if row["status"] == "resolved" else None, "resolved_by": "USR-OPS" if row["status"] == "resolved" else None,
} }
@@ -428,10 +449,10 @@ def load_seed(db: Session) -> SeedResult:
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at) return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
def reset_and_seed(db: Session) -> SeedResult: def reset_and_seed(db: Session, *, preserve_integration_telemetry: bool = False) -> SeedResult:
from app.services.data_quality import run_scan from app.services.data_quality import run_scan
clear_all(db) clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
result = load_seed(db) result = load_seed(db)
db.commit() db.commit()
scan = run_scan(db) scan = run_scan(db)
+102 -104
View File
@@ -2,10 +2,9 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from difflib import SequenceMatcher
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.errors import AppError from app.core.errors import AppError
@@ -15,6 +14,7 @@ from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.data_quality_duplicate_scan import scan_duplicate_customers
from app.services.vehicle_status import ( from app.services.vehicle_status import (
RECOMMENDATION_CODE_NO_CONFLICT, RECOMMENDATION_CODE_NO_CONFLICT,
VehicleStatusRecommendation, VehicleStatusRecommendation,
@@ -25,7 +25,16 @@ from app.services.vehicle_status import (
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name") REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location") REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
DUPLICATE_THRESHOLD = 70 DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
"""Return the local operational SLA deadline for a newly detected issue."""
return detected_at + {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
"low": timedelta(days=3),
}.get(severity, timedelta(days=1))
@dataclass @dataclass
@@ -36,10 +45,6 @@ class ScanResult:
self.created[rule_type] = self.created.get(rule_type, 0) + 1 self.created[rule_type] = self.created.get(rule_type, 0) + 1
def _normalize(value: str | None) -> str:
return (value or "").strip().lower()
def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uuid.UUID) -> bool: def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uuid.UUID) -> bool:
return ( return (
db.scalar( db.scalar(
@@ -54,15 +59,9 @@ def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uu
) )
def _next_public_ref(db: Session, prefix: str) -> str: def _new_scan_ref(prefix: str) -> str:
existing = db.execute(select(DataQualityIssue.public_ref)).scalars().all() """Generate a stable human-readable prefix with a concurrent-safe suffix."""
numbers = [ return f"{prefix}-{uuid.uuid4().hex[:10].upper()}"
int(ref.rsplit("-", 1)[-1])
for ref in existing
if ref.startswith(f"{prefix}-") and ref.rsplit("-", 1)[-1].isdigit()
]
next_number = (max(numbers) + 1) if numbers else 1
return f"{prefix}-{next_number:04d}"
def _open_issue( def _open_issue(
@@ -109,7 +108,7 @@ def _open_issue(
evidence["previous_decision"] = previous.status evidence["previous_decision"] = previous.status
issue = DataQualityIssue( issue = DataQualityIssue(
public_ref=_next_public_ref(db, "DQ-SCAN"), public_ref=_new_scan_ref("DQ-SCAN"),
rule_type=rule_type, rule_type=rule_type,
entity_type=entity_type, entity_type=entity_type,
entity_id=entity_id, entity_id=entity_id,
@@ -118,63 +117,21 @@ def _open_issue(
evidence_json=evidence, evidence_json=evidence,
proposed_action_json={}, proposed_action_json={},
detected_at=now, detected_at=now,
due_at=issue_due_at(now, severity),
) )
db.add(issue) db.add(issue)
db.flush() db.flush()
scan.bump(rule_type) scan.bump(rule_type)
def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
customers = list(
db.scalars(select(Customer).where(Customer.merged_into_customer_id.is_(None))).all()
)
customers.sort(key=lambda c: c.public_ref)
for i, a in enumerate(customers):
for b in customers[i + 1 :]:
score = 0
signals: list[dict] = []
summary_parts: list[str] = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append({"code": "duplicate.exact_email"})
summary_parts.append("exact email")
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
score += 50
signals.append({"code": "duplicate.exact_phone"})
summary_parts.append("exact phone")
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
score += 10
signals.append({"code": "duplicate.same_postal_code"})
summary_parts.append("exact postal code")
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
_open_issue(
db,
scan,
rule_type="possible_duplicate_customer",
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(summary_parts) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
signals=signals,
)
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:
@@ -286,9 +243,7 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()} vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {} bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars( for booking in db.scalars(
select(Booking).where( select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
Booking.status == "returned", Booking.end_odometer_km.is_not(None)
)
).all(): ).all():
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking) bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
@@ -332,8 +287,11 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
def run_scan( def run_scan(
db: Session, *, actor_label: str | None = None, actor_type: str = "user" db: Session, *, actor_label: str | None = None, actor_type: str = "user"
) -> ScanResult: ) -> ScanResult:
# The check-then-insert work below spans several rules. Serialise whole scans at the
# database boundary so API and n8n triggers cannot both observe an empty condition.
db.scalar(select(func.pg_advisory_xact_lock(DATA_QUALITY_SCAN_LOCK_ID)))
scan = ScanResult() scan = ScanResult()
_scan_duplicate_customers(db, scan) scan_duplicate_customers(db, scan, _open_issue)
_scan_missing_required_fields(db, scan) _scan_missing_required_fields(db, scan)
_scan_odometer_regressions(db, scan) _scan_odometer_regressions(db, scan)
_scan_booking_overlaps(db, scan) _scan_booking_overlaps(db, scan)
@@ -351,10 +309,13 @@ def run_scan(
return scan return scan
def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue: def _load_open_issue(
issue = db.scalar( db: Session, public_ref: str, *, lock: bool = True
select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref) ) -> DataQualityIssue:
) statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
if lock:
statement = statement.with_for_update()
issue = db.scalar(statement)
if issue is None: if issue is None:
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404) raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
if issue.status != "open": if issue.status != "open":
@@ -505,6 +466,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(
@@ -525,19 +510,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:
@@ -549,13 +522,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,
@@ -680,8 +646,10 @@ def resolve_booking_overlap(
return issue return issue
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue: def _load_vehicle_status_conflict_issue(
issue = _load_open_issue(db, public_ref) db: Session, public_ref: str, *, lock: bool = True
) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref, lock=lock)
if issue.rule_type != "vehicle_status_conflict": if issue.rule_type != "vehicle_status_conflict":
raise AppError( raise AppError(
"NOT_A_STATUS_CONFLICT_ISSUE", "NOT_A_STATUS_CONFLICT_ISSUE",
@@ -697,7 +665,7 @@ def preview_vehicle_status_recommendation(
"""Non-mutating: computes and returns the recommendation only. Never resolves the """Non-mutating: computes and returns the recommendation only. Never resolves the
issue, never writes an audit event, never queues automation -- safe to call as often issue, never writes an audit event, never queues automation -- safe to call as often
as the UI needs (e.g. every time the panel is opened) with zero side effects.""" as the UI needs (e.g. every time the panel is opened) with zero side effects."""
issue = _load_vehicle_status_conflict_issue(db, public_ref) issue = _load_vehicle_status_conflict_issue(db, public_ref, lock=False)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id)) vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
if vehicle is None: if vehicle is None:
raise AppError( raise AppError(
@@ -756,9 +724,7 @@ def apply_recommended_status(
# recommendation alone for the post-condition. # recommendation alone for the post-condition.
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id) post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
post_check = evaluate_vehicle_status(vehicle, post_facts) post_check = evaluate_vehicle_status(vehicle, post_facts)
if post_check.recommendation_code not in ( if post_check.recommendation_code not in (RECOMMENDATION_CODE_NO_CONFLICT,):
RECOMMENDATION_CODE_NO_CONFLICT,
):
raise AppError( raise AppError(
"CONFLICT_STILL_PRESENT", "CONFLICT_STILL_PRESENT",
"Applying the recommended status did not resolve the conflict.", "Applying the recommended status did not resolve the conflict.",
@@ -798,6 +764,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(
@@ -827,12 +803,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},
@@ -844,7 +834,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)
@@ -0,0 +1,110 @@
from __future__ import annotations
import uuid
from difflib import SequenceMatcher
from typing import Protocol, TypeVar
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.customer import Customer
DUPLICATE_THRESHOLD = 70
class ScanAccumulator(Protocol):
def bump(self, rule_type: str) -> None: ...
ScanTypeContra = TypeVar("ScanTypeContra", bound=ScanAccumulator, contravariant=True)
class OpenIssue(Protocol[ScanTypeContra]):
def __call__(
self,
db: Session,
scan: ScanTypeContra,
*,
rule_type: str,
entity_type: str,
entity_id: uuid.UUID,
severity: str,
summary: str,
entity_ref: str,
related_refs: list[str],
signals: list[dict] | None = None,
) -> None: ...
def _normalize(value: str | None) -> str:
return (value or "").strip().lower()
def scan_duplicate_customers[ScanType: ScanAccumulator](
db: Session, scan: ScanType, open_issue: OpenIssue[ScanType]
) -> None:
customers = list(
db.scalars(
select(Customer).where(
Customer.merged_into_customer_id.is_(None),
Customer.anonymized_at.is_(None),
)
).all()
)
customers.sort(key=lambda customer: customer.public_ref)
# The threshold cannot be reached without an exact email (60 points) or phone
# (50 points). Block on normalized identifiers so this remains linear for the
# overwhelmingly common case and only scores plausible pairs.
candidate_pairs: set[tuple[int, int]] = set()
for attribute in ("email", "phone"):
blocks: dict[str, list[int]] = {}
for index, customer in enumerate(customers):
key = _normalize(getattr(customer, attribute))
if key:
blocks.setdefault(key, []).append(index)
for indices in blocks.values():
for offset, left in enumerate(indices):
candidate_pairs.update((left, right) for right in indices[offset + 1 :])
for left, right in sorted(candidate_pairs):
a = customers[left]
b = customers[right]
score = 0
signals: list[dict] = []
summary_parts: list[str] = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append({"code": "duplicate.exact_email"})
summary_parts.append("exact email")
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
score += 50
signals.append({"code": "duplicate.exact_phone"})
summary_parts.append("exact phone")
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
score += 10
signals.append({"code": "duplicate.same_postal_code"})
summary_parts.append("exact postal code")
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
open_issue(
db,
scan,
rule_type="possible_duplicate_customer",
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(summary_parts) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
signals=signals,
)
+32 -21
View File
@@ -39,18 +39,14 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
overlap_issue = db.scalar( overlap_issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP") select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
) )
failed_run = db.scalar( failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID))
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
)
knowledge_health = get_knowledge_provider().health() knowledge_health = get_knowledge_provider().health()
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the # Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI # frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
# languages. This service only emits stable identifiers and message codes -- never # languages. This service only emits stable identifiers and message codes -- never
# display prose -- per the message_code + params architecture used across the app. # display prose -- per the message_code + params architecture used across the app.
return_ready = bool( return_ready = bool(booking and booking.status == "active" and booking.end_odometer_km is None)
booking and booking.status == "active" and booking.end_odometer_km is None
)
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open") duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
overlap_ready = bool(overlap_issue and overlap_issue.status == "open") overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
automation_ready = bool(failed_run and failed_run.delivery_status == "failed") automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
@@ -65,7 +61,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if return_ready if return_ready
else "bookingNotFound" if booking is None else "bookingAlreadyProcessed" else "bookingNotFound"
if booking is None
else "bookingAlreadyProcessed"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -81,7 +79,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if duplicate_ready if duplicate_ready
else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved" else "duplicateIssueNotFound"
if duplicate_issue is None
else "issueAlreadyResolved"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -95,7 +95,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if overlap_ready if overlap_ready
else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved" else "overlapIssueNotFound"
if overlap_issue is None
else "issueAlreadyResolved"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -107,7 +109,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if automation_ready if automation_ready
else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered" else "failedEventNotFound"
if failed_run is None
else "eventAlreadyRecovered"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -139,14 +143,21 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
), ),
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
key="ragcore", key="ragcore",
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode", status_code=(
detail_code="ragcoreDetail", "operational"
detail_params={ if knowledge_health.provider == "ragcore" and knowledge_health.available
"count": ( else "demoMode"
knowledge_health.document_count if knowledge_health.provider == "demo" and knowledge_health.available
if knowledge_health.document_count is not None else "unavailable"
else "unknown"
), ),
detail_code=(
"ragcoreDetail"
if knowledge_health.document_count is not None
else "ragcoreSourceDetail"
),
detail_params={
"count": knowledge_health.document_count or 0,
"source_count": knowledge_health.source_document_count,
"collection": knowledge_health.collection, "collection": knowledge_health.collection,
}, },
), ),
@@ -158,7 +169,9 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
# "operational" -- same evidence rule the integration status page uses. # "operational" -- same evidence rule the integration status page uses.
status_code="operational" if mcp_hub.state == "operational" else "notConnected", status_code="operational" if mcp_hub.state == "operational" else "notConnected",
detail_code=( detail_code=(
"mcpDetailEnabled" if mcp_hub.state == "operational" else "mcpDetailNotConnected" "mcpDetailOperational"
if mcp_hub.state == "operational"
else "mcpDetailPrepared"
), ),
detail_params={}, detail_params={},
), ),
@@ -172,9 +185,7 @@ def scenario_integrity_report(db: Session) -> dict:
overview already use, so this can never drift from what a visitor actually sees.""" overview already use, so this can never drift from what a visitor actually sees."""
scenarios = _scenarios(db) scenarios = _scenarios(db)
not_ready = [ not_ready = [
{"id": s.id, "reason_code": s.blocked_reason_code} {"id": s.id, "reason_code": s.blocked_reason_code} for s in scenarios if not s.ready
for s in scenarios
if not s.ready
] ]
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready} return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
+1 -2
View File
@@ -141,8 +141,7 @@ def _deliver_one(event_id: uuid.UUID) -> None:
# succeeded. # succeeded.
success = False success = False
error = ( error = (
"Unexpected non-JSON-object response from n8n " f"Unexpected non-JSON-object response from n8n (status {response.status_code})"
f"(status {response.status_code})"
) )
error_code = "malformedResponse" error_code = "malformedResponse"
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
+99 -15
View File
@@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal import threading
import time
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
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
@@ -17,6 +21,9 @@ from app.schemas import (
) )
settings = get_settings() settings = get_settings()
_hub_health_lock = threading.Lock()
_hub_health_cached_at = 0.0
_hub_health_cached_value: bool | None = None
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are # The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
# built (all with their full node set saved). # built (all with their full node set saved).
@@ -26,6 +33,26 @@ _CANONICAL_WORKFLOWS = (
"Fleet Ops — RAGcore Procedure Sync", "Fleet Ops — RAGcore Procedure Sync",
"Fleet Ops — Workflow Error Handler", "Fleet Ops — Workflow Error Handler",
) )
_STALE_AFTER = {
"Fleet Ops — Scheduled Data Quality Scan": timedelta(hours=2, minutes=30),
"Fleet Ops — RAGcore Procedure Sync": timedelta(hours=30),
}
def _latest_rows_per_workflow(db: Session, action: str) -> Sequence[Row[Any]]:
"""Return the most recent audit row per workflow name for one action.
Heartbeats arrive on every scheduled run, so loading *all* rows and picking the
latest in Python would grow linearly with deployment age. ``DISTINCT ON`` lets
PostgreSQL return exactly one (latest) row per workflow instead.
"""
workflow_name = AuditEvent.after_json["workflow_name"].astext
return db.execute(
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
.where(AuditEvent.action == action)
.distinct(workflow_name)
.order_by(workflow_name, AuditEvent.occurred_at.desc())
).all()
def derive_n8n_status(db: Session) -> N8nIntegrationStatus: def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
@@ -102,9 +129,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
# procedures_sync_result), the same "the workflow's own callback is the evidence" # procedures_sync_result), the same "the workflow's own callback is the evidence"
# pattern the scheduled scan and error handler already use below. # pattern the scheduled scan and error handler already use below.
latest_procedure_sync_at = db.scalar( latest_procedure_sync_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where( select(func.max(AuditEvent.occurred_at)).where(AuditEvent.action == "n8n_procedures_synced")
AuditEvent.action == "n8n_procedures_synced"
)
) )
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error # Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
@@ -129,20 +154,74 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None (latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
) )
evidence_by_workflow = { legacy_evidence_by_workflow = {
"Fleet Ops — Vehicle Return Orchestration": latest_success_at, "Fleet Ops — Vehicle Return Orchestration": latest_success_at,
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at, "Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
"Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at, "Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at,
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at, "Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
} }
workflows = [ heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {}
heartbeat_rows = _latest_rows_per_workflow(db, "n8n_workflow_heartbeat")
for occurred_at, after, metadata in heartbeat_rows:
workflow_name = (after or {}).get("workflow_name")
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in heartbeat_by_workflow:
heartbeat_by_workflow[workflow_name] = (
occurred_at,
(after or {}).get("status", "succeeded"),
(metadata or {}).get("execution_id"),
)
failure_by_workflow: dict[str, tuple[datetime, str | None]] = {}
failure_rows = _latest_rows_per_workflow(db, "n8n_workflow_failure_registered")
for occurred_at, after, metadata in failure_rows:
workflow_name = (after or {}).get("workflow_name")
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in failure_by_workflow:
failure_by_workflow[workflow_name] = (
occurred_at,
(metadata or {}).get("execution_id"),
)
now = datetime.now(UTC)
workflows: list[N8nWorkflowEvidence] = []
for name in _CANONICAL_WORKFLOWS:
legacy_seen = legacy_evidence_by_workflow[name]
heartbeat = heartbeat_by_workflow.get(name)
failure_signal = failure_by_workflow.get(name)
seen_at = heartbeat[0] if heartbeat else legacy_seen
last_status: Literal["succeeded", "failed"] | None = (
"succeeded" if seen_at is not None else None
)
execution_id = heartbeat[2] if heartbeat else None
if heartbeat and heartbeat[1] == "failed":
last_status = "failed"
if failure_signal and (seen_at is None or failure_signal[0] > seen_at):
seen_at = failure_signal[0]
last_status = "failed"
execution_id = failure_signal[1]
workflow_state: Literal["no_evidence", "healthy", "stale", "failed"]
if seen_at is None:
workflow_state = "no_evidence"
elif last_status == "failed":
workflow_state = "failed"
elif name in _STALE_AFTER and now - seen_at > _STALE_AFTER[name]:
workflow_state = "stale"
else:
workflow_state = "healthy"
workflows.append(
N8nWorkflowEvidence( N8nWorkflowEvidence(
name=name, name=name,
built=True, built=True,
last_seen_at=evidence_by_workflow[name], last_seen_at=seen_at,
state=workflow_state,
last_status=last_status,
last_execution_id=execution_id,
) )
for name in _CANONICAL_WORKFLOWS )
]
if state in {"operational", "no_evidence"} and any(
workflow.state in {"failed", "stale"} for workflow in workflows
):
state = "degraded"
return N8nIntegrationStatus( return N8nIntegrationStatus(
configured=bool(settings.n8n_webhook_url), configured=bool(settings.n8n_webhook_url),
@@ -173,9 +252,7 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call `MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`).""" already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
total_calls = ( total_calls = (
db.scalar( db.scalar(select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request"))
select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request")
)
or 0 or 0
) )
latest_call_row = db.execute( latest_call_row = db.execute(
@@ -215,8 +292,15 @@ def _check_hub_reachable() -> bool | None:
`None` means not configured / not checked, never a guess.""" `None` means not configured / not checked, never a guess."""
if not settings.mcp_hub_base_url: if not settings.mcp_hub_base_url:
return None return None
global _hub_health_cached_at, _hub_health_cached_value
now = time.monotonic()
with _hub_health_lock:
if now - _hub_health_cached_at < settings.mcp_hub_health_cache_seconds:
return _hub_health_cached_value
try: try:
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5) response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
return response.status_code == 200 _hub_health_cached_value = response.status_code == 200
except httpx.HTTPError: except httpx.HTTPError:
return False _hub_health_cached_value = False
_hub_health_cached_at = time.monotonic()
return _hub_health_cached_value
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from functools import lru_cache from functools import lru_cache
from typing import Literal, Protocol from typing import Literal, Protocol
@@ -8,6 +9,7 @@ from pydantic import BaseModel
from app.core.config import get_settings from app.core.config import get_settings
EvidenceState = Literal["grounded", "insufficient", "unavailable"] EvidenceState = Literal["grounded", "insufficient", "unavailable"]
KnowledgeStatisticsState = Literal["verified", "sync_reported", "not_reported"]
class SourceCard(BaseModel): class SourceCard(BaseModel):
@@ -36,6 +38,11 @@ class KnowledgeHealth(BaseModel):
# A provider may be healthy without exposing a corpus-size endpoint. `None` means # A provider may be healthy without exposing a corpus-size endpoint. `None` means
# unknown, never "zero procedures". # unknown, never "zero procedures".
document_count: int | None document_count: int | None
source_document_count: int
reported_synced_document_count: int | None
reported_failed_document_count: int | None
last_sync_at: datetime | None
statistics_state: KnowledgeStatisticsState
class KnowledgeProvider(Protocol): class KnowledgeProvider(Protocol):
+142 -16
View File
@@ -14,25 +14,146 @@ DEFAULT_LANGUAGE = "en-GB"
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = { STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
"en-GB": { "en-GB": {
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "a",
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then", "an",
"do", "does", "did", "must", "may", "can", "could", "should", "would", "the",
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how", "is",
"with", "without", "this", "that", "these", "those", "not", "no", "are",
"was",
"were",
"be",
"been",
"being",
"to",
"of",
"in",
"on",
"at",
"for",
"and",
"or",
"but",
"if",
"then",
"do",
"does",
"did",
"must",
"may",
"can",
"could",
"should",
"would",
"i",
"you",
"it",
"we",
"they",
"my",
"your",
"what",
"when",
"how",
"with",
"without",
"this",
"that",
"these",
"those",
"not",
"no",
}, },
"nl-BE": { "nl-BE": {
"een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt", "een",
"van", "in", "op", "voor", "en", "of", "maar", "als", "dan", "de",
"moet", "mag", "kan", "kunnen", "zou", "zouden", "het",
"ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe", "is",
"met", "zonder", "dit", "dat", "deze", "die", "niet", "geen", "zijn",
"was",
"waren",
"worden",
"wordt",
"van",
"in",
"op",
"voor",
"en",
"of",
"maar",
"als",
"dan",
"moet",
"mag",
"kan",
"kunnen",
"zou",
"zouden",
"ik",
"jij",
"u",
"we",
"wij",
"zij",
"mijn",
"jouw",
"wat",
"wanneer",
"hoe",
"met",
"zonder",
"dit",
"dat",
"deze",
"die",
"niet",
"geen",
}, },
"fr-BE": { "fr-BE": {
"un", "une", "le", "la", "les", "des", "est", "sont", "était", "être", "un",
"de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors", "une",
"doit", "peut", "peuvent", "pourrait", "devrait", "le",
"je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment", "la",
"avec", "sans", "ce", "cette", "ces", "cela", "pas", "non", "les",
"des",
"est",
"sont",
"était",
"être",
"de",
"du",
"en",
"sur",
"pour",
"et",
"ou",
"mais",
"si",
"alors",
"doit",
"peut",
"peuvent",
"pourrait",
"devrait",
"je",
"tu",
"vous",
"il",
"elle",
"nous",
"ils",
"mon",
"votre",
"quoi",
"quand",
"comment",
"avec",
"sans",
"ce",
"cette",
"ces",
"cela",
"pas",
"non",
}, },
} }
@@ -141,7 +262,7 @@ _LOW_CONFIDENCE_TEXT = {
_LEAD_ANSWER_TEMPLATE = { _LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}', "en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}', "nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
"fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}', "fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}",
} }
@@ -201,6 +322,11 @@ class DemoKnowledgeProvider:
workspace=self._settings.ragcore_workspace, workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection, collection=self._settings.ragcore_collection,
document_count=self._document_count_by_language[language], document_count=self._document_count_by_language[language],
source_document_count=self._document_count_by_language[language],
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="verified",
) )
def _score( def _score(
+3 -1
View File
@@ -10,7 +10,9 @@ SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
# Stable across runs (and across which language ships first) so a document's RAGcore # Stable across runs (and across which language ships first) so a document's RAGcore
# source_id never changes just because the sync ran on a different day or in a # source_id never changes just because the sync ran on a different day or in a
# different order -- required for RAGcore's upload idempotency to work per document. # different order -- required for RAGcore's upload idempotency to work per document.
_SOURCE_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures") _SOURCE_ID_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures"
)
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]: def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
+240 -5
View File
@@ -1,9 +1,16 @@
from __future__ import annotations from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Lock
from time import monotonic
import httpx import httpx
from app.core.config import get_settings from app.core.config import get_settings
from app.core.observability import KNOWLEDGE_PROVIDER_REQUESTS, KNOWLEDGE_RETRIEVAL_SCORE
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"} _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
@@ -17,9 +24,84 @@ _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
_LEAD_ANSWER_TEMPLATE = { _LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}": {excerpt}', "en-GB": 'Per "{title}": {excerpt}',
"nl-BE": 'Volgens "{title}": {excerpt}', "nl-BE": 'Volgens "{title}": {excerpt}',
"fr-BE": 'Selon « {title} » : {excerpt}', "fr-BE": "Selon « {title} » : {excerpt}",
} }
_DEFAULT_LANGUAGE = "en-GB" _DEFAULT_LANGUAGE = "en-GB"
_MAX_SOURCE_CARDS = 3
_INDEX_LOOKUP_TIMEOUT_SECONDS = 2.0
_INDEX_VERIFICATION_TTL_SECONDS = 300.0
_INDEX_VERIFICATION_WORKERS = 6
_DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
"damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"),
"vehicle": ("vehicle", "car", "voertuig", "wagen", "véhicule", "voiture"),
"return": ("return", "returned", "retour", "terugbrengen", "restitution"),
"fuel": ("fuel", "brandstof", "carburant"),
"booking": ("booking", "reservation", "boeking", "réservation"),
"odometer": ("odometer", "mileage", "kilometer", "kilométrage", "compteur"),
"customer": ("customer", "client", "klant"),
"cleaning": ("cleaning", "reiniging", "poetsen", "nettoyage"),
"maintenance": ("maintenance", "service", "onderhoud", "entretien"),
"conflict": ("conflict", "overlap", "overlapping", "conflit", "chevauchement"),
"checkout": ("checkout", "departure", "vertrek", "départ"),
"availability": ("available", "availability", "beschikbaar", "disponible", "disponibilité"),
"technical": ("technical", "warning", "technisch", "waarschuwing", "technique", "alerte"),
}
def _question_concepts(question: str) -> set[str]:
normalized = question.casefold()
return {
concept
for concept, terms in _DOMAIN_CONCEPTS.items()
if any(term in normalized for term in terms)
}
def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]:
"""Collapse duplicate chunks and re-uploaded document versions.
RAGcore document/version UUIDs change across uploads, so they are not useful
deduplication keys. Human-visible citation identity is the normalized title,
section. Chunks from the same unsectioned document collapse into one card; distinct
named sections remain independently citable.
"""
seen: set[tuple[str, str]] = set()
unique: list[SourceCard] = []
for source in sources:
key = (
source.title.strip().casefold(),
source.section.strip().casefold(),
)
if key in seen:
continue
seen.add(key)
unique.append(source)
if len(unique) == _MAX_SOURCE_CARDS:
break
return unique
def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) -> list[SourceCard]:
if "damage" not in concepts:
return sources
return sorted(
sources,
key=lambda source: (
0 if "damage" in f"{source.document_id} {source.title}".casefold() else 1
),
)
def _retrieval_score(result: dict) -> float | None:
scores = result.get("scores")
if not isinstance(scores, dict):
return None
for name in ("rerank", "fused"):
value = scores.get(name)
if isinstance(value, int | float) and not isinstance(value, bool):
return float(value)
return None
class RAGcoreKnowledgeProvider: class RAGcoreKnowledgeProvider:
@@ -54,6 +136,24 @@ class RAGcoreKnowledgeProvider:
def __init__(self) -> None: def __init__(self) -> None:
self._settings = get_settings() self._settings = get_settings()
self._verification_cache: dict[str, tuple[float, int]] = {}
self._verification_lock = Lock()
self._answers_circuit_lock = Lock()
self._answers_circuit_open_until = 0.0
def _answers_circuit_is_open(self) -> bool:
with self._answers_circuit_lock:
return monotonic() < self._answers_circuit_open_until
def _open_answers_circuit(self) -> None:
with self._answers_circuit_lock:
self._answers_circuit_open_until = monotonic() + max(
0.0, self._settings.ragcore_answers_circuit_breaker_seconds
)
def _close_answers_circuit(self) -> None:
with self._answers_circuit_lock:
self._answers_circuit_open_until = 0.0
def _client(self) -> httpx.Client: def _client(self) -> httpx.Client:
headers = {} headers = {}
@@ -66,16 +166,35 @@ class RAGcoreKnowledgeProvider:
) )
def health(self, language: str = "en-GB") -> KnowledgeHealth: def health(self, language: str = "en-GB") -> KnowledgeHealth:
documents = [
document
for document in iter_procedure_documents(Path(self._settings.knowledge_dir))
if document.language == language
]
verified_document_count: int | None = None
try: try:
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."
if available if available
else f"RAGcore degraded: {body.get('status', 'unknown')}" else f"RAGcore degraded: {body.get('status', 'unknown')}"
) )
if available:
verified_document_count = self._verify_indexed_documents(
client, language, documents
)
if verified_document_count is None:
detail += " Index verification is temporarily unavailable."
else:
detail += (
f" {verified_document_count}/{len(documents)} managed sources have "
"an exact active published document in the configured space."
)
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError) as exc:
available = False available = False
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}" detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
@@ -86,11 +205,71 @@ class RAGcoreKnowledgeProvider:
tenant=self._settings.ragcore_tenant, tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace, workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection, collection=self._settings.ragcore_collection,
# RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit # RAGcore deliberately has no browse/count endpoint. Fleet Ops instead
# so the UI never turns this into the misleading claim "0 procedures". # verifies each managed source through its exact identity lookup and only
document_count=None, # counts an active document with a published active version. RAGcore's
# content_sha256 describes its canonical parsed artifact, not the uploaded
# source bytes, so comparing it with Fleet Ops's source hash would be false.
document_count=verified_document_count,
source_document_count=len(documents),
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state=(
"verified" if verified_document_count is not None else "not_reported"
),
) )
def _verify_indexed_documents(
self, client: httpx.Client, language: str, documents: list[ProcedureDocument]
) -> int | None:
if not self._settings.ragcore_space_id or not documents:
return None
now = monotonic()
with self._verification_lock:
cached = self._verification_cache.get(language)
if cached is not None and now - cached[0] < _INDEX_VERIFICATION_TTL_SECONDS:
return cached[1]
def is_verified(document: ProcedureDocument) -> bool:
response = client.get(
"/v1/documents",
params={
"source_id": document.source_id,
"external_id": f"{document.document_id}.md",
},
timeout=_INDEX_LOOKUP_TIMEOUT_SECONDS,
)
if response.status_code != 200:
raise RuntimeError("RAGcore document verification failed")
body = response.json()
items = body.get("items") if isinstance(body, dict) else None
if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict):
return False
item = items[0]
active_version = item.get("active_version")
return bool(
item.get("space_id") == self._settings.ragcore_space_id
and item.get("source_id") == document.source_id
and item.get("external_id") == f"{document.document_id}.md"
and item.get("status") == "active"
and isinstance(active_version, dict)
and active_version.get("status") == "published"
)
try:
with ThreadPoolExecutor(
max_workers=min(_INDEX_VERIFICATION_WORKERS, len(documents))
) as executor:
verified_count = sum(executor.map(is_verified, documents))
except (httpx.HTTPError, RuntimeError, TypeError, ValueError):
return None
with self._verification_lock:
self._verification_cache[language] = (monotonic(), verified_count)
return verified_count
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer: def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
unavailable = GroundedAnswer( unavailable = GroundedAnswer(
answer="", answer="",
@@ -102,6 +281,9 @@ class RAGcoreKnowledgeProvider:
if not self._settings.ragcore_space_id: if not self._settings.ragcore_space_id:
return unavailable return unavailable
if self._answers_circuit_is_open():
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
else:
answered = self._ask_via_answers(question, correlation_id) answered = self._ask_via_answers(question, correlation_id)
if answered is not None: if answered is not None:
return answered return answered
@@ -128,9 +310,13 @@ class RAGcoreKnowledgeProvider:
}, },
) )
if response.status_code != 200: if response.status_code != 200:
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "non_2xx").inc()
self._open_answers_circuit()
return None return None
body = response.json() body = response.json()
except (httpx.HTTPError, ValueError): except (httpx.HTTPError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "error").inc()
self._open_answers_circuit()
return None return None
try: try:
@@ -145,9 +331,12 @@ class RAGcoreKnowledgeProvider:
) )
for citation in citations.values() for citation in citations.values()
] ]
sources = _deduplicate_sources(sources)
answerability = body.get("answerability", "not_answerable") answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient" evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
self._close_answers_circuit()
return GroundedAnswer( return GroundedAnswer(
answer=body.get("answer", "") if evidence_state == "grounded" else "", answer=body.get("answer", "") if evidence_state == "grounded" else "",
evidence_state=evidence_state, evidence_state=evidence_state,
@@ -156,6 +345,8 @@ class RAGcoreKnowledgeProvider:
correlation_id=correlation_id, correlation_id=correlation_id,
) )
except (TypeError, KeyError, ValueError): except (TypeError, KeyError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "malformed").inc()
self._open_answers_circuit()
return None return None
def _ask_via_search_fallback( def _ask_via_search_fallback(
@@ -179,13 +370,17 @@ class RAGcoreKnowledgeProvider:
}, },
) )
if response.status_code != 200: if response.status_code != 200:
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "non_2xx").inc()
return unavailable return unavailable
body = response.json() body = response.json()
except (httpx.HTTPError, ValueError): except (httpx.HTTPError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "error").inc()
return unavailable return unavailable
try: try:
results = body.get("results", []) results = body.get("results", [])
if not isinstance(results, list):
raise TypeError("results must be a list")
sources = [ sources = [
SourceCard( SourceCard(
document_id=str(result["citation"]["document_id"]), document_id=str(result["citation"]["document_id"]),
@@ -197,9 +392,30 @@ class RAGcoreKnowledgeProvider:
for result in results for result in results
] ]
except (TypeError, KeyError, ValueError): except (TypeError, KeyError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
return unavailable return unavailable
if not sources: for result in results:
score = _retrieval_score(result)
if score is not None:
KNOWLEDGE_RETRIEVAL_SCORE.observe(score)
all_sources = _deduplicate_sources(sources)
concepts = _question_concepts(question)
qualified_sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
]
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
if not all_sources:
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
return GroundedAnswer( return GroundedAnswer(
answer="", answer="",
evidence_state="insufficient", evidence_state="insufficient",
@@ -208,9 +424,28 @@ class RAGcoreKnowledgeProvider:
correlation_id=correlation_id, correlation_id=correlation_id,
) )
damage_evidence = any(
term
in (
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
).casefold()
for source in sources
for term in _DOMAIN_CONCEPTS["damage"]
)
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=all_sources,
provider=self.name,
correlation_id=correlation_id,
)
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE]) template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
lead = sources[0] lead = sources[0]
answer = template.format(title=lead.title, excerpt=lead.excerpt) answer = template.format(title=lead.title, excerpt=lead.excerpt)
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "grounded").inc()
return GroundedAnswer( return GroundedAnswer(
answer=answer, answer=answer,
evidence_state="grounded", evidence_state="grounded",
+44 -16
View File
@@ -1,8 +1,10 @@
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 from datetime import UTC, datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -18,12 +20,14 @@ from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, RegisterReturnRequest from app.schemas import CurrentUser, RegisterReturnRequest
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
REF_PREFIX = "INSP"
def _new_inspection_ref() -> str:
"""Generate a collision-resistant public reference without reading mutable counts.
def _next_public_ref(db: Session) -> str: Return commands for different bookings can commit concurrently. A count-based
existing = db.execute(select(Inspection.public_ref)).scalars().all() reference made those independent transactions race for the same unique value.
return f"{REF_PREFIX}-{len(existing) + 1:04d}" """
return f"INSP-{uuid.uuid4().hex[:10].upper()}"
def _derive_vehicle_status_with_reason( def _derive_vehicle_status_with_reason(
@@ -169,17 +173,17 @@ def preview_vehicle_return(
return booking, vehicle, evaluation return booking, vehicle, evaluation
def register_vehicle_return( def request_fingerprint(body: RegisterReturnRequest) -> str:
canonical = json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _replay_or_reject(
db: Session, db: Session,
existing: IdempotencyRecord,
booking_ref: str, booking_ref: str,
body: RegisterReturnRequest, fingerprint: str,
idempotency_key: str,
actor: CurrentUser,
) -> tuple[int, dict]: ) -> tuple[int, dict]:
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
booking = db.get(Booking, existing.booking_id) booking = db.get(Booking, existing.booking_id)
if booking is None or booking.public_ref != booking_ref: if booking is None or booking.public_ref != booking_ref:
raise AppError( raise AppError(
@@ -187,8 +191,29 @@ def register_vehicle_return(
"This idempotency key was already used for a different booking.", "This idempotency key was already used for a different booking.",
status_code=409, status_code=409,
) )
if existing.request_fingerprint is not None and existing.request_fingerprint != fingerprint:
raise AppError(
"IDEMPOTENCY_KEY_REUSED",
"This idempotency key was already used with a different request body.",
status_code=409,
)
return existing.response_status, existing.response_body return existing.response_status, existing.response_body
def register_vehicle_return(
db: Session,
booking_ref: str,
body: RegisterReturnRequest,
idempotency_key: str,
actor: CurrentUser,
) -> tuple[int, dict]:
fingerprint = request_fingerprint(body)
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
return _replay_or_reject(db, existing, booking_ref, fingerprint)
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True) booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
# Re-check after acquiring the row lock: a concurrent identical-key request may have # Re-check after acquiring the row lock: a concurrent identical-key request may have
@@ -197,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(
@@ -211,7 +236,7 @@ def register_vehicle_return(
evaluation = evaluate_return(db, booking, vehicle, body, now=now) evaluation = evaluate_return(db, booking, vehicle, body, now=now)
inspection = Inspection( inspection = Inspection(
public_ref=_next_public_ref(db), public_ref=_new_inspection_ref(),
booking_id=booking.id, booking_id=booking.id,
vehicle_id=vehicle.id, vehicle_id=vehicle.id,
type="return", type="return",
@@ -253,6 +278,7 @@ def register_vehicle_return(
}, },
proposed_action_json={}, proposed_action_json={},
detected_at=now, detected_at=now,
due_at=now + timedelta(days=1),
) )
db.add(issue) db.add(issue)
db.flush() db.flush()
@@ -325,6 +351,7 @@ def register_vehicle_return(
"odometer_regression": evaluation.odometer_regression, "odometer_regression": evaluation.odometer_regression,
"quality_issue_ref": quality_issue_ref, "quality_issue_ref": quality_issue_ref,
"workflow_event_id": str(event.event_id), "workflow_event_id": str(event.event_id),
"correlation_id": str(correlation_id),
"next_booking_risk": evaluation.next_booking_risk, "next_booking_risk": evaluation.next_booking_risk,
} }
@@ -332,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,
) )
@@ -345,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
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import SessionPayload, session_token_hash
from app.models.revoked_session import RevokedSession
settings = get_settings()
def is_session_revoked(db: Session, token: str) -> bool:
token_hash = session_token_hash(token)
revoked_id = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
return revoked_id is not None
def revoke_session(db: Session, token: str, payload: SessionPayload) -> None:
db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC)))
token_hash = session_token_hash(token)
existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
if existing is not None:
return
db.add(
RevokedSession(
token_hash=token_hash,
expires_at=datetime.fromtimestamp(
payload.issued_at + settings.session_ttl_seconds, UTC
),
)
)
+4 -12
View File
@@ -175,9 +175,7 @@ def evaluate_vehicle_status(
if facts.has_active_rental and not blocking_reasons: if facts.has_active_rental and not blocking_reasons:
if current == "rented": if current == "rented":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result( return result("rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False)
"rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False
)
if facts.has_active_rental and blocking_reasons: if facts.has_active_rental and blocking_reasons:
# Explicitly forbidden shortcut this evaluator must never take: an active # Explicitly forbidden shortcut this evaluator must never take: an active
@@ -190,24 +188,18 @@ def evaluate_vehicle_status(
if facts.service_threshold_reached: if facts.service_threshold_reached:
if current == "maintenance": if current == "maintenance":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result( return result("maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False)
"maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False
)
if facts.has_booking_conflict: if facts.has_booking_conflict:
if current == "blocked": if current == "blocked":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result( return result("blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False)
"blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False
)
# No active rental, no maintenance need, no booking conflict. # No active rental, no maintenance need, no booking conflict.
if current in ("available", "cleaning", "blocked"): if current in ("available", "cleaning", "blocked"):
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
if current == "rented": if current == "rented":
return result( return result("available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False)
"available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False
)
if current == "maintenance": if current == "maintenance":
# No positive fact confirms maintenance is actually finished (no completed # No positive fact confirms maintenance is actually finished (no completed
# service record is tracked here) -- clearing "maintenance" without such a # service record is tracked here) -- clearing "maintenance" without such a
+3 -1
View File
@@ -1,5 +1,7 @@
#!/bin/sh #!/bin/sh
set -e set -e
alembic upgrade head if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
alembic upgrade head
fi
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 exec uvicorn app.main:app --host 0.0.0.0 --port 8000
+6 -3
View File
@@ -14,13 +14,17 @@ dependencies = [
"sqlalchemy>=2.0,<3", "sqlalchemy>=2.0,<3",
"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",
"authlib>=1.6,<2",
"itsdangerous>=2.2,<3",
"prometheus-client>=0.24,<1"
] ]
[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"
] ]
@@ -30,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
+173
View File
@@ -0,0 +1,173 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --constraint=requirements.lock --output-file=requirements-prod.lock pyproject.toml
#
alembic==1.18.5
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
annotated-doc==0.0.5
# via
# -c requirements.lock
# fastapi
annotated-types==0.8.0
# via
# -c requirements.lock
# pydantic
anyio==4.14.2
# via
# -c requirements.lock
# httpx
# starlette
# watchfiles
authlib==1.7.2
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
certifi==2026.7.22
# via
# -c requirements.lock
# httpcore
# httpx
cffi==2.1.1
# via
# -c requirements.lock
# cryptography
click==8.4.2
# via
# -c requirements.lock
# uvicorn
cryptography==50.0.0
# via
# -c requirements.lock
# authlib
# joserfc
fastapi==0.141.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
greenlet==3.5.4
# via
# -c requirements.lock
# sqlalchemy
h11==0.16.0
# via
# -c requirements.lock
# httpcore
# uvicorn
httpcore==1.0.9
# via
# -c requirements.lock
# httpx
httptools==0.8.0
# via
# -c requirements.lock
# uvicorn
httpx==0.28.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
idna==3.18
# via
# -c requirements.lock
# anyio
# httpx
itsdangerous==2.2.0
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
joserfc==1.7.4
# via
# -c requirements.lock
# authlib
mako==1.3.12
# via
# -c requirements.lock
# alembic
markupsafe==3.0.3
# via
# -c requirements.lock
# mako
prometheus-client==0.26.0
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
psycopg[binary]==3.3.4
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
psycopg-binary==3.3.4
# via
# -c requirements.lock
# psycopg
pycparser==3.0
# via
# -c requirements.lock
# cffi
pydantic==2.13.4
# via
# -c requirements.lock
# fastapi
# pydantic-settings
pydantic-core==2.46.4
# via
# -c requirements.lock
# pydantic
pydantic-settings==2.14.2
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
python-dotenv==1.2.2
# via
# -c requirements.lock
# pydantic-settings
# uvicorn
pyyaml==6.0.3
# via
# -c requirements.lock
# uvicorn
sqlalchemy==2.0.51
# via
# -c requirements.lock
# alembic
# mobilityops-api (pyproject.toml)
starlette==1.3.1
# via
# -c requirements.lock
# fastapi
typing-extensions==4.16.0
# via
# -c requirements.lock
# alembic
# anyio
# fastapi
# psycopg
# pydantic
# pydantic-core
# sqlalchemy
# starlette
# typing-inspection
typing-inspection==0.4.2
# via
# -c requirements.lock
# fastapi
# pydantic
# pydantic-settings
uvicorn[standard]==0.52.1
# via
# -c requirements.lock
# mobilityops-api (pyproject.toml)
uvloop==0.22.1
# via
# -c requirements.lock
# uvicorn
watchfiles==1.2.0
# via
# -c requirements.lock
# uvicorn
websockets==17.0.1
# via
# -c requirements.lock
# uvicorn
+28 -4
View File
@@ -13,14 +13,23 @@ annotated-types==0.8.0
anyio==4.14.2 anyio==4.14.2
# via # via
# httpx # httpx
# httpx2
# starlette # starlette
# watchfiles # watchfiles
authlib==1.7.2
# via mobilityops-api (pyproject.toml)
certifi==2026.7.22 certifi==2026.7.22
# via # via
# httpcore # httpcore
# httpx # httpx
cffi==2.1.1
# via cryptography
click==8.4.2 click==8.4.2
# via uvicorn # via uvicorn
cryptography==50.0.0
# via
# authlib
# joserfc
fastapi==0.141.1 fastapi==0.141.1
# via mobilityops-api (pyproject.toml) # via mobilityops-api (pyproject.toml)
greenlet==3.5.4 greenlet==3.5.4
@@ -28,19 +37,29 @@ greenlet==3.5.4
h11==0.16.0 h11==0.16.0
# via # via
# httpcore # httpcore
# httpcore2
# uvicorn # uvicorn
httpcore==1.0.9 httpcore==1.0.9
# via httpx # via httpx
httpcore2==2.10.0
# via httpx2
httptools==0.8.0 httptools==0.8.0
# via uvicorn # via uvicorn
httpx==0.28.1 httpx==0.28.1
# via mobilityops-api (pyproject.toml) # via mobilityops-api (pyproject.toml)
httpx2==2.10.0
# via mobilityops-api (pyproject.toml)
idna==3.18 idna==3.18
# via # via
# anyio # anyio
# httpx # httpx
# httpx2
iniconfig==2.3.0 iniconfig==2.3.0
# via pytest # via pytest
itsdangerous==2.2.0
# via mobilityops-api (pyproject.toml)
joserfc==1.7.4
# via authlib
librt==0.13.0 librt==0.13.0
# via mypy # via mypy
mako==1.3.12 mako==1.3.12
@@ -57,10 +76,14 @@ pathspec==1.1.1
# via mypy # via mypy
pluggy==1.6.0 pluggy==1.6.0
# via pytest # via pytest
prometheus-client==0.26.0
# via mobilityops-api (pyproject.toml)
psycopg[binary]==3.3.4 psycopg[binary]==3.3.4
# via mobilityops-api (pyproject.toml) # via mobilityops-api (pyproject.toml)
psycopg-binary==3.3.4 psycopg-binary==3.3.4
# via psycopg # via psycopg
pycparser==3.0
# via cffi
pydantic==2.13.4 pydantic==2.13.4
# via # via
# fastapi # fastapi
@@ -72,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
@@ -91,11 +110,16 @@ sqlalchemy==2.0.51
# mobilityops-api (pyproject.toml) # mobilityops-api (pyproject.toml)
starlette==1.3.1 starlette==1.3.1
# via fastapi # via fastapi
truststore==0.10.4
# via
# httpcore2
# httpx2
typing-extensions==4.16.0 typing-extensions==4.16.0
# via # via
# alembic # alembic
# anyio # anyio
# fastapi # fastapi
# httpx2
# mypy # mypy
# psycopg # psycopg
# pydantic # pydantic
+31
View File
@@ -0,0 +1,31 @@
"""Generate the checked-in API contract from the FastAPI application."""
from __future__ import annotations
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.main import app # noqa: E402
def main() -> None:
schema = app.openapi()
schema["info"]["description"] = (
"Generated contract for Fleet Ops. The visible product name is Fleet Ops; "
"MobilityOps remains the technical repository and service identifier."
)
schema["servers"] = [{"url": "http://localhost:8128"}]
output = ROOT / "contracts" / "openapi.yaml"
output.write_text(
yaml.safe_dump(schema, sort_keys=False, allow_unicode=True, width=100),
encoding="utf-8",
)
if __name__ == "__main__":
main()
+1 -3
View File
@@ -66,9 +66,7 @@ def test_return_registered_audit_event_exposes_before_after_and_link(ops_client)
) )
key = "test-audit-before-after-001" key = "test-audit-before-after-001"
events = ops_client.get( events = ops_client.get("/api/v1/audit", params={"action": "return_registered"}).json()
"/api/v1/audit", params={"action": "return_registered"}
).json()
event = next(e for e in events if e["metadata"]["idempotency_key"] == key) event = next(e for e in events if e["metadata"]["idempotency_key"] == key)
assert event["before"] == {"status": "active"} assert event["before"] == {"status": "active"}
assert event["after"]["status"] == "returned" assert event["after"]["status"] == "returned"
+136
View File
@@ -4,6 +4,71 @@ def test_unauthenticated_dashboard_is_rejected(client):
assert response.json()["error"]["code"] == "401" assert response.json()["error"]["code"] == "401"
def test_oidc_status_is_disabled_without_configuration(client):
assert client.get("/api/v1/auth/oidc/status").json() == {
"enabled": False,
"provider_name": None,
}
assert client.get("/api/v1/auth/oidc/login").status_code == 404
def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch):
import app.api.routers.auth as auth_router
class FakeClient:
async def authorize_access_token(self, _request):
return {
"userinfo": {
"sub": "external-subject-1",
"email": "oidc.user@example.test",
"email_verified": True,
"name": "OIDC User",
}
}
class FakeOAuth:
def create_client(self, _name):
return FakeClient()
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"].endswith("/dashboard")
session = client.get("/api/v1/auth/session")
assert session.status_code == 200
assert session.json()["display_name"] == "OIDC User"
assert session.json()["role"] == "rental_employee"
def test_oidc_callback_rejects_missing_email_verification_claim(client, monkeypatch):
import app.api.routers.auth as auth_router
class FakeClient:
async def authorize_access_token(self, _request):
return {"userinfo": {"sub": "unverified-subject", "email": "new@example.test"}}
class FakeOAuth:
def create_client(self, _name):
return FakeClient()
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
assert response.status_code == 401
assert client.get("/api/v1/auth/session").status_code == 401
def test_demo_login_grants_access(ops_client): def test_demo_login_grants_access(ops_client):
response = ops_client.get("/api/v1/dashboard") response = ops_client.get("/api/v1/dashboard")
assert response.status_code == 200 assert response.status_code == 200
@@ -25,6 +90,28 @@ def test_operations_manager_can_reset_demo(ops_client):
assert body["scenario_integrity"]["not_ready"] == [] assert body["scenario_integrity"]["not_ready"] == []
def test_demo_reset_rejects_concurrent_rebuild(ops_client):
import app.api.routers.demo as demo_router
assert demo_router._reset_guard.acquire(blocking=False)
try:
response = ops_client.post("/api/v1/demo/reset")
finally:
demo_router._reset_guard.release()
assert response.status_code == 409
def test_demo_reset_cooldown_returns_retry_after(ops_client, monkeypatch):
import app.api.routers.demo as demo_router
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 60)
monkeypatch.setattr(demo_router, "_last_reset_monotonic", demo_router.time.monotonic())
response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 429
assert int(response.headers["retry-after"]) >= 1
monkeypatch.setattr(demo_router, "_last_reset_monotonic", 0.0)
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch): def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
import app.api.routers.demo as demo_router import app.api.routers.demo as demo_router
@@ -62,6 +149,55 @@ def test_logout_invalidates_session(ops_client):
assert after.status_code == 401 assert after.status_code == 401
def test_logout_rejects_a_cookie_even_if_the_browser_retains_it(ops_client):
from app.core.config import get_settings
cookie_name = get_settings().session_cookie_name
stolen_token = ops_client.cookies.get(cookie_name)
assert stolen_token
logout = ops_client.post("/api/v1/auth/logout")
assert logout.status_code == 200
# Simulate the browser cookie race (or a copied cookie): server-side revocation is
# authoritative and must reject the original signed token independently of deletion.
ops_client.cookies.set(cookie_name, stolen_token)
assert ops_client.get("/api/v1/auth/session").status_code == 401
# Remove the deliberately injected hostless cookie before exercising a normal browser
# login. Otherwise httpx sends it alongside the real testserver cookie, which is not a
# state a browser can create for the same origin/path pair.
ops_client.cookies.clear()
# A fresh login in the same second receives a distinct signed token and remains valid.
fresh_login = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
assert fresh_login.status_code == 200
assert ops_client.get("/api/v1/auth/session").status_code == 200
def test_logout_without_a_session_is_safe(client): def test_logout_without_a_session_is_safe(client):
response = client.post("/api/v1/demo/logout") response = client.post("/api/v1/demo/logout")
assert response.status_code == 200 assert response.status_code == 200
def test_demo_reset_preserves_integration_telemetry(ops_client, client):
from app.core.config import get_settings
settings = get_settings()
probe = client.get(
"/api/v1/integrations/mcp/operations-summary",
headers={
"X-Service-Token": settings.mcp_hub_service_token,
"X-Client-Id": "itworx-mcp-hub:mobilityops:reset-probe",
},
)
assert probe.status_code == 200
assert ops_client.post("/api/v1/demo/reset").status_code == 200
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
events = client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
assert any(
event["actor_label"] == "itworx-mcp-hub"
and event["metadata"].get("reported_client_id", "").endswith(":reset-probe")
for event in events
)
+237
View File
@@ -1,3 +1,11 @@
import threading
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from app.main import app
def test_list_bookings_filters_by_vehicle(ops_client): def test_list_bookings_filters_by_vehicle(ops_client):
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"}) response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"})
assert response.status_code == 200 assert response.status_code == 200
@@ -6,6 +14,235 @@ def test_list_bookings_filters_by_vehicle(ops_client):
assert all(b["vehicle_ref"] == "MO-024" for b in bookings) assert all(b["vehicle_ref"] == "MO-024" for b in bookings)
def test_list_bookings_supports_bounded_search_pages(ops_client):
response = ops_client.get(
"/api/v1/bookings",
params={"query": "BK-", "page": 1, "page_size": 25},
)
assert response.status_code == 200
body = response.json()
assert body["page"] == 1
assert body["page_size"] == 25
assert body["total"] > 25
assert body["total_pages"] > 1
assert len(body["items"]) == 25
def test_list_bookings_filters_operational_window_and_location(ops_client):
booking = ops_client.get("/api/v1/bookings").json()[0]
vehicle = ops_client.get(f"/api/v1/vehicles/{booking['vehicle_ref']}").json()
starts_at = datetime.fromisoformat(booking["starts_at"])
response = ops_client.get(
"/api/v1/bookings",
params={
"starts_from": (starts_at - timedelta(minutes=1)).isoformat(),
"starts_to": (starts_at + timedelta(minutes=1)).isoformat(),
"location": vehicle["location"],
"sort": "starts_asc",
},
)
assert response.status_code == 200
assert booking["public_ref"] in {item["public_ref"] for item in response.json()}
def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
conflict = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": existing["vehicle_ref"],
"starts_at": existing["starts_at"],
"ends_at": existing["ends_at"],
},
)
assert conflict.status_code == 409
available_vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[
0
]["public_ref"]
created = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": available_vehicle,
"starts_at": "2030-09-01T10:00:00Z",
"ends_at": "2030-09-02T12:00:00Z",
},
)
assert created.status_code == 201
body = created.json()
assert body["status"] == "reserved"
assert body["vehicle_ref"] == available_vehicle
assert body["requirements_complete"] is False
def test_booking_requirements_are_explicit_and_audited(ops_client):
window = {"starts_at": "2031-09-01T10:00:00Z", "ends_at": "2031-09-02T12:00:00Z"}
vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0]
booking = ops_client.post(
"/api/v1/bookings",
json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window},
).json()
checkout = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/checkout",
json={
"start_odometer_km": 100000,
"fuel_level_percent": 90,
"cleanliness_ok": True,
"damage_reported": False,
"technical_warning": False,
},
)
assert checkout.status_code == 409
confirmed = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
json={"confirmation": "Licence and rental conditions checked"},
)
assert confirmed.status_code == 200
assert confirmed.json()["requirements_complete"] is True
audit = ops_client.get("/api/v1/audit", params={"action": "booking_requirements_completed"})
assert audit.status_code == 200
assert any(item["entity_ref"] == booking["public_ref"] for item in audit.json())
def test_customer_search_returns_canonical_customers(ops_client):
response = ops_client.get("/api/v1/customers", params={"query": "CUS-"})
assert response.status_code == 200
assert response.json()
assert all(item["public_ref"].startswith("CUS-") for item in response.json())
def test_booking_availability_excludes_overlapping_vehicle(ops_client):
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
response = ops_client.get(
"/api/v1/bookings/availability",
params={"starts_at": existing["starts_at"], "ends_at": existing["ends_at"]},
)
assert response.status_code == 200
assert existing["vehicle_ref"] not in {item["public_ref"] for item in response.json()}
def test_reserved_booking_can_be_cancelled_once(ops_client):
window = {"starts_at": "2032-09-01T10:00:00Z", "ends_at": "2032-09-02T12:00:00Z"}
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
assert available
create_response = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": available[0]["public_ref"],
**window,
},
)
assert create_response.status_code == 201
created = create_response.json()
response = ops_client.post(
f"/api/v1/bookings/{created['public_ref']}/cancel",
json={"reason": "Customer changed plans"},
)
assert response.status_code == 200
assert response.json()["status"] == "cancelled"
repeated = ops_client.post(
f"/api/v1/bookings/{created['public_ref']}/cancel",
json={"reason": "Customer changed plans"},
)
assert repeated.status_code == 409
def test_reserved_booking_can_be_rescheduled_with_overlap_protection(ops_client):
window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
booking = ops_client.post(
"/api/v1/bookings",
json={"customer_ref": "CUS-0001", "vehicle_ref": existing["vehicle_ref"], **window},
).json()
updated = ops_client.patch(
f"/api/v1/bookings/{booking['public_ref']}/schedule",
json={
"starts_at": "2033-09-03T10:00:00Z",
"ends_at": "2033-09-04T12:00:00Z",
"reason": "Customer requested a later collection",
},
)
assert updated.status_code == 200
assert updated.json()["starts_at"].startswith("2033-09-03T10:00:00")
conflict = ops_client.patch(
f"/api/v1/bookings/{booking['public_ref']}/schedule",
json={
"starts_at": existing["starts_at"],
"ends_at": existing["ends_at"],
"reason": "Conflicting test move",
},
)
assert conflict.status_code == 409
def test_concurrent_bookings_only_reserve_vehicle_once():
results: list[int] = []
seed_client = TestClient(app)
seed_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
window = {"starts_at": "2040-09-01T10:00:00Z", "ends_at": "2040-09-02T12:00:00Z"}
available = seed_client.get("/api/v1/bookings/availability", params=window).json()
assert available
vehicle_ref = available[0]["public_ref"]
def submit() -> None:
client = TestClient(app)
client.post("/api/v1/demo/login", json={"role": "operations_manager"})
response = client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": vehicle_ref,
**window,
},
)
results.append(response.status_code)
threads = [threading.Thread(target=submit) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert results.count(201) == 1
assert results.count(409) == 1
def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
window = {"starts_at": "2050-09-01T10:00:00Z", "ends_at": "2050-09-02T12:00:00Z"}
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
vehicle_option = next(item for item in available if item["operational_status"] == "available")
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
booking = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": vehicle["public_ref"],
"requirements_complete": True,
**window,
},
).json()
response = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/checkout",
json={
"start_odometer_km": vehicle["odometer_km"],
"fuel_level_percent": 95,
"cleanliness_ok": True,
"damage_reported": False,
"technical_warning": False,
},
)
assert response.status_code == 200
assert response.json()["activated"] is True
assert response.json()["booking_status"] == "active"
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
assert updated_vehicle["operational_status"] == "rented"
assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"])
def test_get_booking_detail(ops_client): def test_get_booking_detail(ops_client):
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
assert response.status_code == 200 assert response.status_code == 200
+68 -12
View File
@@ -53,6 +53,49 @@ def test_list_issues_requires_operations_manager(employee_client):
assert response.status_code == 403 assert response.status_code == 403
def test_manager_can_assign_prioritised_quality_work_and_filter_it(ops_client):
assignee = next(user for user in ops_client.get("/api/v1/users").json() if user["active"])
open_issues = ops_client.get("/api/v1/data-quality/issues", params={"status": "open"}).json()
refs = [issue["public_ref"] for issue in open_issues[:2]]
due_at = "2030-01-15T12:00:00+00:00"
updated = ops_client.post(
"/api/v1/data-quality/issues/bulk-work",
json={
"issue_refs": refs,
"assigned_to_ref": assignee["public_ref"],
"due_at": due_at,
},
)
assert updated.status_code == 200
assert {issue["public_ref"] for issue in updated.json()["updated"]} == set(refs)
assert all(
issue["assigned_to_ref"] == assignee["public_ref"] for issue in updated.json()["updated"]
)
filtered = ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "assigned_to_ref": assignee["public_ref"]},
).json()
assert set(refs).issubset({issue["public_ref"] for issue in filtered})
audits = ops_client.get("/api/v1/audit", params={"action": "data_quality_work_updated"}).json()
assert len(audits) >= 2
cleared = ops_client.post(
"/api/v1/data-quality/issues/bulk-work",
json={"issue_refs": refs, "clear_assignment": True},
)
assert cleared.status_code == 200
assert all(issue["assigned_to_ref"] is None for issue in cleared.json()["updated"])
def test_employee_cannot_assign_quality_work(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/bulk-work",
json={"issue_refs": ["DQ-DEMO-OVERLAP"], "clear_assignment": True},
)
assert response.status_code == 403
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client): def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
response = ops_client.get( response = ops_client.get(
"/api/v1/data-quality/issues", "/api/v1/data-quality/issues",
@@ -148,9 +191,7 @@ def test_merge_customers_s2_scenario_rewires_and_audits(ops_client):
issue = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE").json() issue = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE").json()
assert issue["status"] == "resolved" assert issue["status"] == "resolved"
audit_events = ops_client.get( audit_events = ops_client.get("/api/v1/audit", params={"action": "customer_merged"}).json()
"/api/v1/audit", params={"action": "customer_merged"}
).json()
assert len(audit_events) >= 1 assert len(audit_events) >= 1
# Already-resolved issue cannot be merged again. # Already-resolved issue cannot be merged again.
@@ -313,10 +354,21 @@ def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
def test_apply_recommended_status_resolves_conflict(ops_client): def test_apply_recommended_status_resolves_conflict(ops_client):
target = _first_open(ops_client, "vehicle_status_conflict") targets = ops_client.get(
preview = ops_client.post( "/api/v1/data-quality/issues",
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation" params={"rule_type": "vehicle_status_conflict", "status": "open"},
).json() ).json()
target = None
preview = None
for candidate in targets:
candidate_preview = ops_client.post(
f"/api/v1/data-quality/issues/{candidate['public_ref']}/status-recommendation"
).json()
if candidate_preview["safe_to_apply"]:
target = candidate
preview = candidate_preview
break
assert target is not None and preview is not None
assert preview["safe_to_apply"] is True assert preview["safe_to_apply"] is True
assert preview["manual_review_required"] is False assert preview["manual_review_required"] is False
@@ -431,9 +483,7 @@ def test_manual_scan_records_audit_event(ops_client):
scan = ops_client.post("/api/v1/data-quality/scan") scan = ops_client.post("/api/v1/data-quality/scan")
assert scan.status_code == 200 assert scan.status_code == 200
events = ops_client.get( events = ops_client.get("/api/v1/audit", params={"action": "data_quality_scan_run"}).json()
"/api/v1/audit", params={"action": "data_quality_scan_run"}
).json()
assert len(events) >= 1 assert len(events) >= 1
assert "created" in events[0]["metadata"] assert "created" in events[0]["metadata"]
@@ -444,9 +494,7 @@ def _reset_demo(ops_client) -> None:
# in before making any further authenticated call with the same client. # in before making any further authenticated call with the same client.
response = ops_client.post("/api/v1/demo/reset") response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
login_response = ops_client.post( login_response = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
"/api/v1/demo/login", json={"role": "operations_manager"}
)
assert login_response.status_code == 200, login_response.text assert login_response.status_code == 200, login_response.text
@@ -548,3 +596,11 @@ def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
) )
assert match is not None, "expected a new issue linked back to the rejected one" assert match is not None, "expected a new issue linked back to the rejected one"
assert match["evidence"]["previous_decision"] == "rejected" assert match["evidence"]["previous_decision"] == "rejected"
def test_scan_public_refs_are_collision_resistant() -> None:
from app.services.data_quality import _new_scan_ref
refs = {_new_scan_ref("DQ-SCAN") for _ in range(1000)}
assert len(refs) == 1000
assert all(ref.startswith("DQ-SCAN-") and len(ref) == 18 for ref in refs)
@@ -0,0 +1,33 @@
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.core.db import SessionLocal
@pytest.mark.parametrize(
("statement", "constraint_name"),
(
(
"UPDATE vehicles SET odometer_km = -1 WHERE public_ref = 'MO-001'",
"ck_vehicles_odometer",
),
(
"UPDATE bookings SET ends_at = starts_at WHERE public_ref = 'BK-DEMO-RETURN'",
"ck_bookings_time_window",
),
(
"UPDATE data_quality_issues SET status = 'invented' "
"WHERE public_ref = 'DQ-DEMO-DUPLICATE'",
"ck_data_quality_status",
),
("UPDATE outbox_events SET attempts = -1", "ck_outbox_attempts"),
),
)
def test_database_rejects_invalid_domain_state(statement: str, constraint_name: str) -> None:
with SessionLocal() as db:
with pytest.raises(IntegrityError) as caught:
db.execute(text(statement))
db.commit()
db.rollback()
assert constraint_name in str(caught.value)
+336
View File
@@ -0,0 +1,336 @@
"""Regression tests for the hardening pass (security, robustness, data-quality edge cases)."""
from __future__ import annotations
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from threading import Barrier
import pytest
from sqlalchemy import delete, func, select
from app.core.config import Settings, get_settings, insecure_default_secrets
from app.core.db import SessionLocal
from app.core.errors import AppError
from app.core.observability import UNMATCHED_ROUTE_LABEL
from app.core.ratelimit import FailedAttemptLimiter, SlidingWindowLimiter
from app.models.audit import AuditEvent
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.schemas import CurrentUser
from app.services.data_quality import defer_issue, run_scan
from tests.test_return import _activate_booking, _return_body
def test_insecure_defaults_include_mounted_mcp_boundary_even_when_status_check_is_disabled():
defaults = Settings(_env_file=None)
assert "app_secret" in insecure_default_secrets(defaults)
assert "mcp_hub_service_token" in insecure_default_secrets(defaults)
hardened = Settings(
_env_file=None,
app_secret="x" * 32,
n8n_callback_token="c" * 32,
mcp_hub_service_token="m" * 32,
)
assert insecure_default_secrets(hardened) == []
def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
monkeypatch.setenv("MOBILITYOPS_ENV", "production")
monkeypatch.setenv("APP_SECRET", "replace-in-production")
get_settings.cache_clear()
try:
with pytest.raises(RuntimeError, match="placeholder secrets"):
get_settings()
finally:
get_settings.cache_clear()
monkeypatch.undo()
get_settings.cache_clear()
# The cached settings the running app relies on must be intact afterwards.
assert get_settings().mobilityops_env == "test"
@pytest.mark.parametrize(
("public_url", "secure_cookie", "message"),
[
("http://fleetops.example.test", "true", "must use HTTPS"),
("https://fleetops.example.test", "false", "must be true"),
],
)
def test_production_refuses_cleartext_or_insecure_session_cookie(
monkeypatch, public_url, secure_cookie, message
):
values = {
"MOBILITYOPS_ENV": "production",
"APP_SECRET": "a" * 32,
"N8N_CALLBACK_TOKEN": "c" * 32,
"MCP_HUB_SERVICE_TOKEN": "m" * 32,
"MOBILITYOPS_PUBLIC_URL": public_url,
"SESSION_COOKIE_SECURE": secure_cookie,
}
for name, value in values.items():
monkeypatch.setenv(name, value)
get_settings.cache_clear()
try:
with pytest.raises(RuntimeError, match=message):
get_settings()
finally:
get_settings.cache_clear()
monkeypatch.undo()
get_settings.cache_clear()
assert get_settings().mobilityops_env == "test"
def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60)
for _ in range(3):
assert limiter.retry_after_seconds("1.2.3.4") == 0
limiter.record_failure("1.2.3.4")
assert limiter.retry_after_seconds("1.2.3.4") > 0
assert limiter.retry_after_seconds("5.6.7.8") == 0
limiter.reset("1.2.3.4")
assert limiter.retry_after_seconds("1.2.3.4") == 0
def test_sliding_window_limiter_counts_successes_and_isolates_keys():
limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60)
assert limiter.consume("session-a") == 0
assert limiter.consume("session-a") == 0
assert limiter.consume("session-a") > 0
assert limiter.consume("session-b") == 0
def test_login_client_key_uses_proxy_appended_peer_not_spoofed_leading_value():
from starlette.requests import Request
from app.api.routers.auth import _client_key
request = Request(
{
"type": "http",
"headers": [(b"x-forwarded-for", b"203.0.113.99, 198.51.100.7")],
"client": ("172.20.0.3", 12345),
}
)
assert _client_key(request) == "198.51.100.7"
def test_audit_export_accepts_naive_datetimes(ops_client):
response = ops_client.get(
"/api/v1/audit/export.csv",
params={"occurred_from": "2026-01-01T00:00:00", "occurred_to": "2026-01-15T00:00:00"},
)
assert response.status_code == 200
def test_audit_list_rejects_non_uuid_correlation_id(ops_client):
response = ops_client.get("/api/v1/audit", params={"correlation_id": "not-a-uuid"})
assert response.status_code == 422
def test_unmatched_routes_do_not_create_metric_series(client):
probe = f"/api/v1/does-not-exist-{uuid.uuid4().hex}"
assert client.get(probe).status_code == 404
metrics = client.get("/metrics").text
assert probe not in metrics
assert UNMATCHED_ROUTE_LABEL in metrics
def test_bookings_page_for_unknown_vehicle_keeps_page_shape(ops_client):
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-NOPE", "page": 1})
assert response.status_code == 200
assert response.json() == {
"items": [],
"page": 1,
"page_size": 25,
"total": 0,
"total_pages": 1,
}
def test_return_idempotency_key_rejects_different_body(ops_client):
booking_ref = _activate_booking("MO-011", start_odometer_km=30000)
key = "test-return-fingerprint-001"
first = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=30500),
headers={"Idempotency-Key": key},
)
assert first.status_code == 201
replay = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=30500),
headers={"Idempotency-Key": key},
)
assert replay.status_code == 201
mismatch = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=30999),
headers={"Idempotency-Key": key},
)
assert mismatch.status_code == 409
assert mismatch.json()["error"]["code"] == "IDEMPOTENCY_KEY_REUSED"
def test_return_callback_rejects_malformed_correlation_id(client):
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()
def test_concurrent_quality_scans_leave_only_one_open_issue_per_condition():
barrier = Barrier(2)
def scan() -> None:
with SessionLocal() as db:
barrier.wait()
run_scan(db)
with ThreadPoolExecutor(max_workers=2) as executor:
list(executor.map(lambda _index: scan(), range(2)))
with SessionLocal() as db:
duplicates = db.execute(
select(
DataQualityIssue.rule_type,
DataQualityIssue.entity_type,
DataQualityIssue.entity_id,
func.count(DataQualityIssue.id),
)
.where(DataQualityIssue.status == "open")
.group_by(
DataQualityIssue.rule_type,
DataQualityIssue.entity_type,
DataQualityIssue.entity_id,
)
.having(func.count(DataQualityIssue.id) > 1)
).all()
assert duplicates == []
def test_concurrent_issue_resolution_records_exactly_one_decision():
issue_id = uuid.uuid4()
issue_ref = f"DQ-RACE-{uuid.uuid4().hex[:8].upper()}"
with SessionLocal() as db:
db.add(
DataQualityIssue(
id=issue_id,
public_ref=issue_ref,
rule_type="missing_required_field",
entity_type="customer",
entity_id=uuid.uuid4(),
severity="low",
status="open",
evidence_json={},
proposed_action_json={},
detected_at=datetime.now(UTC),
)
)
db.commit()
actor = CurrentUser(
public_ref="USR-RACE", display_name="Race Manager", role="operations_manager"
)
barrier = Barrier(2)
def resolve() -> str:
with SessionLocal() as db:
barrier.wait()
try:
return defer_issue(db, issue_ref, actor).status
except AppError as exc:
return exc.code
try:
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(executor.map(lambda _index: resolve(), range(2)))
assert sorted(outcomes) == ["ISSUE_NOT_OPEN", "deferred"]
with SessionLocal() as db:
decisions = db.scalar(
select(func.count(AuditEvent.id)).where(
AuditEvent.entity_id == issue_id,
AuditEvent.action == "data_quality_issue_deferred",
)
)
assert decisions == 1
finally:
with SessionLocal() as db:
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == issue_id))
db.execute(delete(DataQualityIssue).where(DataQualityIssue.id == issue_id))
db.commit()
+32
View File
@@ -7,3 +7,35 @@ def test_health() -> None:
response = TestClient(app).get("/health") response = TestClient(app).get("/health")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"status": "ok", "service": "mobilityops-api"} assert response.json() == {"status": "ok", "service": "mobilityops-api"}
def test_liveness_is_process_only() -> None:
response = TestClient(app).get("/health/live")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_readiness_checks_the_canonical_database() -> None:
response = TestClient(app).get("/health/ready")
assert response.status_code == 200
assert response.json() == {
"status": "ready",
"service": "mobilityops-api",
"database": "up",
}
def test_readiness_degrades_when_database_is_unavailable(monkeypatch) -> None:
import app.main as main_module
class BrokenSession:
def __enter__(self):
raise ConnectionError("database unavailable")
def __exit__(self, *_args):
return False
monkeypatch.setattr(main_module, "SessionLocal", BrokenSession)
response = TestClient(app).get("/health/ready")
assert response.status_code == 503
assert response.json()["database"] == "down"
+1
View File
@@ -124,6 +124,7 @@ def test_integration_status_lists_all_four_canonical_workflows(ops_client):
# their own first real signal -- there is no run evidence yet either. # their own first real signal -- there is no run evidence yet either.
assert ragcore_sync["built"] is True assert ragcore_sync["built"] is True
assert ragcore_sync["last_seen_at"] is None assert ragcore_sync["last_seen_at"] is None
assert ragcore_sync["state"] == "no_evidence"
def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client): def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client):
+37
View File
@@ -264,3 +264,40 @@ def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
status = ops_client.get("/api/v1/integrations/status").json()["n8n"] status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"]) ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"])
assert ragcore_sync["last_seen_at"] is not None assert ragcore_sync["last_seen_at"] is not None
def test_workflow_heartbeat_is_idempotent_and_drives_live_status(client, ops_client):
settings = get_settings()
execution_id = str(uuid.uuid4())
body = {
"workflow_id": "mobilityops-scheduled-quality-scan",
"workflow_name": "Fleet Ops — Scheduled Data Quality Scan",
"execution_id": execution_id,
"status": "succeeded",
}
headers = {"X-Service-Token": settings.n8n_callback_token}
first = client.post("/api/v1/integrations/n8n/heartbeat", json=body, headers=headers)
second = client.post("/api/v1/integrations/n8n/heartbeat", json=body, headers=headers)
assert first.status_code == 200
assert first.json()["status"] == "registered"
assert second.json()["status"] == "already_registered"
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
workflow = next(w for w in status["workflows"] if w["name"] == body["workflow_name"])
assert workflow["state"] == "healthy"
assert workflow["last_status"] == "succeeded"
assert workflow["last_execution_id"] == execution_id
def test_workflow_heartbeat_rejects_unknown_workflow(client):
response = client.post(
"/api/v1/integrations/n8n/heartbeat",
json={
"workflow_id": "unknown",
"workflow_name": "Unknown workflow",
"execution_id": "test-unknown-001",
"status": "succeeded",
},
headers={"X-Service-Token": get_settings().n8n_callback_token},
)
assert response.status_code == 422
+330 -8
View File
@@ -5,8 +5,12 @@ from pathlib import Path
import httpx import httpx
from app.api.routers import knowledge as knowledge_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.ratelimit import SlidingWindowLimiter
from app.services.knowledge import KnowledgeHealth
from app.services.knowledge.demo import DemoKnowledgeProvider from app.services.knowledge.demo import DemoKnowledgeProvider
from app.services.knowledge.procedures import iter_procedure_documents
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
@@ -73,6 +77,8 @@ def test_demo_provider_health_reports_document_count():
assert health.provider == "demo" assert health.provider == "demo"
assert health.available is True assert health.available is True
assert health.document_count == 11 assert health.document_count == 11
assert health.source_document_count == 11
assert health.statistics_state == "verified"
def test_demo_provider_health_reports_document_count_per_language(): def test_demo_provider_health_reports_document_count_per_language():
@@ -107,9 +113,7 @@ def test_demo_provider_grounds_damage_question_in_french():
def test_demo_provider_insufficient_evidence_message_is_localized(): def test_demo_provider_insufficient_evidence_message_is_localized():
provider = DemoKnowledgeProvider() provider = DemoKnowledgeProvider()
nl_answer = provider.ask( nl_answer = provider.ask("Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE")
"Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE"
)
fr_answer = provider.ask( fr_answer = provider.ask(
"Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE" "Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE"
) )
@@ -137,14 +141,22 @@ def test_ask_question_requires_authentication(client):
assert response.status_code == 401 assert response.status_code == 401
def test_knowledge_questions_are_bounded_per_session_and_client(ops_client, monkeypatch):
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60)
monkeypatch.setattr(knowledge_router, "_question_limiter", limiter)
body = {"question": "What is the vehicle return procedure?"}
assert ops_client.post("/api/v1/knowledge/questions", json=body).status_code == 200
blocked = ops_client.post("/api/v1/knowledge/questions", json=body)
assert blocked.status_code == 429
assert int(blocked.headers["retry-after"]) >= 1
def test_ask_question_is_audited_without_leaking_full_text(ops_client): def test_ask_question_is_audited_without_leaking_full_text(ops_client):
ops_client.post( ops_client.post(
"/api/v1/knowledge/questions", "/api/v1/knowledge/questions",
json={"question": "What must I do when a vehicle returns with damage?"}, json={"question": "What must I do when a vehicle returns with damage?"},
) )
events = ops_client.get( events = ops_client.get("/api/v1/audit", params={"action": "knowledge_question_asked"}).json()
"/api/v1/audit", params={"action": "knowledge_question_asked"}
).json()
assert len(events) >= 1 assert len(events) >= 1
metadata = events[0]["metadata"] metadata = events[0]["metadata"]
assert "evidence_state" in metadata assert "evidence_state" in metadata
@@ -158,6 +170,79 @@ def test_knowledge_status_endpoint(ops_client):
assert response.json()["provider"] == "demo" assert response.json()["provider"] == "demo"
def test_ragcore_status_separates_sync_report_from_unverifiable_index(
client, ops_client, monkeypatch
):
class FakeRagcoreProvider:
def health(self, language="en-GB"):
return KnowledgeHealth(
provider="ragcore",
available=True,
detail="ready",
tenant="fleet-ops",
workspace="operations",
collection="internal-procedures",
document_count=None,
source_document_count=11,
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="not_reported",
)
monkeypatch.setattr(knowledge_router, "get_knowledge_provider", FakeRagcoreProvider)
settings = get_settings()
sync = client.post(
"/api/v1/integrations/n8n/procedures-sync-result",
json={"execution_id": "rag-statistics-test", "synced": 32, "failed": 1},
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert sync.status_code == 200
response = ops_client.get("/api/v1/knowledge/status?language=nl-BE")
assert response.status_code == 200
status = response.json()
assert status["document_count"] is None
assert status["source_document_count"] == 11
assert status["reported_synced_document_count"] == 32
assert status["reported_failed_document_count"] == 1
assert status["last_sync_at"] is not None
assert status["statistics_state"] == "sync_reported"
def test_ragcore_status_preserves_stronger_verified_index_evidence(client, ops_client, monkeypatch):
class VerifiedRagcoreProvider:
def health(self, language="en-GB"):
return KnowledgeHealth(
provider="ragcore",
available=True,
detail="ready and verified",
tenant="fleet-ops",
workspace="operations",
collection="internal-procedures",
document_count=11,
source_document_count=11,
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="verified",
)
monkeypatch.setattr(knowledge_router, "get_knowledge_provider", VerifiedRagcoreProvider)
settings = get_settings()
sync = client.post(
"/api/v1/integrations/n8n/procedures-sync-result",
json={"execution_id": "rag-verified-statistics-test", "synced": 33, "failed": 0},
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert sync.status_code == 200
status = ops_client.get("/api/v1/knowledge/status?language=nl-BE").json()
assert status["document_count"] == 11
assert status["reported_synced_document_count"] == 33
assert status["statistics_state"] == "verified"
class _FakeResponse: class _FakeResponse:
def __init__(self, status_code: int, body: dict): def __init__(self, status_code: int, body: dict):
self.status_code = status_code self.status_code = status_code
@@ -168,7 +253,14 @@ class _FakeResponse:
class _FakeClient: class _FakeClient:
def __init__(self, get_response=None, post_response=None, post_responses=None, raise_on=None): def __init__(
self,
get_response=None,
post_response=None,
post_responses=None,
raise_on=None,
get_handler=None,
):
self._get_response = get_response self._get_response = get_response
self._post_response = post_response self._post_response = post_response
# Maps a path (e.g. "/v1/search") to its own response, for tests that need # Maps a path (e.g. "/v1/search") to its own response, for tests that need
@@ -177,6 +269,7 @@ class _FakeClient:
# existing single-endpoint test keeps working unchanged. # existing single-endpoint test keeps working unchanged.
self._post_responses = post_responses or {} self._post_responses = post_responses or {}
self._raise_on = raise_on self._raise_on = raise_on
self._get_handler = get_handler
def __enter__(self): def __enter__(self):
return self return self
@@ -184,9 +277,13 @@ class _FakeClient:
def __exit__(self, *args): def __exit__(self, *args):
return False return False
def get(self, path): def get(self, path, params=None, timeout=None):
if self._raise_on == "get": if self._raise_on == "get":
raise httpx.ConnectError("no ragcore in this environment") raise httpx.ConnectError("no ragcore in this environment")
if self._get_handler is not None:
return self._get_handler(path, params, timeout)
if path == "/v1/documents":
return _FakeResponse(503, {})
return self._get_response return self._get_response
def post(self, path, json=None): def post(self, path, json=None):
@@ -228,6 +325,53 @@ def test_ragcore_provider_health_reports_ready_status(monkeypatch):
health = provider.health() health = provider.health()
assert health.provider == "ragcore" assert health.provider == "ragcore"
assert health.available is True assert health.available is True
assert health.document_count is None
assert health.source_document_count == 11
assert health.statistics_state == "not_reported"
def test_ragcore_provider_verifies_published_documents_and_caches_count(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
procedure_documents = [
document
for document in iter_procedure_documents(Path(provider._settings.knowledge_dir))
if document.language == "en-GB"
]
documents = {document.source_id: document for document in procedure_documents}
lookups: list[str] = []
def get_handler(path, params, _timeout):
if path == "/health/ready":
return _FakeResponse(200, {"status": "ok"})
document = documents[params["source_id"]]
lookups.append(document.source_id)
return _FakeResponse(
200,
{
"items": [
{
"space_id": "space-1",
"source_id": document.source_id,
"external_id": f"{document.document_id}.md",
"status": "active",
"active_version": {
"status": "published",
"content_sha256": "provider-canonical-hash",
},
}
]
},
)
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(get_handler=get_handler))
first = provider.health("en-GB")
second = provider.health("en-GB")
assert first.document_count == 11
assert first.statistics_state == "verified"
assert second.document_count == 11
assert len(lookups) == 11
def test_ragcore_provider_health_reports_degraded_status(monkeypatch): def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
@@ -288,6 +432,67 @@ def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch)
assert source.excerpt assert source.excerpt
def test_ragcore_sources_deduplicate_reuploaded_versions_and_cap_cards(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
citations = []
for index in range(5):
citations.append(
{
"id": f"cite-{index}",
"document_id": f"doc-{index}",
"document_version_id": f"version-{index}",
"title": "Damage procedure" if index < 2 else f"Procedure {index}",
"section": "Return",
"excerpt": (
f"Record visible damage before release, chunk {index}."
if index < 2
else f"Unique procedure evidence {index}."
),
}
)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(citations=citations))),
)
answer = provider.ask("What must I do about vehicle damage?", "dedupe-test")
assert len(answer.sources) == 3
assert sum(source.title == "Damage procedure" for source in answer.sources) == 1
def test_knowledge_feedback_is_audited_and_can_be_changed(ops_client):
answer = ops_client.post(
"/api/v1/knowledge/questions",
json={"question": "What must I do when a vehicle returns with damage?"},
).json()
payload = {"correlation_id": answer["correlation_id"], "helpful": True}
assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200
payload["helpful"] = False
assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200
events = ops_client.get(
"/api/v1/audit", params={"action": "knowledge_feedback_recorded"}
).json()
matching = [e for e in events if e["correlation_id"] == answer["correlation_id"]]
assert len(matching) == 1
assert matching[0]["metadata"]["helpful"] is False
def test_knowledge_feedback_cannot_target_another_users_exchange(client):
assert client.post("/api/v1/demo/login", json={"role": "rental_employee"}).status_code == 200
answer = client.post(
"/api/v1/knowledge/questions",
json={"question": "How do I register a vehicle return?"},
).json()
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
response = client.post(
"/api/v1/knowledge/feedback",
json={"correlation_id": answer["correlation_id"], "helpful": True},
)
assert response.status_code == 404
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch): def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
provider = RAGcoreKnowledgeProvider() provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
@@ -411,6 +616,35 @@ def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypa
) )
def test_ragcore_answers_circuit_skips_repeated_generation_failure(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(provider._settings, "ragcore_answers_circuit_breaker_seconds", 60.0)
search_response = _FakeResponse(200, _search_body())
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": search_response,
}
),
)
assert (
provider.ask("What is the vehicle return procedure?", "first").evidence_state
== "grounded"
)
monkeypatch.setattr(
provider,
"_ask_via_answers",
lambda *_args: (_ for _ in ()).throw(AssertionError("open circuit called answers")),
)
second = provider.ask("What is the vehicle return procedure?", "second")
assert second.evidence_state == "grounded"
def test_ragcore_provider_fallback_answer_is_localized(monkeypatch): def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
provider = RAGcoreKnowledgeProvider() provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
@@ -450,3 +684,91 @@ def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkey
assert answer.evidence_state == "insufficient" assert answer.evidence_state == "insufficient"
assert answer.answer == "" assert answer.answer == ""
assert answer.sources == [] assert answer.sources == []
def test_ragcore_search_fallback_rejects_out_of_domain_question(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask(
"Who won the football world cup in 1998?",
"test-correlation-out-of-domain",
)
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
search = _search_body()
search["results"].append(
{
"citation": {
"document_id": "damage-procedure",
"document_version_id": "version-2",
"title": "damage-procedure.md",
"section": "Damage",
"excerpt": "Record damage and keep the vehicle blocked.",
},
"rank": 2,
"scores": {"fused": 0.01},
}
)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, search),
}
),
)
answer = provider.ask("Wat moet ik doen bij schade?", "test-correlation-damage", "nl-BE")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert any(source.document_id == "damage-procedure" for source in answer.sources)
def test_ragcore_search_fallback_accepts_top_ranked_ragcore_damage_evidence(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
search = _search_body()
search["results"].append(
{
"citation": {
"document_id": "damage-procedure",
"document_version_id": "version-2",
"title": "damage-procedure.md",
"section": "Damage",
"excerpt": "Record damage and keep the vehicle blocked.",
},
"rank": 1,
# RAGcore uses reciprocal-rank fusion; a genuine rank-one result is about
# 1 / (60 + 1), not a normalized 0..1 relevance score.
"scores": {"fused": 0.01639344262295082},
}
)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, search),
}
),
)
answer = provider.ask("Wat moet ik doen bij schade?", "strong-damage", "nl-BE")
assert answer.evidence_state == "grounded"
assert answer.sources[0].document_id == "damage-procedure"
+31 -8
View File
@@ -5,7 +5,7 @@ def _headers(token: str | None = None, client_id: str = "test-mcp-client"):
settings = get_settings() settings = get_settings()
return { return {
"X-Service-Token": token if token is not None else settings.mcp_hub_service_token, "X-Service-Token": token if token is not None else settings.mcp_hub_service_token,
"X-Client-Id": client_id, "X-Client-Id": f"itworx-mcp-hub:mobilityops:{client_id}",
} }
@@ -16,12 +16,31 @@ def test_operations_summary_requires_service_token(client):
assert response.status_code == 401 assert response.status_code == 401
def test_operations_summary_rejects_spoofed_client_identity(client):
settings = get_settings()
response = client.get(
"/api/v1/integrations/mcp/operations-summary",
headers={"X-Service-Token": settings.mcp_hub_service_token, "X-Client-Id": "spoofed"},
)
assert response.status_code == 403
def test_operations_summary_rejects_cross_tenant_request(client):
response = client.get(
"/api/v1/integrations/mcp/operations-summary",
headers={**_headers(), "X-Tenant-Id": "another-tenant"},
)
assert response.status_code == 403
def test_operations_summary_returns_metrics(client): def test_operations_summary_returns_metrics(client):
response = client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers()) response = client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers())
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert "metrics" in body assert "metrics" in body
assert body["metrics"]["available"] >= 0 assert body["metrics"]["available"] >= 0
assert response.headers["x-correlation-id"]
assert response.headers["x-tenant-id"] == get_settings().ragcore_tenant
def test_attention_vehicles_filters_by_severity(client): def test_attention_vehicles_filters_by_severity(client):
@@ -46,9 +65,7 @@ def test_attention_vehicles_respects_limit(client):
def test_vehicle_details_known_ref(client): def test_vehicle_details_known_ref(client):
response = client.get( response = client.get("/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers())
"/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers()
)
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert body["public_ref"] == "MO-016" assert body["public_ref"] == "MO-016"
@@ -56,9 +73,7 @@ def test_vehicle_details_known_ref(client):
def test_vehicle_details_unknown_ref_is_404(client): def test_vehicle_details_unknown_ref_is_404(client):
response = client.get( response = client.get("/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers())
"/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers()
)
assert response.status_code == 404 assert response.status_code == 404
@@ -72,6 +87,8 @@ def test_search_knowledge_grounded_and_respects_max_sources(client):
body = response.json() body = response.json()
assert body["evidence_state"] == "grounded" assert body["evidence_state"] == "grounded"
assert len(body["sources"]) == 1 assert len(body["sources"]) == 1
assert int(response.headers["x-sources-available"]) >= 1
assert response.headers["x-sources-returned"] == "1"
def test_mcp_tool_requests_are_audited(client, ops_client): def test_mcp_tool_requests_are_audited(client, ops_client):
@@ -79,6 +96,8 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
assert len(events) >= 1 assert len(events) >= 1
assert events[0]["actor_type"] == "service" assert events[0]["actor_type"] == "service"
assert events[0]["actor_label"] == "itworx-mcp-hub"
assert events[0]["metadata"]["reported_client_id"].endswith(":probe-1")
def test_search_knowledge_respects_requested_locale(client): def test_search_knowledge_respects_requested_locale(client):
@@ -118,7 +137,11 @@ def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_
) )
assert response.status_code == 200 assert response.status_code == 200
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
matching = [e for e in events if e["actor_label"] == "no-correlation-probe"] matching = [
e
for e in events
if e["metadata"].get("reported_client_id", "").endswith(":no-correlation-probe")
]
assert len(matching) >= 1 assert len(matching) >= 1
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
+88
View File
@@ -0,0 +1,88 @@
"""Guard against model/migration drift.
The functional suite builds its schema with ``Base.metadata.create_all`` for speed, so a
column or index added to a model but never written into an Alembic migration would only
surface on the first real deployment. This test runs the migration chain from an empty
database and asserts that Alembic's autogenerate sees nothing left to do.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from alembic.autogenerate import compare_metadata
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from alembic import command
from app.core.config import get_settings
from app.models import Base
BACKEND_DIR = Path(__file__).resolve().parents[1]
SCRATCH_DB = "mobilityops_migration_check"
@pytest.fixture(scope="module")
def migrated_database_url() -> str:
settings = get_settings()
base_url = make_url(settings.database_url)
admin_engine = create_engine(
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
)
with admin_engine.connect() as conn:
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}"'))
conn.execute(text(f'CREATE DATABASE "{SCRATCH_DB}"'))
scratch_url = base_url.set(database=SCRATCH_DB).render_as_string(hide_password=False)
try:
yield scratch_url
finally:
admin_engine.dispose()
admin_engine = create_engine(
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
)
with admin_engine.connect() as conn:
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)'))
admin_engine.dispose()
def _alembic_config(database_url: str) -> Config:
config = Config(str(BACKEND_DIR / "alembic.ini"))
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
config.set_main_option("sqlalchemy.url", database_url)
return config
def test_migrations_upgrade_from_empty_and_match_models(migrated_database_url: str) -> None:
config = _alembic_config(migrated_database_url)
# env.py reads DATABASE_URL from settings; override it for the scratch database.
import os
previous = os.environ.get("DATABASE_URL")
os.environ["DATABASE_URL"] = migrated_database_url
get_settings.cache_clear()
try:
command.upgrade(config, "head")
finally:
if previous is None:
os.environ.pop("DATABASE_URL", None)
else:
os.environ["DATABASE_URL"] = previous
get_settings.cache_clear()
engine = create_engine(migrated_database_url)
try:
with engine.connect() as conn:
context = MigrationContext.configure(
conn, opts={"compare_type": True, "compare_server_default": False}
)
diff = compare_metadata(context, Base.metadata)
finally:
engine.dispose()
assert diff == [], (
"Models and Alembic migrations have drifted; write a migration for:\n"
+ "\n".join(repr(entry) for entry in diff)
)
+53
View File
@@ -0,0 +1,53 @@
import json
import logging
import uuid
from app.core.observability import JsonFormatter
def test_request_correlation_id_is_echoed_and_used_in_errors(client):
correlation_id = str(uuid.uuid4())
response = client.get(
"/api/v1/dashboard",
headers={"X-Correlation-Id": correlation_id},
)
assert response.status_code == 401
assert response.headers["X-Correlation-Id"] == correlation_id
assert response.json()["error"]["correlation_id"] == correlation_id
def test_invalid_request_correlation_id_is_replaced(client):
response = client.get("/health/live", headers={"X-Correlation-Id": "not-a-uuid"})
assert response.status_code == 200
assert uuid.UUID(response.headers["X-Correlation-Id"])
def test_metrics_expose_http_database_and_outbox_state(client):
client.get("/health/ready")
response = client.get("/metrics")
assert response.status_code == 200
assert "mobilityops_http_requests_total" in response.text
assert "mobilityops_database_ready 1.0" in response.text
assert 'mobilityops_outbox_events{scenario="synthetic",status="failed"}' in response.text
assert "mobilityops_knowledge_provider_requests_total" in response.text
assert "mobilityops_knowledge_retrieval_score" in response.text
def test_metrics_token_is_enforced_when_configured(client, monkeypatch):
import app.api.routers.observability as router
monkeypatch.setattr(router.settings, "metrics_bearer_token", "metrics-secret")
assert client.get("/metrics").status_code == 401
assert (
client.get("/metrics", headers={"Authorization": "Bearer metrics-secret"}).status_code
== 200
)
def test_json_formatter_emits_machine_readable_fields():
record = logging.LogRecord("mobilityops.test", logging.INFO, __file__, 1, "ready", (), None)
record.status_code = 200
payload = json.loads(JsonFormatter().format(record))
assert payload["message"] == "ready"
assert payload["status_code"] == 200
assert payload["timestamp"].endswith("+00:00")
+36
View File
@@ -0,0 +1,36 @@
from app.api.routers import auth as auth_router
from app.core.db import SessionLocal
from app.core.security import hash_password
from app.models.user import User
def test_password_login_is_available_only_outside_demo_mode(ops_client, monkeypatch):
user = User(
public_ref="USR-REAL",
email="manager@example.test",
password_hash=hash_password("correct-horse-battery-staple"),
display_name="Real Manager",
role="operations_manager",
active=True,
)
with SessionLocal() as db:
db.add(user)
db.commit()
monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False)
response = ops_client.post(
"/api/v1/auth/login",
json={"email": "manager@example.test", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 200
assert response.json()["public_ref"] == "USR-REAL"
assert ops_client.get("/api/v1/auth/session").json()["display_name"] == "Real Manager"
def test_password_login_rejects_invalid_credentials(ops_client, monkeypatch):
monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False)
response = ops_client.post(
"/api/v1/auth/login",
json={"email": "unknown@example.test", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 401
+96
View File
@@ -0,0 +1,96 @@
from sqlalchemy import delete, select
from app.core.db import SessionLocal
from app.models.audit import AuditEvent
from app.models.customer import Customer
def test_privacy_retention_uses_persisted_counts(ops_client):
response = ops_client.get("/api/v1/privacy/retention")
assert response.status_code == 200
body = response.json()
assert body["customers_total"] >= 180
assert body["minimum_booking_retention_days"] == 30
assert body["audit_retention_days"] == 2555
def test_privacy_retention_requires_manager(employee_client):
assert employee_client.get("/api/v1/privacy/retention").status_code == 403
def test_customer_export_is_downloadable_and_audited_without_mutation(ops_client):
response = ops_client.get("/api/v1/privacy/customers/CUS-0001/export")
assert response.status_code == 200
assert response.headers["content-disposition"] == 'attachment; filename="CUS-0001-privacy.json"'
assert response.json()["customer"]["public_ref"] == "CUS-0001"
assert isinstance(response.json()["bookings"], list)
events = ops_client.get("/api/v1/audit", params={"action": "privacy_customer_exported"}).json()
assert any(event["metadata"]["customer_ref"] == "CUS-0001" for event in events)
def test_active_customer_cannot_be_anonymized(ops_client):
response = ops_client.post(
"/api/v1/privacy/customers/CUS-0042/anonymize",
json={"confirmation": "CUS-0042", "reason": "Verified erasure request"},
)
assert response.status_code == 409
def test_eligible_customer_is_irreversibly_anonymized_without_pii_in_audit(ops_client):
with SessionLocal() as db:
customer = Customer(
public_ref="CUS-PRIVACY",
first_name="Private",
last_name="Person",
email="private.person@example.test",
phone="+32000000000",
postal_code="1000",
city="Brussels",
date_of_birth=None,
)
db.add(customer)
db.commit()
customer_id = customer.id
try:
mismatch = ops_client.post(
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
json={"confirmation": "CUS-WRONG", "reason": "Verified erasure request"},
)
assert mismatch.status_code == 422
response = ops_client.post(
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
json={"confirmation": "CUS-PRIVACY", "reason": "Verified erasure request"},
)
assert response.status_code == 200
assert response.json()["status"] == "anonymized"
with SessionLocal() as db:
customer = db.scalar(select(Customer).where(Customer.id == customer_id))
assert customer is not None
assert customer.email is None
assert customer.phone is None
assert customer.first_name == "Anoniem"
event = db.scalar(
select(AuditEvent)
.where(AuditEvent.action == "privacy_customer_anonymized")
.order_by(AuditEvent.occurred_at.desc())
)
assert event is not None
serialized = f"{event.before_json}{event.after_json}{event.metadata_json}"
assert "private.person@example.test" not in serialized
finally:
with SessionLocal() as db:
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
db.execute(delete(Customer).where(Customer.id == customer_id))
db.commit()
def test_audit_csv_export_is_bounded_and_audited(ops_client):
response = ops_client.get("/api/v1/audit/export.csv")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/csv")
assert response.text.startswith("id,occurred_at,actor_type")
invalid = ops_client.get(
"/api/v1/audit/export.csv",
params={"occurred_from": "2025-01-01T00:00:00Z", "occurred_to": "2026-01-01T00:00:00Z"},
)
assert invalid.status_code == 422
+10
View File
@@ -120,6 +120,8 @@ def test_preview_performs_no_writes_and_matches_commit(ops_client):
assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"] assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"]
assert commit_body["odometer_regression"] == preview_body["odometer_regression"] assert commit_body["odometer_regression"] == preview_body["odometer_regression"]
assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"] assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"]
assert commit_body["correlation_id"]
assert len(commit_body["correlation_id"]) == 36
def test_preview_detects_odometer_regression(ops_client): def test_preview_detects_odometer_regression(ops_client):
@@ -305,3 +307,11 @@ def test_concurrent_returns_only_one_succeeds():
assert results.count(201) == 1 assert results.count(201) == 1
assert results.count(409) == 2 assert results.count(409) == 2
def test_return_public_refs_are_collision_resistant() -> None:
from app.services.returns import _new_inspection_ref
refs = {_new_inspection_ref() for _ in range(1000)}
assert len(refs) == 1000
assert all(ref.startswith("INSP-") and len(ref) == 15 for ref in refs)

Some files were not shown because too many files have changed in this diff Show More