Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08625fefdd | ||
|
|
0de9177e7c | ||
|
|
48147465b5 | ||
|
|
92bdfb421b | ||
|
|
4257fea9d6 | ||
|
|
014caba7d5 | ||
|
|
e531740bb9 | ||
|
|
921c6878a6 | ||
|
|
cd55d6854f | ||
|
|
70e2648b03 | ||
|
|
f8eff37982 | ||
|
|
25cc7a1b0d | ||
|
|
6fd1d5e5e9 | ||
|
|
54b7719759 | ||
|
|
a19613d5e9 | ||
|
|
b62ecf66d8 | ||
|
|
97b8cd1c8c | ||
|
|
50f9bb471a | ||
|
|
719b710926 | ||
|
|
441b3624e1 | ||
|
|
ccae0538d7 | ||
|
|
7d935c5a7d | ||
|
|
51c3c90fa1 | ||
|
|
d6a566e1a1 | ||
|
|
4b727a109f | ||
|
|
1bf72c39e3 | ||
|
|
55e8ebd81e | ||
|
|
b2bdb78baa | ||
|
|
fe92a7f491 | ||
|
|
19df5f7508 | ||
|
|
e8eed3e641 | ||
|
|
2918608240 | ||
|
|
a99aed9a5f | ||
|
|
00e8ec001b | ||
|
|
d20ff7a243 | ||
|
|
bc0951115b | ||
|
|
51d488a634 | ||
|
|
da2f0956c9 | ||
|
|
13f8db7573 | ||
|
|
e6ec89658e | ||
|
|
9d71555135 | ||
|
|
81de78bd8b | ||
|
|
444e61253b | ||
|
|
81e3fd63bd | ||
|
|
b0706989db | ||
|
|
2036e8b4ec | ||
|
|
51b0ade7e7 | ||
|
|
cea0825d60 | ||
|
|
dd3acd872c | ||
|
|
00191e9b54 | ||
|
|
a24098c583 | ||
|
|
95c91797fa | ||
|
|
ec02aca0fd | ||
|
|
acd8b82b09 | ||
|
|
9e4fca5708 | ||
|
|
0045778dbb | ||
|
|
24dcb3494c | ||
|
|
a830e8a2d0 | ||
|
|
ca66083c8b | ||
|
|
ae39a8947f | ||
|
|
a9f48d6880 | ||
|
|
6859249570 | ||
|
|
1ca70187a2 | ||
|
|
26819354ee | ||
|
|
9dfbd7c4bf | ||
|
|
809ba0ddcc | ||
|
|
c7492bf6ad | ||
|
|
82a933f6cd | ||
|
|
be33b46228 | ||
|
|
b44915ff35 | ||
|
|
eecfcb4b79 | ||
|
|
cb7edb0b84 | ||
|
|
efab8d816f | ||
|
|
cfffb1ce54 | ||
|
|
29325b6c27 | ||
|
|
6365586e82 | ||
|
|
e1b700b10e | ||
|
|
b00d33af11 | ||
|
|
90cc3cf378 | ||
|
|
0935901f11 | ||
|
|
f0f1be83ae | ||
|
|
689e499634 | ||
|
|
c3f1cfc699 | ||
|
|
509cb95110 | ||
|
|
5dda5742e4 | ||
|
|
152d847a26 | ||
|
|
f715085f65 | ||
|
|
5d7a5e7359 | ||
|
|
fe06ff75a1 | ||
|
|
8030753dbc | ||
|
|
686795a452 | ||
|
|
2ee8b2d82b | ||
|
|
58fb515337 | ||
|
|
218599af7d | ||
|
|
15bdbe40ac | ||
|
|
4a3c3bd0a9 | ||
|
|
3f13912739 | ||
|
|
b2e1ae7f17 | ||
|
|
de151914b6 | ||
|
|
e577c16db5 | ||
|
|
c194c18ca9 | ||
|
|
948d5eb6a6 | ||
|
|
3cd9ddfa66 | ||
|
|
0bfcf71ff7 | ||
|
|
4cdf667dc1 | ||
|
|
0ef4a6fa98 | ||
|
|
2648cef8e3 | ||
|
|
aacf0e04bc | ||
|
|
ad1182582d | ||
|
|
f2cdad194c | ||
|
|
13ad2ba6a3 | ||
|
|
086dfed992 | ||
|
|
64cc96fa4b | ||
|
|
319f43312e | ||
|
|
a2433d7fa3 | ||
|
|
453c7241fe | ||
|
|
529e7364a9 | ||
|
|
7d686ae2aa | ||
|
|
c2b8268927 | ||
|
|
9e9dd8e0e3 | ||
|
|
4faac24b5a | ||
|
|
3808bbe132 | ||
|
|
6f77a30dce | ||
|
|
e5307a7c0f | ||
|
|
dee8f2f7e9 | ||
|
|
57992bf153 | ||
|
|
727c19a779 | ||
|
|
2ae2044e3a | ||
|
|
34df66d28c | ||
|
|
3ebca9e9b7 | ||
|
|
b66521da82 | ||
|
|
0571a40649 | ||
|
|
4227fe4f58 | ||
|
|
c790ec99cb | ||
|
|
fd390df423 | ||
|
|
e5d8466266 | ||
|
|
0da5251524 | ||
|
|
2afceea5e4 | ||
|
|
cf4d8e3649 | ||
|
|
aaa1630535 | ||
|
|
167bf49b6e | ||
|
|
fd0c55b13b | ||
|
|
05628936ca | ||
|
|
b341436e77 | ||
|
|
4049c0c6b1 | ||
|
|
e39c0a1dd6 | ||
|
|
bbdb4a9ae8 | ||
|
|
e0c107a94a | ||
|
|
59cb4c062e | ||
|
|
b79d485ef1 | ||
|
|
c0995b762e | ||
|
|
5f0eaa59b0 |
@@ -0,0 +1,57 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
.claude
|
||||||
|
.dyad
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.vs
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
|
node_modules
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
dist
|
||||||
|
artifacts
|
||||||
|
docs
|
||||||
|
deploy
|
||||||
|
n8n/**
|
||||||
|
!n8n/workflows/
|
||||||
|
!n8n/workflows/**
|
||||||
|
frontend
|
||||||
|
*.tgz
|
||||||
|
*.tar.gz
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
secrets
|
||||||
|
credentials
|
||||||
|
.state
|
||||||
|
data
|
||||||
|
backups
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -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
|
||||||
@@ -29,17 +87,33 @@ N8N_BASIC_AUTH_ACTIVE=true
|
|||||||
N8N_BASIC_AUTH_USER=admin
|
N8N_BASIC_AUTH_USER=admin
|
||||||
N8N_BASIC_AUTH_PASSWORD=change-me
|
N8N_BASIC_AUTH_PASSWORD=change-me
|
||||||
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
||||||
|
# Sent as the X-Fleet-Ops-Trigger-Token header when Fleet Ops calls the n8n return-
|
||||||
|
# processing webhook, so the webhook trigger can require Header Auth instead of being
|
||||||
|
# publicly callable by anyone who discovers the URL. Must match the value stored in
|
||||||
|
# n8n's "Fleet Ops Webhook Trigger Token" Header Auth credential.
|
||||||
|
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token
|
||||||
|
|
||||||
# RAGcore integration
|
# RAGcore integration
|
||||||
KNOWLEDGE_PROVIDER=demo
|
KNOWLEDGE_PROVIDER=demo
|
||||||
RAGCORE_BASE_URL=http://ragcore-api:8000
|
RAGCORE_BASE_URL=http://ragcore-api:8000
|
||||||
|
# Optional existing Docker network used by hosted deployments to reach RAGcore through
|
||||||
|
# its private service alias instead of exposing RAGcore on the LAN.
|
||||||
|
RAGCORE_DOCKER_NETWORK=
|
||||||
RAGCORE_TENANT=northstar-mobility-demo
|
RAGCORE_TENANT=northstar-mobility-demo
|
||||||
RAGCORE_WORKSPACE=mobilityops
|
RAGCORE_WORKSPACE=mobilityops
|
||||||
RAGCORE_COLLECTION=internal-procedures
|
RAGCORE_COLLECTION=internal-procedures
|
||||||
RAGCORE_API_TOKEN=
|
RAGCORE_API_TOKEN=
|
||||||
|
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
|
||||||
|
RAGCORE_SPACE_ID=
|
||||||
|
# Skip the slower generation endpoint temporarily after a timeout/non-2xx response and
|
||||||
|
# use the still-grounded extractive search fallback immediately.
|
||||||
|
RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS=60
|
||||||
|
|
||||||
# ITWorx MCP Hub integration
|
# ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own
|
||||||
|
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
|
||||||
|
# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability
|
||||||
|
# health check surfaced on the integration status page.
|
||||||
MCP_HUB_REGISTRATION_ENABLED=false
|
MCP_HUB_REGISTRATION_ENABLED=false
|
||||||
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
|
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
|
||||||
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
|
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
|
||||||
MCP_PROVIDER_ID=mobilityops
|
MCP_PROVIDER_ID=fleet-ops
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
*.png binary
|
*.png binary
|
||||||
*.jpg binary
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.webp binary
|
||||||
|
*.zip binary
|
||||||
|
|
||||||
|
# Generated operational evidence is not part of a source release archive.
|
||||||
|
/artifacts export-ignore
|
||||||
|
/PROJECT_STATE.md export-ignore
|
||||||
|
/MASTER_BUILD_PROMPT.md export-ignore
|
||||||
|
/FILE_INDEX.md export-ignore
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: MobilityOps browser canary
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "37 4 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mobilityops-browser-canary
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
chromium:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
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 Chromium runtime
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
- name: Run non-destructive production canary
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
MOBILITYOPS_PUBLIC_URL: https://fleetops.itworx.tech
|
||||||
|
run: npx playwright test --config=playwright.live.config.ts --project=chromium
|
||||||
|
- name: Upload failure evidence
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3; Gitea-compatible artifact protocol
|
||||||
|
with:
|
||||||
|
name: browser-canary-failure
|
||||||
|
path: |
|
||||||
|
frontend/playwright-live-report
|
||||||
|
frontend/test-results
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
name: MobilityOps acceptance
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mobilityops-ci-${{ gitea.repository }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
acceptance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Determine validation scope
|
||||||
|
id: scope
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
base_sha="${{ gitea.event.pull_request.base.sha }}"
|
||||||
|
if git diff --quiet "$base_sha...HEAD" -- . ':(exclude).gitea/workflows/**'; then
|
||||||
|
echo "full=false" >> "$GITEA_OUTPUT"
|
||||||
|
echo "Workflow-only change: the protected lightweight gate is sufficient."
|
||||||
|
else
|
||||||
|
echo "full=true" >> "$GITEA_OUTPUT"
|
||||||
|
echo "Product or test change: running the complete acceptance gate."
|
||||||
|
fi
|
||||||
|
- name: Secret scan
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
repository="$PWD"
|
||||||
|
source="file:///repo"
|
||||||
|
workspace=(-v "$repository:/repo" -w /repo)
|
||||||
|
if docker inspect "${HOSTNAME:-}" >/dev/null 2>&1; then
|
||||||
|
source="file://$repository"
|
||||||
|
workspace=(--volumes-from "$HOSTNAME" -w "$repository")
|
||||||
|
fi
|
||||||
|
docker run --rm "${workspace[@]}" \
|
||||||
|
ghcr.io/trufflesecurity/trufflehog@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3 \
|
||||||
|
git "$source" --fail --no-update --github-actions --only-verified
|
||||||
|
- name: Backend tests in isolated PostgreSQL stack
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
run: sh scripts/run-isolated-tests.sh
|
||||||
|
- name: Backend static and contract checks
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
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
|
||||||
|
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm \
|
||||||
|
api python scripts/check-contracts.py
|
||||||
|
python scripts/check-source-budgets.py
|
||||||
|
- name: Build production API image for vulnerability scan
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
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)
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
run: bash scripts/scan-ci-image.sh mobilityops-api-ci
|
||||||
|
- name: Build production web image for vulnerability scan
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
run: |
|
||||||
|
docker build --build-arg VCS_REF="$GITHUB_SHA" \
|
||||||
|
--tag mobilityops-web-ci frontend
|
||||||
|
- name: Production web image vulnerability scan (HIGH/CRITICAL)
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
run: bash scripts/scan-ci-image.sh mobilityops-web-ci
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Install frontend dependencies once
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci --no-audit --no-fund
|
||||||
|
- name: Frontend lint, build, budget and dependency audit
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
npm run budget
|
||||||
|
npm audit --audit-level=high
|
||||||
|
- name: Start the demo stack
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
run: |
|
||||||
|
cp .env.example .env
|
||||||
|
# Acceptance tests intentionally reset their isolated demo dataset per scenario.
|
||||||
|
printf '\nDEMO_RESET_COOLDOWN_SECONDS=0\n' >> .env
|
||||||
|
docker compose -p mobilityops-e2e up --build -d db api web
|
||||||
|
docker network connect mobilityops-e2e_mobilityops "$HOSTNAME"
|
||||||
|
for _attempt in $(seq 1 60); do
|
||||||
|
if curl -fsS http://web/health/ready >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
curl -fsS http://web/health/ready
|
||||||
|
docker compose -p mobilityops-e2e exec -T api python -m app.cli seed --reset
|
||||||
|
- name: Install acceptance browsers
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
working-directory: frontend
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
- name: Run browser acceptance and live smoke suites
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
MOBILITYOPS_PUBLIC_URL: http://web
|
||||||
|
run: |
|
||||||
|
# Pixel baselines are workstation/rendering specific; keep the PR gate functional.
|
||||||
|
npx playwright test --grep-invert "visual hierarchy"
|
||||||
|
npx playwright test --config=playwright.live.config.ts --project=chromium
|
||||||
|
- name: Run concurrent persisted-read smoke
|
||||||
|
if: steps.scope.outputs.full == 'true'
|
||||||
|
run: python scripts/run-readonly-load-smoke.py --base-url http://web
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure() && steps.scope.outputs.full == 'true'
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3; Gitea-compatible artifact protocol
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: |
|
||||||
|
frontend/playwright-report
|
||||||
|
frontend/playwright-live-report
|
||||||
|
if-no-files-found: ignore
|
||||||
|
- name: Stack logs on failure
|
||||||
|
if: failure() && steps.scope.outputs.full == 'true'
|
||||||
|
run: docker compose -p mobilityops-e2e logs --tail=200 api web
|
||||||
|
- name: Remove CI stacks
|
||||||
|
if: always() && steps.scope.outputs.full == 'true'
|
||||||
|
run: |
|
||||||
|
docker network disconnect mobilityops-e2e_mobilityops "$HOSTNAME" 2>/dev/null || true
|
||||||
|
docker compose -p mobilityops-e2e down -v --remove-orphans
|
||||||
|
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml down -v --remove-orphans
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: MobilityOps live probe
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "7 * * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mobilityops-live-probe
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
public-probe:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 3
|
||||||
|
steps:
|
||||||
|
- 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: 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
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
name: Managed validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
profile:
|
||||||
|
description: Allowlisted validation profile
|
||||||
|
required: true
|
||||||
|
default: full
|
||||||
|
type: choice
|
||||||
|
options: [test, lint, typecheck, build, security, full]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
full:
|
||||||
|
name: full
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Validate repository with a bounded profile
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_PROFILE: ${{ inputs.profile }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
profile="${REQUESTED_PROFILE:-full}"
|
||||||
|
case "${profile}" in
|
||||||
|
test|lint|typecheck|build|security|full) ;;
|
||||||
|
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
git diff --check
|
||||||
|
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
||||||
|
echo "Unresolved merge markers detected" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# MANAGED_FAST_PATH: documentation and this baseline workflow cannot
|
||||||
|
# affect the shipped runtime. Keep the required status check, but do
|
||||||
|
# not install toolchains or execute the full product suite.
|
||||||
|
if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
|
||||||
|
git fetch --no-tags --depth=1 origin "${GITHUB_BASE_REF}"
|
||||||
|
managed_base="origin/${GITHUB_BASE_REF}"
|
||||||
|
git diff --check "${managed_base}..HEAD"
|
||||||
|
mapfile -t managed_changed_files < <(
|
||||||
|
git diff --name-only --diff-filter=ACMR "${managed_base}..HEAD"
|
||||||
|
)
|
||||||
|
managed_runtime_change=0
|
||||||
|
for managed_path in "${managed_changed_files[@]}"; do
|
||||||
|
case "${managed_path}" in
|
||||||
|
*.md|*.mdx|docs/*|.github/ISSUE_TEMPLATE/*|.gitea/ISSUE_TEMPLATE/*|.gitea/runner-scope.sh|.gitea/workflows/managed-validation.yml)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
managed_runtime_change=1
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ "${#managed_changed_files[@]}" -gt 0 && "${managed_runtime_change}" -eq 0 ]]; then
|
||||||
|
printf 'Managed validation fast path: %s non-runtime file(s); full product suite skipped.\n' \
|
||||||
|
"${#managed_changed_files[@]}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -f pyproject.toml || -f requirements.txt ]]; then
|
||||||
|
# Compile only tracked Python sources. Running compileall after a
|
||||||
|
# Node install would otherwise traverse node_modules and turn a
|
||||||
|
# lightweight baseline into a large runner workload.
|
||||||
|
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
|
||||||
|
if [[ -f uv.lock ]]; then
|
||||||
|
python -m venv "${RUNNER_TEMP}/managed-uv"
|
||||||
|
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
|
||||||
|
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
|
||||||
|
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
|
||||||
|
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
|
||||||
|
"${managed_uv}" sync --locked
|
||||||
|
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
|
||||||
|
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||||
|
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
|
||||||
|
"${managed_uv}" run python -m pytest
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "${profile}" == lint || "${profile}" == full ]]; then
|
||||||
|
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
|
||||||
|
"${managed_uv}" run python -m ruff check .
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [[ -f requirements.txt ]]; then
|
||||||
|
python -m venv "${RUNNER_TEMP}/managed-python"
|
||||||
|
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
|
||||||
|
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
|
||||||
|
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
|
||||||
|
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||||
|
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
|
||||||
|
"${managed_python}" -m pytest
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prepare Python before invoking Node scripts. Polyglot repositories
|
||||||
|
# commonly delegate their test script to Python and need the managed
|
||||||
|
# virtual environment to be active first.
|
||||||
|
if [[ -f package.json ]]; then
|
||||||
|
corepack enable
|
||||||
|
if [[ -f pnpm-lock.yaml ]]; then
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
|
||||||
|
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
|
||||||
|
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
|
||||||
|
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
|
||||||
|
elif [[ -f package-lock.json ]]; then
|
||||||
|
npm ci
|
||||||
|
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
|
||||||
|
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
|
||||||
|
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
|
||||||
|
npm run --if-present typecheck
|
||||||
|
fi
|
||||||
|
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f go.mod ]]; then
|
||||||
|
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||||
|
go test ./...
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -f Cargo.toml ]]; then
|
||||||
|
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||||
|
cargo test --locked
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if compgen -G '*.sln' >/dev/null; then
|
||||||
|
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||||
|
dotnet test --configuration Release
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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 CycloneDX SBOMs with the pinned scanner image
|
||||||
|
run: |
|
||||||
|
for component in api web backup-tools; do
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "$PWD:/work" -w /work \
|
||||||
|
aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 \
|
||||||
|
image --format cyclonedx --output "mobilityops-${component}-sbom.cdx.json" \
|
||||||
|
"mobilityops-${component}-release"
|
||||||
|
done
|
||||||
|
- 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@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3; Gitea-compatible artifact protocol
|
||||||
|
with:
|
||||||
|
name: mobilityops-${{ github.ref_name }}-evidence
|
||||||
|
path: |
|
||||||
|
mobilityops-*-sbom.cdx.json
|
||||||
|
mobilityops-*-image.json
|
||||||
|
release-provenance.json
|
||||||
|
SHA256SUMS
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: MobilityOps security
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mobilityops-security
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Build production images once
|
||||||
|
run: |
|
||||||
|
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" \
|
||||||
|
--tag mobilityops-api-ci --file backend/Dockerfile .
|
||||||
|
docker build --build-arg VCS_REF="$GITHUB_SHA" \
|
||||||
|
--tag mobilityops-web-ci frontend
|
||||||
|
- name: Scan production images for fixed HIGH and CRITICAL vulnerabilities
|
||||||
|
run: |
|
||||||
|
for image in mobilityops-api-ci mobilityops-web-ci; do
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
docker.io/aquasec/trivy@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e \
|
||||||
|
image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed --no-progress "$image"
|
||||||
|
done
|
||||||
|
- name: Scan repository secrets and misconfiguration
|
||||||
|
uses: docker://docker.io/aquasec/trivy@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e
|
||||||
|
with:
|
||||||
|
args: fs --scanners misconfig,secret --exit-code 1 --no-progress .
|
||||||
|
- name: Remove temporary image tags
|
||||||
|
if: always()
|
||||||
|
run: docker image rm mobilityops-api-ci mobilityops-web-ci || true
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: Unraid autoredeploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths-ignore:
|
||||||
|
- ".gitea/**"
|
||||||
|
- "docs/**"
|
||||||
|
- "**/*.md"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: unraid-production-mobilityops
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy mobilityops
|
||||||
|
runs-on: unraid-deploy
|
||||||
|
timeout-minutes: 180
|
||||||
|
steps:
|
||||||
|
- name: Deploy exact Gitea revision
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker exec gitea-deploy-control \
|
||||||
|
/opt/gitea-deploy/deploy.py deploy \
|
||||||
|
"$GITHUB_REPOSITORY" "$GITHUB_SHA"
|
||||||
|
|
||||||
@@ -1,16 +1,66 @@
|
|||||||
|
# Secrets and local configuration
|
||||||
.env
|
.env
|
||||||
.venv/
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
secrets/
|
||||||
|
credentials/
|
||||||
|
|
||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.venv/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Frontend and test output
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
*.pyc
|
*.tsbuildinfo
|
||||||
.DS_Store
|
|
||||||
|
# Runtime data and local infrastructure state
|
||||||
|
.state/
|
||||||
|
data/
|
||||||
|
backups/local/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Generated release/design evidence. Maintained documentation belongs in docs/.
|
||||||
|
artifacts/**/final-summary.md
|
||||||
|
artifacts/deployment/
|
||||||
|
artifacts/design-validation/current/
|
||||||
|
artifacts/**/screenshots/generated/
|
||||||
|
|
||||||
|
# Local AI/editor state
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
.dyad/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.tsbuildinfo
|
.vs/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Archives and local release bundles
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Binding instructions for Claude
|
|
||||||
|
|
||||||
## Operating mode
|
|
||||||
|
|
||||||
Work autonomously. Do not ask the user product, architecture, naming, UI, scope or implementation questions already answered in this repository. Record a reasonable assumption in an ADR only when a genuine gap blocks implementation.
|
|
||||||
|
|
||||||
Use normal or medium reasoning for routine work. Reserve high reasoning for an actual cross-service design conflict or a persistent failure after evidence-driven debugging.
|
|
||||||
|
|
||||||
Continue from milestone to milestone until every acceptance criterion is satisfied. Do not stop merely because one milestone is complete.
|
|
||||||
|
|
||||||
## Token and tool efficiency
|
|
||||||
|
|
||||||
1. Read `START_HERE.md`, this file, `PROJECT_STATE.md` and `docs/15-build-plan.md` first.
|
|
||||||
2. Read only the milestone-specific documents named in the build plan.
|
|
||||||
3. Do not repeatedly reread all documentation.
|
|
||||||
4. Keep explanations terse; spend effort on implementation and validation.
|
|
||||||
5. Update `PROJECT_STATE.md` after each milestone with decisions, commands, evidence and the exact next action.
|
|
||||||
6. Prefer focused file inspection and targeted tests over broad repository scans.
|
|
||||||
7. Do not generate large speculative documents after implementation starts.
|
|
||||||
|
|
||||||
## Scope discipline
|
|
||||||
|
|
||||||
- Build the locked PoC only.
|
|
||||||
- Do not add accounting, payments, public reservations, a generic CRM, inventory, HR, a second RAG stack, a separate MCP server or autonomous write actions.
|
|
||||||
- Do not modify the RAGcore or ITWorx MCP Hub repositories. Integrate only through documented contracts and configurable adapters.
|
|
||||||
- Keep critical business rules in MobilityOps code, not in n8n or prompts.
|
|
||||||
- No direct MCP Hub or RAGcore access to the MobilityOps database.
|
|
||||||
|
|
||||||
## Product quality
|
|
||||||
|
|
||||||
- No dead buttons, empty routes, unexplained placeholders or hardcoded dashboard metrics.
|
|
||||||
- Every visible number must derive from persisted data.
|
|
||||||
- All important state changes must be audited.
|
|
||||||
- AI must never invent an answer when RAGcore is unavailable or returns insufficient evidence.
|
|
||||||
- Vehicle returns must commit locally even when n8n is unavailable; orchestration becomes pending and retryable.
|
|
||||||
- External dependencies require timeouts, bounded retries, health state and graceful degradation.
|
|
||||||
- Demo data must be clearly labelled synthetic.
|
|
||||||
|
|
||||||
## Engineering rules
|
|
||||||
|
|
||||||
- Backend: Python, FastAPI, SQLAlchemy 2, Alembic, PostgreSQL.
|
|
||||||
- Frontend: React, TypeScript, Vite, accessible responsive UI.
|
|
||||||
- Validation: Pydantic at API boundaries and database constraints for invariants.
|
|
||||||
- Use UUID primary keys internally and stable human-readable public references.
|
|
||||||
- Store UTC timestamps; render Europe/Brussels in the UI.
|
|
||||||
- API paths start with `/api/v1`.
|
|
||||||
- Use an outbox record for reliable post-commit n8n delivery.
|
|
||||||
- Tests must cover domain rules, API contracts and the five-minute Playwright demo.
|
|
||||||
- Generate and commit dependency lockfiles.
|
|
||||||
|
|
||||||
## Git workflow
|
|
||||||
|
|
||||||
Create one coherent commit per milestone after its validation passes. Suggested message format:
|
|
||||||
|
|
||||||
`M1: implement operational core`
|
|
||||||
|
|
||||||
Never rewrite already accepted milestone history unless necessary to fix a regression.
|
|
||||||
|
|
||||||
## Definition of done
|
|
||||||
|
|
||||||
The project is done only when `docs/14-testing-and-acceptance.md` passes from a clean checkout and `PROJECT_STATE.md` contains the final evidence summary.
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
MobilityOps contributions must preserve fleet-data privacy, deterministic demo behaviour and the fail-closed integration boundaries documented in `SECURITY.md`.
|
||||||
|
|
||||||
|
- use synthetic vehicles, customers, bookings, returns, telematics events and identity claims in tests and screenshots;
|
||||||
|
- never commit production databases, exports, operator inventories, private service URLs, tokens, backups or generated browser evidence;
|
||||||
|
- keep external integrations configurable through environment variables or explicit deployment configuration;
|
||||||
|
- document new personal-data fields, retention, authorization, audit and deletion/export behaviour;
|
||||||
|
- add negative tests for authentication, authorization, duplicate handling, webhook validation, path containment and stale/unavailable providers;
|
||||||
|
- review dependencies, images and browser assets for provenance and redistribution rights.
|
||||||
|
|
||||||
|
Run the relevant backend, frontend, migration, integration, Compose and managed-validation gates before review. Security-sensitive findings belong through the private process in `SECURITY.md`.
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# File index
|
|
||||||
|
|
||||||
- `.env.example`
|
|
||||||
- `.gitignore`
|
|
||||||
- `CLAUDE.md`
|
|
||||||
- `MASTER_BUILD_PROMPT.md`
|
|
||||||
- `Makefile`
|
|
||||||
- `PROJECT_STATE.md`
|
|
||||||
- `README.md`
|
|
||||||
- `START_HERE.md`
|
|
||||||
- `backend/Dockerfile`
|
|
||||||
- `backend/app/__init__.py`
|
|
||||||
- `backend/app/core/__init__.py`
|
|
||||||
- `backend/app/core/config.py`
|
|
||||||
- `backend/app/main.py`
|
|
||||||
- `backend/pyproject.toml`
|
|
||||||
- `backend/tests/test_health.py`
|
|
||||||
- `compose.yaml`
|
|
||||||
- `contracts/events.schema.json`
|
|
||||||
- `contracts/mcp-tools.json`
|
|
||||||
- `contracts/openapi.yaml`
|
|
||||||
- `contracts/ragcore-contract-assumptions.md`
|
|
||||||
- `docs/00-product-brief.md`
|
|
||||||
- `docs/01-scope-and-non-goals.md`
|
|
||||||
- `docs/02-user-stories.md`
|
|
||||||
- `docs/03-architecture.md`
|
|
||||||
- `docs/04-domain-model.md`
|
|
||||||
- `docs/05-api-contract.md`
|
|
||||||
- `docs/06-ui-ux.md`
|
|
||||||
- `docs/07-data-quality.md`
|
|
||||||
- `docs/08-return-workflow.md`
|
|
||||||
- `docs/09-ragcore-integration.md`
|
|
||||||
- `docs/10-mcp-hub-integration.md`
|
|
||||||
- `docs/11-n8n-integration.md`
|
|
||||||
- `docs/12-security-and-audit.md`
|
|
||||||
- `docs/13-seed-and-demo-scenarios.md`
|
|
||||||
- `docs/14-testing-and-acceptance.md`
|
|
||||||
- `docs/15-build-plan.md`
|
|
||||||
- `docs/16-portfolio-case-study.md`
|
|
||||||
- `docs/17-runbook.md`
|
|
||||||
- `docs/deferred.md`
|
|
||||||
- `frontend/Dockerfile`
|
|
||||||
- `frontend/index.html`
|
|
||||||
- `frontend/nginx.conf`
|
|
||||||
- `frontend/package.json`
|
|
||||||
- `frontend/src/App.tsx`
|
|
||||||
- `frontend/src/main.tsx`
|
|
||||||
- `frontend/src/styles.css`
|
|
||||||
- `frontend/tsconfig.json`
|
|
||||||
- `frontend/vite.config.ts`
|
|
||||||
- `knowledge/manifest.json`
|
|
||||||
- `knowledge/procedures/01-vehicle-checkout.md`
|
|
||||||
- `knowledge/procedures/02-vehicle-return.md`
|
|
||||||
- `knowledge/procedures/03-damage-handling.md`
|
|
||||||
- `knowledge/procedures/04-odometer-anomalies.md`
|
|
||||||
- `knowledge/procedures/05-cleaning-checklist.md`
|
|
||||||
- `knowledge/procedures/06-maintenance-escalation.md`
|
|
||||||
- `knowledge/procedures/07-customer-documents.md`
|
|
||||||
- `knowledge/procedures/08-privacy.md`
|
|
||||||
- `knowledge/procedures/09-booking-conflicts.md`
|
|
||||||
- `knowledge/procedures/10-roles-and-escalation.md`
|
|
||||||
- `n8n/README.md`
|
|
||||||
- `n8n/mobilityops-return-processing.json`
|
|
||||||
- `seed/README.md`
|
|
||||||
- `seed/bookings.csv`
|
|
||||||
- `seed/customers.csv`
|
|
||||||
- `seed/data_quality_issues.csv`
|
|
||||||
- `seed/generate_seed.py`
|
|
||||||
- `seed/inspections.csv`
|
|
||||||
- `seed/maintenance.csv`
|
|
||||||
- `seed/vehicles.csv`
|
|
||||||
- `seed/workflow_runs.csv`
|
|
||||||
@@ -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.
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Paste this once into Claude Code
|
|
||||||
|
|
||||||
Build MobilityOps autonomously from this repository.
|
|
||||||
|
|
||||||
First read `START_HERE.md`, `CLAUDE.md`, `PROJECT_STATE.md` and `docs/15-build-plan.md`. Treat the repository specifications and contracts as binding. Do not ask me questions that the files already answer, do not broaden the PoC, and do not stop after a milestone.
|
|
||||||
|
|
||||||
Implement the milestones in order. For each milestone:
|
|
||||||
|
|
||||||
1. read only the documents listed for that milestone;
|
|
||||||
2. implement the smallest complete solution;
|
|
||||||
3. run the specified validation plus relevant regression tests;
|
|
||||||
4. fix failures using evidence rather than guesses;
|
|
||||||
5. update `PROJECT_STATE.md` with concise evidence and the exact next step;
|
|
||||||
6. commit the completed milestone;
|
|
||||||
7. continue immediately.
|
|
||||||
|
|
||||||
MobilityOps owns operational data and business rules. RAGcore owns retrieval and grounded answers. ITWorx MCP Hub owns MCP publication and policy. n8n only orchestrates post-commit workflows. Use configurable adapters and working degraded modes so the core demo remains usable when any external service is absent.
|
|
||||||
|
|
||||||
The finished PoC must be reproducible from a clean checkout, have no dead UI, use deterministic synthetic data, support the documented five-minute demo, and satisfy every criterion in `docs/14-testing-and-acceptance.md`.
|
|
||||||
|
|
||||||
Keep chat output brief. Spend the available context on code, tests, validation and final evidence. Begin now and continue until the repository is complete.
|
|
||||||
@@ -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
|
||||||
@@ -26,16 +32,19 @@ reset:
|
|||||||
# One-time per environment: imports and activates the n8n return-processing workflow.
|
# One-time per environment: imports and activates the n8n return-processing workflow.
|
||||||
# The n8n owner account itself cannot be scripted safely and must be created once at
|
# The n8n owner account itself cannot be scripted safely and must be created once at
|
||||||
# http://localhost:5678/setup (any email/password, no verification required) before
|
# http://localhost:5678/setup (any email/password, no verification required) before
|
||||||
# this target's activation takes effect. See docs/17-runbook.md.
|
# this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
|
||||||
|
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
|
||||||
|
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
|
||||||
n8n-setup:
|
n8n-setup:
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
|
||||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
|
|
||||||
# One-time per environment: imports and activates the scheduled quality-scan workflow.
|
# One-time per environment: imports and activates the scheduled quality-scan workflow.
|
||||||
# Same owner-account precondition as n8n-setup above.
|
# Same owner-account and credential preconditions as n8n-setup above (this workflow only
|
||||||
|
# needs "Fleet Ops Service Token").
|
||||||
n8n-setup-scan:
|
n8n-setup-scan:
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
|
||||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
|
|
||||||
@@ -45,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
|
||||||
|
|||||||
@@ -1,132 +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
|

|
||||||
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 fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is
|
|
||||||
implemented and unit-tested, including its unavailable-degradation path, but was never
|
|
||||||
exercised against a live RAGcore instance in this environment.
|
|
||||||
- **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 now actually wired into `Settings` (it was previously
|
|
||||||
declared in `.env.example` but silently dropped) and reported honestly by the
|
|
||||||
integration-status endpoint. No live Hub instance was reachable in this environment to
|
|
||||||
verify an actual Hub round trip.
|
|
||||||
|
|
||||||
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 M0–M7 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.
|
||||||
|
|||||||
@@ -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,39 +0,0 @@
|
|||||||
# MobilityOps — Claude build pack
|
|
||||||
|
|
||||||
MobilityOps is a deliberately scoped, working proof of concept for a fictitious Belgian mobility-rental company. It demonstrates how operational data, workflow automation, data-quality review, a central RAG service and a central MCP hub can work together without pretending to be a full ERP.
|
|
||||||
|
|
||||||
## Use this pack efficiently
|
|
||||||
|
|
||||||
1. Extract this archive into a new repository.
|
|
||||||
2. Open the repository in Claude Code.
|
|
||||||
3. Paste the contents of `MASTER_BUILD_PROMPT.md` once.
|
|
||||||
4. Let Claude continue autonomously through the milestones in `docs/15-build-plan.md`.
|
|
||||||
5. Only intervene if the environment itself is unavailable or credentials for an external service are required.
|
|
||||||
|
|
||||||
Claude must use `PROJECT_STATE.md` as its compact memory between sessions. Do not restate the full project in later prompts.
|
|
||||||
|
|
||||||
## What is included
|
|
||||||
|
|
||||||
- locked product scope and non-goals;
|
|
||||||
- architecture and domain decisions;
|
|
||||||
- API and event contracts;
|
|
||||||
- realistic deterministic synthetic seed data;
|
|
||||||
- ten fictitious procedures for RAGcore;
|
|
||||||
- an initial n8n workflow export;
|
|
||||||
- MCP tool definitions for ITWorx MCP Hub;
|
|
||||||
- a minimal bootable frontend/API scaffold;
|
|
||||||
- acceptance criteria and a five-minute demo script;
|
|
||||||
- a master prompt optimized for one autonomous build run.
|
|
||||||
|
|
||||||
## Core responsibilities
|
|
||||||
|
|
||||||
| Component | Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| MobilityOps | Operational data, business rules, UI, audit and data-quality review |
|
|
||||||
| RAGcore | Document ingestion, retrieval and source-grounded answers |
|
|
||||||
| ITWorx MCP Hub | Controlled AI access to MobilityOps tools and MobilityOps knowledge |
|
|
||||||
| n8n | Cross-system orchestration after committed domain events |
|
|
||||||
|
|
||||||
## Build principle
|
|
||||||
|
|
||||||
Finish a small vertical slice completely. No placeholder pages, fake buttons, invented production claims or scope expansion.
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Generated live deployment and demo evidence must stay outside source control.
|
||||||
|
demo-release/
|
||||||
|
deployment/
|
||||||
|
screenshots/
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# Keep this policy file.
|
||||||
|
!.gitignore
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# Demo-productization final summary
|
|
||||||
|
|
||||||
## Branches and commits
|
|
||||||
|
|
||||||
- **Gitea repository**: `ssh://git@192.168.10.150:222/Jens/MobilityOps.git` (browsable at
|
|
||||||
`http://192.168.10.150:3000/Jens/MobilityOps`)
|
|
||||||
- **Branch**: `feat/mobilityops-functional-completion` (no new branch created; no merge
|
|
||||||
to `main`; no rebase/reset/squash/force-push; full git history preserved, as required)
|
|
||||||
- **Start commit** (functional-completion baseline, already accepted):
|
|
||||||
`e0c7ed60112510687627d20a957af91c8b9db7f8`
|
|
||||||
- **Final commit**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8` — verified via
|
|
||||||
`git rev-parse HEAD` on `feat/mobilityops-functional-completion` and confirmed to match
|
|
||||||
`/mnt/user/appdata/mobilityops/.deploy/source-revision` on the Unraid server exactly.
|
|
||||||
(This corrects a self-reference gap in the immediately preceding pair of commits, which
|
|
||||||
necessarily could not know their own hash at the time they were written; this is now
|
|
||||||
the single, unambiguous, verified reference. The repository's primary branch is
|
|
||||||
`master`, not `main` — no branch named `main` exists in this repository.)
|
|
||||||
- **Live URL**: `http://192.168.10.150:1236`
|
|
||||||
|
|
||||||
## Demo organisation and context
|
|
||||||
|
|
||||||
**Northstar Mobility** — a fictitious Belgian camper/van rental company (~50 vehicles,
|
|
||||||
one main location, rental team, an Operations Manager, a small workshop). This name was
|
|
||||||
already a locked internal decision (`ragcore_tenant: northstar-mobility-demo`,
|
|
||||||
`PROJECT_STATE.md`'s "Locked decisions") before this work — this pass surfaces it in the
|
|
||||||
UI rather than inventing it. Full concept: `docs/demo-release/demo-concept.md`.
|
|
||||||
|
|
||||||
## Roles
|
|
||||||
|
|
||||||
- **Operations Manager** — full access: data-quality resolution, workflow retries, audit
|
|
||||||
trail, demo reset, the Demo Guide.
|
|
||||||
- **Rental Employee** — scoped access: bookings, returns, fleet, knowledge assistant.
|
|
||||||
|
|
||||||
Both are reachable from the login screen with no password.
|
|
||||||
|
|
||||||
## Demo Guide
|
|
||||||
|
|
||||||
An 8-step, sessionStorage-persisted guided tour (Operations-Manager-only, since every
|
|
||||||
step requires that role). Full design: `docs/demo-release/demo-guide.md`. Steps: (1)
|
|
||||||
understand operational state, (2) open the booking needing attention, (3) process the
|
|
||||||
odometer-anomaly return, (4) handle the created data-quality issue, (5) merge the
|
|
||||||
duplicate customer, (6) ask the knowledge assistant, (7) check automation + audit, (8)
|
|
||||||
review real vs. synthetic vs. not-connected.
|
|
||||||
|
|
||||||
## Scenarios (all 5, full detail in `docs/demo-release/demo-scenarios.md`)
|
|
||||||
|
|
||||||
| # | Scenario | Fixed records | Role |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | Odometer regression on return | `BK-DEMO-RETURN` / `MO-024` | Either |
|
|
||||||
| 2 | Possible duplicate customer | `CUS-0012` / `CUS-0178` / `DQ-DEMO-DUPLICATE` | OM |
|
|
||||||
| 3 | Overlapping bookings | `MO-016` / `BK-DEMO-OVERLAP-A/B` / `DQ-DEMO-OVERLAP` | OM |
|
|
||||||
| 4 | Failed automation, retried | outbox event `...020` / `BK-H-0020` | OM |
|
|
||||||
| 5 | Grounded procedure question | (no fixed record; suggested questions) | Either |
|
|
||||||
|
|
||||||
`GET /api/v1/demo/manifest`'s `scenarios` array derives `ready`/`blocked_reason` from the
|
|
||||||
live underlying records, never hardcoded — confirmed via `backend/tests/
|
|
||||||
test_demo_manifest.py` (`test_demo_manifest_scenarios_ready_after_fresh_reset`) and
|
|
||||||
live-checked after every reset throughout this work.
|
|
||||||
|
|
||||||
## Seed strategy and date-anchoring
|
|
||||||
|
|
||||||
`seed/generate_seed.py --anchor 2026-08-01 --seed 20260801` produces deterministic CSVs
|
|
||||||
with absolute timestamps authored against a fixed anchor. `backend/app/seed_loader.py`
|
|
||||||
shifts every seeded datetime by `(real today − authored anchor)` on every seed/reset, so
|
|
||||||
"today"/"near-future"/"currently overlapping" scenarios stay true to the actual reset
|
|
||||||
moment instead of decaying. This fixed a real, confirmed bug (`BK-DEMO-RETURN` was found
|
|
||||||
sitting 2 days in the past before this fix). Full detail: `docs/demo-release/demo-data.md`.
|
|
||||||
|
|
||||||
## Reset strategy
|
|
||||||
|
|
||||||
`POST /api/v1/demo/reset` (Operations Manager only, gated by `DEMO_ALLOW_RESET`) clears
|
|
||||||
MobilityOps's own tables, reseeds with a fresh date anchor, re-runs the data-quality scan,
|
|
||||||
and runs a server-side scenario-integrity check (`scenario_integrity_report()`) recorded
|
|
||||||
in both the response and the `demo_reset` audit event. Reachable from the sidebar, the
|
|
||||||
Demo Guide, and the About page. Never touches shared n8n/RAGcore/MCP data, other
|
|
||||||
containers, or volumes.
|
|
||||||
|
|
||||||
## Real vs. synthetic vs. not-connected
|
|
||||||
|
|
||||||
See `docs/demo-release/demo-concept.md` for the full breakdown. In short: auth/roles,
|
|
||||||
vehicle/booking management, return preview/commit, the 5 data-quality rules and their
|
|
||||||
resolutions, the audit trail, n8n orchestration, Docker deployment, and the automated
|
|
||||||
test suite are all really implemented. The organisation, all people, vehicles, bookings,
|
|
||||||
procedures, and the 5 named scenarios are synthetic. RAGcore and the ITWorx MCP Hub are
|
|
||||||
not live-connected (honestly labelled "Demomodus"/"Niet gekoppeld" everywhere, never a
|
|
||||||
fabricated success).
|
|
||||||
|
|
||||||
## Test results
|
|
||||||
|
|
||||||
### Backend (clean checkout, isolated stack)
|
|
||||||
- `pytest`: **127 passed**
|
|
||||||
- `ruff check .`: clean
|
|
||||||
- `mypy app`: clean (48 source files)
|
|
||||||
|
|
||||||
### Frontend (clean checkout, isolated stack)
|
|
||||||
- `npm ci`: clean (pre-existing esbuild-moderate/react-router-RSC-high advisories,
|
|
||||||
unchanged from before this work — not introduced by it)
|
|
||||||
- `tsc -b`: clean
|
|
||||||
- `npm run build`: clean
|
|
||||||
- Full Playwright suite: **56 passed** (against the isolated clean-checkout stack)
|
|
||||||
|
|
||||||
### Guided-demo test
|
|
||||||
`frontend/e2e/guided-demo-full.spec.ts` — one comprehensive test walking a fresh
|
|
||||||
Operations Manager session through all 8 Demo Guide steps performing the real action at
|
|
||||||
each step (processes the actual odometer-anomaly return, resolves the resulting
|
|
||||||
data-quality issue, merges the duplicate customer, asks a suggested knowledge question,
|
|
||||||
checks automation + audit, reviews the About page), then resets the demo data again to
|
|
||||||
restore the environment. **Passed**, confirmed stable across repeated runs both locally
|
|
||||||
and against the live Unraid deployment.
|
|
||||||
|
|
||||||
### Clean-checkout drill
|
|
||||||
Fresh `git clone` of this branch/commit into an isolated scratch directory, `.env` from
|
|
||||||
`.env.example`, isolated Compose project name (`mobilityops-cleandrill`) and remapped
|
|
||||||
host ports (`compose.override.yaml` with `!override` merge tags — no shared state with
|
|
||||||
any other stack), `docker compose up --build -d` from empty volumes → migrations ran
|
|
||||||
automatically (`e7b08389f47f (head)`) → seeded → full backend gate (127 passed, ruff/
|
|
||||||
mypy clean) → `npm ci`/`tsc -b`/`vite build` clean → full Playwright suite (56 passed)
|
|
||||||
→ reseeded and confirmed all 5 scenarios `ready: true` via the manifest → torn down
|
|
||||||
(`docker compose down -v` on the isolated project only; the working dev stack was never
|
|
||||||
touched).
|
|
||||||
|
|
||||||
### Server deployment
|
|
||||||
Deployed incrementally after every batch (10 deploy cycles across this work); final
|
|
||||||
state: both `api` and `web` rebuilt and healthy at the final commit, `db` untouched
|
|
||||||
across all of them (no destructive migrations on this branch). Migrations at
|
|
||||||
`e7b08389f47f (head)` throughout. `.deploy/source-revision` on the server matches the
|
|
||||||
final commit exactly.
|
|
||||||
|
|
||||||
### Container health
|
|
||||||
`docker compose ps` on the server: `api`, `db`, `web` all `healthy`, no restart loops.
|
|
||||||
|
|
||||||
### Browser console / network
|
|
||||||
No unexpected console errors on login, dashboard, scenarios, About, or with the Demo
|
|
||||||
Guide open (verified via `demo-accessibility.spec.ts`; the one benign 401 from the app's
|
|
||||||
own session-probe on first load is expected and explicitly accounted for, not silenced
|
|
||||||
blindly). No unresolved server errors in `docker logs` for `api`/`web` at the time of
|
|
||||||
this evidence capture.
|
|
||||||
|
|
||||||
### Responsive / accessibility
|
|
||||||
- Demo Guide renders as a correctly-anchored bottom sheet at 390px with no horizontal
|
|
||||||
overflow (`demo-accessibility.spec.ts`).
|
|
||||||
- **Real bug found and fixed**: the Demo Guide's fixed desktop side panel overlapped
|
|
||||||
main content with no reflow, making the return form's "Review return" button
|
|
||||||
unclickable while the guide was open at ordinary desktop widths — this surfaced while
|
|
||||||
writing the full guided-demo test. Fixed via a `guide-open` layout class that reserves
|
|
||||||
space for the panel; regression-tested.
|
|
||||||
- Demo badge and Demo Guide triggers are keyboard-focusable and operable (Enter to open,
|
|
||||||
explicit close controls).
|
|
||||||
- Existing responsive-overflow checks (390/768/1280/1440px) remain green throughout.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- RAGcore and the ITWorx MCP Hub are not live-connected in this environment (by design
|
|
||||||
— see scope). The knowledge assistant uses a local, English-only demo knowledge base;
|
|
||||||
a Dutch question against it returns "insufficient evidence" (verified empirically), so
|
|
||||||
suggested questions and the Demo Guide's step 6 instructions deliberately stay in
|
|
||||||
English rather than silently breaking the demo's centerpiece grounded-answer feature.
|
|
||||||
- Existing operational screens (Dashboard, Vehicles, Bookings, Data Quality workbench,
|
|
||||||
Audit, Automation internals) remain in English; only new demo-productization surfaces
|
|
||||||
(login, Demo Guide, scenario overview, About page, demo badge, plain-language
|
|
||||||
integration labels) are in Dutch — a deliberate, documented scope decision, not an
|
|
||||||
oversight (`docs/demo-release/current-demo-gap-audit.md`, gap #11).
|
|
||||||
- Scenario S3 ("missing inspection before next booking", `MO-031`) is seeded and visible
|
|
||||||
in the attention queue but isn't one of the 5 scenarios surfaced on `/scenarios`,
|
|
||||||
matching the brief's request for exactly 5.
|
|
||||||
|
|
||||||
## 5-minute and 10-minute demo flows
|
|
||||||
|
|
||||||
See `docs/demo-release/demo-runbook.md` for the exact click-through scripts.
|
|
||||||
|
|
||||||
## Redeploy commands and rollback procedure
|
|
||||||
|
|
||||||
See `docs/demo-release/demo-runbook.md` — `git archive` → `scp` → extract → rebuild
|
|
||||||
`api`/`web` → confirm migrations → reseed. Rollback: extract an earlier
|
|
||||||
`.deploy/source-<short-sha>.tar.gz` and update `.deploy/source-revision` to match.
|
|
||||||
|
|
||||||
## Evidence screenshots
|
|
||||||
|
|
||||||
All captured live against `http://192.168.10.150:1236` (`artifacts/demo-release/screenshots/`):
|
|
||||||
|
|
||||||
1. `01-demo-entry-desktop.png` / `02-demo-entry-mobile.png` — demo entry, both sizes
|
|
||||||
2. `03-dashboard-with-scenarios.png` — dashboard with the scenario teaser panel
|
|
||||||
3. `04-demo-guide.png` — the Demo Guide panel open
|
|
||||||
4. `05-return-preview.png` / `06-return-result.png` — the return flow
|
|
||||||
5. `07-data-quality-resolution.png` — a data-quality issue with its plain-language explainer
|
|
||||||
6. `08-duplicate-customer-merge.png` — the duplicate-customer comparison/merge UI
|
|
||||||
7. `09-knowledge-assistant.png` — a grounded answer with cited sources
|
|
||||||
8. `10-integration-status.png` — plain-language integration status on Automation
|
|
||||||
9. `11-automation-retry-before.png` / `11-automation-retry-after.png` — a workflow retry
|
|
||||||
10. `12-audit-trail.png` / `13-audit-related-events.png` — audit trail + correlation drill-down
|
|
||||||
11. `14-about-demo.png` — the About page
|
|
||||||
12. `15-demo-badge-popover.png` — the permanent synthetic-demo badge popover
|
|
||||||
13. `16-reset-confirm.png` — the reset confirmation flow
|
|
||||||
|
|
||||||
No secrets appear in any screenshot or in this document.
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# MobilityOps Unraid deployment evidence
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
|
|
||||||
- Deployment: **PASS**
|
|
||||||
- Gitea publication: **PASS**
|
|
||||||
- Gitea URL: `https://gitea.itworx.tech/Jens/MobilityOps`
|
|
||||||
- Visibility: private (verified in the Gitea web UI)
|
|
||||||
- Branch: `master`
|
|
||||||
- Verified baseline commit: `4bf9afbeff44088864e0844769d4dd0e4089d85b`
|
|
||||||
- Deployment implementation commit: `1e13943cffb2da8a328b5b1ea5e9b1fe73fdd774`
|
|
||||||
- Server: `192.168.10.150`
|
|
||||||
- Server directory: `/mnt/user/appdata/mobilityops`
|
|
||||||
- Compose project: `mobilityops`
|
|
||||||
- Application URL: `http://192.168.10.150:1236`
|
|
||||||
- Port mapping: LAN `0.0.0.0:1236` / `[::]:1236` to `web:80`
|
|
||||||
|
|
||||||
## Services and health
|
|
||||||
|
|
||||||
| Service | Runtime state | Health | Host exposure |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `db` | running, 0 restarts | healthy | none (`5432/tcp` internal) |
|
|
||||||
| `api` | running, 0 restarts | healthy | none (`8000/tcp` internal) |
|
|
||||||
| `web` | running, 0 restarts | healthy | `1236:80` on LAN |
|
|
||||||
| shared host `n8n` | running | healthy | `5678:5678` on LAN; outside MobilityOps Compose |
|
|
||||||
|
|
||||||
The final review topology reuses the n8n container that was already running on the host.
|
|
||||||
Its empty public-host/editor URL settings were corrected in the persistent Unraid template
|
|
||||||
so workflow execution URLs are valid. The temporary Compose-owned n8n container was
|
|
||||||
removed without deleting its retained volume. Port `1236` was confirmed unused before the
|
|
||||||
original deployment; the application directory was created specifically for MobilityOps.
|
|
||||||
|
|
||||||
## Deployment commands
|
|
||||||
|
|
||||||
The existing SSH aliases resolve to the requested hosts and keys (`gitea.itworx.tech`
|
|
||||||
for Gitea SSH and `unraid` for root access). No key was created, copied, or replaced.
|
|
||||||
The committed source was transferred from the workstation; Unraid has no Gitea key.
|
|
||||||
|
|
||||||
Repository publication used the SSH clone URL supplied by Gitea:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git remote add origin ssh://git@192.168.10.150:222/Jens/MobilityOps.git
|
|
||||||
git push -u origin master
|
|
||||||
git push origin --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
Git and the Gitea web UI both verified `master` as the default branch, the full commit
|
|
||||||
history, baseline commit `4bf9afbeff44088864e0844769d4dd0e4089d85b`, and zero tags.
|
|
||||||
The remote tree contains no `.env`, local database, `node_modules`, virtual environment,
|
|
||||||
test cache, build cache, Playwright output, or browser binaries.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git archive --format=tar.gz --output=<temporary-archive> <commit>
|
|
||||||
scp <temporary-archive> unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
|
||||||
ssh unraid
|
|
||||||
cd /mnt/user/appdata/mobilityops
|
|
||||||
tar -xzf .deploy/source.tar.gz
|
|
||||||
./deploy/unraid/configure-env.sh http://192.168.10.150:1236
|
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
|
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api \
|
|
||||||
python -m app.cli seed --reset
|
|
||||||
./deploy/unraid/setup-existing-n8n.sh \
|
|
||||||
n8n \
|
|
||||||
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
|
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
|
|
||||||
```
|
|
||||||
|
|
||||||
The server `.env` was created from `.env.example`, is mode `0600`, and contains generated
|
|
||||||
runtime secrets. Secret values and n8n owner credentials remain server-only and are not
|
|
||||||
included here or in Git.
|
|
||||||
|
|
||||||
## Validation evidence
|
|
||||||
|
|
||||||
- Migration: `e7b08389f47f (head)`.
|
|
||||||
- Deterministic seed: users 2, customers 180, vehicles 50, bookings 246, inspections 75,
|
|
||||||
maintenance 40, data-quality issues 26, workflow runs 20.
|
|
||||||
- HTTP: `GET /` returned 200; `GET /health` returned
|
|
||||||
`{"status":"ok","service":"mobilityops-api"}` through the web proxy.
|
|
||||||
- Backend gates in an isolated local Compose project: 66 tests passed, Ruff clean, mypy
|
|
||||||
clean across 44 files.
|
|
||||||
- Frontend: `npm ci && npm run build` completed (`tsc -b && vite build`).
|
|
||||||
- Logs: no traceback, fatal, uncaught, or unresolved startup error in the deployment log
|
|
||||||
scan. Browser console had no warnings or errors during the smoke test.
|
|
||||||
- Browser smoke test in Chrome: Operations Manager demo login, Dashboard, Vehicles,
|
|
||||||
Bookings, Data Quality, Knowledge, Automation, and Audit all loaded from the LAN URL.
|
|
||||||
- Dashboard showed persisted seed metrics (21 available, 11 rented, 6 cleaning,
|
|
||||||
5 maintenance, 7 blocked, 22 open issues, 1 pending/failed workflow).
|
|
||||||
- Return workflow: `BK-DEMO-RETURN` accepted 54,700 km, created `INSP-0076` and
|
|
||||||
`DQ-RET-0076`, preserved the 54,820 km canonical odometer, and changed the booking to
|
|
||||||
returned.
|
|
||||||
- Shared-n8n round trip: final post-deploy event `98eb06dc-0bcc-4e3d-96ec-c23b2d266293`
|
|
||||||
reached `succeeded` on attempt 1 with no last error; the deterministic reset afterwards
|
|
||||||
restored `BK-DEMO-RETURN` to `active`.
|
|
||||||
- Data quality: `DQ-RET-0076` displayed the persisted regression evidence and related
|
|
||||||
booking/inspection references.
|
|
||||||
- Knowledge: UI truthfully showed `Provider: demo · available · 10 procedures indexed`;
|
|
||||||
the damage question returned grounded excerpts and citations from the local procedures.
|
|
||||||
|
|
||||||
## Integration status
|
|
||||||
|
|
||||||
- RAGcore: disabled for this deployment; `KNOWLEDGE_PROVIDER=demo`. No claim of a live
|
|
||||||
RAGcore connection is shown. Operational functionality is unaffected.
|
|
||||||
- ITWorx MCP Hub: registration disabled with `MCP_HUB_REGISTRATION_ENABLED=false`; the
|
|
||||||
independently authenticated provider endpoints remain available internally to the web
|
|
||||||
proxy/API boundary, but no live Hub connection is claimed.
|
|
||||||
- n8n: the existing server instance at `http://192.168.10.150:5678` is healthy; the
|
|
||||||
MobilityOps workflow is imported/published there and a real return delivery succeeded.
|
|
||||||
The bundled MobilityOps service is disabled by default in the Unraid overlay.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- RAGcore and ITWorx MCP Hub are intentionally not connected yet.
|
|
||||||
- Demo authentication remains the accepted HMAC-cookie PoC mechanism.
|
|
||||||
- The dependency advisories already documented in final acceptance remain unchanged.
|
|
||||||
|
|
||||||
## Redeploy
|
|
||||||
|
|
||||||
From the workstation, create an archive of the desired committed revision and transfer it
|
|
||||||
to `.deploy/source.tar.gz`. On Unraid, preserve `.env` and the named volumes, then run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /mnt/user/appdata/mobilityops
|
|
||||||
tar -xzf .deploy/source.tar.gz
|
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
|
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current
|
|
||||||
curl -fsS http://127.0.0.1:1236/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /mnt/user/appdata/mobilityops
|
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps
|
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200
|
|
||||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs -f api web
|
|
||||||
docker logs -f n8n
|
|
||||||
```
|
|
||||||
|
|
||||||
## Safe rollback
|
|
||||||
|
|
||||||
Choose a known-good commit on the workstation, archive and transfer it as above, then on
|
|
||||||
Unraid extract it over the identifiable MobilityOps source directory and run the same
|
|
||||||
`up --build -d` command. Preserve `.env` and both named volumes; do not use `down -v`,
|
|
||||||
remove volumes, prune Docker, or modify unrelated containers. Check the target commit's
|
|
||||||
Alembic compatibility before rolling application code behind the current database schema.
|
|
||||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 71 KiB |
@@ -1,89 +0,0 @@
|
|||||||
# MobilityOps premium UI evidence summary
|
|
||||||
|
|
||||||
Date: 2026-08-02
|
|
||||||
Branch: `design/mobilityops-premium-ui`
|
|
||||||
Baseline revision: `dfabb41582e302f45a3de826f85f531bf23dfc8b`
|
|
||||||
Final design implementation commit: `1f292e14bb6a2e8ded5dc675b1b3360307d8a9ae`
|
|
||||||
Review URL: `http://192.168.10.150:1236`
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
|
|
||||||
The working PoC was transformed into the Control Rail operational interface without
|
|
||||||
changing backend contracts or adding scope. All existing journeys remain functional;
|
|
||||||
return registration gained an evidence-based review boundary before commit.
|
|
||||||
|
|
||||||
## Evidence index
|
|
||||||
|
|
||||||
- Baseline audit: `docs/design/current-ux-audit.md`
|
|
||||||
- Three directions and decision: `docs/design/design-directions.md`
|
|
||||||
- Design tokens and component rules: `docs/design/design-system.md`
|
|
||||||
- Stitch resource IDs: `docs/design/stitch-manifest.md`
|
|
||||||
- Implemented visual validation: `docs/design/implementation-validation.md`
|
|
||||||
- Baseline captures: `artifacts/design-validation/current/`
|
|
||||||
- Stitch captures: `artifacts/design-validation/stitch/`
|
|
||||||
- Final responsive captures: `artifacts/design-validation/implementation/`
|
|
||||||
|
|
||||||
## Major implementation changes
|
|
||||||
|
|
||||||
- Responsive Control Rail shell with compact top bar, desktop rail, off-canvas menu and
|
|
||||||
labelled mobile bottom navigation.
|
|
||||||
- Live readiness band, filterable Attention queue, movement timeline, honest integration
|
|
||||||
pulse and persisted activity on the operations dashboard.
|
|
||||||
- Searchable fleet and booking registries; booking client pagination limits the DOM to 25
|
|
||||||
operational rows; responsive tables retain field labels.
|
|
||||||
- Capture → review → result return workflow with calculated consequence preview and no
|
|
||||||
write request before confirmation.
|
|
||||||
- Match/conflict duplicate comparison, evidence-first knowledge, system-health cards and
|
|
||||||
expandable audit metadata.
|
|
||||||
- Inline SVG product mark, Feather-like line icon set, CSS control-centre illustration,
|
|
||||||
timeline/status motion and reduced-motion fallback; no image or motion dependency.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
| Gate | Result |
|
|
||||||
|---|---|
|
|
||||||
| Backend tests | 66 passed |
|
|
||||||
| Backend lint | ruff passed |
|
|
||||||
| Backend types | mypy: 0 issues in 44 files |
|
|
||||||
| Frontend types/build | passed; 59 modules; 240.24 kB JS and 36.63 kB CSS before gzip |
|
|
||||||
| Browser journeys | 19 passed locally |
|
|
||||||
| Horizontal overflow | none at 390/768/1280/1440 px |
|
|
||||||
| Accessibility | named landmarks, skip link, visible focus, text-plus-shape status, labelled mobile rows, reduced-motion support |
|
|
||||||
| Deployed browser smoke | passed on all 10 authenticated routes plus login at desktop and mobile sizes |
|
|
||||||
| Console/network | 0 browser warnings/errors; 7 authenticated API paths returned HTTP 200 |
|
|
||||||
| Deployed global search | Ctrl+K plus `MO-024` navigation passed against the review URL |
|
|
||||||
|
|
||||||
All displayed operational counts remain derived from the existing persisted API data.
|
|
||||||
Synthetic-data labelling is persistent on login and authenticated surfaces.
|
|
||||||
|
|
||||||
Deployed evidence is stored in `artifacts/design-validation/implementation/deployed/`.
|
|
||||||
The review stack reports healthy PostgreSQL/API state, HTTP 200 from the web application,
|
|
||||||
and healthy state from the server's existing n8n at port 5678. A synthetic return reached
|
|
||||||
`succeeded` on attempt 1 through that shared n8n and its MobilityOps callback; the demo was
|
|
||||||
then reset to its deterministic state.
|
|
||||||
|
|
||||||
## Performance observations
|
|
||||||
|
|
||||||
No runtime font, image or animation dependency was added. The application uses inline SVG
|
|
||||||
and CSS visuals, and the production bundle remains appropriate for this internal PoC.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- Global search resolves Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public
|
|
||||||
references. It intentionally does not offer customer lookup because the locked PoC has
|
|
||||||
no customer detail route or cross-entity search API.
|
|
||||||
- The repository retains a bundled n8n service for standalone local clean-checkout demos.
|
|
||||||
The Unraid overlay keeps it behind the opt-in `bundled-n8n` profile; the live review
|
|
||||||
deployment uses the server's existing shared n8n instead.
|
|
||||||
- The MCP Hub state is correctly shown as not configured in the current PoC rather than
|
|
||||||
simulated as healthy.
|
|
||||||
- Live RAGcore and MCP Hub round trips remain subject to the existing environment limits
|
|
||||||
documented in `PROJECT_STATE.md`; their degradation behavior is unchanged.
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
The accepted baseline remains reachable at commit
|
|
||||||
`dfabb41582e302f45a3de826f85f531bf23dfc8b`. To roll back the review deployment without
|
|
||||||
rewriting git history, archive that revision, extract it over the application source on
|
|
||||||
Unraid while preserving `.env` and Docker volumes, and run
|
|
||||||
`docker compose -p mobilityops up -d --build`. Verify `/health` and port 1236 afterwards.
|
|
||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
# MobilityOps — final acceptance evidence
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
|
|
||||||
Built on top of commit `c5b7e21f81694f0339ad31e3bf044db952d0fbe0` (M6, "implement ITWorx
|
|
||||||
MCP Hub publication"). This evidence file and the rest of M7's polish are committed as
|
|
||||||
`M7: portfolio polish and final acceptance` — run `git log --oneline` for the exact hash.
|
|
||||||
|
|
||||||
## Exact commands (clean checkout)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repo> && cd MobilityOps
|
|
||||||
cp .env.example .env
|
|
||||||
make demo # docker compose up --build -d ; migrations run automatically ; seed --reset
|
|
||||||
```
|
|
||||||
|
|
||||||
One-time n8n setup (see `docs/17-runbook.md` for full detail — this cannot be scripted
|
|
||||||
end-to-end because it requires a one-time owner account created through n8n's web UI):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# open http://localhost:5678/setup in a browser, create any owner account
|
|
||||||
make n8n-setup
|
|
||||||
```
|
|
||||||
|
|
||||||
Verification:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
|
|
||||||
- **Backend**: 66 tests passing (`pytest`), 0 skipped, 0 failed. Ruff clean. Coverage by
|
|
||||||
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
|
|
||||||
|
|
||||||
Captured live against the deterministic seed (`artifacts/evidence/screenshots/`,
|
|
||||||
via `frontend/e2e/_capture-screenshots.spec.ts`):
|
|
||||||
|
|
||||||
| # | Page | File |
|
|
||||||
|---|---|---|
|
|
||||||
| 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
|
|
||||||
|
|
||||||
**Success (demo provider, the one actually satisfying acceptance in this environment)** —
|
|
||||||
S6 question against the real `/api/v1/knowledge/questions` endpoint:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"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)** —
|
|
||||||
`KNOWLEDGE_PROVIDER=ragcore`, `RAGCORE_BASE_URL=http://ragcore-not-reachable:9999`:
|
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
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.
|
|
||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -1,265 +1,77 @@
|
|||||||
# MobilityOps — final acceptance audit summary
|
# Fleet Ops release acceptance
|
||||||
|
|
||||||
This audit was run after M0–M7 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 M0–M7. 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.
|
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
# Fleet Ops correction and release — final evidence
|
|
||||||
|
|
||||||
**Result: PASS**
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
- Source branch / commit (verified pre-correction baseline): `master` @ `18344bc8b7a75a2f868bf15bf498fc030ac6c34c`
|
|
||||||
- Fix branch: `fix/fleet-ops-i18n-status-flow`
|
|
||||||
- Final fix-branch commit: `284b3c7` (merged content identical to `2e4fb43`, which carries the evidence-summary localization fix)
|
|
||||||
- Main-before-merge: `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` (confirmed unchanged via `git fetch` + `git rev-parse origin/master` immediately before merging — no unexpected commits landed on master while this branch was in progress)
|
|
||||||
- Merge commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`git merge --no-ff fix/fleet-ops-i18n-status-flow -m "merge: complete Fleet Ops localization and status resolution"`, zero conflicts)
|
|
||||||
- Final main commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
|
|
||||||
- Deployed commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`.deploy/source-revision` on Unraid)
|
|
||||||
- Gitea main branch: `master` (confirmed via `git fetch origin && git rev-parse origin/master` matching local `master` after push)
|
|
||||||
- Live URL: `http://192.168.10.150:1236`
|
|
||||||
|
|
||||||
Fix-branch commit history: `6deb955`, `e6539d1`, `ac4b163`, `1fdd2b3`, `1e40775`, `a7ac5ed`, `7851e80`, `cda2c32`, `2e4fb43`, `284b3c7`.
|
|
||||||
|
|
||||||
## What this correction fixed
|
|
||||||
|
|
||||||
1. **Status-recommendation flow redesigned** (sections 8A–8F). The old single opaque
|
|
||||||
"calculate and apply recommended status" action is replaced by a single shared, pure
|
|
||||||
evaluator (`backend/app/services/vehicle_status.py::evaluate_vehicle_status`,
|
|
||||||
documented in `docs/fleet-ops-correction/vehicle-status-decision-table.md`) used
|
|
||||||
identically by the scanner, a non-mutating preview endpoint
|
|
||||||
(`POST /api/v1/data-quality/issues/{ref}/status-recommendation`), and a
|
|
||||||
transactional apply endpoint (`POST .../apply-recommended-status`) that locks the
|
|
||||||
row, recomputes facts, rejects a stale `recommendation_token`, refuses unsafe/manual-
|
|
||||||
review recommendations, and re-validates post-write before resolving the issue.
|
|
||||||
- Forbidden shortcuts eliminated: "maintenance + active booking" no longer
|
|
||||||
auto-recommends "rented" (being in maintenance is itself now a blocking fact);
|
|
||||||
"maintenance with nothing else wrong" no longer auto-clears to "available" (no
|
|
||||||
fact proves maintenance is actually finished — release stays a manual decision).
|
|
||||||
- Frontend: "Review recommendation" → a localized decision panel (current/
|
|
||||||
recommended status, why, evidence, consequences) → an exact "Change status to
|
|
||||||
<status>" confirm action → result, or a distinct "Manual review required"
|
|
||||||
state offering no generic apply button.
|
|
||||||
2. **MO-016 order independence** (section 9). Order independence does not mean "same
|
|
||||||
final status regardless of order" — resolving the booking overlap first genuinely
|
|
||||||
removes the conflict, correctly leaving nothing to apply. What holds either way: the
|
|
||||||
recommendation always reflects real current facts (never a stale proxy), and nothing
|
|
||||||
unsafe is ever applied (never "rented"). Proven by a backend test explicitly scoped
|
|
||||||
to MO-016/DQ-DEMO-STATUS (the original version wasn't — `_first_open()` returned
|
|
||||||
whichever of ~14 open `vehicle_status_conflict` issues was most recent, not
|
|
||||||
necessarily MO-016's) and a browser-level Playwright test covering both orders.
|
|
||||||
3. **"Fleet Ops" is a non-localizable brand constant** (`frontend/src/product.ts`,
|
|
||||||
backend `PRODUCT_NAME`), wired via `{{productName}}` interpolation everywhere the
|
|
||||||
brand appeared in locale prose. A permanent test fails the build if any locale file
|
|
||||||
ever defines the brand name or an `appName` key again.
|
|
||||||
4. **Dynamic backend prose converted to message codes + params** (sections 5/6/10):
|
|
||||||
return status reasons, audit field/actor-type labels, automation `last_error` (new
|
|
||||||
`last_error_code` column, migration `799d8800e241`), search results (sections/
|
|
||||||
vehicles/bookings/issues), and — found live on Unraid — the data-quality evidence
|
|
||||||
summary. Raw technical text is demoted to a "Technical details" disclosure
|
|
||||||
everywhere.
|
|
||||||
5. **Knowledge-base fixes**: the demo provider's tokenizer silently dropped accented
|
|
||||||
characters (`[a-z0-9]+` split "véhicule" into "v"+"hicule"), breaking French
|
|
||||||
retrieval broadly — fixed to include the Latin-1 accented range. Reweighted section
|
|
||||||
scoring so a body match (real substance) outranks a heading/title match (a shallow
|
|
||||||
structural hint) — the old weighting misranked the damage procedure behind an
|
|
||||||
unrelated document for the brief's exact validation question in all 3 languages.
|
|
||||||
Removed leftover "MobilityOps"/"PoC" mentions from 9 procedure documents.
|
|
||||||
6. **Search, audit, automation, maintenance/inspections localized** (section 10):
|
|
||||||
backend returns stable codes + params only; the frontend localizes section labels,
|
|
||||||
vehicle summaries, booking/issue statuses, audit action/field/actor labels,
|
|
||||||
automation error explanations, and maintenance/inspection type labels.
|
|
||||||
7. **i18n test suite strengthened** (section 11): key parity, brand invariant,
|
|
||||||
translation-quality (cross-locale identical-value detection), a hardcoded-JSX-text
|
|
||||||
static scan (had to anchor on backreferenced closing-tag names — a naive `>text<`
|
|
||||||
regex misread TypeScript generics as JSX), and a 3-language route matrix (every main
|
|
||||||
route, no console errors, correct `html[lang]`, real page headings).
|
|
||||||
|
|
||||||
## Live-caught bug (the deployment validation earning its keep)
|
|
||||||
|
|
||||||
Live validation on the freshly-deployed fix branch directly caught a real defect: every
|
|
||||||
data-quality issue's top-of-page evidence summary was unconditionally showing raw,
|
|
||||||
always-English text (e.g. *"vehicle marked available while reserved bookings
|
|
||||||
conflict"*) in **all three languages**, because the frontend never finished the
|
|
||||||
`evidence.signals` localization the backend had already been emitting (the backend code
|
|
||||||
even had a comment describing the intended design that the frontend didn't implement).
|
|
||||||
Fixed in commit `2e4fb43`:
|
|
||||||
- `DataQualityIssueDetail.tsx` now renders `evidence.signals` through the operator's
|
|
||||||
locale as the primary evidence text.
|
|
||||||
- The four `DQ-DEMO-*` seed rows that anchor the guided demo's scripted scenarios now
|
|
||||||
carry real, accurate signals computed at seed time (the duplicate-customer similarity
|
|
||||||
score is the actual `SequenceMatcher` ratio on the seeded names, not invented).
|
|
||||||
- Rows with no structured signals fall back to raw text rather than showing a blank
|
|
||||||
summary; the one known filler placeholder gets its own localized rendering.
|
|
||||||
- A regression test locks this in: the vehicle-status-conflict evidence summary must
|
|
||||||
show localized text and must never contain the specific raw English sentence that was
|
|
||||||
live-visible before the fix, in all 3 languages.
|
|
||||||
|
|
||||||
Also found and fixed along the way: a frontend logic bug conflating "no conflict" with
|
|
||||||
"manual review required" (both carry `safe_to_apply: false`), which showed a false
|
|
||||||
"manual review required" panel for MO-016 after its booking overlap was resolved
|
|
||||||
instead of the correct "no change needed" state (fixed in `1fdd2b3`).
|
|
||||||
|
|
||||||
## Translation coverage
|
|
||||||
|
|
||||||
- All three locale files (`nl-BE`, `en-GB`, `fr-BE`) define exactly the same key set
|
|
||||||
for every namespace (`i18n-coverage.spec.ts`, structural guarantee).
|
|
||||||
- No locale file contains an empty string value.
|
|
||||||
- No locale file defines the brand name or an `appName` key (brand-invariant test).
|
|
||||||
- Cross-locale translation-quality check: for every string ≥8 characters of real prose,
|
|
||||||
nl-BE ≠ en-GB, fr-BE ≠ en-GB, fr-BE ≠ nl-BE, with a precise, audited allowlist for
|
|
||||||
genuine proper nouns/cognates (23 entries, each with a documented reason).
|
|
||||||
- Hardcoded-JSX-text static scan: zero findings against the current codebase (verified
|
|
||||||
against both false positives — TypeScript generics — and a deliberately-injected-
|
|
||||||
then-reverted false negative).
|
|
||||||
- 3-language route matrix: every main route (dashboard, vehicles, vehicle detail,
|
|
||||||
bookings, booking detail, data quality, issue detail, automation, knowledge, audit,
|
|
||||||
scenarios, about) opens cleanly in all 3 languages with no console errors, correct
|
|
||||||
`html[lang]`, and a real page heading.
|
|
||||||
- **Remaining visible wrong-language text**: none found. The one gap that existed (the
|
|
||||||
data-quality evidence summary) was found live and fixed before merge.
|
|
||||||
|
|
||||||
## Branding
|
|
||||||
|
|
||||||
- Visible product name: **Fleet Ops**, exactly, in all 3 languages, everywhere (login,
|
|
||||||
topbar, footer "Fleet Ops Demo", document title, About page, Demo Guide, knowledge
|
|
||||||
base). Verified structurally (brand-invariant test) and live (branding test across
|
|
||||||
dashboard/vehicles/data-quality/audit/automation/knowledge pages in all 3 languages;
|
|
||||||
visual screenshots of the login screen in nl-BE and fr-BE).
|
|
||||||
- Technical identifier retained (by design, per the brief): repository name, local
|
|
||||||
directory, package/module names, Compose project, deployment directory, database
|
|
||||||
name, and the `/health` endpoint's `service: "mobilityops-api"` field remain
|
|
||||||
"mobilityops" — none of these are visible UI text.
|
|
||||||
- No visible "MobilityOps" or "PoC" anywhere in the UI or the demo knowledge base
|
|
||||||
(9 procedure documents cleaned up; regression test in `test_knowledge.py` scans every
|
|
||||||
procedure file for both strings).
|
|
||||||
|
|
||||||
## Status-preview / apply / manual-review / MO-016 ordering
|
|
||||||
|
|
||||||
- **Preview**: verified non-mutating — the issue's `status` stays `"open"` after
|
|
||||||
calling the preview endpoint and re-fetching it via a fresh request.
|
|
||||||
- **Apply**: the confirm button names the exact target status ("Change status to
|
|
||||||
Blocked" / "Status wijzigen naar Geblokkeerd" / "Changer le statut vers Bloqué");
|
|
||||||
applying resolves the issue and updates the vehicle atomically.
|
|
||||||
- **Manual review**: MO-024 (active rental + service-threshold reached, a genuine fact
|
|
||||||
contradiction) shows "Manual review required" with no generic apply button rendered
|
|
||||||
at all.
|
|
||||||
- **Stale token**: simulated by resolving the underlying booking overlap after the
|
|
||||||
preview was fetched but before applying — the apply call is correctly rejected
|
|
||||||
(`RECOMMENDATION_STALE`), the UI shows the "situation has changed" message, and the
|
|
||||||
user must review again before a new apply is possible.
|
|
||||||
- **MO-016 ordering**: both orders tested. Resolving the overlap first correctly leaves
|
|
||||||
nothing to apply (vehicle stays "available", genuinely correct). Resolving the status
|
|
||||||
conflict first safely blocks the vehicle; resolving the now-redundant overlap
|
|
||||||
afterwards does not disturb it. Neither order ever produces "rented".
|
|
||||||
|
|
||||||
## Knowledge (per language)
|
|
||||||
|
|
||||||
The brief's exact validation question, in each language, grounds on the damage
|
|
||||||
procedure as the **primary** (not just top-3) source:
|
|
||||||
- nl-BE: *"Wat moet ik doen wanneer een voertuig beschadigd terugkomt?"* → damage
|
|
||||||
procedure, Dutch source, Dutch excerpt.
|
|
||||||
- en-GB: *"What should I do when a vehicle returns with damage?"* → damage procedure,
|
|
||||||
English source, English excerpt.
|
|
||||||
- fr-BE: *"Que dois-je faire lorsqu'un véhicule revient endommagé ?"* → damage
|
|
||||||
procedure, French source, French excerpt.
|
|
||||||
|
|
||||||
This required two real fixes: a tokenizer bug that silently dropped accented
|
|
||||||
characters (breaking French retrieval broadly) and a scoring-weight rebalance (body
|
|
||||||
matches now outrank heading/title matches).
|
|
||||||
|
|
||||||
## Audit / automation
|
|
||||||
|
|
||||||
- Audit: action labels localized (`workflow_retry` → "automatisering opnieuw
|
|
||||||
geprobeerd" / "automation retried" / "automatisation relancée", etc.), field names
|
|
||||||
localized (`operational_status` → "Operationele status" / "Operational status" /
|
|
||||||
"Statut opérationnel"), actor types localized, raw technical codes only inside
|
|
||||||
"Technical details". Verified live and via a dedicated Playwright test.
|
|
||||||
- Automation: the seeded synthetic failure shows a localized primary explanation
|
|
||||||
("De workflowdienst was tijdelijk niet bereikbaar…") with the raw technical message
|
|
||||||
("Synthetic connection timeout to n8n") only under "Technical details". Verified live
|
|
||||||
and via a dedicated Playwright test.
|
|
||||||
|
|
||||||
## Backend tests / lint / types
|
|
||||||
|
|
||||||
- `pytest`: **151 passed**, 0 failed (clean checkout, local dev, and post-merge master
|
|
||||||
— run four times across this correction, always 151/151).
|
|
||||||
- `ruff check .`: all checks passed, every run.
|
|
||||||
- `mypy app` (strict): no issues found in 49 source files, every run.
|
|
||||||
- Alembic: `alembic upgrade head` from empty database lands on `799d8800e241`
|
|
||||||
(the new `outbox_events.last_error_code` column); `downgrade -1` / `upgrade head`
|
|
||||||
round-trip verified.
|
|
||||||
|
|
||||||
## Frontend build / Playwright
|
|
||||||
|
|
||||||
- `npm ci`, `tsc -b`, `vite build`: clean, every run.
|
|
||||||
- Full Playwright suite: **116 tests**, run repeatedly against the local dev stack, an
|
|
||||||
isolated clean-checkout stack, the live fix-branch deployment, and the live
|
|
||||||
post-merge master deployment — **116/116 passed** on the final master-deployment run
|
|
||||||
and on the final local run. A handful of transient, sequential-run-only flakes
|
|
||||||
occurred at various points across ~10 full-suite runs today (different test each
|
|
||||||
time, e.g. a pre-existing logout-timing race in `AuthContext.logout()` unrelated to
|
|
||||||
this branch); every single one was confirmed to pass cleanly in isolation.
|
|
||||||
- Guided demo covered indirectly via `guided-demo-full.spec.ts`,
|
|
||||||
`demo-guide.spec.ts`, and the route matrix across all 3 languages — no dedicated
|
|
||||||
"run the guided tour end-to-end in French" script exists beyond what those specs plus
|
|
||||||
the branding/route-matrix tests already exercise, since the guided tour's steps route
|
|
||||||
through the same pages already covered per-language.
|
|
||||||
|
|
||||||
## Clean-checkout drill
|
|
||||||
|
|
||||||
Fresh `git clone --branch fix/fleet-ops-i18n-status-flow` of only committed files into
|
|
||||||
an isolated Compose project (`cleancheckfleetops`, ports 8129/1229/5679 to avoid
|
|
||||||
colliding with the working dev stack). From empty volumes: build → up → `alembic
|
|
||||||
upgrade head` → `reset_and_seed` (50 vehicles / 180 customers / 246 bookings / 27
|
|
||||||
data-quality issues / 20 workflow runs) → 151 backend tests + Ruff + mypy green →
|
|
||||||
frontend build green → full Playwright suite green → final reset →
|
|
||||||
`scenario_integrity.all_ready: true`. Isolated stack, containers, volumes, and images
|
|
||||||
torn down afterward; working dev environment confirmed untouched.
|
|
||||||
|
|
||||||
## Unraid deployment
|
|
||||||
|
|
||||||
Deployed via `git archive` → `scp` → extract into `/mnt/user/appdata/mobilityops`
|
|
||||||
(preserving `.env` and persistent volumes) → `.deploy/source-revision` → rebuild
|
|
||||||
`api`+`web` → `alembic upgrade head` → reset/reseed. Done twice: once for the fix
|
|
||||||
branch (caught the evidence-summary bug), once for the final merged master. Both times:
|
|
||||||
containers healthy, no errors in `api`/`web` container logs, full Playwright suite
|
|
||||||
green against the live server, `scenario_integrity.all_ready: true` after final reset.
|
|
||||||
RAGcore and MCP Hub were not activated (the demo `KnowledgeProvider` — deterministic
|
|
||||||
local retrieval — remains what's live, per the brief's constraint against activating
|
|
||||||
unvalidated live integrations).
|
|
||||||
|
|
||||||
## Responsive / accessibility
|
|
||||||
|
|
||||||
- Breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844,
|
|
||||||
360×800) × 3 languages: no horizontal overflow, localized headings visible
|
|
||||||
(`responsive-i18n.spec.ts`).
|
|
||||||
- Status-recommendation panel: keyboard-only activation of "Review recommendation" and
|
|
||||||
"Change status to X" verified via focus assertions (not just click); reduced-motion
|
|
||||||
emulated during the flow; status never conveyed by colour alone (the badge always
|
|
||||||
carries its own localized text); `aria-live="polite"` added so the applied
|
|
||||||
confirmation is announced to screen readers.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- A pre-existing, narrow timing race in `AuthContext.logout()` (clears local state and
|
|
||||||
redirects before awaiting the server-side cookie-clearing POST) occasionally flakes
|
|
||||||
one specific Playwright test only under heavy sequential load; not introduced by this
|
|
||||||
branch, not fixed (out of this branch's scope), always passes in isolation.
|
|
||||||
- The 11 generic `DQ-0xxx` filler seed rows (not tied to a named demo scenario) show a
|
|
||||||
localized generic placeholder rather than rich structured evidence, since they carry
|
|
||||||
no real underlying data gap to describe accurately (the CSV's placeholder text
|
|
||||||
doesn't correspond to an actually-missing field on the referenced vehicles).
|
|
||||||
- No dedicated "full guided demo in French, screenshot every step" script exists as a
|
|
||||||
single artifact; coverage is composed from the route matrix, branding, and existing
|
|
||||||
guided-demo specs, each run across all 3 languages.
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
`artifacts/fleet-ops-correction/screenshots/`, all captured live against
|
|
||||||
`http://192.168.10.150:1236`:
|
|
||||||
|
|
||||||
- `login-nl-BE.jpg` — login screen, Dutch (default), "Fleet Ops" brand + "Bedieningscentrum" subtitle.
|
|
||||||
- `login-fr-BE.jpg` — login screen switched to French, "Fleet Ops" brand + "Centre de contrôle" subtitle, "Organisation de démo : Northstar Mobility (fictive)".
|
|
||||||
- `dq-demo-status-fr-BE-collapsed.jpg` — DQ-DEMO-STATUS in French: the localized evidence summary ("Ce véhicule a deux réservations qui se chevauchent…") replacing the raw English sentence, in its collapsed pre-review state.
|
|
||||||
- `dq-demo-status-fr-BE-clean-reload.jpg` — the same page after a clean reload, confirming the fix is stable across navigation.
|
|
||||||
|
|
||||||
One capture attempt mid-session showed the brand rendered as "Vlootoperaties" instead
|
|
||||||
of "Fleet Ops" — investigated immediately via `document.documentElement` inspection and
|
|
||||||
confirmed to be **Chrome's own built-in page-translate feature** auto-triggering on the
|
|
||||||
automation browser profile (`class="translated-ltr"`, `lang` rewritten to bare `"nl"`
|
|
||||||
by Google Translate, not the app), re-triggering specifically on React DOM mutations
|
|
||||||
from clicking through the panel. Not an application defect: a clean reload immediately
|
|
||||||
after showed the correct "Fleet Ops" brand and correctly localized French content
|
|
||||||
again, and none of the 116 Playwright tests (which run in a clean automated browser
|
|
||||||
context without this extension behaviour) ever observed it.
|
|
||||||
|
|
||||||
## Rollback procedure
|
|
||||||
|
|
||||||
1. `ssh unraid`, `cd /mnt/user/appdata/mobilityops`.
|
|
||||||
2. `git archive --format=tar 18344bc -o` (from a local clone) → `scp` → extract, or
|
|
||||||
restore from the previous `.deploy/source-revision` (`18344bc8b7a75a2f868bf15bf498fc030ac6c34c`).
|
|
||||||
3. `echo 18344bc8b7a75a2f868bf15bf498fc030ac6c34c > .deploy/source-revision`.
|
|
||||||
4. `docker compose -f compose.yaml -f compose.unraid.yaml build api web && ... up -d api web`.
|
|
||||||
5. `alembic downgrade e7b08389f47f` if the `last_error_code` column must also be
|
|
||||||
rolled back (not required for a same-schema rollback within this correction's own
|
|
||||||
history, only if reverting past the whole correction).
|
|
||||||
6. Re-seed and re-verify `scenario_integrity.all_ready: true`.
|
|
||||||
|
|
||||||
The fix branch `fix/fleet-ops-i18n-status-flow` was not deleted.
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# Fleet Ops release — final-product-polish evidence
|
|
||||||
|
|
||||||
## Result: PASS
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
- Original feature-branch baseline before this task: `257a4cf` (`docs(polish): audit finale demo-afwerking`)
|
|
||||||
- Feature-branch commits added this task, on `feat/mobilityops-functional-completion`:
|
|
||||||
- `337f871` — polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
|
|
||||||
- `845db14` — fix: mobile topbar overflow at 421-440px and add trilingual responsive coverage
|
|
||||||
- Feature branch final commit: `845db14e172539b1d10e40f6a3249a72122deb41`
|
|
||||||
- `master` before merge (verified against the previously recorded baseline): `e0c7ed60112510687627d20a957af91c8b9db7f8` — unchanged, no unexpected commits, no conflicts (confirmed via `git merge-tree` dry run before merging)
|
|
||||||
- Merge commit on `master`: `18a765d62345ea9a6660d04fb868f218cf4d0b6e` (`merge: release Fleet Ops multilingual demo`, `--no-ff`)
|
|
||||||
- Final `master` commit (pushed and deployed): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
|
|
||||||
- Deployed commit on Unraid (`.deploy/source-revision`): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
|
|
||||||
- Feature branch was **not** deleted, per instruction.
|
|
||||||
|
|
||||||
## URL
|
|
||||||
|
|
||||||
- Live review deployment: `http://192.168.10.150:1236`
|
|
||||||
|
|
||||||
## Visible branding
|
|
||||||
|
|
||||||
- Product name "Fleet Ops" (with a space) visible in: sidebar brand lockup, browser tab title, login screen, footer product line, About page heading ("What Fleet Ops is and isn't" / "Wat Fleet Ops wel en niet is" / "Ce que Fleet Ops est et n'est pas"), demo badge popover, dashboard copy, all 3 languages.
|
|
||||||
- No visible "MobilityOps" or "PoC"/"proof of concept" wording remains in user-facing copy (verified by full-page inspection of all main routes in all 3 languages plus a targeted source grep for stray hardcoded strings). The repository, Docker image names, and internal git history retain "MobilityOps" (out of scope; not user-visible).
|
|
||||||
- Retained technical identifiers (unchanged, as instructed): API paths (`/api/v1/...`), Docker Compose project name (`mobilityops`), internal vehicle/customer reference prefixes (`MO-`, `CUS-`), Gitea repository name.
|
|
||||||
|
|
||||||
## Supported locales
|
|
||||||
|
|
||||||
- `nl-BE` (default for a fresh session, unauthenticated visitor)
|
|
||||||
- `en-GB`
|
|
||||||
- `fr-BE`
|
|
||||||
- Persisted via `localStorage` key `fleetops.language`; survives refresh, logout/login, and demo reset. No flags used — accessible `<select>` language picker (visible name/code) in the topbar (desktop/tablet) and inside the mobile navigation drawer (≤960px, to avoid topbar overflow). `document.documentElement.lang` kept in sync. All dates/numbers rendered via `Intl.DateTimeFormat`/`Intl.NumberFormat` (`Europe/Brussels` timezone).
|
|
||||||
|
|
||||||
## Translation coverage
|
|
||||||
|
|
||||||
- `frontend/e2e/i18n-coverage.spec.ts`: recursively compares every key path across all 3 locale files for all 14 namespaces (`common, auth, navigation, dashboard, fleet, bookings, returns, quality, knowledge, integrations, audit, demo, errors, accessibility`) and fails the build on any missing key or empty string value. **2/2 passed** in every gate run this task (local, clean-checkout, and live-deployment runs).
|
|
||||||
- Command: `npx playwright test e2e/i18n-coverage.spec.ts --project=chromium`
|
|
||||||
|
|
||||||
## Knowledge-base locales
|
|
||||||
|
|
||||||
- `knowledge/procedures/{nl-BE,en-GB,fr-BE}/` — 11 procedure documents per language (same `document_id`s across languages so citations stay stable): vehicle checkout, vehicle return, damage handling, odometer anomalies, cleaning checklist, maintenance escalation, customer documents, privacy, booking conflicts, roles/escalation, and a new **vehicle availability** procedure (added this task to cover the "vehicle-available-again" guided-demo step explicitly).
|
|
||||||
- `DemoKnowledgeProvider` now retrieves per-language (only searches the UI-selected language's corpus), with localized "no match"/"low confidence" boilerplate text per language; the frontend passes the active UI language on every `/api/v1/knowledge/questions` and `/api/v1/knowledge/status` call.
|
|
||||||
- Verified live in all 3 languages this task (see Browser evidence below): NL/EN/FR suggested questions each return grounded, correctly-cited, same-language answers.
|
|
||||||
- Backend unit tests: `test_demo_provider_grounds_damage_question_in_dutch`, `test_demo_provider_grounds_damage_question_in_french`, `test_demo_provider_health_reports_document_count_per_language`, `test_demo_provider_insufficient_evidence_message_is_localized` — all passing.
|
|
||||||
|
|
||||||
## Demo Guide — adaptive per breakpoint
|
|
||||||
|
|
||||||
- **Extra-wide desktop (≥1440px)**: docked rail (`.demo-guide-panel.is-wide`), fixed 420px minimum width, no drop shadow (reads as part of the layout), never auto-collapses. Verified: `demo-guide.spec.ts` → "wide desktop viewport docks the guide as a rail that never collapses to a chip".
|
|
||||||
- **Standard desktop/tablet (701–1439px)**: floating non-modal panel that auto-collapses to a persistent, closable progress chip ("Demo-gids · stap X van Y") the instant "Ga naar deze stap" is used; chip has its own expand action and a separate close (×) control; reopens on one click; content reflow padding shrinks to 0 while collapsed so nothing is permanently blocked. Verified: 3 dedicated tests in `demo-guide.spec.ts`.
|
|
||||||
- **Mobile (≤700px)**: bottom sheet with collapsed / half / full states, a drag-handle button that cycles states, no horizontal overflow, primary actions (Volgende/Ga naar deze stap) reachable in the half state. Verified: `demo-guide.spec.ts` → "mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", plus `demo-accessibility.spec.ts` → "demo guide is usable as a mobile bottom sheet".
|
|
||||||
- **Cross-cutting (4D)**: "Ga naar deze stap" scrolls the on-page target into view, moves programmatic focus to it (`tabindex=-1` + `.focus()`), and applies a 2.2s outline pulse (`.demo-guide-highlight`, disabled under `prefers-reduced-motion`); Escape collapses the standard-tier panel first, then closes it on a second press; progress (`currentIndex`/`completed`) persists in `sessionStorage` across navigation and reload. Verified: `demo-guide.spec.ts` → "Escape collapses the standard-tier panel, then closes it" and "going to a step scrolls, focuses and highlights the on-page target".
|
|
||||||
- Fixed along the way: two dangling `aria-labelledby` references (`SectionHeading` never actually set the referenced `id`) on Dashboard and Data Quality Issue Detail panels.
|
|
||||||
|
|
||||||
## Data Quality Workbench improvements
|
|
||||||
|
|
||||||
- Replaced plain radio rows with accessible `.choice-card` selectable tiles (title, consequence detail, `:has(input:checked)`/`.is-selected` state, visible focus ring, hover state) across the duplicate-customer survivor choice, odometer-regression decision, and booking-overlap block choice.
|
|
||||||
- Clear action hierarchy: primary resolve/apply/merge action uses `.button-primary`; defer uses a de-emphasized `.button-tertiary`; reject uses `.button-tertiary-destructive` (muted, turns critical-red only on hover) — no longer visually competing with the recommended resolution.
|
|
||||||
- Technical evidence (`evidence_json`) collapsed by default behind a localized "Technical details" `<details>` disclosure.
|
|
||||||
- Contrast/opacity audited: no unintended overlays, disabled-looking text, or weak borders found beyond the (fixed) dangling-aria-labelledby issue.
|
|
||||||
|
|
||||||
## Terminology mapping
|
|
||||||
|
|
||||||
- Achieved via the i18next namespace architecture itself rather than a separate module: technical codes (rule types, statuses, action codes, integration states) resolve through dedicated JSON keys (`quality:ruleTypes.*`, `quality:list.status*`, `audit:actions.*`, `integrations:statusLabels.*`, `fleet:statuses.*`, `bookings:statuses.*`) with a human label in all 3 languages; raw technical values (correlation IDs, full UUIDs, raw evidence JSON) are confined to "Technical details" disclosures. Example mappings implemented: `possible_duplicate_customer` → "Possible duplicate customer"/"Mogelijke dubbele klant"/"Client peut-être en double"; `demo_login` → "Logged in"/"Ingelogd"/"Connecté"; n8n `degraded` → "Retry available"/"Opnieuw proberen mogelijk"/"Nouvelle tentative possible"; `not_configured`/`disabled` → "Not connected"/"Niet gekoppeld"/"Non connecté".
|
|
||||||
|
|
||||||
## Automation / audit improvements
|
|
||||||
|
|
||||||
- Automation ledger: succeeded events group and collapse when >3 in view ("Show N succeeded jobs"/"Hide individual jobs"), filter chips (needs-attention/recent/succeeded/all), meaningful short refs (`AUT-RET-####` derived from the aggregate ref, full UUID behind a `<details>`), localized event types and statuses.
|
|
||||||
- Audit trail: events grouped by `correlation_id` into one card with a human action-label heading (`audit:actions.*`), related-event count and an expandable technical list; readable before/after diff (`ChangeDiff` component: humanized field names, `set to`/`was`/`X → Y` phrasing) instead of raw JSON by default; short reference (`AUD-XXXXXXXX`) with full UUID and correlation ID behind "Technical details".
|
|
||||||
|
|
||||||
## Attention Queue / clickable rows
|
|
||||||
|
|
||||||
- Full "stretched link" pattern applied to: Attention Queue, Today's movements, Vehicles table, Bookings table, Data Quality table. Entire row is one activation target (pointer cursor, hover state, keyboard-focusable, Enter/Space activates), secondary in-row links (e.g. the vehicle reference inside a booking row) remain independently clickable via `.cell-link { z-index: 2 }` layered above the row overlay.
|
|
||||||
- Dedicated tests in `frontend/e2e/clickable-rows.spec.ts` (8 tests): click on empty row space, keyboard focus + Enter, mobile-viewport click, secondary-link independence, correct routing for each of the 5 surfaces, pointer-cursor/focus-ring check.
|
|
||||||
|
|
||||||
## Test results (all commands re-run against this exact final state)
|
|
||||||
|
|
||||||
### Backend (local dev stack, clean-checkout instance, and live Unraid deployment — all three, all green)
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose exec api pytest -q → 131 passed
|
|
||||||
docker compose exec api ruff check . → All checks passed!
|
|
||||||
docker compose exec api mypy app → Success: no issues found in 48 source files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
```
|
|
||||||
cd frontend && npm run build → tsc -b && vite build: success
|
|
||||||
```
|
|
||||||
|
|
||||||
### Playwright (92 tests; run against local dev stack, the isolated clean-checkout stack, and the live Unraid deployment — 92/92 passed in all three runs)
|
|
||||||
|
|
||||||
```
|
|
||||||
npx playwright test --project=chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
Suites: `demo-accessibility`, `demo-entry`, `demo-guide` (including the 3 new adaptive-breakpoint tests, chip close-control test, Escape test, scroll/focus/highlight test), `demo-legibility`, `demo`, `guided-demo-full`, `i18n-coverage`, `interactive-elements`, `responsive-i18n` (7 breakpoints × 3 languages = 21 tests), `ui-redesign`, `clickable-rows` (new, 8 tests).
|
|
||||||
|
|
||||||
## Clean-checkout drill (evidence)
|
|
||||||
|
|
||||||
Performed in an isolated environment (separate Compose project `mobilityops-clean`, separate host ports 8129/1229, no shared volumes or n8n) so the user's existing long-running dev/n8n environment was never touched:
|
|
||||||
|
|
||||||
1. `git clone` of the local repository at commit `845db14` (feature branch, pre-merge) into a scratch directory.
|
|
||||||
2. `cp .env.example .env` (project name and ports overridden for isolation only).
|
|
||||||
3. `docker compose up --build -d db api web` — migrations ran automatically on API startup.
|
|
||||||
4. `docker compose exec api python -m app.cli seed --reset` — deterministic seed loaded (users:2, customers:180, vehicles:50, bookings:246, inspections:75, maintenance:40, data_quality_issues:26, workflow_runs:20).
|
|
||||||
5. `docker compose exec api pytest -q` → 131 passed. `ruff check .` → clean. `mypy app` → clean.
|
|
||||||
6. `npm ci && npm run build` → clean build.
|
|
||||||
7. `npx playwright test --project=chromium` (pointed at the isolated stack via `MOBILITYOPS_PUBLIC_URL`) → 92 passed.
|
|
||||||
8. Live browser verification in English and French (Dutch already covered as the automated-suite default): guided-demo dashboard, knowledge-assistant grounded answers in both languages with correct same-language citations.
|
|
||||||
9. `POST /api/v1/demo/reset` → `scenario_integrity: {"all_ready": true, "not_ready": []}`.
|
|
||||||
10. Isolated stack torn down (`docker compose down -v`) — original dev environment (containers, n8n owner account/workflows) confirmed untouched and healthy throughout.
|
|
||||||
|
|
||||||
No PASS was claimed from pre-existing containers at any point — every gate above ran against a stack built from empty volumes.
|
|
||||||
|
|
||||||
## Server deployment evidence
|
|
||||||
|
|
||||||
- Deployed via the established safe method: `git archive` from the exact commit → `scp` to `.deploy/source-<sha>.tar.gz` on Unraid → extract → update `.deploy/source-revision` → `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web` (db never rebuilt; server `.env` and named volumes — Postgres, n8n — preserved throughout).
|
|
||||||
- Deployed twice this task: once for the feature branch (`845db14`) for pre-merge live validation, once for the merged `master` (`18a765d`) for the final release.
|
|
||||||
- Post-deploy, both times: migrations confirmed at head (`e7b08389f47f`), reseed run, `pytest`/`ruff`/`mypy` re-run in the container (all green), full 92-test Playwright suite re-run against the live URL (all green), console/network inspected via live browser (no errors, all `/api/*` calls 200), demo reset performed, `scenario_integrity.all_ready: true` confirmed both times.
|
|
||||||
- Real shared n8n instance (`http://192.168.10.150:5678`) integration confirmed live: the seeded failed-demo automation event correctly shows "Retry available"/"Opnieuw proberen mogelijk" (not the raw `degraded` string) on the Integration pulse card.
|
|
||||||
|
|
||||||
## Responsive / accessibility
|
|
||||||
|
|
||||||
- No-horizontal-overflow verified across the full 7-breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844, 360×800) in all 3 languages (`responsive-i18n.spec.ts`, 21 tests) plus the original 4-breakpoint English suite (`ui-redesign.spec.ts`).
|
|
||||||
- Real bug found and fixed during this pass: the new topbar language switcher pushed the 421–440px range into horizontal overflow (the existing "compact topbar" breakpoint stopped at 420px). Fixed by widening that breakpoint to 440px; re-verified clean at exactly 430px in all 3 languages.
|
|
||||||
- Focus-visible outlines, `prefers-reduced-motion` handling (demo-guide highlight pulse, bottom-sheet height transitions), and keyboard reachability verified via `demo-accessibility.spec.ts` and the new adaptive-guide/clickable-row tests.
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
`artifacts/fleet-ops-release/screenshots/`:
|
|
||||||
- `01-login-nl.jpg` — login screen, Dutch default, language selector visible
|
|
||||||
- `02-dashboard-nl-desktop.jpg` — dashboard, Dutch, Attention Queue + Integration status
|
|
||||||
- `03-data-quality-choice-cards.jpg` — Data Quality Workbench choice-card redesign (duplicate-customer merge)
|
|
||||||
- `04-integrations-nl.jpg` — Integrations page, grouped/filterable automation ledger
|
|
||||||
- `05-audit-trail-nl.jpg` — Audit trail, correlation-grouped human action labels
|
|
||||||
- `06-dashboard-en-desktop.jpg` — dashboard, English
|
|
||||||
- `07-dashboard-fr-desktop.jpg` — dashboard, French
|
|
||||||
- `08-about-fr.jpg` — About page, French, confirming full rebrand + translated content
|
|
||||||
- `09-mobile-guide-bottom-sheet.png` — mobile bottom sheet, half state (390×844)
|
|
||||||
- `10-mobile-guide-full.png` — mobile bottom sheet, full state (390×844)
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- Data-quality evidence "summary" strings (the free-text detail line under each Attention Queue/Data Quality row, e.g. "exact email; exact phone; similar name") remain English-only — these are generated deep in the deterministic rule engine as diagnostic strings, not yet converted to message codes. The rule-type label, status, and all surrounding UI are fully localized; only this one diagnostic fragment is not. Documented as a follow-up, not blocking.
|
|
||||||
- RAGcore and ITWorx MCP Hub remain honestly labelled as not live-connected (unchanged from prior milestones) — the demo knowledge base is the multilingual, fully-verified stand-in.
|
|
||||||
- Vehicle/customer internal reference prefixes (`MO-`, `CUS-`) were left unchanged; they are generic internal codes, not user-visible "MobilityOps" branding, and changing them was out of scope for this task.
|
|
||||||
- Automated live-browser evidence for the guided demo was captured in Dutch (via the automated Playwright suite, which defaults to the app's own nl-BE default) and manually spot-checked live in English and French (knowledge assistant, dashboard, About page); a full manual click-through of all 8 guided-demo steps was not repeated live in all 3 languages beyond the automated `guided-demo-full.spec.ts` (Dutch) and the targeted EN/FR checks documented above, given the exhaustive automated coverage already exercising the same code paths per language via `responsive-i18n.spec.ts` and `i18n-coverage.spec.ts`.
|
|
||||||
|
|
||||||
## Rollback procedure
|
|
||||||
|
|
||||||
- `.deploy/source-revision` on Unraid records the exact deployed commit (`18a765d62345ea9a6660d04fb868f218cf4d0b6e`).
|
|
||||||
- Prior tarballs remain in `.deploy/` on the server, including `.deploy/source-845db14.tar.gz` (feature branch, pre-merge) and `.deploy/source-4a268c7.tar.gz` (previous release, pre-polish).
|
|
||||||
- To roll back: extract the desired `source-<short-sha>.tar.gz`, update `.deploy/source-revision` to match, and re-run `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`. Database migrations on this branch are additive only; no destructive migration was introduced.
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
# MobilityOps functional-completion — final summary
|
|
||||||
|
|
||||||
## Outcome: PASS
|
|
||||||
|
|
||||||
All achievable functional-completion requirements were audited, implemented, tested
|
|
||||||
locally (including a genuine clean-checkout drill), committed, pushed, deployed to
|
|
||||||
Unraid and re-verified against the live server after every batch.
|
|
||||||
|
|
||||||
## Revisions
|
|
||||||
|
|
||||||
- Starting branch: `design/mobilityops-premium-ui`
|
|
||||||
- Starting/observed commit: `54dc952915a4874fcdf14781e1c37feb0e253851`
|
|
||||||
- Completion branch: `feat/mobilityops-functional-completion`
|
|
||||||
- Final commit: `5b2827eb7e84e40d97c4d025debb2af1246908e0` (this is the parent commit
|
|
||||||
the deploy below targets; the commit that actually records this string is
|
|
||||||
necessarily one commit later — `git log -1` on this branch is the authoritative
|
|
||||||
source of the true HEAD)
|
|
||||||
- Gitea branch URL: `https://gitea.itworx.tech/Jens/MobilityOps` (SSH remote
|
|
||||||
`ssh://git@192.168.10.150:222/Jens/MobilityOps.git`), branch
|
|
||||||
`feat/mobilityops-functional-completion`
|
|
||||||
- Deployed URL: `http://192.168.10.150:1236`
|
|
||||||
- Server deployment directory: `/mnt/user/appdata/mobilityops`
|
|
||||||
- Server Compose project: `mobilityops`
|
|
||||||
|
|
||||||
## Exact commands executed (representative — run after every batch)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local backend gate
|
|
||||||
docker compose exec -T api python -m app.cli seed --reset
|
|
||||||
docker compose exec -T api pytest -q
|
|
||||||
docker compose run --rm api ruff check .
|
|
||||||
docker compose run --rm api mypy app
|
|
||||||
|
|
||||||
# Local frontend gate
|
|
||||||
cd frontend && npx tsc -b --noEmit && npm run build
|
|
||||||
|
|
||||||
# Local e2e (against the local dev stack)
|
|
||||||
npx playwright test
|
|
||||||
|
|
||||||
# Deploy the exact committed revision
|
|
||||||
COMMIT=$(git rev-parse HEAD)
|
|
||||||
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
|
|
||||||
scp -P 22 /tmp/source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
|
||||||
ssh unraid "cd /mnt/user/appdata/mobilityops && tar -xzf .deploy/source.tar.gz && echo $COMMIT > .deploy/source-revision"
|
|
||||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web"
|
|
||||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current"
|
|
||||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
|
|
||||||
|
|
||||||
# e2e against the live server
|
|
||||||
MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236 npx playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
Clean-checkout drill (once, section 14):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone --branch feat/mobilityops-functional-completion \
|
|
||||||
<repo> /tmp/mobilityops-clean-checkout
|
|
||||||
cd /tmp/mobilityops-clean-checkout
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose -p mobilityops-clean up --build -d # isolated project name/ports
|
|
||||||
docker compose -p mobilityops-clean exec -T api alembic current
|
|
||||||
docker compose -p mobilityops-clean exec -T api python -m app.cli seed --reset
|
|
||||||
docker compose -p mobilityops-clean exec -T api pytest -q
|
|
||||||
docker compose -p mobilityops-clean run --rm api ruff check .
|
|
||||||
docker compose -p mobilityops-clean run --rm api mypy app
|
|
||||||
cd frontend && npm ci && npx tsc -b --noEmit && npm run build
|
|
||||||
MOBILITYOPS_PUBLIC_URL=http://localhost:11228 npx playwright test
|
|
||||||
docker compose -p mobilityops-clean down -v # isolated project only
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test and validation results
|
|
||||||
|
|
||||||
| Gate | Local dev stack | Clean-checkout (isolated) | Live Unraid |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `pytest` | 117 passed | 117 passed | — (not applicable; no test runner on the review host) |
|
|
||||||
| `ruff check .` | clean | clean | — |
|
|
||||||
| `mypy app` | 0 issues / 46 files | 0 issues / 46 files | — |
|
|
||||||
| `npx tsc -b` | clean | clean | — |
|
|
||||||
| `npm run build` | clean | clean | — |
|
|
||||||
| `npx playwright test` | 37 passed | 37 passed | **37 passed** (against `http://192.168.10.150:1236`) |
|
|
||||||
| `npm audit` | 4 known advisories (unchanged — see Known limitations) | same | — |
|
|
||||||
|
|
||||||
Every batch (1 through 5) was deployed and re-verified with the full 37-test Playwright
|
|
||||||
suite against the live server before moving to the next batch, not only at the end.
|
|
||||||
|
|
||||||
## Application URLs and ports
|
|
||||||
|
|
||||||
| Service | URL / port | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Web (Unraid) | `http://192.168.10.150:1236` | only MobilityOps-owned service exposed on the LAN |
|
|
||||||
| API (Unraid) | Compose-network only | reached through the web nginx `/api/` proxy |
|
|
||||||
| PostgreSQL (Unraid) | Compose-network only | never exposed |
|
|
||||||
| Shared n8n (Unraid) | `http://192.168.10.150:5678` | pre-existing host infrastructure, outside the MobilityOps Compose project |
|
|
||||||
| Web (local dev) | `http://localhost:1228` | |
|
|
||||||
| API (local dev) | `http://localhost:8128` | |
|
|
||||||
| n8n (local dev) | `http://localhost:5678` | bundled, `bundled-n8n` profile |
|
|
||||||
|
|
||||||
## Demo users and access method
|
|
||||||
|
|
||||||
Two fixed seeded identities, selected via the login screen's role buttons (no
|
|
||||||
password): **Amelie De Ridder** (`USR-OPS`, Operations Manager) and **Karim
|
|
||||||
Boujaddaine** (`USR-EMP`, Rental Employee). `POST /api/v1/demo/login` issues an
|
|
||||||
HttpOnly, `SameSite=Lax` signed session cookie; `GET /api/v1/demo/session` (marked
|
|
||||||
`Cache-Control: no-store`) is what the browser actually trusts on every load, not a
|
|
||||||
locally cached copy.
|
|
||||||
|
|
||||||
## Implemented functionality (this pass, on top of the already-accepted M0–M7/design baseline)
|
|
||||||
|
|
||||||
- Fixed two confirmed defects: Vehicles and Bookings both computed a filtered/paginated
|
|
||||||
result but rendered the raw array.
|
|
||||||
- Server-backed session lifecycle (`GET /demo/session`, `POST /demo/logout`), central
|
|
||||||
401 handling, no more `sessionStorage`-as-authority.
|
|
||||||
- A role matrix enforced server-side (403 on every manager-only action for Rental
|
|
||||||
Employee, not just a hidden button) and mirrored in the nav/route guards.
|
|
||||||
- Authoritative, non-mutating return preview (`POST /bookings/{ref}/return-preview`)
|
|
||||||
sharing its evaluation function with commit — fixed a real bug where the frontend's
|
|
||||||
guessed preview text was wrong (damage → described as "maintenance", actual rule
|
|
||||||
"blocked"; the no-contradiction case → described as "available", actual rule always
|
|
||||||
"cleaning" first).
|
|
||||||
- Audit API/UI now expose `before`/`after` (the columns existed but were never
|
|
||||||
serialized) plus a resolved safe entity link.
|
|
||||||
- Typed related-entity snapshots (booking_overlap's related refs are bookings, not
|
|
||||||
vehicles — previously silently unresolved) and a bounded resolution flow for every
|
|
||||||
one of the five data-quality rule types, plus an audited manual scan trigger and
|
|
||||||
documented recurrence linking (`reopened_from`/`previous_decision`).
|
|
||||||
- Role-aware backend search (`GET /api/v1/search`) replacing a blind client-side regex
|
|
||||||
guesser, with a real results panel, keyboard navigation and debouncing.
|
|
||||||
- A safe, confirmed demo-reset UI trigger (the endpoint already existed and was
|
|
||||||
already gated).
|
|
||||||
- Truthful aggregate n8n integration status (`GET /api/v1/integrations/status`) from
|
|
||||||
outbox delivery counts, replacing a single-most-recent-event read; fixed
|
|
||||||
`MCP_HUB_REGISTRATION_ENABLED` being declared in `.env.example` but never wired into
|
|
||||||
`Settings`.
|
|
||||||
- Bounded outbox delivery-lease recovery for a process crash between claim and outcome.
|
|
||||||
- A second n8n workflow (scheduled quality scan), independent of RAGcore/MCP Hub.
|
|
||||||
|
|
||||||
## RAGcore integration status
|
|
||||||
|
|
||||||
Unchanged from the prior baseline and honestly reported throughout: the demo
|
|
||||||
`KnowledgeProvider` (deterministic TF-IDF extractive retrieval over local procedure
|
|
||||||
documents) satisfies the knowledge-assistant acceptance criteria and is fully verified.
|
|
||||||
A `RAGcoreKnowledgeProvider` HTTP adapter is implemented and unit-tested (including its
|
|
||||||
unavailable-degradation path) but was never exercised against a live RAGcore instance in
|
|
||||||
this environment — no live RAGcore instance exists to test against.
|
|
||||||
`KNOWLEDGE_PROVIDER=demo` on the Unraid deployment; no simulated live connection is ever
|
|
||||||
shown.
|
|
||||||
|
|
||||||
## MCP Hub integration status
|
|
||||||
|
|
||||||
The four read-only provider endpoints are implemented, tested, and directly
|
|
||||||
curl-verified with correct service-token auth enforcement and audit logging.
|
|
||||||
`MCP_HUB_REGISTRATION_ENABLED` — previously declared in `.env.example` but silently
|
|
||||||
dropped by `extra="ignore"` since it had no `Settings` field — is now actually wired in
|
|
||||||
and honestly reported (`GET /api/v1/integrations/status`'s `mcp_hub.state`). It is
|
|
||||||
`false` on the Unraid deployment (`state: "not_configured"`). No live Hub instance was
|
|
||||||
reachable in this environment to verify an actual Hub round trip.
|
|
||||||
|
|
||||||
## n8n integration status
|
|
||||||
|
|
||||||
Fully implemented and live-verified. The original return-processing workflow: verified
|
|
||||||
against both the local bundled instance and the shared Unraid instance, including a real
|
|
||||||
degraded-mode drill in an earlier session (n8n stopped mid-flow → return still committed
|
|
||||||
locally, event stayed `pending` with backoff, self-healed once n8n returned) and the
|
|
||||||
manual-retry path. This pass adds:
|
|
||||||
|
|
||||||
- **Truthful status**: `GET /api/v1/integrations/status` derives n8n health from
|
|
||||||
aggregate outbox counts (pending/delivering/succeeded/failed), not the single most
|
|
||||||
recent event.
|
|
||||||
- **Stale-delivery-lease recovery**: a claimed-but-never-resolved `delivering` row (the
|
|
||||||
process crashing between claim and outcome) is now recoverable; unit-tested including
|
|
||||||
a simulated crash, and confirmed a still-alive worker's unexpired lease is never
|
|
||||||
touched.
|
|
||||||
- **Second workflow**: `mobilityops-scheduled-quality-scan` (hourly + manual-test
|
|
||||||
trigger, ships `"active": false"`), calling `POST
|
|
||||||
/api/v1/integrations/n8n/scheduled-scan`. Live-verified two ways: (1) executed
|
|
||||||
end-to-end via the Manual test trigger against the **local** n8n instance — full
|
|
||||||
green execution in the n8n editor, confirmed by the resulting
|
|
||||||
`data_quality_scan_run` audit event (`actor_type=service`); (2) published to the
|
|
||||||
**shared Unraid n8n** via `deploy/unraid/setup-scheduled-scan.sh` and the resulting
|
|
||||||
endpoint directly curl-verified against the live deployed API, also confirmed via the
|
|
||||||
audit trail. The shared instance's own UI could not be browser-tested directly — it
|
|
||||||
runs `N8N_SECURE_COOKIE=true` and refuses login over the plain-HTTP LAN URL used for
|
|
||||||
automated testing here, which is correct, pre-existing shared-infrastructure
|
|
||||||
behaviour and out of scope to change.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- RAGcore and ITWorx MCP Hub remain honestly not-live-connected — no live instance of
|
|
||||||
either exists in this environment (environment limitation, not a code defect).
|
|
||||||
- `npm audit`: one moderate esbuild/Vite dev-server-only advisory (fix requires a Vite
|
|
||||||
major upgrade, deliberately deferred), and a
|
|
||||||
react-router RSC-mode advisory that doesn't apply (the app never uses RSC/SSR mode) —
|
|
||||||
both pre-existing, confirmed unchanged by this pass's clean `npm ci`.
|
|
||||||
- Demo authentication remains the accepted HMAC-cookie PoC mechanism tied to two fixed
|
|
||||||
seeded identities — not a production identity provider.
|
|
||||||
- The scheduled quality-scan workflow's own n8n-engine execution was verified live
|
|
||||||
against the local bundled n8n and, for the HTTP round trip specifically, against the
|
|
||||||
shared Unraid n8n's resulting API call — not against a full n8n-engine execution *on
|
|
||||||
the shared instance itself*, for the browser-access reason above.
|
|
||||||
- `n8n_delivery_lease_seconds` (120s default) is a code-level tunable, not exposed in
|
|
||||||
`.env.example`, consistent with the existing `n8n_dispatch_interval_seconds`/
|
|
||||||
`n8n_max_attempts`/`n8n_http_timeout_seconds` tunables already handled that way.
|
|
||||||
|
|
||||||
## Clean deployment instructions
|
|
||||||
|
|
||||||
See `deploy/unraid/README.md` and `docs/17-runbook.md` for the full runbook. Redeploy
|
|
||||||
the exact committed revision:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
COMMIT=<commit to deploy>
|
|
||||||
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
|
|
||||||
scp -P 22 /tmp/source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
|
||||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
|
||||||
&& tar -xzf .deploy/source.tar.gz \
|
|
||||||
&& echo $COMMIT > .deploy/source-revision \
|
|
||||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web \
|
|
||||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current \
|
|
||||||
&& curl -fsS http://127.0.0.1:1236/health"
|
|
||||||
```
|
|
||||||
|
|
||||||
`.env` and both named volumes (`mobilityops-db`, `mobilityops-n8n`) are preserved by
|
|
||||||
this flow; nothing outside the `mobilityops` Compose project is touched.
|
|
||||||
|
|
||||||
## Five-minute demonstration flow
|
|
||||||
|
|
||||||
1. Open `http://192.168.10.150:1236`, log in as **Operations Manager**.
|
|
||||||
2. Dashboard: point out the persisted readiness metrics and the now-truthful n8n
|
|
||||||
integration-status card (aggregate counts, not just the latest event).
|
|
||||||
3. Global search (`Ctrl/Cmd+K`): type a vehicle, booking or issue reference; use arrow
|
|
||||||
keys + Enter to navigate; show the no-results state for a nonsense query.
|
|
||||||
4. Bookings: filter by status, page through results (max 25/page), confirm page 2
|
|
||||||
differs from page 1.
|
|
||||||
5. Open an active booking → capture a return with a below-canonical odometer reading →
|
|
||||||
the review step shows the server's authoritative evaluation (odometer regression
|
|
||||||
flagged, resulting status and reason) → confirm → result screen distinguishes local
|
|
||||||
commit success from queued-not-yet-confirmed n8n delivery, links to the created
|
|
||||||
data-quality issue.
|
|
||||||
6. Data quality: run a manual scan; open the newly flagged (or an existing) issue for
|
|
||||||
each rule type and show its dedicated bounded resolution flow (not raw JSON).
|
|
||||||
7. Audit trail: filter by the correlation ID from the return above; show the
|
|
||||||
human-readable before/after change summary, expand the raw-JSON `<details>`.
|
|
||||||
8. Switch role to **Rental Employee**: show Data Quality/Integrations/Audit are absent
|
|
||||||
from the nav, and that direct URL navigation to any of them shows the restricted
|
|
||||||
message rather than partial data or a crash.
|
|
||||||
9. Switch back to Operations Manager, trigger **Reset demo data** with confirmation,
|
|
||||||
land back at login, log in again to confirm deterministic data was restored.
|
|
||||||
@@ -1,16 +1,36 @@
|
|||||||
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
|
||||||
|
COPY contracts ./contracts
|
||||||
|
COPY scripts/check-contracts.py ./scripts/check-contracts.py
|
||||||
|
COPY n8n/workflows ./n8n/workflows
|
||||||
|
USER app
|
||||||
|
|
||||||
|
FROM runtime-base AS runtime
|
||||||
|
# Run migrations and the API as an unprivileged user; nothing here needs root.
|
||||||
|
USER app
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["./entrypoint.sh"]
|
CMD ["./entrypoint.sh"]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,22 +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 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 sqlalchemy import select
|
from fastapi.responses import Response
|
||||||
|
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, 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
|
||||||
@@ -36,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:
|
||||||
@@ -51,26 +134,51 @@ def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid
|
|||||||
return refs
|
return refs
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AuditEventOut])
|
@router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
|
||||||
def list_audit_events(
|
def list_audit_events(
|
||||||
actor_label: str | None = Query(default=None),
|
actor_label: str | None = Query(default=None),
|
||||||
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),
|
||||||
correlation_id: str | None = Query(default=None),
|
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
|
||||||
limit: int = Query(default=100, le=500),
|
correlation_id: uuid.UUID | None = Query(default=None),
|
||||||
|
occurred_from: datetime | None = Query(default=None),
|
||||||
|
occurred_to: datetime | None = Query(default=None),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(require_operations_manager),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[AuditEventOut]:
|
) -> list[AuditEventOut] | AuditEventPageOut:
|
||||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
|
||||||
if actor_label:
|
if actor_label:
|
||||||
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
||||||
if action:
|
if action:
|
||||||
stmt = stmt.where(AuditEvent.action == action)
|
stmt = stmt.where(AuditEvent.action == action)
|
||||||
if entity_type:
|
if entity_type:
|
||||||
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
||||||
|
if entity_ref:
|
||||||
|
matched_ids: set[uuid.UUID] = set()
|
||||||
|
for model in _ENTITY_MODELS.values():
|
||||||
|
matched_ids.update(
|
||||||
|
db.scalars(
|
||||||
|
select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if not matched_ids:
|
||||||
|
if page is None:
|
||||||
|
return []
|
||||||
|
return AuditEventPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||||
|
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
|
||||||
if correlation_id:
|
if correlation_id:
|
||||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||||
events = db.scalars(stmt).all()
|
if occurred_from:
|
||||||
|
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
|
||||||
|
if occurred_to:
|
||||||
|
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
events = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
entity_refs = _resolve_entity_refs(db, events)
|
entity_refs = _resolve_entity_refs(db, events)
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
@@ -94,4 +202,13 @@ def list_audit_events(
|
|||||||
metadata=e.metadata_json,
|
metadata=e.metadata_json,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
if page is None:
|
||||||
|
return out
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return AuditEventPageOut(
|
||||||
|
items=out,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -1,20 +1,36 @@
|
|||||||
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.data_quality import open_odometer_regression_issue
|
||||||
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 +51,339 @@ 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:
|
||||||
return []
|
if page is None:
|
||||||
|
return []
|
||||||
|
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||||
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
||||||
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).with_for_update()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
customer is None
|
||||||
|
or customer.merged_into_customer_id is not None
|
||||||
|
or customer.anonymized_at is not None
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="Customer is unavailable for booking")
|
||||||
|
# Serialise booking creation per vehicle. The overlap check must run after
|
||||||
|
# acquiring this lock, otherwise two concurrent requests can both pass it.
|
||||||
|
vehicle = db.scalar(
|
||||||
|
select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref).with_for_update()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
vehicle is None
|
||||||
|
or not vehicle.active
|
||||||
|
or vehicle.operational_status in {"maintenance", "blocked"}
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="Vehicle is unavailable for booking")
|
||||||
|
overlap = db.scalar(
|
||||||
|
select(Booking.id).where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status.in_(("reserved", "active")),
|
||||||
|
Booking.starts_at < body.ends_at,
|
||||||
|
Booking.ends_at > body.starts_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if overlap is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
|
||||||
|
booking = Booking(
|
||||||
|
public_ref=f"BK-{uuid.uuid4().hex[:10].upper()}",
|
||||||
|
customer_id=customer.id,
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
ends_at=body.ends_at,
|
||||||
|
status="reserved",
|
||||||
|
start_odometer_km=None,
|
||||||
|
end_odometer_km=None,
|
||||||
|
# Requirements are intentionally confirmed in a separate, audited action.
|
||||||
|
# Never allow booking creation to bypass that operational checkpoint.
|
||||||
|
requirements_complete=False,
|
||||||
|
)
|
||||||
|
db.add(booking)
|
||||||
|
db.flush()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="booking_created",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
after={"public_ref": booking.public_ref, "vehicle_ref": vehicle.public_ref},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _to_out(booking, customer, vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult)
|
||||||
|
def checkout_booking(
|
||||||
|
public_ref: str,
|
||||||
|
body: CheckoutBookingRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CheckoutBookingResult:
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||||
|
if booking is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
if booking.status != "reserved":
|
||||||
|
raise HTTPException(status_code=409, detail="Only a reserved booking can be checked out")
|
||||||
|
if not booking.requirements_complete:
|
||||||
|
raise HTTPException(status_code=409, detail="Booking requirements are incomplete")
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Booking references a missing vehicle")
|
||||||
|
if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout")
|
||||||
|
active_conflict = db.scalar(
|
||||||
|
select(Booking.id).where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status == "active",
|
||||||
|
Booking.id != booking.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if active_conflict is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle already has an active booking")
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
before_booking = {
|
||||||
|
"status": booking.status,
|
||||||
|
"start_odometer_km": booking.start_odometer_km,
|
||||||
|
}
|
||||||
|
before_vehicle = {
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
}
|
||||||
|
attention_reasons: list[str] = []
|
||||||
|
if body.start_odometer_km < vehicle.odometer_km:
|
||||||
|
attention_reasons.append("odometer_regression")
|
||||||
|
if not body.cleanliness_ok:
|
||||||
|
attention_reasons.append("cleanliness")
|
||||||
|
if body.damage_reported:
|
||||||
|
attention_reasons.append("damage")
|
||||||
|
if body.technical_warning:
|
||||||
|
attention_reasons.append("technical_warning")
|
||||||
|
|
||||||
|
inspection = Inspection(
|
||||||
|
public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}",
|
||||||
|
booking_id=booking.id,
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
type="checkout",
|
||||||
|
fuel_level_percent=body.fuel_level_percent,
|
||||||
|
cleanliness_ok=body.cleanliness_ok,
|
||||||
|
damage_reported=body.damage_reported,
|
||||||
|
technical_warning=body.technical_warning,
|
||||||
|
notes=body.notes,
|
||||||
|
odometer_km=body.start_odometer_km,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
completed_by=user.display_name,
|
||||||
|
)
|
||||||
|
db.add(inspection)
|
||||||
|
if "odometer_regression" in attention_reasons:
|
||||||
|
open_odometer_regression_issue(
|
||||||
|
db,
|
||||||
|
vehicle=vehicle,
|
||||||
|
reading_ref=inspection.public_ref,
|
||||||
|
reading_km=body.start_odometer_km,
|
||||||
|
canonical_km=vehicle.odometer_km,
|
||||||
|
source_type="checkout",
|
||||||
|
related_refs=[booking.public_ref, inspection.public_ref],
|
||||||
|
actor_label=user.display_name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
if attention_reasons:
|
||||||
|
booking.status = "blocked"
|
||||||
|
vehicle.operational_status = (
|
||||||
|
"maintenance" if body.damage_reported or body.technical_warning else "cleaning"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
booking.status = "active"
|
||||||
|
booking.start_odometer_km = body.start_odometer_km
|
||||||
|
vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km)
|
||||||
|
vehicle.operational_status = "rented"
|
||||||
|
vehicle.version += 1
|
||||||
|
db.flush()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="booking_checkout_recorded",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before_booking,
|
||||||
|
after={
|
||||||
|
"inspection_ref": inspection.public_ref,
|
||||||
|
"booking_status": booking.status,
|
||||||
|
"start_odometer_km": booking.start_odometer_km,
|
||||||
|
"vehicle_status": vehicle.operational_status,
|
||||||
|
"attention_reasons": attention_reasons,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="vehicle_status_changed",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before_vehicle,
|
||||||
|
after={
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
},
|
||||||
|
metadata={"booking_ref": booking.public_ref, "inspection_ref": inspection.public_ref},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return CheckoutBookingResult(
|
||||||
|
booking_ref=booking.public_ref,
|
||||||
|
vehicle_ref=vehicle.public_ref,
|
||||||
|
inspection_ref=inspection.public_ref,
|
||||||
|
booking_status=booking.status,
|
||||||
|
resulting_vehicle_status=vehicle.operational_status,
|
||||||
|
activated=booking.status == "active",
|
||||||
|
attention_reasons=attention_reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/availability", response_model=list[AvailableVehicleOut])
|
||||||
|
def list_available_vehicles(
|
||||||
|
starts_at: datetime,
|
||||||
|
ends_at: datetime,
|
||||||
|
query: str | None = Query(default=None, max_length=100),
|
||||||
|
limit: int = Query(default=25, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[AvailableVehicleOut]:
|
||||||
|
if ends_at <= starts_at:
|
||||||
|
raise HTTPException(status_code=422, detail="Booking end must be after its start")
|
||||||
|
overlapping_vehicle_ids = select(Booking.vehicle_id).where(
|
||||||
|
Booking.status.in_(("reserved", "active")),
|
||||||
|
Booking.starts_at < ends_at,
|
||||||
|
Booking.ends_at > starts_at,
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Vehicle)
|
||||||
|
.where(
|
||||||
|
Vehicle.active.is_(True),
|
||||||
|
Vehicle.operational_status.not_in(("maintenance", "blocked")),
|
||||||
|
Vehicle.id.not_in(overlapping_vehicle_ids),
|
||||||
|
)
|
||||||
|
.order_by(Vehicle.location, Vehicle.public_ref)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if query and query.strip():
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.public_ref.ilike(term),
|
||||||
|
Vehicle.make.ilike(term),
|
||||||
|
Vehicle.model.ilike(term),
|
||||||
|
Vehicle.registration_number.ilike(term),
|
||||||
|
Vehicle.location.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
AvailableVehicleOut(
|
||||||
|
public_ref=vehicle.public_ref,
|
||||||
|
make=vehicle.make,
|
||||||
|
model=vehicle.model,
|
||||||
|
registration_number=vehicle.registration_number,
|
||||||
|
location=vehicle.location,
|
||||||
|
operational_status=vehicle.operational_status,
|
||||||
|
)
|
||||||
|
for vehicle in db.scalars(stmt).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{public_ref}", response_model=BookingOut)
|
@router.get("/{public_ref}", response_model=BookingOut)
|
||||||
@@ -72,6 +402,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 +551,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 +559,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)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.schemas import CurrentUser, CustomerOptionOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CustomerOptionOut])
|
||||||
|
def search_customers(
|
||||||
|
query: str = Query(min_length=2, max_length=100),
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[CustomerOptionOut]:
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
customers = db.scalars(
|
||||||
|
select(Customer)
|
||||||
|
.where(
|
||||||
|
Customer.merged_into_customer_id.is_(None),
|
||||||
|
Customer.anonymized_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Customer.public_ref.ilike(term),
|
||||||
|
Customer.first_name.ilike(term),
|
||||||
|
Customer.last_name.ilike(term),
|
||||||
|
Customer.email.ilike(term),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(Customer.last_name, Customer.first_name)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
CustomerOptionOut(
|
||||||
|
public_ref=customer.public_ref,
|
||||||
|
display_name=f"{customer.first_name} {customer.last_name}",
|
||||||
|
email=customer.email,
|
||||||
|
)
|
||||||
|
for customer in customers
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -19,6 +20,7 @@ from app.schemas import (
|
|||||||
AutomationRunOut,
|
AutomationRunOut,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
DashboardOut,
|
DashboardOut,
|
||||||
|
EvidenceSignalOut,
|
||||||
TodayItem,
|
TodayItem,
|
||||||
)
|
)
|
||||||
from app.services.operations import compute_metrics
|
from app.services.operations import compute_metrics
|
||||||
@@ -29,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)
|
||||||
@@ -61,19 +73,34 @@ def get_dashboard(
|
|||||||
entity = customers_by_id.get(issue.entity_id)
|
entity = customers_by_id.get(issue.entity_id)
|
||||||
link_type = "customer"
|
link_type = "customer"
|
||||||
link_ref = entity.public_ref if entity else ""
|
link_ref = entity.public_ref if entity else ""
|
||||||
|
# The backend never emits prose for the attention queue -- only stable signal
|
||||||
|
# codes + raw data params, exactly like the issue detail page's evidence list
|
||||||
|
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
|
||||||
|
# that turns these into the operator's selected language; `evidence_json["summary"]`
|
||||||
|
# is a technical fallback only, never rendered here.
|
||||||
|
signals = [
|
||||||
|
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
|
||||||
|
for s in issue.evidence_json.get("signals", [])
|
||||||
|
]
|
||||||
attention_items.append(
|
attention_items.append(
|
||||||
AttentionItem(
|
AttentionItem(
|
||||||
kind="quality_issue",
|
kind="quality_issue",
|
||||||
severity=issue.severity,
|
severity=issue.severity,
|
||||||
rule_type=issue.rule_type,
|
rule_type=issue.rule_type,
|
||||||
detail=issue.evidence_json.get("summary", ""),
|
evidence_signals=signals,
|
||||||
link_type=link_type,
|
link_type=link_type,
|
||||||
link_ref=link_ref,
|
link_ref=link_ref,
|
||||||
issue_ref=issue.public_ref,
|
issue_ref=issue.public_ref,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3))
|
# Curate a credible severity mix instead of letting `high` dominate every slot:
|
||||||
attention_items = attention_items[:8]
|
# each item's real severity is unchanged, only the display selection is capped per
|
||||||
|
# tier (a handful of "now", then "today", then "later") so a heavy day of high-severity
|
||||||
|
# issues doesn't crowd out medium/low ones the operator should still see.
|
||||||
|
high_items = [i for i in attention_items if i.severity == "high"]
|
||||||
|
medium_items = [i for i in attention_items if i.severity == "medium"]
|
||||||
|
low_items = [i for i in attention_items if i.severity == "low"]
|
||||||
|
attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8]
|
||||||
|
|
||||||
today = _today()
|
today = _today()
|
||||||
bookings = db.scalars(select(Booking)).all()
|
bookings = db.scalars(select(Booking)).all()
|
||||||
@@ -81,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),
|
||||||
|
|||||||
@@ -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 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,13 +11,18 @@ 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.maintenance import MaintenanceRecord
|
||||||
|
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,
|
||||||
|
DataQualityIssuePageOut,
|
||||||
MergeCustomersRequest,
|
MergeCustomersRequest,
|
||||||
MergeCustomersResult,
|
MergeCustomersResult,
|
||||||
ProvideFieldsRequest,
|
ProvideFieldsRequest,
|
||||||
@@ -25,6 +32,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,
|
||||||
@@ -41,6 +49,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,
|
||||||
@@ -50,27 +59,160 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues", response_model=list[DataQualityIssueOut])
|
@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
|
||||||
def list_issues(
|
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_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]:
|
) -> 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)
|
||||||
issues = db.scalars(stmt).all()
|
if assigned_to_ref == "unassigned":
|
||||||
return [_to_out(i) for i in issues]
|
stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None))
|
||||||
|
elif assigned_to_ref:
|
||||||
|
stmt = stmt.join(DataQualityIssue.assigned_to_user).where(
|
||||||
|
User.public_ref == assigned_to_ref
|
||||||
|
)
|
||||||
|
if overdue is True:
|
||||||
|
stmt = stmt.where(
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.due_at < datetime.now(UTC),
|
||||||
|
)
|
||||||
|
if demo_only is True:
|
||||||
|
# Server-side so the guided demo scenarios are found on any page, not only the
|
||||||
|
# 25 rows currently loaded in the browser.
|
||||||
|
stmt = stmt.where(DataQualityIssue.public_ref.like("DQ-DEMO-%"))
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
issues = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
items = [_to_out(i) for i in issues]
|
||||||
|
if page is None:
|
||||||
|
return items
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return DataQualityIssuePageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult)
|
||||||
|
def update_issue_work_queue(
|
||||||
|
body: BulkDataQualityWorkRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> BulkDataQualityWorkResult:
|
||||||
|
refs = list(dict.fromkeys(body.issue_refs))
|
||||||
|
if (
|
||||||
|
body.assigned_to_ref is None
|
||||||
|
and not body.clear_assignment
|
||||||
|
and body.due_at is None
|
||||||
|
and not body.clear_due_at
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="No work queue change was requested")
|
||||||
|
if body.assigned_to_ref is not None and body.clear_assignment:
|
||||||
|
raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment")
|
||||||
|
if body.due_at is not None and body.clear_due_at:
|
||||||
|
raise HTTPException(status_code=422, detail="Choose a due date or clear the due date")
|
||||||
|
if body.due_at is not None and body.due_at.tzinfo is None:
|
||||||
|
raise HTTPException(status_code=422, detail="Due date must include a timezone")
|
||||||
|
|
||||||
|
assignee = None
|
||||||
|
if body.assigned_to_ref is not None:
|
||||||
|
assignee = db.scalar(
|
||||||
|
select(User).where(
|
||||||
|
User.public_ref == body.assigned_to_ref,
|
||||||
|
User.active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if assignee is None:
|
||||||
|
raise HTTPException(status_code=422, detail="Active assignee not found")
|
||||||
|
|
||||||
|
issues = list(
|
||||||
|
db.scalars(
|
||||||
|
select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update()
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if len(issues) != len(refs):
|
||||||
|
found = {issue.public_ref for issue in issues}
|
||||||
|
missing = next(ref for ref in refs if ref not in found)
|
||||||
|
raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found")
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
if issue.status != "open":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Data quality issue {issue.public_ref} is not open",
|
||||||
|
)
|
||||||
|
before = {
|
||||||
|
"assigned_to_ref": issue.assigned_to_user.public_ref
|
||||||
|
if issue.assigned_to_user
|
||||||
|
else None,
|
||||||
|
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||||
|
}
|
||||||
|
if body.assigned_to_ref is not None:
|
||||||
|
issue.assigned_to_user = assignee
|
||||||
|
elif body.clear_assignment:
|
||||||
|
issue.assigned_to_user = None
|
||||||
|
if body.due_at is not None:
|
||||||
|
issue.due_at = body.due_at
|
||||||
|
elif body.clear_due_at:
|
||||||
|
issue.due_at = None
|
||||||
|
after = {
|
||||||
|
"assigned_to_ref": assignee.public_ref
|
||||||
|
if body.assigned_to_ref is not None and assignee
|
||||||
|
else (None if body.clear_assignment else before["assigned_to_ref"]),
|
||||||
|
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||||
|
}
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="data_quality_work_updated",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
for issue in issues:
|
||||||
|
db.refresh(issue)
|
||||||
|
return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue in issues])
|
||||||
|
|
||||||
|
|
||||||
# Every public reference in this system carries its entity type in its own prefix
|
# Every public reference in this system carries its entity type in its own prefix
|
||||||
@@ -83,6 +225,8 @@ _PREFIX_TO_TYPE = {
|
|||||||
"MO-": "vehicle",
|
"MO-": "vehicle",
|
||||||
"BK-": "booking",
|
"BK-": "booking",
|
||||||
"INSP-": "inspection",
|
"INSP-": "inspection",
|
||||||
|
"MAINT-": "maintenance",
|
||||||
|
"MNT-": "maintenance",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +259,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
|||||||
return {
|
return {
|
||||||
"entity_type": "vehicle",
|
"entity_type": "vehicle",
|
||||||
"public_ref": vehicle.public_ref,
|
"public_ref": vehicle.public_ref,
|
||||||
|
"registration_number": vehicle.registration_number,
|
||||||
"make": vehicle.make,
|
"make": vehicle.make,
|
||||||
"model": vehicle.model,
|
"model": vehicle.model,
|
||||||
"location": vehicle.location,
|
"location": vehicle.location,
|
||||||
@@ -150,6 +295,17 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
|||||||
"completed_at": inspection.completed_at.isoformat(),
|
"completed_at": inspection.completed_at.isoformat(),
|
||||||
"booking_ref": booking.public_ref if booking else None,
|
"booking_ref": booking.public_ref if booking else None,
|
||||||
}
|
}
|
||||||
|
if entity_type == "maintenance":
|
||||||
|
record = db.scalar(select(MaintenanceRecord).where(MaintenanceRecord.public_ref == ref))
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"entity_type": "maintenance",
|
||||||
|
"public_ref": record.public_ref,
|
||||||
|
"odometer_km": record.odometer_km,
|
||||||
|
"occurred_at": record.occurred_at.isoformat(),
|
||||||
|
"category": record.category,
|
||||||
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -221,9 +377,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,
|
||||||
@@ -245,9 +399,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),
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
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
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset, engine
|
||||||
from app.core.security import SessionPayload, create_session_token, read_session_token
|
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
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()
|
||||||
|
_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 +41,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 +55,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 +80,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 +91,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,26 +112,70 @@ 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:
|
||||||
|
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):
|
||||||
integrity = scenario_integrity_report(db)
|
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||||
record_audit_event(
|
replica_lock_connection = None
|
||||||
db,
|
try:
|
||||||
actor_type="user",
|
# A session-level lock on its own connection rejects another replica immediately;
|
||||||
actor_label=user.display_name,
|
# the main DB session can then safely end its auth read transaction and wait on
|
||||||
action="demo_reset",
|
# the normal shared/exclusive data barrier without releasing this replica guard.
|
||||||
entity_type="system",
|
replica_lock_connection = engine.connect()
|
||||||
metadata={
|
locked = replica_lock_connection.scalar(
|
||||||
"counts": result.counts,
|
select(func.pg_try_advisory_lock(_RESET_ADVISORY_LOCK_ID))
|
||||||
"anchor_date": result.anchor_date.isoformat(),
|
)
|
||||||
"scenario_integrity": integrity,
|
if not locked:
|
||||||
},
|
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||||
)
|
begin_exclusive_demo_reset(db)
|
||||||
db.commit()
|
# The audit timestamp is shared by every replica. A process-local monotonic
|
||||||
|
# timestamp cannot protect a multi-replica deployment.
|
||||||
|
last_reset_at = db.scalar(
|
||||||
|
select(AuditEvent.occurred_at)
|
||||||
|
.where(AuditEvent.action == "demo_reset")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
elapsed = (datetime.now(UTC) - last_reset_at).total_seconds() if last_reset_at else None
|
||||||
|
if elapsed is not None and elapsed < settings.demo_reset_cooldown_seconds:
|
||||||
|
retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
result = reset_and_seed(db, preserve_integration_telemetry=True, commit=False)
|
||||||
|
integrity = scenario_integrity_report(db)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="demo_reset",
|
||||||
|
entity_type="system",
|
||||||
|
metadata={
|
||||||
|
"counts": result.counts,
|
||||||
|
"anchor_date": result.anchor_date.isoformat(),
|
||||||
|
"scenario_integrity": integrity,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
end_exclusive_demo_reset(db)
|
||||||
|
finally:
|
||||||
|
if replica_lock_connection is not None:
|
||||||
|
try:
|
||||||
|
replica_lock_connection.scalar(
|
||||||
|
select(func.pg_advisory_unlock(_RESET_ADVISORY_LOCK_ID))
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
replica_lock_connection.close()
|
||||||
|
_reset_guard.release()
|
||||||
response.delete_cookie(settings.session_cookie_name)
|
response.delete_cookie(settings.session_cookie_name)
|
||||||
return {
|
return {
|
||||||
"status": "reset",
|
"status": "reset",
|
||||||
|
|||||||
@@ -4,16 +4,10 @@ from fastapi import APIRouter, Depends
|
|||||||
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.schemas import CurrentUser, IntegrationStatusOut
|
||||||
from app.schemas import (
|
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||||
CurrentUser,
|
|
||||||
IntegrationStatusOut,
|
|
||||||
McpHubIntegrationStatus,
|
|
||||||
)
|
|
||||||
from app.services.integration_status import derive_n8n_status
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=IntegrationStatusOut)
|
@router.get("/status", response_model=IntegrationStatusOut)
|
||||||
@@ -23,8 +17,5 @@ def integration_status(
|
|||||||
) -> IntegrationStatusOut:
|
) -> IntegrationStatusOut:
|
||||||
return IntegrationStatusOut(
|
return IntegrationStatusOut(
|
||||||
n8n=derive_n8n_status(db),
|
n8n=derive_n8n_status(db),
|
||||||
mcp_hub=McpHubIntegrationStatus(
|
mcp_hub=derive_mcp_hub_status(db),
|
||||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
|
||||||
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
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_db
|
from app.api.deps import get_db
|
||||||
@@ -13,23 +15,101 @@ from app.core.config import get_settings
|
|||||||
from app.core.errors import AppError
|
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 ScanResultOut
|
from app.schemas import (
|
||||||
|
N8nHeartbeatIn,
|
||||||
|
N8nHeartbeatResult,
|
||||||
|
ProcedureDocumentOut,
|
||||||
|
ProcedureListOut,
|
||||||
|
ProcedureSyncResultIn,
|
||||||
|
ProcedureSyncResultResult,
|
||||||
|
ReturnCallbackIn,
|
||||||
|
ScanResultOut,
|
||||||
|
WorkflowErrorReportIn,
|
||||||
|
WorkflowErrorReportResult,
|
||||||
|
)
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
from app.services.data_quality import run_scan
|
from app.services.data_quality import run_scan
|
||||||
|
from app.services.knowledge.procedures import iter_procedure_documents
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_idempotency_key(db: Session, namespace: str, key: str) -> None:
|
||||||
|
"""Serialize callback check+insert by a stable, transaction-scoped key."""
|
||||||
|
digest = hashlib.sha256(f"{namespace}:{key}".encode()).digest()
|
||||||
|
lock_id = int.from_bytes(digest[:8], byteorder="big", signed=True)
|
||||||
|
db.scalar(select(func.pg_advisory_xact_lock(lock_id)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
|
||||||
|
def workflow_heartbeat(
|
||||||
|
body: N8nHeartbeatIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> N8nHeartbeatResult:
|
||||||
|
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
|
||||||
|
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
|
||||||
|
_lock_idempotency_key(db, "n8n_workflow_heartbeat", f"{body.execution_id}:{body.status}")
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_heartbeat",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
AuditEvent.after_json["status"].astext == body.status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n workflow heartbeat",
|
||||||
|
action="n8n_workflow_heartbeat",
|
||||||
|
entity_type="automation",
|
||||||
|
after={
|
||||||
|
"workflow_id": body.workflow_id,
|
||||||
|
"workflow_name": body.workflow_name,
|
||||||
|
"status": body.status,
|
||||||
|
},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return N8nHeartbeatResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/return-callback")
|
@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)
|
||||||
@@ -38,9 +118,29 @@ def return_callback(
|
|||||||
"INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be the event's UUID.", status_code=422
|
"INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be the event's UUID.", status_code=422
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update())
|
||||||
if event is None:
|
if event is None:
|
||||||
raise AppError("EVENT_NOT_FOUND", "No outbox event matches this event ID.", status_code=404)
|
raise AppError("EVENT_NOT_FOUND", "No outbox event matches this event ID.", status_code=404)
|
||||||
|
if body.event_id != event_id:
|
||||||
|
raise AppError(
|
||||||
|
"CALLBACK_EVENT_MISMATCH",
|
||||||
|
"Callback event_id does not match Idempotency-Key.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
expected_correlation_id = uuid.UUID(str(event.payload_json["correlation_id"]))
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_EVENT_CORRELATION",
|
||||||
|
"The stored outbox event has no valid correlation ID.",
|
||||||
|
status_code=409,
|
||||||
|
) from exc
|
||||||
|
if body.correlation_id != expected_correlation_id:
|
||||||
|
raise AppError(
|
||||||
|
"CALLBACK_CORRELATION_MISMATCH",
|
||||||
|
"Callback correlation_id does not match the outbox event.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
# Idempotent by event ID: n8n or our own dispatcher may redeliver the same event
|
# Idempotent by event ID: n8n or our own dispatcher may redeliver the same event
|
||||||
# (e.g. a lost response after a timeout), so this callback must not double-record.
|
# (e.g. a lost response after a timeout), so this callback must not double-record.
|
||||||
@@ -60,10 +160,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=expected_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()
|
||||||
@@ -84,8 +182,121 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
|
||||||
|
def workflow_error(
|
||||||
|
body: WorkflowErrorReportIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> WorkflowErrorReportResult:
|
||||||
|
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
|
||||||
|
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
|
||||||
|
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
||||||
|
error report (e.g. after a timed-out response), so this must not double-record."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
_lock_idempotency_key(db, "n8n_workflow_failure", body.execution_id)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n error handler",
|
||||||
|
action="n8n_workflow_failure_registered",
|
||||||
|
entity_type="automation",
|
||||||
|
correlation_id=body.correlation_id,
|
||||||
|
after={
|
||||||
|
"workflow_id": body.workflow_id,
|
||||||
|
"workflow_name": body.workflow_name,
|
||||||
|
"error_category": body.error_category,
|
||||||
|
"error_summary": body.error_summary,
|
||||||
|
"trigger_context": body.trigger_context,
|
||||||
|
"attempt": body.attempt,
|
||||||
|
"retry_action": body.retry_action,
|
||||||
|
"failed_at": body.failed_at.isoformat(),
|
||||||
|
},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return WorkflowErrorReportResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/procedures", response_model=ProcedureListOut)
|
||||||
|
def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut:
|
||||||
|
"""Read-only source list for the RAGcore Procedure Sync workflow: every procedure
|
||||||
|
Markdown file Fleet Ops ships, across every supported language, with a stable
|
||||||
|
per-document id (source_id) and a content hash so the caller can detect changes
|
||||||
|
without re-fetching content it already has."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
|
||||||
|
documents = [
|
||||||
|
ProcedureDocumentOut(
|
||||||
|
id=doc.source_id,
|
||||||
|
language=doc.language,
|
||||||
|
document_id=doc.document_id,
|
||||||
|
title=doc.title,
|
||||||
|
version=doc.version,
|
||||||
|
content=doc.content,
|
||||||
|
content_hash=doc.content_hash,
|
||||||
|
)
|
||||||
|
for doc in iter_procedure_documents(Path(settings.knowledge_dir))
|
||||||
|
]
|
||||||
|
return ProcedureListOut(documents=documents)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult)
|
||||||
|
def procedures_sync_result(
|
||||||
|
body: ProcedureSyncResultIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ProcedureSyncResultResult:
|
||||||
|
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
|
||||||
|
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
|
||||||
|
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
_lock_idempotency_key(db, "n8n_procedure_sync", body.execution_id)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_procedures_synced",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n procedure sync",
|
||||||
|
action="n8n_procedures_synced",
|
||||||
|
entity_type="automation",
|
||||||
|
after={"synced": body.synced, "failed": body.failed},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return ProcedureSyncResultResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,39 @@ 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 +92,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"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -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, 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
|
||||||
@@ -27,44 +27,94 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def _audit_service_request(db: Session, *, client_id: str, tool: str, status_label: str) -> None:
|
def get_correlation_id(
|
||||||
|
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
|
||||||
|
) -> str:
|
||||||
|
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
|
||||||
|
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
|
||||||
|
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
|
||||||
|
if x_correlation_id:
|
||||||
|
try:
|
||||||
|
return str(uuid.UUID(x_correlation_id))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_service_request(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
reported_client_id: str,
|
||||||
|
tool: str,
|
||||||
|
status_label: str,
|
||||||
|
correlation_id: str,
|
||||||
|
metadata: dict[str, object] | None = None,
|
||||||
|
) -> None:
|
||||||
record_audit_event(
|
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.uuid4(),
|
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),
|
||||||
) -> 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, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok"
|
db,
|
||||||
|
reported_client_id=client.reported_client_id,
|
||||||
|
tool="fleet_ops_get_operations_summary",
|
||||||
|
status_label="ok",
|
||||||
|
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),
|
||||||
) -> 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, client_id=client_id, tool="mobilityops_list_attention_vehicles", status_label="ok"
|
db,
|
||||||
|
reported_client_id=client.reported_client_id,
|
||||||
|
tool="fleet_ops_list_attention_vehicles",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return [AttentionVehicleOut(**r) for r in results]
|
return [AttentionVehicleOut(**r) for r in results]
|
||||||
|
|
||||||
@@ -72,16 +122,20 @@ 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),
|
||||||
) -> 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="mobilityops_get_vehicle_details",
|
tool="fleet_ops_get_vehicle_details",
|
||||||
status_label="not_found",
|
status_label="not_found",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
||||||
|
|
||||||
@@ -99,7 +153,11 @@ def vehicle_details(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok"
|
db,
|
||||||
|
reported_client_id=client.reported_client_id,
|
||||||
|
tool="fleet_ops_get_vehicle_details",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return McpVehicleDetailOut(
|
return McpVehicleDetailOut(
|
||||||
public_ref=vehicle.public_ref,
|
public_ref=vehicle.public_ref,
|
||||||
@@ -118,17 +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),
|
||||||
) -> GroundedAnswer:
|
) -> GroundedAnswer:
|
||||||
provider = get_knowledge_provider()
|
provider = get_knowledge_provider()
|
||||||
correlation_id = str(uuid.uuid4())
|
answer = provider.ask(body.question, correlation_id, language=body.locale)
|
||||||
answer = provider.ask(body.question, correlation_id)
|
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="mobilityops_search_knowledge",
|
tool="fleet_ops_search_knowledge",
|
||||||
status_label=answer.evidence_state,
|
status_label=answer.evidence_state,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
metadata={
|
||||||
|
"tenant": client.tenant,
|
||||||
|
"locale": body.locale,
|
||||||
|
"sources_available": source_count_available,
|
||||||
|
"sources_returned": len(answer.sources),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return answer
|
return answer
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 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,13 +16,18 @@ 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,
|
||||||
)
|
)
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality import open_odometer_regression_issue
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||||
|
|
||||||
@@ -34,19 +42,61 @@ def _attention_vehicle_ids(db: Session) -> set:
|
|||||||
return set(rows)
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[VehicleOut])
|
@router.get("", response_model=list[VehicleOut] | VehiclePageOut)
|
||||||
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),
|
||||||
|
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[VehicleOut]:
|
) -> list[VehicleOut] | VehiclePageOut:
|
||||||
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)
|
||||||
vehicles = db.scalars(stmt).all()
|
if location:
|
||||||
|
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
|
||||||
|
if query:
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.public_ref.ilike(term),
|
||||||
|
Vehicle.make.ilike(term),
|
||||||
|
Vehicle.model.ilike(term),
|
||||||
|
Vehicle.location.ilike(term),
|
||||||
|
Vehicle.registration_number.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
attention_ids = _attention_vehicle_ids(db)
|
attention_ids = _attention_vehicle_ids(db)
|
||||||
out = [
|
if attention_only:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.id.in_(attention_ids),
|
||||||
|
Vehicle.operational_status == "blocked",
|
||||||
|
Vehicle.next_service_km <= Vehicle.odometer_km,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
vehicles = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
vehicle_ids = [vehicle.id for vehicle in vehicles]
|
||||||
|
next_bookings: dict[uuid.UUID, Booking] = {}
|
||||||
|
if vehicle_ids:
|
||||||
|
for booking in db.scalars(
|
||||||
|
select(Booking)
|
||||||
|
.where(
|
||||||
|
Booking.vehicle_id.in_(vehicle_ids),
|
||||||
|
Booking.status == "reserved",
|
||||||
|
Booking.starts_at >= datetime.now(UTC),
|
||||||
|
)
|
||||||
|
.order_by(Booking.starts_at.asc())
|
||||||
|
).all():
|
||||||
|
next_bookings.setdefault(booking.vehicle_id, booking)
|
||||||
|
items = [
|
||||||
VehicleOut(
|
VehicleOut(
|
||||||
public_ref=v.public_ref,
|
public_ref=v.public_ref,
|
||||||
make=v.make,
|
make=v.make,
|
||||||
@@ -58,20 +108,43 @@ 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
|
||||||
]
|
]
|
||||||
if attention_only:
|
if page is None:
|
||||||
out = [v for v in out if v.attention]
|
return items
|
||||||
return out
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return VehiclePageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
||||||
def get_vehicle(
|
def get_vehicle(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> VehicleDetailOut:
|
) -> VehicleDetailOut:
|
||||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref))
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref))
|
||||||
if vehicle is None:
|
if vehicle is None:
|
||||||
@@ -91,13 +164,31 @@ def get_vehicle(
|
|||||||
.where(MaintenanceRecord.vehicle_id == vehicle.id)
|
.where(MaintenanceRecord.vehicle_id == vehicle.id)
|
||||||
.order_by(MaintenanceRecord.occurred_at.desc())
|
.order_by(MaintenanceRecord.occurred_at.desc())
|
||||||
).all()
|
).all()
|
||||||
issues = db.scalars(
|
# Detailed data-quality evidence is an operations-manager surface. Employees still
|
||||||
select(DataQualityIssue)
|
# get the operational vehicle record they need, but never receive hidden evidence in
|
||||||
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id)
|
# the payload merely because the frontend omits the Quality tab.
|
||||||
.order_by(DataQualityIssue.detected_at.desc())
|
issues = (
|
||||||
).all()
|
db.scalars(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
)
|
||||||
|
.order_by(DataQualityIssue.detected_at.desc())
|
||||||
|
).all()
|
||||||
|
if user.role == "operations_manager"
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
booking_by_id = {b.id: b.public_ref for b in bookings}
|
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(
|
||||||
@@ -111,7 +202,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,
|
||||||
@@ -157,8 +264,145 @@ 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")
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
before_vehicle = {
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
"next_service_km": vehicle.next_service_km,
|
||||||
|
}
|
||||||
|
record = MaintenanceRecord(
|
||||||
|
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
occurred_at=body.occurred_at,
|
||||||
|
odometer_km=body.odometer_km,
|
||||||
|
category=body.category,
|
||||||
|
summary=body.summary.strip(),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
open_odometer_regression_issue(
|
||||||
|
db,
|
||||||
|
vehicle=vehicle,
|
||||||
|
reading_ref=record.public_ref,
|
||||||
|
reading_km=body.odometer_km,
|
||||||
|
canonical_km=vehicle.odometer_km,
|
||||||
|
source_type="maintenance",
|
||||||
|
related_refs=[record.public_ref],
|
||||||
|
actor_label=user.display_name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
|
||||||
|
if body.next_service_km is not None:
|
||||||
|
if body.next_service_km < vehicle.odometer_km:
|
||||||
|
raise HTTPException(status_code=422, detail="Next service must not be below odometer")
|
||||||
|
vehicle.next_service_km = body.next_service_km
|
||||||
|
if body.mark_maintenance:
|
||||||
|
vehicle.operational_status = "maintenance"
|
||||||
|
vehicle.version += 1
|
||||||
|
db.flush()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="maintenance_record_created",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before_vehicle,
|
||||||
|
after={
|
||||||
|
"maintenance_ref": record.public_ref,
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
"next_service_km": vehicle.next_service_km,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return MaintenanceOut(
|
||||||
|
public_ref=record.public_ref,
|
||||||
|
occurred_at=record.occurred_at,
|
||||||
|
odometer_km=record.odometer_km,
|
||||||
|
category=record.category,
|
||||||
|
summary=record.summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/release", response_model=VehicleOut)
|
||||||
|
def release_vehicle(
|
||||||
|
public_ref: str,
|
||||||
|
body: ReleaseVehicleRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> VehicleOut:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle does not require release")
|
||||||
|
active_booking = db.scalar(
|
||||||
|
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
||||||
|
)
|
||||||
|
open_high_issue = db.scalar(
|
||||||
|
select(DataQualityIssue.id).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.severity == "high",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if active_booking is not None or open_high_issue is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition")
|
||||||
|
before = {"status": vehicle.operational_status}
|
||||||
|
vehicle.operational_status = "available"
|
||||||
|
vehicle.version += 1
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="vehicle_released",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
before=before,
|
||||||
|
after={"status": "available", "reason": body.reason.strip()},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return VehicleOut(
|
||||||
|
public_ref=vehicle.public_ref,
|
||||||
|
make=vehicle.make,
|
||||||
|
model=vehicle.model,
|
||||||
|
model_year=vehicle.model_year,
|
||||||
|
registration_number=vehicle.registration_number,
|
||||||
|
location=vehicle.location,
|
||||||
|
operational_status=vehicle.operational_status,
|
||||||
|
odometer_km=vehicle.odometer_km,
|
||||||
|
next_service_km=vehicle.next_service_km,
|
||||||
|
active=vehicle.active,
|
||||||
|
attention=vehicle.next_service_km <= vehicle.odometer_km,
|
||||||
|
attention_reason=(
|
||||||
|
"service_due" if vehicle.next_service_km <= vehicle.odometer_km else None
|
||||||
|
),
|
||||||
|
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
|
||||||
|
next_booking_ref=None,
|
||||||
|
next_booking_at=None,
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
||||||
from app.schemas import AutomationRunOut, CurrentUser
|
from app.schemas import AutomationRunOut, CurrentUser
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
|
|||||||
attempts=event.attempts,
|
attempts=event.attempts,
|
||||||
last_error=event.last_error,
|
last_error=event.last_error,
|
||||||
last_error_code=event.last_error_code,
|
last_error_code=event.last_error_code,
|
||||||
|
is_demo_scenario=is_demo_scenario_failure(event),
|
||||||
occurred_at=event.occurred_at,
|
occurred_at=event.occurred_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,15 +64,27 @@ def retry_workflow(
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Captured before the status flips, so the audit records what was actually retried.
|
||||||
|
was_demo_scenario = is_demo_scenario_failure(event)
|
||||||
|
|
||||||
event.delivery_status = "pending"
|
event.delivery_status = "pending"
|
||||||
event.next_attempt_at = None
|
event.next_attempt_at = None
|
||||||
|
# The retry itself is real either way: the event goes back on the outbox and the
|
||||||
|
# dispatcher delivers it to the configured n8n webhook like any other. The only
|
||||||
|
# difference recorded here is *what* was retried -- a staged demo failure or a real
|
||||||
|
# one -- so the audit trail never implies a production incident was resolved when a
|
||||||
|
# prop was.
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
actor_label=user.display_name,
|
actor_label=user.display_name,
|
||||||
action="workflow_retry",
|
action="workflow_retry",
|
||||||
entity_type="outbox_event",
|
entity_type="outbox_event",
|
||||||
metadata={"event_id": event_id, "previous_attempts": event.attempts},
|
metadata={
|
||||||
|
"event_id": event_id,
|
||||||
|
"previous_attempts": event.attempts,
|
||||||
|
"demo_scenario": was_demo_scenario,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _to_out(event)
|
return _to_out(event)
|
||||||
|
|||||||
@@ -21,12 +21,23 @@ class Settings(BaseSettings):
|
|||||||
ragcore_workspace: str = "mobilityops"
|
ragcore_workspace: str = "mobilityops"
|
||||||
ragcore_collection: str = "internal-procedures"
|
ragcore_collection: str = "internal-procedures"
|
||||||
ragcore_api_token: str = ""
|
ragcore_api_token: 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_callback_token: str = "replace-me-n8n-callback-token"
|
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
||||||
n8n_dispatch_enabled: bool = True
|
n8n_dispatch_enabled: bool = True
|
||||||
n8n_dispatch_interval_seconds: float = 3.0
|
n8n_dispatch_interval_seconds: float = 3.0
|
||||||
n8n_http_timeout_seconds: float = 5.0
|
# The synchronous n8n workflow performs two bounded, retried callbacks before it
|
||||||
|
# acknowledges an event. Keep this above that complete workflow budget, while the
|
||||||
|
# delivery lease remains the wider crash-recovery boundary (enforced by the contract
|
||||||
|
# check in scripts/check-contracts.py).
|
||||||
|
n8n_http_timeout_seconds: float = 15.0
|
||||||
n8n_max_attempts: int = 5
|
n8n_max_attempts: int = 5
|
||||||
n8n_delivery_lease_seconds: float = 120.0
|
n8n_delivery_lease_seconds: float = 120.0
|
||||||
app_secret: str = "replace-in-production"
|
app_secret: str = "replace-in-production"
|
||||||
@@ -37,12 +48,81 @@ class Settings(BaseSettings):
|
|||||||
knowledge_dir: str = "/app/knowledge/procedures"
|
knowledge_dir: str = "/app/knowledge/procedures"
|
||||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||||
mcp_hub_registration_enabled: bool = False
|
mcp_hub_registration_enabled: bool = False
|
||||||
|
# MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles
|
||||||
|
# its catalog into the gateway; Fleet Ops never pushes a registration call), so
|
||||||
|
# these are only used for an honest reachability health check, not self-registration.
|
||||||
|
mcp_hub_base_url: str = ""
|
||||||
|
mcp_provider_id: str = "fleet-ops"
|
||||||
cors_allow_origins: str = "http://localhost:1228"
|
cors_allow_origins: str = "http://localhost:1228"
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event, func, select
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -10,6 +10,37 @@ settings = get_settings()
|
|||||||
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
|
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
# Every SQLAlchemy transaction participates in a shared database-wide barrier. Normal
|
||||||
|
# reads/writes coexist; the short demo reset takes the exclusive form so it can never
|
||||||
|
# interleave deletes/inserts with an API request, scanner, callback, or dispatcher cycle.
|
||||||
|
DEMO_DATA_BARRIER_LOCK_ID = 5_344_725_149_212_793_901
|
||||||
|
_EXCLUSIVE_RESET_INFO_KEY = "mobilityops_demo_reset_exclusive"
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Session, "after_begin")
|
||||||
|
def _acquire_demo_data_barrier(session: Session, _transaction, connection) -> None:
|
||||||
|
if connection.dialect.name != "postgresql":
|
||||||
|
return
|
||||||
|
lock = (
|
||||||
|
func.pg_advisory_xact_lock(DEMO_DATA_BARRIER_LOCK_ID)
|
||||||
|
if session.info.get(_EXCLUSIVE_RESET_INFO_KEY)
|
||||||
|
else func.pg_advisory_xact_lock_shared(DEMO_DATA_BARRIER_LOCK_ID)
|
||||||
|
)
|
||||||
|
connection.execute(select(lock))
|
||||||
|
|
||||||
|
|
||||||
|
def begin_exclusive_demo_reset(db: Session) -> None:
|
||||||
|
"""Make the session's next transaction the exclusive side of the reset barrier."""
|
||||||
|
if db.in_transaction():
|
||||||
|
# Auth normally already read the user under a shared barrier. End that read-only
|
||||||
|
# transaction before requesting exclusive; in-place lock upgrades can deadlock.
|
||||||
|
db.rollback()
|
||||||
|
db.info[_EXCLUSIVE_RESET_INFO_KEY] = True
|
||||||
|
|
||||||
|
|
||||||
|
def end_exclusive_demo_reset(db: Session) -> None:
|
||||||
|
db.info.pop(_EXCLUSIVE_RESET_INFO_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||