From 03c5b60235cb43fffd36435c7c36e3c01e174c36 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:20:53 +0200 Subject: [PATCH] M1: implement operational core Demo auth, seed import/reset, dashboard, vehicle/booking list+detail, audit trail. Backend: 19 tests passing, ruff clean. Frontend: React Router shell, typed API client, responsive pages. Verified end-to-end via curl and browser. --- .gitignore | 1 + PROJECT_STATE.md | 23 ++- backend/Dockerfile | 13 +- backend/app/api/__init__.py | 0 backend/app/api/deps.py | 41 ++++ backend/app/api/routers/__init__.py | 0 backend/app/api/routers/audit.py | 47 +++++ backend/app/api/routers/bookings.py | 63 ++++++ backend/app/api/routers/dashboard.py | 136 ++++++++++++ backend/app/api/routers/demo.py | 76 +++++++ backend/app/api/routers/vehicles.py | 164 +++++++++++++++ backend/app/cli.py | 35 ++++ backend/app/core/config.py | 6 + backend/app/core/errors.py | 31 +++ backend/app/core/security.py | 52 +++++ backend/app/main.py | 47 ++++- backend/app/schemas.py | 142 +++++++++++++ backend/app/seed_loader.py | 264 ++++++++++++++++++++++++ backend/app/services/__init__.py | 0 backend/app/services/audit.py | 40 ++++ backend/pyproject.toml | 3 +- backend/tests/conftest.py | 38 ++++ backend/tests/test_audit.py | 11 + backend/tests/test_auth.py | 20 ++ backend/tests/test_bookings.py | 19 ++ backend/tests/test_dashboard.py | 30 +++ backend/tests/test_seed.py | 46 +++++ backend/tests/test_vehicles.py | 27 +++ compose.yaml | 7 +- frontend/package-lock.json | 60 +++++- frontend/package.json | 3 +- frontend/src/App.tsx | 76 +++---- frontend/src/api/client.ts | 52 +++++ frontend/src/api/types.ts | 131 ++++++++++++ frontend/src/components/Badge.tsx | 8 + frontend/src/components/Layout.tsx | 56 +++++ frontend/src/components/RequireAuth.tsx | 11 + frontend/src/context/AuthContext.tsx | 54 +++++ frontend/src/main.tsx | 5 +- frontend/src/pages/Audit.tsx | 70 +++++++ frontend/src/pages/BookingDetail.tsx | 41 ++++ frontend/src/pages/Bookings.tsx | 80 +++++++ frontend/src/pages/Dashboard.tsx | 108 ++++++++++ frontend/src/pages/Login.tsx | 52 +++++ frontend/src/pages/VehicleDetail.tsx | 124 +++++++++++ frontend/src/pages/Vehicles.tsx | 90 ++++++++ frontend/src/styles.css | 185 ++++++++++++++++- 47 files changed, 2518 insertions(+), 70 deletions(-) create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/routers/__init__.py create mode 100644 backend/app/api/routers/audit.py create mode 100644 backend/app/api/routers/bookings.py create mode 100644 backend/app/api/routers/dashboard.py create mode 100644 backend/app/api/routers/demo.py create mode 100644 backend/app/api/routers/vehicles.py create mode 100644 backend/app/cli.py create mode 100644 backend/app/core/errors.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/seed_loader.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/audit.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_audit.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_bookings.py create mode 100644 backend/tests/test_dashboard.py create mode 100644 backend/tests/test_seed.py create mode 100644 backend/tests/test_vehicles.py create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/components/Badge.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/RequireAuth.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/pages/Audit.tsx create mode 100644 frontend/src/pages/BookingDetail.tsx create mode 100644 frontend/src/pages/Bookings.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/VehicleDetail.tsx create mode 100644 frontend/src/pages/Vehicles.tsx diff --git a/.gitignore b/.gitignore index 6126a5f..041afbf 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test-results/ .DS_Store .idea/ .vscode/ +*.tsbuildinfo diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 853675f..407817d 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2,7 +2,7 @@ ## Current milestone -M0 — complete. Starting M1 next. +M1 — complete. Starting M2 next. ## Locked decisions @@ -15,6 +15,13 @@ M0 — complete. Starting M1 next. - SQLAlchemy 2 declarative models cover the full domain model (`backend/app/models/`); enums are plain `String` columns validated at the Pydantic/service layer, not native PG enums (simpler migrations). - `backend/requirements.lock` is compiled inside a `python:3.12-slim` container (matches the Dockerfile base image) via `pip-compile --extra dev`; regenerate the same way if `pyproject.toml` changes. - Frontend dependencies pinned (no more `"latest"`); `package-lock.json` committed; Docker build uses `npm ci`. +- Demo auth is a lightweight HMAC-signed cookie (`app/core/security.py`), not a real password/JWT flow — matches "Demo role buttons create an authenticated session; they do not bypass authorization middleware." Two fixed demo users (`USR-OPS` operations_manager, `USR-EMP` rental_employee) are created by the seed loader, not from a CSV (no `users.csv` in `seed/`). +- Seed loader (`backend/app/seed_loader.py`) only supports `seed --reset` (always rebuilds); there is no incremental/idempotent-without-reset mode, since the acceptance criteria only require deterministic reset, not partial import. +- `DataQualityIssue.entity_ref`/`related_ref` from the CSVs are resolved to `entity_type`/`entity_id` (UUID) at load time per the domain model; the original human-readable refs are kept in `evidence_json` (`entity_ref`, `related_refs`) since the API and UI need them and re-resolving UUID→public_ref on every read would be wasteful. +- `backend/app/core/config.py` added `app_secret`, `session_cookie_name`, `session_ttl_seconds`, `seed_dir` (`/app/seed` in-container), `cors_allow_origins` (comma-separated string, not a list — simpler with pydantic-settings env parsing), `demo_today` (drives the dashboard's "Today" section against the deterministic anchor date, default `2026-08-01`). +- `compose.yaml` api build context changed from `./backend` to repo root with `dockerfile: backend/Dockerfile`, so the image can `COPY seed ./seed` (seed CSVs are outside `backend/`). +- Frontend: added `react-router-dom@7.18.2` (bumped from 6.x to clear two real advisories — open redirect + arbitrary constructor injection in v6). One residual `npm audit` finding (RSC-mode CSRF, GHSA-qwww-vcr4-c8h2) does not apply — this SPA never uses React Router's RSC/SSR mode. +- Nav/pages built so far: Dashboard, Vehicles (list+detail with tabs), Bookings (list+detail), Audit. Data Quality, Knowledge and Automation nav items are intentionally omitted until M3/M5/M4 build the pages behind them — CLAUDE.md forbids dead routes/placeholders. ## Completed evidence @@ -30,10 +37,22 @@ M0 — complete. Starting M1 next. - `make` is not installed in this Windows/git-bash shell — validated the underlying `docker compose ...` commands directly instead (Makefile targets are thin wrappers around them and are correct as written for a Linux/CI shell or WSL). - Known accepted gap: `npm audit` reports 1 moderate/1 high transitive `esbuild` advisory (dev-server-only, fixed only by a Vite 8 major bump); left as-is for the PoC, noted here rather than silently upgrading a major version. +### M1 — Operational core +- Backend additions: `app/core/security.py` (HMAC-signed session cookies), `app/api/deps.py` (`get_current_user`, `require_operations_manager`), `app/core/errors.py` (`AppError` + the documented `{"error": {...}}` shape wired as a FastAPI exception handler for both `AppError` and `HTTPException`), `app/seed_loader.py`, `app/cli.py` (`python -m app.cli seed --reset`), `app/services/audit.py`, `app/schemas.py`, routers under `app/api/routers/` (`demo`, `dashboard`, `vehicles`, `bookings`, `audit`). +- Frontend additions: React Router-based app shell (`src/App.tsx`, `src/components/Layout.tsx`, `src/components/RequireAuth.tsx`), `AuthContext`, typed `api` client (`src/api/client.ts`, `src/api/types.ts`), pages `Login`, `Dashboard`, `Vehicles`/`VehicleDetail`, `Bookings`/`BookingDetail`, `Audit`. Full responsive stylesheet (`src/styles.css`) covering nav collapse and table→card layout under 700px, visible focus states, no hover-only actions. +- Commands run and verified from this checkout (container rebuilt each time to pick up code changes): + - `docker compose run --rm api pytest -q` — **19 passed** (new: `test_seed.py`, `test_auth.py`, `test_dashboard.py`, `test_vehicles.py`, `test_bookings.py`, `test_audit.py`; tests seed the real Postgres via `reset_and_seed` in a session fixture, then exercise the FastAPI app through `TestClient`, not mocks). + - `docker compose run --rm api ruff check .` — All checks passed (added `ignore = ["B008"]` — FastAPI's `Depends()`-as-default is idiomatic, not a real bug). + - `npm run build` (local, Node 24) — clean `tsc -b && vite build`. + - `docker compose up -d --build` then `docker compose exec api python -m app.cli seed --reset` — counts: `users:2 customers:180 vehicles:50 bookings:246 inspections:75 maintenance:40 data_quality_issues:15 workflow_runs:20`. + - `curl` end-to-end: `POST /api/v1/demo/login` sets cookie and returns the user; unauthenticated `GET /api/v1/dashboard` → 401 with the documented error shape; authenticated dashboard/vehicle-detail return real seeded data (verified metrics `available:21 rented:11 cleaning:6 maintenance:5 blocked:7`, matching the 50 seeded vehicles). + - Browser smoke test (Chrome via MCP) at desktop width: login page → Operations Manager login → Dashboard (metrics + attention items + today + recent automation all populated) → Vehicle detail `MO-016` (tabs render, "Needs attention" badge correct — it's `DQ-DEMO-OVERLAP`/`DQ-DEMO-STATUS`) → Booking detail `BK-DEMO-RETURN` (matches S1 scenario: vehicle `MO-024`, status `active`, start odometer `53610`). Responsive CSS (`@media max-width:700px`) was written and code-reviewed but the automated resize during this session didn't visibly reflect in the captured screenshot (likely a screenshot-timing quirk of the browser tool, not necessarily a real bug) — **treat the ≤360px layout as visually unverified** and re-check with a real device/DevTools emulation before final acceptance (M7). +- Known accepted gap carried over from M0: `npm audit` residual `esbuild`/Vite-8 dev-server-only advisory. + ## Known blockers None. External service credentials may be absent; use the documented demo/degraded providers. ## Exact next action -Start M1 (operational core): read `docs/02-user-stories.md`, `docs/05-api-contract.md`, `docs/06-ui-ux.md`, `docs/13-seed-and-demo-scenarios.md` (already read this session). Implement: `app/cli.py` seed import/reset from `seed/*.csv`, demo auth/session + role middleware, dashboard/vehicles/bookings read APIs, audit-event writer, and the corresponding React router + pages (Dashboard, Vehicles, Bookings) with the persistent demo-disclosure banner. +Start M2 (vehicle return vertical slice): read `docs/08-return-workflow.md`, `contracts/events.schema.json`. Implement `POST /api/v1/bookings/{public_ref}/return` per the transaction steps in that doc (lock booking+vehicle rows, idempotency by header key, odometer-regression handling, status derivation, quality-issue creation, outbox insert, audit, one commit), a result-summary UI on the booking page, and tests for success/regression/replay/concurrent-submission/rollback. The seeded `BK-DEMO-RETURN` (`MO-024`, currently `active`, start odometer 53610) is the scripted demo scenario (S1) — a return below 53610 should trigger the regression path. diff --git a/backend/Dockerfile b/backend/Dockerfile index bfb14d6..5661f63 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,14 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app -COPY requirements.lock ./ +COPY backend/requirements.lock ./ RUN pip install --no-cache-dir -r requirements.lock -COPY pyproject.toml ./ -COPY app ./app -COPY alembic ./alembic -COPY alembic.ini ./ -COPY tests ./tests +COPY backend/pyproject.toml ./ +COPY backend/app ./app +COPY backend/alembic ./alembic +COPY backend/alembic.ini ./ +COPY backend/tests ./tests +COPY seed ./seed RUN pip install --no-cache-dir --no-deps -e . EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..c969ef3 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from collections.abc import Generator + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.db import SessionLocal +from app.core.security import SessionPayload, read_session_token +from app.schemas import CurrentUser + +settings = get_settings() + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_current_user(request: Request) -> CurrentUser: + token = request.cookies.get(settings.session_cookie_name) + payload: SessionPayload | None = read_session_token(token) if token else None + if payload is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + return CurrentUser( + public_ref=payload.public_ref, display_name=payload.display_name, role=payload.role + ) + + +def require_operations_manager( + user: CurrentUser = Depends(get_current_user), +) -> CurrentUser: + if user.role != "operations_manager": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Operations Manager role required" + ) + return user diff --git a/backend/app/api/routers/__init__.py b/backend/app/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routers/audit.py b/backend/app/api/routers/audit.py new file mode 100644 index 0000000..f12ef00 --- /dev/null +++ b/backend/app/api/routers/audit.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.audit import AuditEvent +from app.schemas import AuditEventOut, CurrentUser + +router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) + + +@router.get("", response_model=list[AuditEventOut]) +def list_audit_events( + actor_label: str | None = Query(default=None), + action: str | None = Query(default=None), + entity_type: str | None = Query(default=None), + correlation_id: str | None = Query(default=None), + limit: int = Query(default=100, le=500), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[AuditEventOut]: + stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit) + if actor_label: + stmt = stmt.where(AuditEvent.actor_label == actor_label) + if action: + stmt = stmt.where(AuditEvent.action == action) + if entity_type: + stmt = stmt.where(AuditEvent.entity_type == entity_type) + if correlation_id: + stmt = stmt.where(AuditEvent.correlation_id == correlation_id) + events = db.scalars(stmt).all() + return [ + AuditEventOut( + id=str(e.id), + actor_type=e.actor_type, + actor_label=e.actor_label, + action=e.action, + entity_type=e.entity_type, + entity_id=str(e.entity_id) if e.entity_id else None, + correlation_id=str(e.correlation_id), + occurred_at=e.occurred_at, + metadata=e.metadata_json, + ) + for e in events + ] diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py new file mode 100644 index 0000000..752d73d --- /dev/null +++ b/backend/app/api/routers/bookings.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.vehicle import Vehicle +from app.schemas import BookingOut, CurrentUser + +router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"]) + + +def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOut: + return BookingOut( + public_ref=booking.public_ref, + customer_ref=customer.public_ref, + vehicle_ref=vehicle.public_ref, + starts_at=booking.starts_at, + ends_at=booking.ends_at, + status=booking.status, + start_odometer_km=booking.start_odometer_km, + end_odometer_km=booking.end_odometer_km, + requirements_complete=booking.requirements_complete, + customer_name=f"{customer.first_name} {customer.last_name}", + ) + + +@router.get("", response_model=list[BookingOut]) +def list_bookings( + status: str | None = Query(default=None), + vehicle_ref: str | None = Query(default=None), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[BookingOut]: + stmt = select(Booking).order_by(Booking.starts_at.desc()) + if status: + stmt = stmt.where(Booking.status == status) + if vehicle_ref: + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) + if vehicle is None: + return [] + stmt = stmt.where(Booking.vehicle_id == vehicle.id) + bookings = db.scalars(stmt).all() + customers = {c.id: c for c in db.scalars(select(Customer)).all()} + vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()} + return [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings] + + +@router.get("/{public_ref}", response_model=BookingOut) +def get_booking( + public_ref: str, + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> BookingOut: + booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref)) + if booking is None: + raise HTTPException(status_code=404, detail="Booking not found") + customer = db.get(Customer, booking.customer_id) + vehicle = db.get(Vehicle, booking.vehicle_id) + return _to_out(booking, customer, vehicle) diff --git a/backend/app/api/routers/dashboard.py b/backend/app/api/routers/dashboard.py new file mode 100644 index 0000000..8f6b03c --- /dev/null +++ b/backend/app/api/routers/dashboard.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from datetime import date, datetime + +from fastapi import APIRouter, Depends +from sqlalchemy import func, 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.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.outbox import OutboxEvent +from app.models.vehicle import Vehicle +from app.schemas import ( + AttentionItem, + AutomationRunOut, + CurrentUser, + DashboardMetrics, + DashboardOut, + TodayItem, +) + +router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"]) +settings = get_settings() + +_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} + + +def _today() -> date: + return datetime.fromisoformat(settings.demo_today).date() + + +@router.get("", response_model=DashboardOut) +def get_dashboard( + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> DashboardOut: + status_counts = dict( + db.execute( + select(Vehicle.operational_status, func.count()).group_by(Vehicle.operational_status) + ).all() + ) + open_issues = db.scalar( + select(func.count()).select_from(DataQualityIssue).where(DataQualityIssue.status == "open") + ) + pending_or_failed = db.scalar( + select(func.count()) + .select_from(OutboxEvent) + .where(OutboxEvent.delivery_status.in_(["pending", "failed"])) + ) + metrics = DashboardMetrics( + available=status_counts.get("available", 0), + rented=status_counts.get("rented", 0), + cleaning=status_counts.get("cleaning", 0), + maintenance=status_counts.get("maintenance", 0), + blocked=status_counts.get("blocked", 0), + open_quality_issues=open_issues or 0, + pending_or_failed_workflows=pending_or_failed or 0, + ) + + vehicles_by_id = {v.id: v for v in db.scalars(select(Vehicle)).all()} + customers_by_id = {c.id: c for c in db.scalars(select(Customer)).all()} + + issues = db.scalars( + select(DataQualityIssue) + .where(DataQualityIssue.status == "open") + .order_by(DataQualityIssue.detected_at.asc()) + ).all() + attention_items = [] + for issue in issues: + if issue.entity_type == "vehicle": + entity = vehicles_by_id.get(issue.entity_id) + link_type = "vehicle" + else: + entity = customers_by_id.get(issue.entity_id) + link_type = "customer" + link_ref = entity.public_ref if entity else "" + title = f"{issue.rule_type.replace('_', ' ').title()} — {link_ref}" + attention_items.append( + AttentionItem( + kind="quality_issue", + severity=issue.severity, + title=title, + detail=issue.evidence_json.get("summary", ""), + link_type=link_type, + link_ref=link_ref, + ) + ) + attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3)) + + today = _today() + bookings = db.scalars(select(Booking)).all() + today_items: list[TodayItem] = [] + for b in bookings: + vehicle = vehicles_by_id.get(b.vehicle_id) + vehicle_ref = vehicle.public_ref if vehicle else "" + if b.starts_at.date() == today and b.status in ("reserved", "active"): + today_items.append( + TodayItem( + kind="departure", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, + scheduled_at=b.starts_at, + ) + ) + if b.ends_at.date() == today and b.status in ("active", "returned"): + today_items.append( + TodayItem( + kind="return", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, + scheduled_at=b.ends_at, + ) + ) + today_items.sort(key=lambda item: item.scheduled_at) + + recent = db.scalars( + select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5) + ).all() + recent_automation = [ + AutomationRunOut( + event_id=str(r.event_id), + event_type=r.event_type, + aggregate_ref=r.payload_json.get("aggregate_ref", ""), + status=r.delivery_status, + attempts=r.attempts, + last_error=r.last_error, + occurred_at=r.occurred_at, + ) + for r in recent + ] + + return DashboardOut( + metrics=metrics, + attention_items=attention_items, + today=today_items, + recent_automation=recent_automation, + ) diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py new file mode 100644 index 0000000..4e4cf9d --- /dev/null +++ b/backend/app/api/routers/demo.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import time + +from fastapi import APIRouter, Depends, Response +from sqlalchemy import 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.core.security import SessionPayload, create_session_token +from app.models.user import User +from app.schemas import CurrentUser, DemoLoginRequest +from app.seed_loader import reset_and_seed +from app.services.audit import record_audit_event + +router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) +settings = get_settings() + + +@router.post("/login", response_model=CurrentUser) +def demo_login( + body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) +) -> CurrentUser: + public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP" + user = db.scalar(select(User).where(User.public_ref == public_ref)) + if user is None: + raise LookupError("Demo users are missing; run the seed loader first.") + + 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()), + ) + ) + response.set_cookie( + settings.session_cookie_name, + token, + httponly=True, + samesite="lax", + max_age=settings.session_ttl_seconds, + ) + record_audit_event( + db, + actor_type="user", + actor_id=user.id, + actor_label=user.display_name, + action="demo_login", + entity_type="user", + entity_id=user.id, + ) + db.commit() + return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=user.role) + + +@router.post("/reset") +def demo_reset( + response: Response, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> dict: + result = reset_and_seed(db) + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="demo_reset", + entity_type="system", + metadata={"counts": result.counts}, + ) + db.commit() + response.delete_cookie(settings.session_cookie_name) + return {"status": "reset", "counts": result.counts} diff --git a/backend/app/api/routers/vehicles.py b/backend/app/api/routers/vehicles.py new file mode 100644 index 0000000..cc783c4 --- /dev/null +++ b/backend/app/api/routers/vehicles.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.inspection import Inspection +from app.models.maintenance import MaintenanceRecord +from app.models.vehicle import Vehicle +from app.schemas import ( + BookingSummaryOut, + CurrentUser, + DataQualityIssueOut, + InspectionOut, + MaintenanceOut, + VehicleDetailOut, + VehicleOut, +) + +router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) + + +def _attention_vehicle_ids(db: Session) -> set: + rows = db.scalars( + select(DataQualityIssue.entity_id).where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.status == "open", + ) + ).all() + return set(rows) + + +@router.get("", response_model=list[VehicleOut]) +def list_vehicles( + status: str | None = Query(default=None), + attention_only: bool = Query(default=False), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[VehicleOut]: + stmt = select(Vehicle).order_by(Vehicle.public_ref) + if status: + stmt = stmt.where(Vehicle.operational_status == status) + vehicles = db.scalars(stmt).all() + attention_ids = _attention_vehicle_ids(db) + out = [ + VehicleOut( + public_ref=v.public_ref, + make=v.make, + model=v.model, + model_year=v.model_year, + registration_number=v.registration_number, + location=v.location, + operational_status=v.operational_status, + odometer_km=v.odometer_km, + next_service_km=v.next_service_km, + active=v.active, + attention=v.id in attention_ids or v.operational_status == "blocked", + ) + for v in vehicles + ] + if attention_only: + out = [v for v in out if v.attention] + return out + + +@router.get("/{public_ref}", response_model=VehicleDetailOut) +def get_vehicle( + public_ref: str, + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> VehicleDetailOut: + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref)) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + + bookings = db.scalars( + select(Booking).where(Booking.vehicle_id == vehicle.id).order_by(Booking.starts_at.desc()) + ).all() + customer_ref_by_id = {c.id: c.public_ref for c in db.scalars(select(Customer)).all()} + inspections = db.scalars( + select(Inspection) + .where(Inspection.vehicle_id == vehicle.id) + .order_by(Inspection.completed_at.desc()) + ).all() + maintenance = db.scalars( + select(MaintenanceRecord) + .where(MaintenanceRecord.vehicle_id == vehicle.id) + .order_by(MaintenanceRecord.occurred_at.desc()) + ).all() + issues = db.scalars( + select(DataQualityIssue) + .where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id) + .order_by(DataQualityIssue.detected_at.desc()) + ).all() + + booking_by_id = {b.id: b.public_ref for b in bookings} + + attention_ids = _attention_vehicle_ids(db) + return VehicleDetailOut( + 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.id in attention_ids or vehicle.operational_status == "blocked", + bookings=[ + BookingSummaryOut( + public_ref=b.public_ref, + customer_ref=customer_ref_by_id.get(b.customer_id, ""), + vehicle_ref=vehicle.public_ref, + starts_at=b.starts_at, + ends_at=b.ends_at, + status=b.status, + ) + for b in bookings + ], + inspections=[ + InspectionOut( + public_ref=i.public_ref, + booking_ref=booking_by_id.get(i.booking_id, ""), + type=i.type, + fuel_level_percent=i.fuel_level_percent, + cleanliness_ok=i.cleanliness_ok, + damage_reported=i.damage_reported, + technical_warning=i.technical_warning, + odometer_km=i.odometer_km, + completed_at=i.completed_at, + ) + for i in inspections + ], + maintenance=[ + MaintenanceOut( + public_ref=m.public_ref, + occurred_at=m.occurred_at, + odometer_km=m.odometer_km, + category=m.category, + summary=m.summary, + ) + for m in maintenance + ], + quality_issues=[ + DataQualityIssueOut( + public_ref=q.public_ref, + rule_type=q.rule_type, + entity_type=q.entity_type, + entity_ref=vehicle.public_ref, + severity=q.severity, + status=q.status, + evidence=q.evidence_json, + detected_at=q.detected_at, + resolved_at=q.resolved_at, + ) + for q in issues + ], + ) diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..970ee25 --- /dev/null +++ b/backend/app/cli.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import argparse + +from app.core.db import SessionLocal +from app.seed_loader import reset_and_seed + + +def main() -> None: + parser = argparse.ArgumentParser(prog="app.cli") + subparsers = parser.add_subparsers(dest="command", required=True) + + seed_parser = subparsers.add_parser("seed", help="Load the deterministic demo dataset") + seed_parser.add_argument( + "--reset", action="store_true", help="Clear existing data before loading" + ) + + args = parser.parse_args() + + if args.command == "seed": + if not args.reset: + raise SystemExit( + "Only 'seed --reset' is supported: seeding always rebuilds the demo dataset." + ) + db = SessionLocal() + try: + result = reset_and_seed(db) + for name, count in result.counts.items(): + print(f"{name}: {count}") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b9f37e1..18346ae 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -15,6 +15,12 @@ class Settings(BaseSettings): ragcore_workspace: str = "mobilityops" ragcore_collection: str = "internal-procedures" n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return" + app_secret: str = "replace-in-production" + session_cookie_name: str = "mobilityops_session" + session_ttl_seconds: int = 60 * 60 * 8 + seed_dir: str = "/app/seed" + cors_allow_origins: str = "http://localhost:1228" + demo_today: str = "2026-08-01" @lru_cache diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..238d86b --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import uuid +from typing import Any + + +class AppError(Exception): + def __init__( + self, + code: str, + message: str, + status_code: int = 400, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + self.details = details or {} + self.correlation_id = str(uuid.uuid4()) + + +def error_body(code: str, message: str, correlation_id: str, details: dict[str, Any]) -> dict: + return { + "error": { + "code": code, + "message": message, + "correlation_id": correlation_id, + "details": details, + } + } diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..46ac28d --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +from dataclasses import dataclass + +from app.core.config import get_settings + +settings = get_settings() + + +@dataclass(frozen=True) +class SessionPayload: + user_id: str + public_ref: str + role: str + display_name: str + issued_at: int + + +def _sign(data: bytes) -> str: + digest = hmac.new(settings.app_secret.encode(), data, hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +def create_session_token(payload: SessionPayload) -> str: + body = json.dumps(payload.__dict__, separators=(",", ":")).encode() + encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=") + signature = _sign(encoded_body.encode()) + return f"{encoded_body}.{signature}" + + +def read_session_token(token: str) -> SessionPayload | None: + try: + encoded_body, signature = token.split(".", 1) + except ValueError: + return None + expected = _sign(encoded_body.encode()) + if not hmac.compare_digest(expected, signature): + return None + padding = "=" * (-len(encoded_body) % 4) + try: + body = json.loads(base64.urlsafe_b64decode(encoded_body + padding)) + except (ValueError, json.JSONDecodeError): + return None + payload = SessionPayload(**body) + if time.time() - payload.issued_at > settings.session_ttl_seconds: + return None + return payload diff --git a/backend/app/main.py b/backend/app/main.py index 2c4cb4a..729708a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,44 @@ -from fastapi import FastAPI +import uuid +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.api.routers import audit, bookings, dashboard, demo, vehicles from app.core.config import get_settings +from app.core.errors import AppError, error_body settings = get_settings() -app = FastAPI(title="MobilityOps API", version="0.0.1") +app = FastAPI(title="MobilityOps API", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(AppError) +def handle_app_error(_request: Request, exc: AppError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=error_body(exc.code, exc.message, exc.correlation_id, exc.details), + ) + + +@app.exception_handler(HTTPException) +def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=error_body( + code=str(exc.status_code), + message=str(exc.detail), + correlation_id=str(uuid.uuid4()), + details={}, + ), + ) @app.get("/health") @@ -18,5 +53,11 @@ def system_status() -> dict[str, object]: "environment": settings.mobilityops_env, "demo_mode": settings.mobilityops_demo_mode, "knowledge_provider": settings.knowledge_provider, - "scaffold": True, } + + +app.include_router(demo.router) +app.include_router(dashboard.router) +app.include_router(vehicles.router) +app.include_router(bookings.router) +app.include_router(audit.router) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..a758092 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + +Role = Literal["operations_manager", "rental_employee"] + + +class DemoLoginRequest(BaseModel): + role: Role + + +class CurrentUser(BaseModel): + public_ref: str + display_name: str + role: Role + + +class VehicleOut(BaseModel): + public_ref: str + make: str + model: str + model_year: int + registration_number: str + location: str + operational_status: str + odometer_km: int + next_service_km: int + active: bool + attention: bool = False + + +class BookingSummaryOut(BaseModel): + public_ref: str + customer_ref: str + vehicle_ref: str + starts_at: datetime + ends_at: datetime + status: str + + +class BookingOut(BookingSummaryOut): + start_odometer_km: int | None + end_odometer_km: int | None + requirements_complete: bool + customer_name: str + + +class InspectionOut(BaseModel): + public_ref: str + booking_ref: str + type: str + fuel_level_percent: int + cleanliness_ok: bool + damage_reported: bool + technical_warning: bool + odometer_km: int + completed_at: datetime + + +class MaintenanceOut(BaseModel): + public_ref: str + occurred_at: datetime + odometer_km: int + category: str + summary: str + + +class DataQualityIssueOut(BaseModel): + public_ref: str + rule_type: str + entity_type: str + entity_ref: str + severity: str + status: str + evidence: dict[str, Any] + detected_at: datetime + resolved_at: datetime | None = None + + +class VehicleDetailOut(VehicleOut): + bookings: list[BookingSummaryOut] = Field(default_factory=list) + inspections: list[InspectionOut] = Field(default_factory=list) + maintenance: list[MaintenanceOut] = Field(default_factory=list) + quality_issues: list[DataQualityIssueOut] = Field(default_factory=list) + + +class DashboardMetrics(BaseModel): + available: int + rented: int + cleaning: int + maintenance: int + blocked: int + open_quality_issues: int + pending_or_failed_workflows: int + + +class AttentionItem(BaseModel): + kind: Literal["quality_issue", "vehicle"] + severity: str + title: str + detail: str + link_type: Literal["vehicle", "booking", "customer"] + link_ref: str + + +class TodayItem(BaseModel): + kind: Literal["departure", "return"] + booking_ref: str + vehicle_ref: str + scheduled_at: datetime + + +class AutomationRunOut(BaseModel): + event_id: str + event_type: str + aggregate_ref: str + status: str + attempts: int + last_error: str | None + occurred_at: datetime + + +class DashboardOut(BaseModel): + metrics: DashboardMetrics + attention_items: list[AttentionItem] + today: list[TodayItem] + recent_automation: list[AutomationRunOut] + + +class AuditEventOut(BaseModel): + id: str + actor_type: str + actor_label: str + action: str + entity_type: str + entity_id: str | None + correlation_id: str + occurred_at: datetime + metadata: dict[str, Any] | None = None diff --git a/backend/app/seed_loader.py b/backend/app/seed_loader.py new file mode 100644 index 0000000..bec7ad2 --- /dev/null +++ b/backend/app/seed_loader.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import csv +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from sqlalchemy import delete, insert +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.audit import AuditEvent +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.inspection import Inspection +from app.models.maintenance import MaintenanceRecord +from app.models.outbox import OutboxEvent +from app.models.user import User +from app.models.vehicle import Vehicle + +settings = get_settings() + +DEMO_USERS = [ + { + "public_ref": "USR-OPS", + "display_name": "Amelie De Ridder", + "role": "operations_manager", + }, + { + "public_ref": "USR-EMP", + "display_name": "Karim Boujaddaine", + "role": "rental_employee", + }, +] + + +def _parse_dt(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _parse_bool(value: str) -> bool: + return value.strip().lower() == "true" + + +def _parse_optional_int(value: str) -> int | None: + value = value.strip() + return int(value) if value else None + + +@dataclass +class SeedResult: + counts: dict[str, int] + + +def _seed_dir() -> Path: + return Path(settings.seed_dir) + + +def _read_csv(name: str) -> list[dict[str, str]]: + path = _seed_dir() / name + with path.open(newline="", encoding="utf-8") as handle: + return list(csv.DictReader(handle)) + + +def clear_all(db: Session) -> None: + for model in ( + AuditEvent, + OutboxEvent, + DataQualityIssue, + Inspection, + MaintenanceRecord, + Booking, + Vehicle, + Customer, + User, + ): + db.execute(delete(model)) + + +def load_seed(db: Session) -> SeedResult: + counts: dict[str, int] = {} + + user_rows = [ + {"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS + ] + db.execute(insert(User), user_rows) + counts["users"] = len(user_rows) + + customer_id_by_ref: dict[str, uuid.UUID] = {} + customer_rows = [] + for row in _read_csv("customers.csv"): + cid = uuid.uuid4() + customer_id_by_ref[row["public_ref"]] = cid + customer_rows.append( + { + "id": cid, + "public_ref": row["public_ref"], + "first_name": row["first_name"], + "last_name": row["last_name"], + "email": row["email"] or None, + "phone": row["phone"] or None, + "postal_code": row["postal_code"] or None, + "city": row["city"] or None, + } + ) + db.execute(insert(Customer), customer_rows) + counts["customers"] = len(customer_rows) + # Second pass for merged_into (self-referencing FK) since target must exist first. + for row in _read_csv("customers.csv"): + merged_ref = row.get("merged_into") or "" + if merged_ref: + db.execute( + Customer.__table__.update() + .where(Customer.id == customer_id_by_ref[row["public_ref"]]) + .values(merged_into_customer_id=customer_id_by_ref[merged_ref]) + ) + + vehicle_id_by_ref: dict[str, uuid.UUID] = {} + vehicle_rows = [] + for row in _read_csv("vehicles.csv"): + vid = uuid.uuid4() + vehicle_id_by_ref[row["public_ref"]] = vid + vehicle_rows.append( + { + "id": vid, + "public_ref": row["public_ref"], + "make": row["make"], + "model": row["model"], + "model_year": int(row["model_year"]), + "registration_number": row["registration_number"], + "location": row["location"], + "operational_status": row["operational_status"], + "odometer_km": int(row["odometer_km"]), + "next_service_km": int(row["next_service_km"]), + "active": _parse_bool(row["active"]), + "version": 1, + } + ) + db.execute(insert(Vehicle), vehicle_rows) + counts["vehicles"] = len(vehicle_rows) + + booking_id_by_ref: dict[str, uuid.UUID] = {} + booking_rows = [] + for row in _read_csv("bookings.csv"): + bid = uuid.uuid4() + booking_id_by_ref[row["public_ref"]] = bid + booking_rows.append( + { + "id": bid, + "public_ref": row["public_ref"], + "customer_id": customer_id_by_ref[row["customer_ref"]], + "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], + "starts_at": _parse_dt(row["starts_at"]), + "ends_at": _parse_dt(row["ends_at"]), + "status": row["status"], + "start_odometer_km": _parse_optional_int(row["start_odometer_km"]), + "end_odometer_km": _parse_optional_int(row["end_odometer_km"]), + "requirements_complete": _parse_bool(row["requirements_complete"]), + } + ) + db.execute(insert(Booking), booking_rows) + counts["bookings"] = len(booking_rows) + + inspection_rows = [] + for row in _read_csv("inspections.csv"): + inspection_rows.append( + { + "id": uuid.uuid4(), + "public_ref": row["public_ref"], + "booking_id": booking_id_by_ref[row["booking_ref"]], + "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], + "type": row["type"], + "fuel_level_percent": int(row["fuel_level_percent"]), + "cleanliness_ok": _parse_bool(row["cleanliness_ok"]), + "damage_reported": _parse_bool(row["damage_reported"]), + "technical_warning": _parse_bool(row["technical_warning"]), + "odometer_km": int(row["odometer_km"]), + "completed_at": _parse_dt(row["completed_at"]), + "completed_by": None, + } + ) + db.execute(insert(Inspection), inspection_rows) + counts["inspections"] = len(inspection_rows) + + maintenance_rows = [] + for row in _read_csv("maintenance.csv"): + maintenance_rows.append( + { + "id": uuid.uuid4(), + "public_ref": row["public_ref"], + "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], + "occurred_at": _parse_dt(row["occurred_at"]), + "odometer_km": int(row["odometer_km"]), + "category": row["category"], + "summary": row["summary"], + } + ) + db.execute(insert(MaintenanceRecord), maintenance_rows) + counts["maintenance"] = len(maintenance_rows) + + def resolve_entity(entity_ref: str) -> tuple[str, uuid.UUID]: + if entity_ref.startswith("CUS-"): + return "customer", customer_id_by_ref[entity_ref] + return "vehicle", vehicle_id_by_ref[entity_ref] + + dq_rows = [] + now = datetime.now(UTC) + for row in _read_csv("data_quality_issues.csv"): + entity_type, entity_id = resolve_entity(row["entity_ref"]) + related_ref = row.get("related_ref") or "" + dq_rows.append( + { + "id": uuid.uuid4(), + "public_ref": row["public_ref"], + "rule_type": row["rule_type"], + "entity_type": entity_type, + "entity_id": entity_id, + "severity": row["severity"], + "status": row["status"], + "evidence_json": { + "summary": row["evidence"], + "entity_ref": row["entity_ref"], + "related_refs": related_ref.split("|") if related_ref else [], + }, + "proposed_action_json": {}, + "detected_at": now, + "resolved_at": now if row["status"] == "resolved" else None, + "resolved_by": "USR-OPS" if row["status"] == "resolved" else None, + } + ) + db.execute(insert(DataQualityIssue), dq_rows) + counts["data_quality_issues"] = len(dq_rows) + + outbox_rows = [] + for row in _read_csv("workflow_runs.csv"): + booking_id = booking_id_by_ref.get(row["aggregate_ref"]) + outbox_rows.append( + { + "event_id": uuid.UUID(row["event_id"]), + "event_type": row["event_type"], + "aggregate_type": "booking", + "aggregate_id": booking_id or uuid.uuid4(), + "payload_json": {"aggregate_ref": row["aggregate_ref"]}, + "occurred_at": _parse_dt(row["occurred_at"]), + "delivery_status": row["status"], + "attempts": int(row["attempts"]), + "next_attempt_at": None, + "last_error": row["last_error"] or None, + "external_run_id": None, + } + ) + db.execute(insert(OutboxEvent), outbox_rows) + counts["workflow_runs"] = len(outbox_rows) + + return SeedResult(counts=counts) + + +def reset_and_seed(db: Session) -> SeedResult: + clear_all(db) + result = load_seed(db) + db.commit() + return result diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..6bdddfb --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.orm import Session + +from app.models.audit import AuditEvent + + +def record_audit_event( + db: Session, + *, + actor_type: str, + actor_label: str, + action: str, + entity_type: str, + actor_id: uuid.UUID | None = None, + entity_id: uuid.UUID | None = None, + correlation_id: uuid.UUID | None = None, + before: dict[str, Any] | None = None, + after: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, +) -> AuditEvent: + event = AuditEvent( + actor_type=actor_type, + actor_id=actor_id, + actor_label=actor_label, + action=action, + entity_type=entity_type, + entity_id=entity_id, + correlation_id=correlation_id or uuid.uuid4(), + before_json=before, + after_json=after, + metadata_json=metadata, + occurred_at=datetime.now(UTC), + ) + db.add(event) + return event diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 19636cb..4179ff3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,7 +34,8 @@ asyncio_mode = "auto" [tool.ruff] line-length = 100 -extend-exclude = ["alembic/versions"] +extend-exclude = ["alembic/versions", "seed"] [tool.ruff.lint] select = ["E", "F", "I", "B", "UP"] +ignore = ["B008"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..1abc8c8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,38 @@ +import pytest +from fastapi.testclient import TestClient + +from app.core.db import Base, SessionLocal, engine +from app.main import app +from app.seed_loader import reset_and_seed + + +@pytest.fixture(scope="session", autouse=True) +def _seeded_database(): + Base.metadata.create_all(engine) + db = SessionLocal() + try: + reset_and_seed(db) + finally: + db.close() + yield + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def login(client: TestClient, role: str) -> TestClient: + response = client.post("/api/v1/demo/login", json={"role": role}) + assert response.status_code == 200 + return client + + +@pytest.fixture +def ops_client(client: TestClient) -> TestClient: + return login(client, "operations_manager") + + +@pytest.fixture +def employee_client(client: TestClient) -> TestClient: + return login(client, "rental_employee") diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py new file mode 100644 index 0000000..9d986c8 --- /dev/null +++ b/backend/tests/test_audit.py @@ -0,0 +1,11 @@ +def test_demo_login_is_audited(ops_client): + response = ops_client.get("/api/v1/audit", params={"action": "demo_login"}) + assert response.status_code == 200 + events = response.json() + assert len(events) >= 1 + assert events[0]["action"] == "demo_login" + + +def test_audit_requires_authentication(client): + response = client.get("/api/v1/audit") + assert response.status_code == 401 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..4931bde --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,20 @@ +def test_unauthenticated_dashboard_is_rejected(client): + response = client.get("/api/v1/dashboard") + assert response.status_code == 401 + assert response.json()["error"]["code"] == "401" + + +def test_demo_login_grants_access(ops_client): + response = ops_client.get("/api/v1/dashboard") + assert response.status_code == 200 + + +def test_rental_employee_cannot_reset_demo(employee_client): + response = employee_client.post("/api/v1/demo/reset") + assert response.status_code == 403 + + +def test_operations_manager_can_reset_demo(ops_client): + response = ops_client.post("/api/v1/demo/reset") + assert response.status_code == 200 + assert response.json()["counts"]["vehicles"] == 50 diff --git a/backend/tests/test_bookings.py b/backend/tests/test_bookings.py new file mode 100644 index 0000000..802cdf7 --- /dev/null +++ b/backend/tests/test_bookings.py @@ -0,0 +1,19 @@ +def test_list_bookings_filters_by_vehicle(ops_client): + response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"}) + assert response.status_code == 200 + bookings = response.json() + assert len(bookings) >= 1 + assert all(b["vehicle_ref"] == "MO-024" for b in bookings) + + +def test_get_booking_detail(ops_client): + response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "active" + assert body["vehicle_ref"] == "MO-024" + + +def test_get_booking_404_for_unknown_ref(ops_client): + response = ops_client.get("/api/v1/bookings/BK-UNKNOWN") + assert response.status_code == 404 diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..f25642f --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,30 @@ +def test_dashboard_metrics_are_persisted_counts(ops_client): + response = ops_client.get("/api/v1/dashboard") + assert response.status_code == 200 + body = response.json() + metrics = body["metrics"] + total = ( + metrics["available"] + + metrics["rented"] + + metrics["cleaning"] + + metrics["maintenance"] + + metrics["blocked"] + ) + assert total == 50 + assert metrics["open_quality_issues"] >= 1 + assert metrics["pending_or_failed_workflows"] >= 1 + + +def test_dashboard_attention_items_link_to_records(ops_client): + response = ops_client.get("/api/v1/dashboard") + body = response.json() + assert len(body["attention_items"]) > 0 + for item in body["attention_items"]: + assert item["link_ref"] + assert item["severity"] in ("low", "medium", "high") + + +def test_dashboard_recent_automation_capped_at_five(ops_client): + response = ops_client.get("/api/v1/dashboard") + body = response.json() + assert len(body["recent_automation"]) == 5 diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py new file mode 100644 index 0000000..e18ebac --- /dev/null +++ b/backend/tests/test_seed.py @@ -0,0 +1,46 @@ +from sqlalchemy import func, select + +from app.core.db import SessionLocal +from app.models.booking import Booking +from app.models.customer import Customer +from app.models.data_quality import DataQualityIssue +from app.models.outbox import OutboxEvent +from app.models.user import User +from app.models.vehicle import Vehicle + + +def test_seed_counts_match_deterministic_dataset(): + db = SessionLocal() + try: + assert db.scalar(select(func.count()).select_from(Vehicle)) == 50 + assert db.scalar(select(func.count()).select_from(Customer)) == 180 + assert db.scalar(select(func.count()).select_from(Booking)) == 246 + assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 15 + assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20 + assert db.scalar(select(func.count()).select_from(User)) == 2 + finally: + db.close() + + +def test_seed_demo_scenarios_present(): + db = SessionLocal() + try: + booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN")) + assert booking is not None + assert booking.status == "active" + + duplicate_customer = db.scalar(select(Customer).where(Customer.public_ref == "CUS-0178")) + assert duplicate_customer is not None + + duplicate_issue = db.scalar( + select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE") + ) + assert duplicate_issue is not None + assert duplicate_issue.rule_type == "possible_duplicate_customer" + + failed_run = db.scalar( + select(OutboxEvent).where(OutboxEvent.delivery_status == "failed") + ) + assert failed_run is not None + finally: + db.close() diff --git a/backend/tests/test_vehicles.py b/backend/tests/test_vehicles.py new file mode 100644 index 0000000..12f2f87 --- /dev/null +++ b/backend/tests/test_vehicles.py @@ -0,0 +1,27 @@ +def test_list_vehicles_filters_by_status(ops_client): + response = ops_client.get("/api/v1/vehicles", params={"status": "maintenance"}) + assert response.status_code == 200 + vehicles = response.json() + assert len(vehicles) > 0 + assert all(v["operational_status"] == "maintenance" for v in vehicles) + + +def test_attention_only_filters_flagged_vehicles(ops_client): + response = ops_client.get("/api/v1/vehicles", params={"attention_only": True}) + assert response.status_code == 200 + vehicles = response.json() + assert len(vehicles) > 0 + assert all(v["attention"] for v in vehicles) + + +def test_vehicle_detail_includes_related_records(ops_client): + response = ops_client.get("/api/v1/vehicles/MO-016") + assert response.status_code == 200 + body = response.json() + assert body["public_ref"] == "MO-016" + assert len(body["quality_issues"]) >= 1 + + +def test_vehicle_detail_404_for_unknown_ref(ops_client): + response = ops_client.get("/api/v1/vehicles/MO-999") + assert response.status_code == 404 diff --git a/compose.yaml b/compose.yaml index 09576df..d7af9a6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,12 +15,17 @@ services: networks: [mobilityops] api: - build: ./backend + build: + context: . + dockerfile: backend/Dockerfile environment: MOBILITYOPS_ENV: ${MOBILITYOPS_ENV:-development} MOBILITYOPS_DEMO_MODE: ${MOBILITYOPS_DEMO_MODE:-true} DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops} TZ: ${TZ:-Europe/Brussels} + APP_SECRET: ${APP_SECRET:-replace-in-production} + DEMO_TODAY: ${DEMO_TODAY:-2026-08-01} + CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228} KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo} RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000} RAGCORE_TENANT: ${RAGCORE_TENANT:-northstar-mobility-demo} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11248df..256948a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.1", "dependencies": { "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-router-dom": "7.18.2" }, "devDependencies": { "@types/react": "18.3.12", @@ -1326,6 +1327,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1593,6 +1607,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -1658,6 +1710,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d07e8ba..39ab6a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-router-dom": "7.18.2" }, "devDependencies": { "@types/react": "18.3.12", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dfced3f..4094eda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,49 +1,37 @@ -import { useEffect, useState } from "react"; - -const API = import.meta.env.VITE_API_BASE_URL ?? ""; - -type Status = { - service: string; - environment: string; - demo_mode: boolean; - knowledge_provider: string; - scaffold: boolean; -}; +import { Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import { Layout } from "./components/Layout"; +import { RequireAuth } from "./components/RequireAuth"; +import { Login } from "./pages/Login"; +import { Dashboard } from "./pages/Dashboard"; +import { Vehicles } from "./pages/Vehicles"; +import { VehicleDetail } from "./pages/VehicleDetail"; +import { Bookings } from "./pages/Bookings"; +import { BookingDetail } from "./pages/BookingDetail"; +import { Audit } from "./pages/Audit"; export function App() { - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - fetch(`${API}/api/v1/system/status`) - .then((response) => { - if (!response.ok) throw new Error(`API returned ${response.status}`); - return response.json(); - }) - .then(setStatus) - .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Unknown error")); - }, []); - return ( -
-
-

Synthetic proof of concept

-

MobilityOps

-

Connected operations for vehicle rental and service teams.

-
-
-

Scaffold status

- {error &&

API unavailable: {error}

} - {!error && !status &&

Connecting to the MobilityOps API…

} - {status && ( -
-
Service
{status.service}
-
Environment
{status.environment}
-
Knowledge provider
{status.knowledge_provider}
-
- )} -

This is the bootable project scaffold. Claude must replace it with the complete scoped application described in the build pack.

-
-
+ + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..8022147 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,52 @@ +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; + +export class ApiError extends Error { + status: number; + code: string; + correlationId: string; + + constructor(status: number, code: string, message: string, correlationId: string) { + super(message); + this.status = status; + this.code = code; + this.correlationId = correlationId; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE}${path}`, { + ...init, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + if (!response.ok) { + let body: { error?: { code: string; message: string; correlation_id: string } } | undefined; + try { + body = await response.json(); + } catch { + body = undefined; + } + const error = body?.error; + throw new ApiError( + response.status, + error?.code ?? String(response.status), + error?.message ?? response.statusText, + error?.correlation_id ?? "", + ); + } + + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown, headers?: Record) => + request(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }), +}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..e60c928 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,131 @@ +export type Role = "operations_manager" | "rental_employee"; + +export interface CurrentUser { + public_ref: string; + display_name: string; + role: Role; +} + +export interface Vehicle { + public_ref: string; + make: string; + model: string; + model_year: number; + registration_number: string; + location: string; + operational_status: string; + odometer_km: number; + next_service_km: number; + active: boolean; + attention: boolean; +} + +export interface BookingSummary { + public_ref: string; + customer_ref: string; + vehicle_ref: string; + starts_at: string; + ends_at: string; + status: string; +} + +export interface Booking extends BookingSummary { + start_odometer_km: number | null; + end_odometer_km: number | null; + requirements_complete: boolean; + customer_name: string; +} + +export interface Inspection { + public_ref: string; + booking_ref: string; + type: string; + fuel_level_percent: number; + cleanliness_ok: boolean; + damage_reported: boolean; + technical_warning: boolean; + odometer_km: number; + completed_at: string; +} + +export interface MaintenanceRecord { + public_ref: string; + occurred_at: string; + odometer_km: number; + category: string; + summary: string; +} + +export interface DataQualityIssue { + public_ref: string; + rule_type: string; + entity_type: string; + entity_ref: string; + severity: "low" | "medium" | "high"; + status: "open" | "deferred" | "resolved" | "rejected"; + evidence: Record; + detected_at: string; + resolved_at: string | null; +} + +export interface VehicleDetail extends Vehicle { + bookings: BookingSummary[]; + inspections: Inspection[]; + maintenance: MaintenanceRecord[]; + quality_issues: DataQualityIssue[]; +} + +export interface DashboardMetrics { + available: number; + rented: number; + cleaning: number; + maintenance: number; + blocked: number; + open_quality_issues: number; + pending_or_failed_workflows: number; +} + +export interface AttentionItem { + kind: string; + severity: "low" | "medium" | "high"; + title: string; + detail: string; + link_type: "vehicle" | "booking" | "customer"; + link_ref: string; +} + +export interface TodayItem { + kind: "departure" | "return"; + booking_ref: string; + vehicle_ref: string; + scheduled_at: string; +} + +export interface AutomationRun { + event_id: string; + event_type: string; + aggregate_ref: string; + status: string; + attempts: number; + last_error: string | null; + occurred_at: string; +} + +export interface Dashboard { + metrics: DashboardMetrics; + attention_items: AttentionItem[]; + today: TodayItem[]; + recent_automation: AutomationRun[]; +} + +export interface AuditEvent { + id: string; + actor_type: string; + actor_label: string; + action: string; + entity_type: string; + entity_id: string | null; + correlation_id: string; + occurred_at: string; + metadata: Record | null; +} diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx new file mode 100644 index 0000000..7cbfc09 --- /dev/null +++ b/frontend/src/components/Badge.tsx @@ -0,0 +1,8 @@ +export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) { + const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low"; + return {label} severity; +} + +export function StatusBadge({ status }: { status: string }) { + return {status.replace(/_/g, " ")}; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..2962a2d --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,56 @@ +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +const NAV_ITEMS = [ + { to: "/dashboard", label: "Dashboard" }, + { to: "/vehicles", label: "Vehicles" }, + { to: "/bookings", label: "Bookings" }, + { to: "/audit", label: "Audit" }, +]; + +export function Layout() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + function handleLogout() { + logout(); + navigate("/login"); + } + + return ( +
+

+ Synthetic demo environment — no real customer or vehicle data. +

+
+ MobilityOps + +
+ {user && ( + <> + + {user.display_name} · {user.role === "operations_manager" ? "Operations Manager" : "Rental Employee"} + + + + )} +
+
+
+ +
+
+ ); +} diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx new file mode 100644 index 0000000..70dec36 --- /dev/null +++ b/frontend/src/components/RequireAuth.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export function RequireAuth({ children }: { children: ReactNode }) { + const { user } = useAuth(); + if (!user) { + return ; + } + return <>{children}; +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..73960a0 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,54 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { api, ApiError } from "../api/client"; +import type { CurrentUser, Role } from "../api/types"; + +interface AuthState { + user: CurrentUser | null; + loading: boolean; + loginAs: (role: Role) => Promise; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +const STORAGE_KEY = "mobilityops.demo-user"; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(() => { + const stored = sessionStorage.getItem(STORAGE_KEY); + return stored ? (JSON.parse(stored) as CurrentUser) : null; + }); + const [loading, setLoading] = useState(false); + + const loginAs = useCallback(async (role: Role) => { + setLoading(true); + try { + const loggedIn = await api.post("/api/v1/demo/login", { role }); + setUser(loggedIn); + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(loggedIn)); + } finally { + setLoading(false); + } + }, []); + + const logout = useCallback(() => { + setUser(null); + sessionStorage.removeItem(STORAGE_KEY); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} + +export function isSessionExpired(error: unknown): boolean { + return error instanceof ApiError && error.status === 401; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e7e51d8..c25e4e3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/frontend/src/pages/Audit.tsx b/frontend/src/pages/Audit.tsx new file mode 100644 index 0000000..485d3f5 --- /dev/null +++ b/frontend/src/pages/Audit.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import type { AuditEvent } from "../api/types"; + +export function Audit() { + const [events, setEvents] = useState(null); + const [error, setError] = useState(null); + const [action, setAction] = useState(""); + + useEffect(() => { + const params = new URLSearchParams(); + if (action) params.set("action", action); + api + .get(`/api/v1/audit?${params.toString()}`) + .then(setEvents) + .catch(() => setError("Audit trail is unavailable right now.")); + }, [action]); + + return ( +
+

Audit

+ +
+ +
+ + {error &&

{error}

} + {!error && !events &&

Loading audit trail…

} + {events && events.length === 0 &&

No audit events match this filter.

} + + {events && events.length > 0 && ( + + + + + + + + + + + + + {events.map((e) => ( + + + + + + + + ))} + +
Audit events
WhenActorActionEntityCorrelation
+ + {e.actor_label} ({e.actor_type}){e.action}{e.entity_type}{e.correlation_id.slice(0, 8)}
+ )} +
+ ); +} diff --git a/frontend/src/pages/BookingDetail.tsx b/frontend/src/pages/BookingDetail.tsx new file mode 100644 index 0000000..2b10478 --- /dev/null +++ b/frontend/src/pages/BookingDetail.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../api/client"; +import type { Booking } from "../api/types"; +import { StatusBadge } from "../components/Badge"; + +export function BookingDetail() { + const { publicRef } = useParams<{ publicRef: string }>(); + const [booking, setBooking] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!publicRef) return; + setBooking(null); + setError(null); + api + .get(`/api/v1/bookings/${publicRef}`) + .then(setBooking) + .catch(() => setError("This booking could not be found.")); + }, [publicRef]); + + if (error) return

{error}

; + if (!booking) return

Loading booking…

; + + return ( +
+

← Back to bookings

+

{booking.public_ref}

+

+
+
Customer
{booking.customer_name} ({booking.customer_ref})
+
Vehicle
{booking.vehicle_ref}
+
Starts
{new Date(booking.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
+
Ends
{new Date(booking.ends_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
+
Start odometer
{booking.start_odometer_km ?? "—"} km
+
End odometer
{booking.end_odometer_km ?? "—"} km
+
Requirements complete
{booking.requirements_complete ? "Yes" : "No"}
+
+
+ ); +} diff --git a/frontend/src/pages/Bookings.tsx b/frontend/src/pages/Bookings.tsx new file mode 100644 index 0000000..b2fe9b2 --- /dev/null +++ b/frontend/src/pages/Bookings.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api/client"; +import type { Booking } from "../api/types"; +import { StatusBadge } from "../components/Badge"; + +const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"]; + +export function Bookings() { + const [bookings, setBookings] = useState(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState(""); + + useEffect(() => { + const params = new URLSearchParams(); + if (status) params.set("status", status); + api + .get(`/api/v1/bookings?${params.toString()}`) + .then(setBookings) + .catch(() => setError("Booking list is unavailable right now.")); + }, [status]); + + return ( +
+

Bookings

+ +
+ +
+ + {error &&

{error}

} + {!error && !bookings &&

Loading bookings…

} + {bookings && bookings.length === 0 &&

No bookings match these filters.

} + + {bookings && bookings.length > 0 && ( + + + + + + + + + + + + + {bookings.map((b) => ( + + + + + + + + ))} + +
Bookings
ReferenceCustomerVehicleWindowStatus
+ {b.public_ref} + {b.customer_name} + {b.vehicle_ref} + + {new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")} + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..183c216 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api/client"; +import type { Dashboard as DashboardData } from "../api/types"; +import { SeverityBadge, StatusBadge } from "../components/Badge"; + +const METRIC_LABELS: Record = { + available: "Available", + rented: "Rented", + cleaning: "Cleaning", + maintenance: "Maintenance", + blocked: "Blocked", + open_quality_issues: "Open quality issues", + pending_or_failed_workflows: "Pending/failed workflows", +}; + +export function Dashboard() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + api + .get("/api/v1/dashboard") + .then(setData) + .catch(() => setError("Dashboard data is unavailable right now.")); + }, []); + + if (error) return

{error}

; + if (!data) return

Loading dashboard…

; + + return ( +
+

Dashboard

+ +
+

Operational metrics

+
    + {(Object.keys(METRIC_LABELS) as (keyof DashboardData["metrics"])[]).map((key) => ( +
  • + {data.metrics[key]} + {METRIC_LABELS[key]} +
  • + ))} +
+
+ +
+

Attention required

+ {data.attention_items.length === 0 &&

Nothing needs attention right now.

} +
    + {data.attention_items.map((item, index) => ( +
  • + +
    +

    + {item.link_type === "vehicle" ? ( + {item.title} + ) : ( + item.title + )} +

    +

    {item.detail}

    +
    +
  • + ))} +
+
+ +
+

Today

+ {data.today.length === 0 &&

No departures or returns scheduled today.

} +
    + {data.today.map((item) => ( +
  • + {item.kind === "departure" ? "Departure" : "Return"} + {item.booking_ref} + {item.vehicle_ref} + +
  • + ))} +
+
+ +
+

Recent automation

+ {data.recent_automation.length === 0 &&

No automation runs recorded yet.

} +
    + {data.recent_automation.map((run) => ( +
  • + + {run.event_type} + {run.aggregate_ref} + +
  • + ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..6c0df9f --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import type { Role } from "../api/types"; + +export function Login() { + const { loginAs, loading } = useAuth(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + + async function handleLogin(role: Role) { + setError(null); + try { + await loginAs(role); + navigate("/dashboard"); + } catch { + setError("Could not start a demo session. The API may be unavailable."); + } + } + + return ( +
+

+ Synthetic demo environment — no real customer or vehicle data. +

+
+

Synthetic proof of concept

+

MobilityOps

+

Connected operations for vehicle rental and service teams.

+
+
+

Choose a demo role

+ {error && ( +

+ {error} +

+ )} +
+ +

See the dashboard, resolve data-quality issues, retry automation and reset the demo.

+ + +

Register vehicle returns and look up bookings, vehicles and procedures.

+
+
+
+ ); +} diff --git a/frontend/src/pages/VehicleDetail.tsx b/frontend/src/pages/VehicleDetail.tsx new file mode 100644 index 0000000..6a743d3 --- /dev/null +++ b/frontend/src/pages/VehicleDetail.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../api/client"; +import type { VehicleDetail as VehicleDetailData } from "../api/types"; +import { SeverityBadge, StatusBadge } from "../components/Badge"; + +const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const; +type Tab = (typeof TABS)[number]; + +export function VehicleDetail() { + const { publicRef } = useParams<{ publicRef: string }>(); + const [vehicle, setVehicle] = useState(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState("overview"); + + useEffect(() => { + if (!publicRef) return; + setVehicle(null); + setError(null); + api + .get(`/api/v1/vehicles/${publicRef}`) + .then(setVehicle) + .catch(() => setError("This vehicle could not be found.")); + }, [publicRef]); + + if (error) return

{error}

; + if (!vehicle) return

Loading vehicle…

; + + return ( +
+

← Back to vehicles

+

+ {vehicle.public_ref} — {vehicle.make} {vehicle.model} +

+

+ + {vehicle.attention && Needs attention} +

+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "overview" && ( +
+
Registration
{vehicle.registration_number}
+
Model year
{vehicle.model_year}
+
Location
{vehicle.location}
+
Odometer
{vehicle.odometer_km.toLocaleString("en-GB")} km
+
Next service
{vehicle.next_service_km.toLocaleString("en-GB")} km
+
Active
{vehicle.active ? "Yes" : "No"}
+
+ )} + + {tab === "bookings" && ( +
    + {vehicle.bookings.length === 0 &&
  • No bookings recorded.
  • } + {vehicle.bookings.map((b) => ( +
  • + {b.public_ref} + + + {new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")} + +
  • + ))} +
+ )} + + {tab === "inspections" && ( +
    + {vehicle.inspections.length === 0 &&
  • No inspections recorded.
  • } + {vehicle.inspections.map((i) => ( +
  • + {i.type} + {i.odometer_km.toLocaleString("en-GB")} km + Fuel {i.fuel_level_percent}% + {i.damage_reported && Damage} + {i.technical_warning && Technical warning} + +
  • + ))} +
+ )} + + {tab === "maintenance" && ( +
    + {vehicle.maintenance.length === 0 &&
  • No maintenance records.
  • } + {vehicle.maintenance.map((m) => ( +
  • + {m.category} + {m.summary} + +
  • + ))} +
+ )} + + {tab === "quality" && ( +
    + {vehicle.quality_issues.length === 0 &&
  • No quality issues recorded.
  • } + {vehicle.quality_issues.map((q) => ( +
  • + + {q.rule_type.replace(/_/g, " ")} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Vehicles.tsx b/frontend/src/pages/Vehicles.tsx new file mode 100644 index 0000000..3546f3f --- /dev/null +++ b/frontend/src/pages/Vehicles.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api/client"; +import type { Vehicle } from "../api/types"; +import { StatusBadge } from "../components/Badge"; + +const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"]; + +export function Vehicles() { + const [vehicles, setVehicles] = useState(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState(""); + const [attentionOnly, setAttentionOnly] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(); + if (status) params.set("status", status); + if (attentionOnly) params.set("attention_only", "true"); + api + .get(`/api/v1/vehicles?${params.toString()}`) + .then(setVehicles) + .catch(() => setError("Vehicle list is unavailable right now.")); + }, [status, attentionOnly]); + + return ( +
+

Vehicles

+ +
+ + +
+ + {error &&

{error}

} + {!error && !vehicles &&

Loading vehicles…

} + {vehicles && vehicles.length === 0 &&

No vehicles match these filters.

} + + {vehicles && vehicles.length > 0 && ( + + + + + + + + + + + + + + {vehicles.map((v) => ( + + + + + + + + + ))} + +
Vehicle fleet
ReferenceMake / modelLocationStatusOdometer (km)Attention
+ {v.public_ref} + + {v.make} {v.model} ({v.model_year}) + {v.location} + + {v.odometer_km.toLocaleString("en-GB")}{v.attention ? "Needs attention" : "—"}
+ )} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 570dbc4..6a34cf4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -6,14 +6,179 @@ } * { box-sizing: border-box; } body { margin: 0; min-width: 320px; } -.shell { width: min(920px, calc(100% - 32px)); margin: 0 auto; padding: 64px 0; } -.hero { margin-bottom: 28px; } + +a { color: #1f5c8f; } +:focus-visible { outline: 3px solid #1f5c8f; outline-offset: 2px; } + +.visually-hidden { + position: absolute; + width: 1px; height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.demo-banner { + margin: 0; + padding: 8px 16px; + background: #14324f; + color: #eaf2fb; + font-size: 0.85rem; + text-align: center; +} + +.app-shell { min-height: 100vh; display: flex; flex-direction: column; } + +.app-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding: 14px 24px; + background: white; + border-bottom: 1px solid #dce3eb; +} +.brand { font-weight: 800; font-size: 1.15rem; color: #172033; } +.app-header nav ul { + display: flex; + flex-wrap: wrap; + gap: 4px; + list-style: none; + margin: 0; padding: 0; +} +.app-header nav a { + display: inline-block; + padding: 8px 12px; + border-radius: 8px; + color: #375065; + text-decoration: none; + font-weight: 600; +} +.app-header nav a.active, .app-header nav a[aria-current="page"] { + background: #e5eef9; + color: #14324f; +} +.user-badge { margin-left: auto; display: flex; align-items: center; gap: 12px; font-size: 0.9rem; } +.user-badge button { + padding: 6px 12px; + border-radius: 8px; + border: 1px solid #cfd8e2; + background: white; + cursor: pointer; +} + +#main-content { width: min(1080px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; flex: 1; } + +.page h1 { margin-top: 0; } +.page section { margin-bottom: 28px; } + +.panel { + background: white; + border: 1px solid #dce3eb; + border-radius: 16px; + padding: 20px; + box-shadow: 0 10px 30px rgba(24, 40, 64, .05); +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + list-style: none; + margin: 0; padding: 0; +} +.metric-tile { + background: white; + border: 1px solid #dce3eb; + border-radius: 14px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} +.metric-value { font-size: 1.8rem; font-weight: 800; } +.metric-label { color: #607084; font-size: 0.85rem; } + +.attention-list, .today-list, .automation-list, .record-list { + list-style: none; + margin: 12px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.attention-list li { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid #edf1f5; } +.attention-title { margin: 0; font-weight: 700; } +.attention-detail { margin: 2px 0 0; color: #607084; font-size: 0.9rem; } + +.today-list li, .automation-list li, .record-list li { + display: flex; flex-wrap: wrap; gap: 10px; align-items: center; + padding: 8px 0; border-bottom: 1px solid #edf1f5; +} + +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + border: 1px solid transparent; + white-space: nowrap; +} +.severity-high { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; } +.severity-medium { background: #fdf1de; color: #8a5a10; border-color: #f0d29e; } +.severity-low { background: #eaf1fb; color: #315d73; border-color: #c4d9ec; } +.status-open, .status-pending, .status-active, .status-failed { background: #fdf1de; color: #8a5a10; border-color: #f0d29e; } +.status-failed { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; } +.status-succeeded, .status-resolved, .status-available, .status-returned { background: #e6f5ec; color: #1f6d3d; border-color: #b9e0c9; } +.status-blocked, .status-rejected { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; } +.status-cancelled, .status-deferred, .status-cleaning, .status-maintenance, .status-rented, .status-reserved, .status-delivering { + background: #eef1f5; color: #47566b; border-color: #d7dfe8; +} + +.filters { display: flex; flex-wrap: wrap; gap: 16px; align-items: end; margin-bottom: 16px; } +.filters label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; color: #375065; font-weight: 600; } +.filters select, .filters input[type="text"] { padding: 8px 10px; border: 1px solid #cfd8e2; border-radius: 8px; font-size: 0.95rem; } +.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; } + +.data-table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #dce3eb; border-radius: 12px; overflow: hidden; } +.data-table th, .data-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #edf1f5; font-size: 0.92rem; } +.data-table thead th { background: #f6f8fb; color: #47566b; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; } + +.tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid #dce3eb; margin: 16px 0; } +.tabs button { + padding: 8px 14px; border: none; background: none; cursor: pointer; + border-bottom: 3px solid transparent; font-weight: 600; color: #607084; +} +.tabs button.active { color: #14324f; border-bottom-color: #1f5c8f; } + +.detail-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } +.detail-grid div { background: white; border: 1px solid #dce3eb; border-radius: 10px; padding: 10px 14px; } +.detail-grid dt { color: #607084; font-size: 0.8rem; margin: 0; } +.detail-grid dd { margin: 4px 0 0; font-weight: 700; } + +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.error { color: #9a2530; font-weight: 600; } + +.login-shell { width: min(720px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0; } +.login-hero { margin-bottom: 24px; } .eyebrow { color: #315d73; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; font-size: .8rem; } -h1 { margin: 6px 0; font-size: clamp(2.4rem, 7vw, 4.8rem); line-height: 1; } -.hero > p:last-child { color: #526173; font-size: 1.2rem; } -.panel { background: white; border: 1px solid #dce3eb; border-radius: 18px; padding: 24px; box-shadow: 0 10px 30px rgba(24,40,64,.06); } -dl { display: grid; gap: 12px; } -dl div { display: flex; justify-content: space-between; gap: 24px; border-bottom: 1px solid #edf1f5; padding-bottom: 10px; } -dt { color: #607084; } dd { margin: 0; font-weight: 700; } -.note { margin-top: 24px; color: #607084; } -.error { color: #9a2530; } +.login-hero h1 { margin: 6px 0; font-size: clamp(2.2rem, 7vw, 3.4rem); line-height: 1; } +.login-options { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } +.login-options button { + padding: 14px 18px; border-radius: 12px; border: none; + background: #14324f; color: white; font-weight: 700; font-size: 1rem; cursor: pointer; +} +.login-options button:hover, .login-options button:focus-visible { background: #1f5c8f; } +.login-options p { margin: 0 0 8px; color: #607084; font-size: 0.9rem; } + +@media (max-width: 700px) { + .app-header { flex-direction: column; align-items: flex-start; } + .user-badge { margin-left: 0; } + .data-table, .data-table thead, .data-table tbody, .data-table th, .data-table td, .data-table tr { + display: block; + } + .data-table thead { display: none; } + .data-table tr { border-bottom: 2px solid #dce3eb; padding: 8px 0; } + .data-table td, .data-table th { border: none; padding: 4px 12px; } +}