From 775a1ff69eb309019ec31e01a119fe7c25f03f7a Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 28 Sep 2025 20:35:46 -0500 Subject: [PATCH] Added Documents Feature --- .env.development | 22 + .gitignore | 2 + AI-INDEX.md | 24 + CLAUDE.md | 82 +--- Makefile | 27 +- README.md | 206 +-------- backend/README.md | 16 +- backend/package.json | 4 +- backend/src/_system/migrations/run-all.ts | 9 +- backend/src/app.ts | 46 +- backend/src/core/README.md | 23 + backend/src/core/config/config-loader.ts | 295 ++++++++++++ backend/src/core/config/environment.ts | 52 --- backend/src/core/config/tenant.ts | 21 +- backend/src/core/plugins/auth.plugin.ts | 20 +- .../core/storage/adapters/minio.adapter.ts | 66 +++ backend/src/core/storage/storage.service.ts | 49 ++ backend/src/features/documents/README.md | 35 ++ .../documents/api/documents.controller.ts | 325 +++++++++++++ .../documents/api/documents.routes.ts | 60 +++ .../documents/api/documents.validation.ts | 21 + .../documents/data/documents.repository.ts | 94 ++++ .../documents/domain/documents.service.ts | 55 +++ .../documents/domain/documents.types.ts | 46 ++ backend/src/features/documents/index.ts | 6 + .../migrations/001_create_documents_table.sql | 47 ++ .../integration/documents.integration.test.ts | 435 ++++++++++++++++++ .../tests/unit/documents.repository.test.ts | 333 ++++++++++++++ .../tests/unit/documents.service.test.ts | 261 +++++++++++ .../tests/unit/storage.adapter.test.ts | 256 +++++++++++ backend/src/features/maintenance/README.md | 13 +- backend/src/features/stations/README.md | 15 +- .../google-maps/google-maps.client.ts | 4 +- .../domain/platform-integration.service.ts | 4 +- .../vehicles/domain/vehicles.service.ts | 9 +- .../vehicles/external/vpic/vpic.client.ts | 4 +- backend/src/index.ts | 6 +- docker-compose.yml | 63 +-- docker-compose.yml.backup | 368 --------------- docs/DOCUMENTATION-AUDIT-REPORT.md | 221 +++++++++ docs/PROMPTS.md | 71 +++ docs/README.md | 161 +------ docs/TESTING.md | 7 +- docs/changes/DOCUMENTS.md | 299 ++++++++++++ docs/{ => changes}/MULTI-TENANT-REDESIGN.md | 0 frontend/README.md | 34 ++ frontend/src/App.tsx | 37 +- frontend/src/components/Layout.tsx | 10 + .../error-boundaries/MobileErrorBoundary.tsx | 9 +- frontend/src/core/store/navigation.ts | 4 +- .../features/documents/api/documents.api.ts | 51 ++ .../components/AddDocumentDialog.tsx | 26 ++ .../documents/components/DocumentForm.tsx | 342 ++++++++++++++ .../components/DocumentPreview.test.tsx | 258 +++++++++++ .../documents/components/DocumentPreview.tsx | 60 +++ .../components/GestureImageViewer.tsx | 282 ++++++++++++ .../features/documents/hooks/useDocuments.ts | 227 +++++++++ .../documents/hooks/useUploadWithProgress.ts | 69 +++ .../mobile/DocumentsMobileScreen.test.tsx | 409 ++++++++++++++++ .../mobile/DocumentsMobileScreen.tsx | 211 +++++++++ .../documents/pages/DocumentDetailPage.tsx | 168 +++++++ .../documents/pages/DocumentsPage.tsx | 146 ++++++ .../documents/types/documents.types.ts | 41 ++ frontend/tsconfig.build.json | 10 +- mvp-platform-services/vehicles/README.md | 2 + mvp-platform-services/vehicles/api/config.py | 20 +- 66 files changed, 5655 insertions(+), 944 deletions(-) create mode 100644 .env.development create mode 100644 AI-INDEX.md create mode 100644 backend/src/core/README.md create mode 100644 backend/src/core/config/config-loader.ts delete mode 100644 backend/src/core/config/environment.ts create mode 100644 backend/src/core/storage/adapters/minio.adapter.ts create mode 100644 backend/src/core/storage/storage.service.ts create mode 100644 backend/src/features/documents/README.md create mode 100644 backend/src/features/documents/api/documents.controller.ts create mode 100644 backend/src/features/documents/api/documents.routes.ts create mode 100644 backend/src/features/documents/api/documents.validation.ts create mode 100644 backend/src/features/documents/data/documents.repository.ts create mode 100644 backend/src/features/documents/domain/documents.service.ts create mode 100644 backend/src/features/documents/domain/documents.types.ts create mode 100644 backend/src/features/documents/index.ts create mode 100644 backend/src/features/documents/migrations/001_create_documents_table.sql create mode 100644 backend/src/features/documents/tests/integration/documents.integration.test.ts create mode 100644 backend/src/features/documents/tests/unit/documents.repository.test.ts create mode 100644 backend/src/features/documents/tests/unit/documents.service.test.ts create mode 100644 backend/src/features/documents/tests/unit/storage.adapter.test.ts delete mode 100644 docker-compose.yml.backup create mode 100644 docs/DOCUMENTATION-AUDIT-REPORT.md create mode 100644 docs/PROMPTS.md create mode 100644 docs/changes/DOCUMENTS.md rename docs/{ => changes}/MULTI-TENANT-REDESIGN.md (100%) create mode 100644 frontend/README.md create mode 100644 frontend/src/features/documents/api/documents.api.ts create mode 100644 frontend/src/features/documents/components/AddDocumentDialog.tsx create mode 100644 frontend/src/features/documents/components/DocumentForm.tsx create mode 100644 frontend/src/features/documents/components/DocumentPreview.test.tsx create mode 100644 frontend/src/features/documents/components/DocumentPreview.tsx create mode 100644 frontend/src/features/documents/components/GestureImageViewer.tsx create mode 100644 frontend/src/features/documents/hooks/useDocuments.ts create mode 100644 frontend/src/features/documents/hooks/useUploadWithProgress.ts create mode 100644 frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx create mode 100644 frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx create mode 100644 frontend/src/features/documents/pages/DocumentDetailPage.tsx create mode 100644 frontend/src/features/documents/pages/DocumentsPage.tsx create mode 100644 frontend/src/features/documents/types/documents.types.ts diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..7ce67a0 --- /dev/null +++ b/.env.development @@ -0,0 +1,22 @@ +# Development Environment Variables +# This file is for local development only - NOT for production k8s deployment +# In k8s, these values come from ConfigMaps and Secrets + +# Frontend Vite Configuration (build-time only) +VITE_AUTH0_DOMAIN=motovaultpro.us.auth0.com +VITE_AUTH0_CLIENT_ID=yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3 +VITE_AUTH0_AUDIENCE=https://api.motovaultpro.com +VITE_API_BASE_URL=/api +VITE_TENANT_ID=admin + +# Docker Compose Development Configuration +# These variables are used by docker-compose for container build args only +AUTH0_DOMAIN=motovaultpro.us.auth0.com +AUTH0_CLIENT_ID=yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3 +AUTH0_AUDIENCE=https://api.motovaultpro.com +TENANT_ID=admin + +# NOTE: Backend services no longer use this file +# Backend configuration comes from: +# - /app/config/production.yml (non-sensitive config) +# - /run/secrets/ (sensitive secrets) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 533490b..3aeead7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ .env +.env.local +.env.backup dist/ *.log .DS_Store diff --git a/AI-INDEX.md b/AI-INDEX.md new file mode 100644 index 0000000..b2dc2d9 --- /dev/null +++ b/AI-INDEX.md @@ -0,0 +1,24 @@ +# MotoVaultPro AI Index + +- Load Order: `.ai/context.json`, then `docs/README.md`. +- Architecture: Hybrid platform — platform microservices + modular monolith app. +- Work Modes: + - Feature work: `backend/src/features/{feature}/` (start with `README.md`). + - Platform work: `docs/PLATFORM-SERVICES.md` (+ service local README). + - Cross-service: platform doc + consuming feature doc. +- Commands (containers only): + - `make setup | start | rebuild | migrate | test | logs` + - Shells: `make shell-backend` `make shell-frontend` +- Docs Hubs: + - Docs index: `docs/README.md` + - Testing: `docs/TESTING.md` + - Database: `docs/DATABASE-SCHEMA.md` + - Security: `docs/SECURITY.md` + - Vehicles API: `docs/VEHICLES-API.md` +- Core Backend Modules: `backend/src/core/` (see `backend/src/core/README.md`). +- Frontend Overview: `frontend/README.md`. +- URLs and Hosts: + - Frontend: `https://admin.motovaultpro.com` + - Backend health: `http://localhost:3001/health` + - Add to `/etc/hosts`: `127.0.0.1 motovaultpro.com admin.motovaultpro.com` + diff --git a/CLAUDE.md b/CLAUDE.md index a98cc69..e459ed9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,12 @@ ### AI Context Efficiency **CRITICAL**: All development practices and choices should be made taking into account the most context efficient interaction with another AI. Any AI should be able to understand this application with minimal prompting. +## Never Use Emojis +Maintain professional documentation standards without emoji usage. + +## Mobile + Desktop Requirement +**ALL features MUST be implemented and tested on BOTH mobile and desktop.** This is a hard requirement that cannot be skipped. Every component, page, and feature needs responsive design and mobile-first considerations. + ### Codebase Integrity Rules - Justify every new file and folder as being needed for the final production application. - Never make up things that aren't part of the actual project @@ -29,9 +35,9 @@ make logs # Monitor for build/runtime errors ``` ### 3. Docker-Tested Component Development (Production-only) -- All testing in containers: `make shell-frontend` for debugging -- No dev servers; production builds served by nginx -- Changes require rebuild to reflect in production containers +- Use local dev briefly to pinpoint bugs (hook ordering, missing navigation, Suspense fallback behavior) +- Validate all fixes in containers. + ## Quality Standards @@ -76,43 +82,12 @@ Leverage subagents aggressively for better results: ## AI Loading Context Strategies -### For AI Assistants: Instant Codebase Understanding -To efficiently understand and maintain this codebase, follow this exact sequence: - -#### 1. Load Core Context (Required - 2 minutes) -``` -Read these files in order: -1. .ai/context.json - Loading strategies and feature metadata -2. docs/README.md - Documentation navigation hub -``` - -#### 2. For Specific Tasks - -**Working on Application Features** -- Load entire feature directory: `backend/src/features/[feature-name]/` -- Start with README.md for complete API and business rules -- Everything needed is in this single directory -- Remember: Features are modules within a single application service, not independent microservices - -**Working on Platform Services** -- Load `docs/PLATFORM-SERVICES.md` for complete service architecture -- Hierarchical vehicle API patterns -- Service-to-service communication -- Platform service deployment and operations - -**Cross-Service Work** -- Load platform service docs + consuming feature documentation - -**Database Work** -- Application DB: Load `docs/DATABASE-SCHEMA.md` for app schema -- Platform Services: Load `docs/PLATFORM-SERVICES.md` for service schemas - -**Testing Work** -- Load `docs/TESTING.md` for Docker-based testing workflow -- Only use docker containers for testing. Never install local tools if they do not exist already -- Frontend now uses Jest (like backend). `make test` runs backend + frontend tests -- Jest config file: `frontend/jest.config.ts` (TypeScript configuration) -- Only vehicles feature has implemented tests; other features have scaffolded test directories +Canonical sources only — avoid duplication: +- Loading strategy and metadata: `.ai/context.json` +- Documentation hub and links: `docs/README.md` +- Feature work: `backend/src/features/{feature}/README.md` +- Platform architecture: `docs/PLATFORM-SERVICES.md` +- Testing workflow: `docs/TESTING.md` ## Architecture Context for AI @@ -129,29 +104,4 @@ Read these files in order: - **User-Scoped Data**: All application data isolated by user_id ### Common AI Tasks -```bash -# Run all migrations (inside containers) -make migrate - -# Run all tests (backend + frontend) inside containers -make test - -# Run specific application feature tests (backend) -make shell-backend -npm test -- features/vehicles - -# Run frontend tests only (inside disposable node container) -make test-frontend - -# View logs (all services) -make logs - -# Container shell access -make shell-backend # Application service -``` - -## Never Use Emojis -Maintain professional documentation standards without emoji usage. - -## Mobile + Desktop Requirement -**ALL features MUST be implemented and tested on BOTH mobile and desktop.** This is a hard requirement that cannot be skipped. Every component, page, and feature needs responsive design and mobile-first considerations. \ No newline at end of file +See `Makefile` for authoritative commands and `docs/README.md` for navigation. diff --git a/Makefile b/Makefile index e3aa3a1..763a78b 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ help: @echo " make logs-backend - View backend logs only" @echo " make logs-frontend - View frontend logs only" @echo " make shell-backend - Open shell in backend container" - @echo " make shell-frontend- Open shell in frontend container" + @echo " make shell-frontend - Open shell in frontend container" @echo " make migrate - Run database migrations" @echo "" @echo "K8s-Ready Architecture Commands:" @@ -48,7 +48,7 @@ setup: @sleep 15 # Wait for databases to be ready @docker compose exec admin-backend node dist/_system/migrations/run-all.js @echo "" - @echo "✅ K8s-ready setup complete!" + @echo "K8s-ready setup complete!" @echo "Access application at: https://admin.motovaultpro.com" @echo "Access platform landing at: https://motovaultpro.com" @echo "Traefik dashboard at: http://localhost:8080" @@ -126,29 +126,30 @@ traefik-logs: @echo "Traefik access and error logs:" @docker compose logs -f traefik + service-discovery: - @echo "🔍 Service Discovery Status:" + @echo "Service Discovery Status:" @echo "" @echo "Discovered Services:" - @curl -s http://localhost:8080/api/http/services 2>/dev/null | jq -r '.[].name' | grep -v internal | sed 's/^/ ✅ /' || echo " ❌ Traefik not ready yet" + @curl -s http://localhost:8080/api/http/services 2>/dev/null | jq -r '.[].name' | grep -v internal | sed 's/^/ - /' || echo " Traefik not ready yet" @echo "" @echo "Active Routes:" - @curl -s http://localhost:8080/api/http/routers 2>/dev/null | jq -r '.[].name' | grep -v internal | sed 's/^/ ➡️ /' || echo " ❌ No routes discovered yet" + @curl -s http://localhost:8080/api/http/routers 2>/dev/null | jq -r '.[].name' | grep -v internal | sed 's/^/ -> /' || echo " No routes discovered yet" network-inspect: - @echo "🌐 K8s-Ready Network Architecture:" + @echo "K8s-Ready Network Architecture:" @echo "" @echo "Created Networks:" @docker network ls --filter name=motovaultpro --format "table {{.Name}}\t{{.Driver}}\t{{.Scope}}" | grep -v default || echo "Networks not created yet" @echo "" @echo "Network Isolation Details:" - @echo " 🔐 frontend - Public-facing (Traefik + frontend services)" - @echo " 🔒 backend - API services (internal isolation)" - @echo " 🗄️ database - Data persistence (internal isolation)" - @echo " 🏗️ platform - Platform microservices (internal isolation)" + @echo " - frontend - Public-facing (Traefik + frontend services)" + @echo " - backend - API services (internal isolation)" + @echo " - database - Data persistence (internal isolation)" + @echo " - platform - Platform microservices (internal isolation)" health-check-all: - @echo "🏥 Service Health Status:" + @echo "Service Health Status:" @docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Health}}" @echo "" @echo "Network Connectivity Test:" @@ -167,7 +168,7 @@ generate-certs: -out certs/motovaultpro.com.crt \ -config <(echo '[dn]'; echo 'CN=motovaultpro.com'; echo '[req]'; echo 'distinguished_name = dn'; echo '[SAN]'; echo 'subjectAltName=DNS:motovaultpro.com,DNS:admin.motovaultpro.com,DNS:*.motovaultpro.com,IP:127.0.0.1,IP:172.30.1.64') \ -extensions SAN - @echo "✅ Certificate generated with SAN for mobile compatibility (includes $(shell hostname -I | awk '{print $$1}'))" + @echo "Certificate generated with SAN for mobile compatibility (includes $(shell hostname -I | awk '{print $$1}'))" # Enhanced log commands with filtering @@ -181,4 +182,4 @@ logs-backend-full: @docker compose logs -f admin-backend admin-postgres admin-redis admin-minio logs-clear: - @sudo sh -c "truncate -s 0 /var/lib/docker/containers/**/*-json.log" \ No newline at end of file + @sudo sh -c "truncate -s 0 /var/lib/docker/containers/**/*-json.log" diff --git a/README.md b/README.md index c267366..586b806 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,30 @@ -# MotoVaultPro - Hybrid Platform: Microservices + Modular Monolith +# MotoVaultPro — Hybrid Platform -## CRITICAL REQUIREMENT: Mobile + Desktop Development -**ALL features MUST be implemented and tested on BOTH mobile and desktop.** This is a hard requirement that cannot be skipped. Every component, page, and feature needs responsive design and mobile-first considerations. +Modular monolith application with independent platform microservices. -## Architecture Overview -Hybrid platform combining true microservices (MVP Platform Services) with a modular monolithic application. The MotoVaultPro application is a single service containing self-contained feature capsules in `backend/src/features/[name]/`. Platform services provide shared capabilities with independent deployment and scaling. +## Requirements +- Mobile + Desktop: Implement and test every feature on both. +- Docker-first, production-only: All testing and validation in containers. +- See `CLAUDE.md` for development partnership guidelines. -### Core Principles -- **Production-Only Development**: All services run in production mode only -- **Docker-First**: All development in containers, no local installs -- **Platform Service Independence**: Platform services are completely independent microservices -- **Feature Capsule Organization**: Application features are self-contained modules within a single service -- **Hybrid Deployment**: Platform services deploy independently, application features deploy together -- **User-Scoped Data**: All application data isolated by user_id - -## Quick Start - -### Setup Environment +## Quick Start (containers) ```bash -# One-time setup (ensure .env exists, then build and start containers) -make setup - -# Start full microservices environment -make start # Starts application + platform services +make setup # build + start + migrate +make start # start services +make rebuild # rebuild on changes +make logs # tail all logs +make migrate # run DB migrations +make test # backend + frontend tests ``` -### Common Development Tasks -```bash -# Run all migrations (inside containers) -make migrate +## Documentation +- AI quickload: `AI-INDEX.md` +- Docs hub: `docs/README.md` +- Features: `backend/src/features/{name}/README.md` +- Frontend: `frontend/README.md` +- Backend core: `backend/src/core/README.md` -# Run all tests (backend + frontend) inside containers -make test - -# Run specific application feature tests (backend) -make shell-backend -npm test -- features/vehicles - -# Run frontend tests only (inside disposable node container) -make test-frontend - -# View logs (all services) -make logs - -# Container shell access -make shell-backend # Application service -make shell-frontend -make shell-platform-vehicles # Platform service shell - -# Rebuild after code/dependency changes -make rebuild -``` - -## Architecture Components - -### MVP Platform Services - -#### Platform Vehicles Service (Primary) -- **Architecture**: 3-container microservice (DB, ETL, API) -- **API**: FastAPI with hierarchical endpoints -- **Database**: PostgreSQL with normalized vehicles schema (port 5433) -- **Cache**: Dedicated Redis instance (port 6380) -- **Cache Strategy**: Year-based hierarchical caching -- **Key Endpoints**: - ``` - GET /vehicles/makes?year={year} - GET /vehicles/models?year={year}&make_id={make_id} - GET /vehicles/trims?year={year}&make_id={make_id}&model_id={model_id} - GET /vehicles/engines?year={year}&make_id={make_id}&model_id={model_id} - GET /vehicles/transmissions?year={year}&make_id={make_id}&model_id={model_id} - POST /vehicles/vindecode - ``` - -#### Platform Tenants Service -- **Architecture**: Independent microservice for multi-tenant management -- **API**: FastAPI on port 8001 -- **Database**: Dedicated PostgreSQL (port 5434) -- **Cache**: Dedicated Redis instance (port 6381) - -### Application Service (Modular Monolith) - -The application is a **single Node.js service** containing multiple feature capsules. All features deploy together in the `admin-backend` container but maintain logical separation through the capsule pattern. - -#### Feature Capsule Structure -``` -features/[name]/ -├── README.md # Feature overview & API -├── index.ts # Public exports only -├── api/ # HTTP layer -├── domain/ # Business logic -├── data/ # Database layer -├── migrations/ # Feature's schema -├── external/ # Feature's external APIs -├── events/ # Event handlers -├── tests/ # All tests -└── docs/ # Documentation -``` - -**Deployment**: All features bundled in single `admin-backend` container -**Database**: Shared PostgreSQL instance with feature-specific tables -**Communication**: Features access shared resources, not service-to-service calls - -#### Current Features -- **vehicles**: Consumes MVP Platform Vehicles service via HTTP API -- **fuel-logs**: Depends on vehicles feature for vehicle validation -- **maintenance**: Depends on vehicles feature; basic structure implemented -- **stations**: Partial implementation with Google Maps integration -- **tenant-management**: Multi-tenant functionality - -## SSL Configuration for Production Development -- Place `motovaultpro.com.crt` and `motovaultpro.com.key` in `./certs` -- **Application Frontend**: `https://admin.motovaultpro.com` (requires DNS or hosts file entry) -- **Platform Landing**: `https://motovaultpro.com` (marketing site) -- **Hosts file setup**: Add `127.0.0.1 motovaultpro.com admin.motovaultpro.com` to `/etc/hosts` -- Generate self-signed certs: - ```bash - openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout certs/motovaultpro.com.key \ - -out certs/motovaultpro.com.crt \ - -subj "/CN=motovaultpro.com" - ``` - -## Authentication & Security -- **Backend**: Auth0 JWT validation via Fastify using `@fastify/jwt` and `get-jwks` -- **All protected endpoints**: Require valid `Authorization: Bearer ` -- **Service-to-Service**: Platform services use service tokens -- **Environment Variables**: - - `PLATFORM_VEHICLES_API_URL` — base URL for vehicles service - - `PLATFORM_VEHICLES_API_KEY` — service token for inter-service auth - -## External Services - -### Application Services -- **PostgreSQL**: Application database (port 5432) -- **Redis**: Application caching layer (port 6379) -- **MinIO**: Object storage (port 9000/9001) - -### MVP Platform Services -- **Platform PostgreSQL**: Platform services database (port 5434) -- **Platform Redis**: Platform services caching (port 6381) -- **MVP Platform Vehicles DB**: PostgreSQL with normalized vehicles schema (port 5433) -- **MVP Platform Vehicles Redis**: Vehicles service cache (port 6380) -- **MVP Platform Vehicles API**: FastAPI hierarchical vehicle endpoints (port 8000) -- **MVP Platform Tenants API**: FastAPI multi-tenant management (port 8001) - -### External APIs -- **Google Maps**: Station location API (via stations feature) -- **Auth0**: Authentication and authorization - -## Service Health Check -```bash -# Application Services -# Frontend: https://admin.motovaultpro.com -# Backend: http://localhost:3001/health -# MinIO Console: http://localhost:9001 - -# MVP Platform Services -# Platform Vehicles API: http://localhost:8000/health -# Platform Vehicles Docs: http://localhost:8000/docs -# Platform Tenants API: http://localhost:8001/health -# Platform Landing: https://motovaultpro.com -``` - -## Service Dependencies - -### Platform Services (Independent) -1. **mvp-platform-vehicles** (independent platform service) - -### Application Features (Logical Dependencies) -**Note**: All features deploy together in single application container -1. **vehicles** (consumes platform service, base application feature) -2. **fuel-logs** (depends on vehicles table via foreign keys) -3. **maintenance** (depends on vehicles table via foreign keys) -4. **stations** (independent feature) -5. **tenant-management** (cross-cutting tenant functionality) - -## Documentation Navigation -- **Platform Services**: `docs/PLATFORM-SERVICES.md` -- **Vehicles API (Authoritative)**: `docs/VEHICLES-API.md` -- **Application Features**: `backend/src/features/[name]/README.md` -- **Database**: `docs/DATABASE-SCHEMA.md` -- **Testing**: `docs/TESTING.md` -- **Security**: `docs/SECURITY.md` - -## Adding New Features -```bash -./scripts/generate-feature-capsule.sh [feature-name] -# Creates complete capsule structure with all subdirectories -``` \ No newline at end of file +## URLs and Hosts +- Frontend: `https://admin.motovaultpro.com` +- Backend health: `http://localhost:3001/health` +- Add to `/etc/hosts`: `127.0.0.1 motovaultpro.com admin.motovaultpro.com` diff --git a/backend/README.md b/backend/README.md index 98a94aa..879f400 100644 --- a/backend/README.md +++ b/backend/README.md @@ -49,16 +49,26 @@ make test ## Core Modules ### Configuration (`src/core/config/`) -- `environment.ts` - Environment variable validation +- `config-loader.ts` - Environment variable loading and validation - `database.ts` - PostgreSQL connection pool - `redis.ts` - Redis client and cache service +- `tenant.ts` - Tenant configuration utilities -### Security (Fastify Plugin) -- `src/core/plugins/auth.plugin.ts` - Auth plugin (Auth0 JWT via JWKS; tokens required in all environments) +### Security (Fastify Plugins) +- `src/core/plugins/auth.plugin.ts` - Auth0 JWT via JWKS (@fastify/jwt + get-jwks) +- `src/core/plugins/error.plugin.ts` - Error handling +- `src/core/plugins/logging.plugin.ts` - Request logging ### Logging (`src/core/logging/`) - `logger.ts` - Structured logging with Winston +### Middleware +- `src/core/middleware/tenant.ts` - Tenant extraction and validation + +### Storage +- `src/core/storage/` - Storage abstractions +- `src/core/storage/adapters/minio.adapter.ts` - MinIO S3-compatible adapter + ## Feature Development To create a new feature capsule: diff --git a/backend/package.json b/backend/package.json index 58f6f6b..9c4259e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,11 +19,12 @@ "pg": "^8.11.3", "ioredis": "^5.3.2", "minio": "^7.1.3", + "@fastify/multipart": "^8.1.0", "axios": "^1.6.2", "opossum": "^8.0.0", "winston": "^3.11.0", - "dotenv": "^16.3.1", "zod": "^3.22.4", + "js-yaml": "^4.1.0", "fastify": "^4.24.3", "@fastify/cors": "^9.0.1", "@fastify/helmet": "^11.1.1", @@ -37,6 +38,7 @@ "devDependencies": { "@types/node": "^20.10.0", "@types/pg": "^8.10.9", + "@types/js-yaml": "^4.0.9", "typescript": "^5.6.3", "ts-node": "^10.9.1", "nodemon": "^3.0.1", diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts index 018d89f..0170e63 100644 --- a/backend/src/_system/migrations/run-all.ts +++ b/backend/src/_system/migrations/run-all.ts @@ -4,14 +4,10 @@ import { Pool } from 'pg'; import { readFileSync, readdirSync } from 'fs'; import { join, resolve } from 'path'; -import { env } from '../../core/config/environment'; +import { appConfig } from '../../core/config/config-loader'; const pool = new Pool({ - host: env.DB_HOST, - port: env.DB_PORT, - database: env.DB_NAME, - user: env.DB_USER, - password: env.DB_PASSWORD, + connectionString: appConfig.getDatabaseUrl(), }); // Define migration order based on dependencies and packaging layout @@ -20,6 +16,7 @@ const pool = new Pool({ // and user-preferences trigger depends on it; so run vehicles before core/user-preferences. const MIGRATION_ORDER = [ 'features/vehicles', // Primary entity, defines update_updated_at_column() + 'features/documents', // Depends on vehicles; provides documents table 'core/user-preferences', // Depends on update_updated_at_column() 'features/fuel-logs', // Depends on vehicles 'features/maintenance', // Depends on vehicles diff --git a/backend/src/app.ts b/backend/src/app.ts index 7232a08..50834eb 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -5,17 +5,20 @@ import Fastify, { FastifyInstance } from 'fastify'; import cors from '@fastify/cors'; import helmet from '@fastify/helmet'; +import fastifyMultipart from '@fastify/multipart'; // Core plugins import authPlugin from './core/plugins/auth.plugin'; import loggingPlugin from './core/plugins/logging.plugin'; import errorPlugin from './core/plugins/error.plugin'; +import { appConfig } from './core/config/config-loader'; // Fastify feature routes import { vehiclesRoutes } from './features/vehicles/api/vehicles.routes'; import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes'; import { stationsRoutes } from './features/stations/api/stations.routes'; import tenantManagementRoutes from './features/tenant-management/index'; +import { documentsRoutes } from './features/documents/api/documents.routes'; async function buildApp(): Promise { const app = Fastify({ @@ -27,6 +30,36 @@ async function buildApp(): Promise { await app.register(cors); await app.register(loggingPlugin); await app.register(errorPlugin); + + // Multipart upload support with config-driven size limits + const parseSizeToBytes = (val: string): number => { + // Accept forms like "10MB", "5M", "1048576", "20kb", case-insensitive + const s = String(val).trim().toLowerCase(); + const match = s.match(/^(\d+)(b|kb|k|mb|m|gb|g)?$/i); + if (!match) { + // Fallback: try to parse integer bytes + const n = parseInt(s, 10); + return Number.isFinite(n) && n > 0 ? n : 10 * 1024 * 1024; // default 10MB + } + const num = parseInt(match[1], 10); + const unit = match[2] || 'b'; + switch (unit) { + case 'b': return num; + case 'k': + case 'kb': return num * 1024; + case 'm': + case 'mb': return num * 1024 * 1024; + case 'g': + case 'gb': return num * 1024 * 1024 * 1024; + default: return num; + } + }; + const fileSizeLimit = parseSizeToBytes(appConfig.config.performance.max_request_size); + await app.register(fastifyMultipart, { + limits: { + fileSize: fileSizeLimit, + }, + }); // Authentication plugin await app.register(authPlugin); @@ -39,7 +72,17 @@ async function buildApp(): Promise { status: 'healthy', timestamp: new Date().toISOString(), environment: process.env.NODE_ENV, - features: ['vehicles', 'fuel-logs', 'stations', 'maintenance'] + features: ['vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance'] + }); + }); + + // API-prefixed health for Traefik route validation and diagnostics + app.get('/api/health', async (_request, reply) => { + return reply.code(200).send({ + status: 'healthy', + scope: 'api', + timestamp: new Date().toISOString(), + features: ['vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance'] }); }); @@ -67,6 +110,7 @@ async function buildApp(): Promise { // Register Fastify feature routes await app.register(vehiclesRoutes, { prefix: '/api' }); + await app.register(documentsRoutes, { prefix: '/api' }); await app.register(fuelLogsRoutes, { prefix: '/api' }); await app.register(stationsRoutes, { prefix: '/api' }); await app.register(tenantManagementRoutes); diff --git a/backend/src/core/README.md b/backend/src/core/README.md new file mode 100644 index 0000000..057d1ed --- /dev/null +++ b/backend/src/core/README.md @@ -0,0 +1,23 @@ +# Core Module Index + +## Configuration (`src/core/config/`) +- `config-loader.ts` — Load and validate environment variables +- `database.ts` — PostgreSQL connection pool +- `redis.ts` — Redis client and cache helpers +- `tenant.ts` — Tenant configuration utilities + +## Plugins (`src/core/plugins/`) +- `auth.plugin.ts` — Auth0 JWT via JWKS (@fastify/jwt, get-jwks) +- `error.plugin.ts` — Error handling +- `logging.plugin.ts` — Request logging + +## Logging (`src/core/logging/`) +- `logger.ts` — Structured logging (Winston) + +## Middleware +- `middleware/tenant.ts` — Tenant extraction/validation + +## Storage (`src/core/storage/`) +- `storage.service.ts` — Storage abstraction +- `adapters/minio.adapter.ts` — MinIO S3-compatible adapter + diff --git a/backend/src/core/config/config-loader.ts b/backend/src/core/config/config-loader.ts new file mode 100644 index 0000000..e800c28 --- /dev/null +++ b/backend/src/core/config/config-loader.ts @@ -0,0 +1,295 @@ +/** + * K8s-aligned Configuration Loader + * Loads configuration from YAML files and secrets from mounted files + * Replaces environment variable based configuration for production k8s compatibility + */ +import * as yaml from 'js-yaml'; +import * as fs from 'fs'; +import * as path from 'path'; +import { z } from 'zod'; +import { logger } from '../logging/logger'; + +// Configuration schema definition +const configSchema = z.object({ + // Server configuration + server: z.object({ + name: z.string(), + port: z.number(), + environment: z.string(), + tenant_id: z.string(), + node_env: z.string(), + }), + + // Database configuration + database: z.object({ + host: z.string(), + port: z.number(), + name: z.string(), + user: z.string(), + pool_size: z.number().optional().default(20), + }), + + // Redis configuration + redis: z.object({ + host: z.string(), + port: z.number(), + db: z.number().optional().default(0), + }), + + // Auth0 configuration + auth0: z.object({ + domain: z.string(), + audience: z.string(), + }), + + // Platform services configuration + platform: z.object({ + services: z.object({ + vehicles: z.object({ + url: z.string(), + timeout: z.string(), + }), + tenants: z.object({ + url: z.string(), + timeout: z.string(), + }), + }), + }), + + // MinIO configuration + minio: z.object({ + endpoint: z.string(), + port: z.number(), + bucket: z.string(), + }), + + // External APIs configuration + external: z.object({ + vpic: z.object({ + url: z.string(), + timeout: z.string(), + }), + }), + + // Service configuration + service: z.object({ + name: z.string(), + }), + + // CORS configuration + cors: z.object({ + origins: z.array(z.string()), + allow_credentials: z.boolean(), + max_age: z.number(), + }), + + // Frontend configuration + frontend: z.object({ + tenant_id: z.string(), + api_base_url: z.string(), + auth0: z.object({ + domain: z.string(), + audience: z.string(), + }), + }), + + // Health check configuration + health: z.object({ + endpoints: z.object({ + basic: z.string(), + ready: z.string(), + live: z.string(), + startup: z.string(), + }), + probes: z.object({ + startup: z.object({ + initial_delay: z.string(), + period: z.string(), + timeout: z.string(), + failure_threshold: z.number(), + }), + readiness: z.object({ + period: z.string(), + timeout: z.string(), + failure_threshold: z.number(), + }), + liveness: z.object({ + period: z.string(), + timeout: z.string(), + failure_threshold: z.number(), + }), + }), + }), + + // Logging configuration + logging: z.object({ + level: z.string(), + format: z.string(), + destinations: z.array(z.string()), + }), + + // Performance configuration + performance: z.object({ + request_timeout: z.string(), + max_request_size: z.string(), + compression_enabled: z.boolean(), + circuit_breaker: z.object({ + enabled: z.boolean(), + failure_threshold: z.number(), + timeout: z.string(), + }), + }), +}); + +// Secrets schema definition +const secretsSchema = z.object({ + postgres_password: z.string(), + minio_access_key: z.string(), + minio_secret_key: z.string(), + platform_vehicles_api_key: z.string(), + auth0_client_secret: z.string(), + google_maps_api_key: z.string(), +}); + +type Config = z.infer; +type Secrets = z.infer; + +export interface AppConfiguration { + config: Config; + secrets: Secrets; + + // Convenience accessors for common patterns + getDatabaseUrl(): string; + getRedisUrl(): string; + getAuth0Config(): { domain: string; audience: string; clientSecret: string }; + getPlatformServiceConfig(service: 'vehicles' | 'tenants'): { url: string; apiKey: string }; + getMinioConfig(): { endpoint: string; port: number; accessKey: string; secretKey: string; bucket: string }; +} + +class ConfigurationLoader { + private configPath: string; + private secretsDir: string; + private cachedConfig: AppConfiguration | null = null; + + constructor() { + this.configPath = process.env.CONFIG_PATH || '/app/config/production.yml'; + this.secretsDir = process.env.SECRETS_DIR || '/run/secrets'; + } + + private loadYamlConfig(): Config { + if (!fs.existsSync(this.configPath)) { + throw new Error(`Configuration file not found at ${this.configPath}`); + } + + try { + const fileContents = fs.readFileSync(this.configPath, 'utf8'); + const yamlData = yaml.load(fileContents) as any; + + return configSchema.parse(yamlData); + } catch (error) { + logger.error(`Failed to load configuration from ${this.configPath}`, { error }); + throw new Error(`Configuration loading failed: ${error}`); + } + } + + private loadSecrets(): Secrets { + const secrets: Partial = {}; + + const secretFiles = [ + 'postgres-password', + 'minio-access-key', + 'minio-secret-key', + 'platform-vehicles-api-key', + 'auth0-client-secret', + 'google-maps-api-key', + ]; + + for (const secretFile of secretFiles) { + const secretPath = path.join(this.secretsDir, secretFile); + const secretKey = secretFile.replace(/-/g, '_') as keyof Secrets; + + if (fs.existsSync(secretPath)) { + try { + const secretValue = fs.readFileSync(secretPath, 'utf8').trim(); + (secrets as any)[secretKey] = secretValue; + } catch (error) { + logger.error(`Failed to read secret file ${secretPath}`, { error }); + } + } else { + logger.error(`Secret file not found: ${secretPath}`); + } + } + + try { + return secretsSchema.parse(secrets); + } catch (error) { + logger.error('Secrets validation failed', { error }); + throw new Error(`Secrets loading failed: ${error}`); + } + } + + + public load(): AppConfiguration { + if (this.cachedConfig) { + return this.cachedConfig; + } + + const config = this.loadYamlConfig(); + const secrets = this.loadSecrets(); + + this.cachedConfig = { + config, + secrets, + + getDatabaseUrl(): string { + return `postgresql://${config.database.user}:${secrets.postgres_password}@${config.database.host}:${config.database.port}/${config.database.name}`; + }, + + getRedisUrl(): string { + return `redis://${config.redis.host}:${config.redis.port}/${config.redis.db}`; + }, + + getAuth0Config() { + return { + domain: config.auth0.domain, + audience: config.auth0.audience, + clientSecret: secrets.auth0_client_secret, + }; + }, + + getPlatformServiceConfig(service: 'vehicles' | 'tenants') { + const serviceConfig = config.platform.services[service]; + const apiKey = service === 'vehicles' ? secrets.platform_vehicles_api_key : 'mvp-platform-tenants-secret-key'; + + return { + url: serviceConfig.url, + apiKey, + }; + }, + + getMinioConfig() { + return { + endpoint: config.minio.endpoint, + port: config.minio.port, + accessKey: secrets.minio_access_key, + secretKey: secrets.minio_secret_key, + bucket: config.minio.bucket, + }; + }, + }; + + logger.info('Configuration loaded successfully', { + configSource: 'yaml', + secretsSource: 'files', + }); + + return this.cachedConfig; + } +} + +// Export singleton instance +const configLoader = new ConfigurationLoader(); +export const appConfig = configLoader.load(); + +// Export types for use in other modules +export type { Config, Secrets }; \ No newline at end of file diff --git a/backend/src/core/config/environment.ts b/backend/src/core/config/environment.ts deleted file mode 100644 index 99e53dd..0000000 --- a/backend/src/core/config/environment.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @ai-summary Environment configuration with validation - * @ai-context Validates all env vars at startup, single source of truth - */ -import { z } from 'zod'; -import * as dotenv from 'dotenv'; - -dotenv.config(); - -const envSchema = z.object({ - NODE_ENV: z.string().default('production'), - PORT: z.string().transform(Number).default('3001'), - - // Database - DB_HOST: z.string().default('localhost'), - DB_PORT: z.string().transform(Number).default('5432'), - DB_NAME: z.string().default('motovaultpro'), - DB_USER: z.string().default('postgres'), - DB_PASSWORD: z.string().default('password'), - - // Redis - REDIS_HOST: z.string().default('localhost'), - REDIS_PORT: z.string().transform(Number).default('6379'), - - // Auth0 - Required for JWT validation - AUTH0_DOMAIN: z.string().min(1, 'AUTH0_DOMAIN is required for JWT authentication'), - AUTH0_CLIENT_ID: z.string().min(1, 'AUTH0_CLIENT_ID is required'), - AUTH0_CLIENT_SECRET: z.string().min(1, 'AUTH0_CLIENT_SECRET is required'), - AUTH0_AUDIENCE: z.string().min(1, 'AUTH0_AUDIENCE is required for JWT validation'), - - // External APIs - GOOGLE_MAPS_API_KEY: z.string().default('development'), - VPIC_API_URL: z.string().default('https://vpic.nhtsa.dot.gov/api/vehicles'), - - // Platform Services - PLATFORM_VEHICLES_API_URL: z.string().default('http://mvp-platform-vehicles-api:8000'), - PLATFORM_VEHICLES_API_KEY: z.string().default('mvp-platform-vehicles-secret-key'), - - // MinIO - MINIO_ENDPOINT: z.string().default('localhost'), - MINIO_PORT: z.string().transform(Number).default('9000'), - MINIO_ACCESS_KEY: z.string().default('minioadmin'), - MINIO_SECRET_KEY: z.string().default('minioadmin123'), - MINIO_BUCKET: z.string().default('motovaultpro'), -}); - -export type Environment = z.infer; - -// Validate and export - now with defaults for build-time compilation -export const env = envSchema.parse(process.env); - -// Environment configuration validated and exported diff --git a/backend/src/core/config/tenant.ts b/backend/src/core/config/tenant.ts index fc07391..8374aba 100644 --- a/backend/src/core/config/tenant.ts +++ b/backend/src/core/config/tenant.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { appConfig } from './config-loader'; // Simple in-memory cache for tenant validation const tenantValidityCache = new Map(); @@ -17,18 +18,18 @@ export interface TenantConfig { } export const getTenantConfig = (): TenantConfig => { - const tenantId = process.env.TENANT_ID || 'admin'; - + const tenantId = appConfig.config.server.tenant_id; + const databaseUrl = tenantId === 'admin' - ? `postgresql://${process.env.DB_USER || 'motovault_user'}:${process.env.DB_PASSWORD}@${process.env.DB_HOST || 'postgres'}:${process.env.DB_PORT || '5432'}/${process.env.DB_NAME || 'motovault'}` - : `postgresql://motovault_user:${process.env.DB_PASSWORD}@${tenantId}-postgres:5432/motovault`; - + ? appConfig.getDatabaseUrl() + : `postgresql://${appConfig.config.database.user}:${appConfig.secrets.postgres_password}@${tenantId}-postgres:5432/${appConfig.config.database.name}`; + const redisUrl = tenantId === 'admin' - ? `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || '6379'}` + ? appConfig.getRedisUrl() : `redis://${tenantId}-redis:6379`; - - const platformServicesUrl = process.env.PLATFORM_TENANTS_API_URL || 'http://mvp-platform-tenants:8000'; - + + const platformServicesUrl = appConfig.getPlatformServiceConfig('tenants').url; + return { tenantId, databaseUrl, @@ -48,7 +49,7 @@ export const isValidTenant = async (tenantId: string): Promise => { let ok = false; try { - const baseUrl = process.env.PLATFORM_TENANTS_API_URL || 'http://mvp-platform-tenants:8000'; + const baseUrl = appConfig.getPlatformServiceConfig('tenants').url; const url = `${baseUrl}/api/v1/tenants/${encodeURIComponent(tenantId)}`; const resp = await axios.get(url, { timeout: 2000 }); ok = resp.status === 200; diff --git a/backend/src/core/plugins/auth.plugin.ts b/backend/src/core/plugins/auth.plugin.ts index 9b9493d..76e0426 100644 --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -5,7 +5,7 @@ import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; import fp from 'fastify-plugin'; import buildGetJwks from 'get-jwks'; -import { env } from '../config/environment'; +import { appConfig } from '../config/config-loader'; import { logger } from '../logging/logger'; declare module 'fastify' { @@ -19,8 +19,10 @@ declare module 'fastify' { } const authPlugin: FastifyPluginAsync = async (fastify) => { + const auth0Config = appConfig.getAuth0Config(); + // Security validation: ensure AUTH0_DOMAIN is properly configured - if (!env.AUTH0_DOMAIN || !env.AUTH0_DOMAIN.includes('.auth0.com')) { + if (!auth0Config.domain || !auth0Config.domain.includes('.auth0.com')) { throw new Error('AUTH0_DOMAIN must be a valid Auth0 domain'); } @@ -37,7 +39,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { const { header: { kid, alg }, payload: { iss } } = token; // Validate issuer matches Auth0 domain (security: prevent issuer spoofing) - const expectedIssuer = `https://${env.AUTH0_DOMAIN}/`; + const expectedIssuer = `https://${auth0Config.domain}/`; if (iss !== expectedIssuer) { throw new Error(`Invalid issuer: ${iss}`); } @@ -49,16 +51,16 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { alg }); } catch (error) { - logger.error('JWKS key retrieval failed', { + logger.error('JWKS key retrieval failed', { error: error instanceof Error ? error.message : 'Unknown error', - domain: env.AUTH0_DOMAIN + domain: auth0Config.domain }); throw error; } }, verify: { - allowedIss: `https://${env.AUTH0_DOMAIN}/`, - allowedAud: env.AUTH0_AUDIENCE, + allowedIss: `https://${auth0Config.domain}/`, + allowedAud: auth0Config.audience, }, }); @@ -67,9 +69,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { try { await request.jwtVerify(); - logger.info('JWT authentication successful', { + logger.info('JWT authentication successful', { userId: request.user?.sub?.substring(0, 8) + '...', - audience: env.AUTH0_AUDIENCE + audience: auth0Config.audience }); } catch (error) { logger.warn('JWT authentication failed', { diff --git a/backend/src/core/storage/adapters/minio.adapter.ts b/backend/src/core/storage/adapters/minio.adapter.ts new file mode 100644 index 0000000..d31b3eb --- /dev/null +++ b/backend/src/core/storage/adapters/minio.adapter.ts @@ -0,0 +1,66 @@ +import { Client as MinioClient } from 'minio'; +import type { Readable } from 'stream'; +import { appConfig } from '../../config/config-loader'; +import type { HeadObjectResult, SignedUrlOptions, StorageService } from '../storage.service'; + +export function createMinioAdapter(): StorageService { + const { endpoint, port, accessKey, secretKey } = appConfig.getMinioConfig(); + + const client = new MinioClient({ + endPoint: endpoint, + port, + useSSL: false, + accessKey, + secretKey, + }); + + const normalizeMeta = (contentType?: string, metadata?: Record) => { + const meta: Record = { ...(metadata || {}) }; + if (contentType) meta['Content-Type'] = contentType; + return meta; + }; + + const adapter: StorageService = { + async putObject(bucket, key, body, contentType, metadata) { + const meta = normalizeMeta(contentType, metadata); + // For Buffer or string, size is known. For Readable, omit size for chunked encoding. + if (Buffer.isBuffer(body) || typeof body === 'string') { + await client.putObject(bucket, key, body as any, (body as any).length ?? undefined, meta); + } else { + await client.putObject(bucket, key, body as Readable, undefined, meta); + } + }, + + async getObjectStream(bucket, key) { + return client.getObject(bucket, key); + }, + + async deleteObject(bucket, key) { + await client.removeObject(bucket, key); + }, + + async headObject(bucket, key): Promise { + const stat = await client.statObject(bucket, key); + // minio types: size, etag, lastModified, metaData + return { + size: stat.size, + etag: stat.etag, + lastModified: stat.lastModified ? new Date(stat.lastModified) : undefined, + contentType: (stat.metaData && (stat.metaData['content-type'] || stat.metaData['Content-Type'])) || undefined, + metadata: stat.metaData || undefined, + }; + }, + + async getSignedUrl(bucket, key, options?: SignedUrlOptions) { + const expires = Math.max(1, Math.min(7 * 24 * 3600, options?.expiresSeconds ?? 300)); + if (options?.method === 'PUT') { + // MinIO SDK has presignedPutObject for PUT + return client.presignedPutObject(bucket, key, expires); + } + // Default GET + return client.presignedGetObject(bucket, key, expires); + }, + }; + + return adapter; +} diff --git a/backend/src/core/storage/storage.service.ts b/backend/src/core/storage/storage.service.ts new file mode 100644 index 0000000..1f60325 --- /dev/null +++ b/backend/src/core/storage/storage.service.ts @@ -0,0 +1,49 @@ +/** + * Provider-agnostic storage facade with S3-compatible surface. + * Initial implementation backed by MinIO using the official SDK. + */ +import type { Readable } from 'stream'; +import { createMinioAdapter } from './adapters/minio.adapter'; + +export type ObjectBody = Buffer | Readable | string; + +export interface SignedUrlOptions { + method: 'GET' | 'PUT'; + expiresSeconds?: number; // default 300s +} + +export interface HeadObjectResult { + size: number; + etag?: string; + lastModified?: Date; + contentType?: string; + metadata?: Record; +} + +export interface StorageService { + putObject( + bucket: string, + key: string, + body: ObjectBody, + contentType?: string, + metadata?: Record + ): Promise; + + getObjectStream(bucket: string, key: string): Promise; + + deleteObject(bucket: string, key: string): Promise; + + headObject(bucket: string, key: string): Promise; + + getSignedUrl(bucket: string, key: string, options?: SignedUrlOptions): Promise; +} + +// Simple factory — currently only MinIO; can add S3 in future without changing feature code +let singleton: StorageService | null = null; +export function getStorageService(): StorageService { + if (!singleton) { + singleton = createMinioAdapter(); + } + return singleton; +} + diff --git a/backend/src/features/documents/README.md b/backend/src/features/documents/README.md new file mode 100644 index 0000000..0bad646 --- /dev/null +++ b/backend/src/features/documents/README.md @@ -0,0 +1,35 @@ +# Documents Feature Capsule + +## Quick Summary (50 tokens) +Secure vehicle document management with S3-compatible storage. Metadata and file uploads with private access, user and vehicle ownership enforcement, and mobile-first UX. + +## API Endpoints +- GET /api/documents +- GET /api/documents/:id +- POST /api/documents +- PUT /api/documents/:id +- DELETE /api/documents/:id +- GET /api/documents/vehicle/:vehicleId +- POST /api/documents/:id/upload +- GET /api/documents/:id/download + +## Structure +- **api/** - HTTP endpoints, routes, validators +- **domain/** - Business logic, types, rules +- **data/** - Repository, database queries +- **migrations/** - Feature-specific schema +- **tests/** - All feature tests + +## Dependencies +- Internal: core/auth, core/middleware/tenant, core/storage +- Database: documents table + +## Quick Commands +```bash +# Run feature tests +npm test -- features/documents + +# Run migrations (all features) +npm run migrate:all +``` + diff --git a/backend/src/features/documents/api/documents.controller.ts b/backend/src/features/documents/api/documents.controller.ts new file mode 100644 index 0000000..25269e4 --- /dev/null +++ b/backend/src/features/documents/api/documents.controller.ts @@ -0,0 +1,325 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { DocumentsService } from '../domain/documents.service'; +import type { CreateBody, IdParams, ListQuery, UpdateBody } from './documents.validation'; +import { getStorageService } from '../../../core/storage/storage.service'; +import { appConfig } from '../../../core/config/config-loader'; +import { logger } from '../../../core/logging/logger'; +import path from 'path'; +import { Transform, TransformCallback } from 'stream'; + +export class DocumentsController { + private readonly service = new DocumentsService(); + + async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + + logger.info('Documents list requested', { + operation: 'documents.list', + user_id: userId, + filters: { + vehicle_id: request.query.vehicleId, + type: request.query.type, + expires_before: request.query.expiresBefore, + }, + }); + + const docs = await this.service.listDocuments(userId, { + vehicleId: request.query.vehicleId, + type: request.query.type, + expiresBefore: request.query.expiresBefore, + }); + + logger.info('Documents list retrieved', { + operation: 'documents.list.success', + user_id: userId, + document_count: docs.length, + }); + + return reply.code(200).send(docs); + } + + async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + const documentId = request.params.id; + + logger.info('Document get requested', { + operation: 'documents.get', + user_id: userId, + document_id: documentId, + }); + + const doc = await this.service.getDocument(userId, documentId); + if (!doc) { + logger.warn('Document not found', { + operation: 'documents.get.not_found', + user_id: userId, + document_id: documentId, + }); + return reply.code(404).send({ error: 'Not Found' }); + } + + logger.info('Document retrieved', { + operation: 'documents.get.success', + user_id: userId, + document_id: documentId, + vehicle_id: doc.vehicle_id, + document_type: doc.document_type, + }); + + return reply.code(200).send(doc); + } + + async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + + logger.info('Document create requested', { + operation: 'documents.create', + user_id: userId, + vehicle_id: request.body.vehicle_id, + document_type: request.body.document_type, + title: request.body.title, + }); + + const created = await this.service.createDocument(userId, request.body); + + logger.info('Document created', { + operation: 'documents.create.success', + user_id: userId, + document_id: created.id, + vehicle_id: created.vehicle_id, + document_type: created.document_type, + title: created.title, + }); + + return reply.code(201).send(created); + } + + async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + const documentId = request.params.id; + + logger.info('Document update requested', { + operation: 'documents.update', + user_id: userId, + document_id: documentId, + update_fields: Object.keys(request.body), + }); + + const updated = await this.service.updateDocument(userId, documentId, request.body); + if (!updated) { + logger.warn('Document not found for update', { + operation: 'documents.update.not_found', + user_id: userId, + document_id: documentId, + }); + return reply.code(404).send({ error: 'Not Found' }); + } + + logger.info('Document updated', { + operation: 'documents.update.success', + user_id: userId, + document_id: documentId, + vehicle_id: updated.vehicle_id, + title: updated.title, + }); + + return reply.code(200).send(updated); + } + + async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + const documentId = request.params.id; + + logger.info('Document delete requested', { + operation: 'documents.delete', + user_id: userId, + document_id: documentId, + }); + + // If object exists, delete it from storage first + const existing = await this.service.getDocument(userId, documentId); + if (existing && existing.storage_bucket && existing.storage_key) { + const storage = getStorageService(); + try { + await storage.deleteObject(existing.storage_bucket, existing.storage_key); + logger.info('Document file deleted from storage', { + operation: 'documents.delete.storage_cleanup', + user_id: userId, + document_id: documentId, + storage_key: existing.storage_key, + }); + } catch (e) { + logger.warn('Failed to delete document file from storage', { + operation: 'documents.delete.storage_cleanup_failed', + user_id: userId, + document_id: documentId, + storage_key: existing.storage_key, + error: e instanceof Error ? e.message : 'Unknown error', + }); + // Non-fatal: proceed with soft delete + } + } + + await this.service.deleteDocument(userId, documentId); + + logger.info('Document deleted', { + operation: 'documents.delete.success', + user_id: userId, + document_id: documentId, + vehicle_id: existing?.vehicle_id, + had_file: !!(existing?.storage_key), + }); + + return reply.code(204).send(); + } + + async upload(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + const documentId = request.params.id; + + logger.info('Document upload requested', { + operation: 'documents.upload', + user_id: userId, + document_id: documentId, + }); + + const doc = await this.service.getDocument(userId, documentId); + if (!doc) { + logger.warn('Document not found for upload', { + operation: 'documents.upload.not_found', + user_id: userId, + document_id: documentId, + }); + return reply.code(404).send({ error: 'Not Found' }); + } + + const mp = await (request as any).file({ limits: { files: 1 } }); + if (!mp) { + logger.warn('No file provided for upload', { + operation: 'documents.upload.no_file', + user_id: userId, + document_id: documentId, + }); + return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' }); + } + + const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']); + const contentType = mp.mimetype as string | undefined; + if (!contentType || !allowed.has(contentType)) { + logger.warn('Unsupported file type for upload', { + operation: 'documents.upload.unsupported_type', + user_id: userId, + document_id: documentId, + content_type: contentType, + file_name: mp.filename, + }); + return reply.code(415).send({ error: 'Unsupported Media Type' }); + } + + const originalName: string = mp.filename || 'upload'; + const ext = (() => { + const e = path.extname(originalName).replace(/^\./, '').toLowerCase(); + if (e) return e; + if (contentType === 'application/pdf') return 'pdf'; + if (contentType === 'image/jpeg') return 'jpg'; + if (contentType === 'image/png') return 'png'; + return 'bin'; + })(); + + class CountingStream extends Transform { + public bytes = 0; + override _transform(chunk: any, _enc: BufferEncoding, cb: TransformCallback) { + this.bytes += chunk.length || 0; + cb(null, chunk); + } + } + + const counter = new CountingStream(); + mp.file.pipe(counter); + + const storage = getStorageService(); + const bucket = (doc.storage_bucket || appConfig.getMinioConfig().bucket); + const version = 'v1'; + const unique = cryptoRandom(); + const key = `documents/${userId}/${doc.vehicle_id}/${doc.id}/${version}/${unique}.${ext}`; + + await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName }); + + const updated = await this.service['repo'].updateStorageMeta(doc.id, userId, { + storage_bucket: bucket, + storage_key: key, + file_name: originalName, + content_type: contentType, + file_size: counter.bytes, + file_hash: null, + }); + + logger.info('Document upload completed', { + operation: 'documents.upload.success', + user_id: userId, + document_id: documentId, + vehicle_id: doc.vehicle_id, + file_name: originalName, + content_type: contentType, + file_size: counter.bytes, + storage_key: key, + }); + + return reply.code(200).send(updated); + } + + async download(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + const documentId = request.params.id; + + logger.info('Document download requested', { + operation: 'documents.download', + user_id: userId, + document_id: documentId, + }); + + const doc = await this.service.getDocument(userId, documentId); + if (!doc || !doc.storage_bucket || !doc.storage_key) { + logger.warn('Document or file not found for download', { + operation: 'documents.download.not_found', + user_id: userId, + document_id: documentId, + has_document: !!doc, + has_storage_info: !!(doc?.storage_bucket && doc?.storage_key), + }); + return reply.code(404).send({ error: 'Not Found' }); + } + + const storage = getStorageService(); + let head: Partial = {}; + try { + head = await storage.headObject(doc.storage_bucket, doc.storage_key); + } catch { /* ignore */ } + const contentType = head.contentType || doc.content_type || 'application/octet-stream'; + const filename = doc.file_name || path.basename(doc.storage_key); + const inlineTypes = new Set(['application/pdf', 'image/jpeg', 'image/png']); + const disposition = inlineTypes.has(contentType) ? 'inline' : 'attachment'; + + reply.header('Content-Type', contentType); + reply.header('Content-Disposition', `${disposition}; filename="${encodeURIComponent(filename)}"`); + + logger.info('Document download initiated', { + operation: 'documents.download.success', + user_id: userId, + document_id: documentId, + vehicle_id: doc.vehicle_id, + file_name: filename, + content_type: contentType, + disposition: disposition, + file_size: head.size || doc.file_size, + }); + + const stream = await storage.getObjectStream(doc.storage_bucket, doc.storage_key); + return reply.send(stream); + } +} + +function cryptoRandom(): string { + // Safe unique suffix for object keys + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} diff --git a/backend/src/features/documents/api/documents.routes.ts b/backend/src/features/documents/api/documents.routes.ts new file mode 100644 index 0000000..1fd36d2 --- /dev/null +++ b/backend/src/features/documents/api/documents.routes.ts @@ -0,0 +1,60 @@ +/** + * @ai-summary Fastify routes for documents API + */ +import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify'; +import { tenantMiddleware } from '../../../core/middleware/tenant'; +import { DocumentsController } from './documents.controller'; +// Note: Validation uses TypeScript types at handler level; follow existing repo pattern (no JSON schema registration) + +export const documentsRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions +) => { + const ctrl = new DocumentsController(); + const requireAuth = fastify.authenticate.bind(fastify); + + fastify.get('/documents', { + preHandler: [requireAuth, tenantMiddleware as any], + handler: ctrl.list.bind(ctrl) + }); + + fastify.get<{ Params: any }>('/documents/:id', { + preHandler: [requireAuth, tenantMiddleware as any], + handler: ctrl.get.bind(ctrl) + }); + + fastify.get<{ Params: any }>('/documents/vehicle/:vehicleId', { + preHandler: [requireAuth, tenantMiddleware as any], + handler: async (req, reply) => { + const userId = (req as any).user?.sub as string; + const query = { vehicleId: (req.params as any).vehicleId }; + const docs = await ctrl['service'].listDocuments(userId, query); + return reply.code(200).send(docs); + } + }); + + fastify.post<{ Body: any }>('/documents', { + preHandler: [requireAuth, tenantMiddleware as any], + handler: ctrl.create.bind(ctrl) + }); + + fastify.put<{ Params: any; Body: any }>('/documents/:id', { + preHandler: [requireAuth, tenantMiddleware as any], + handler: ctrl.update.bind(ctrl) + }); + + fastify.delete<{ Params: any }>('/documents/:id', { + preHandler: [requireAuth, tenantMiddleware as any], + handler: ctrl.remove.bind(ctrl) + }); + + fastify.post<{ Params: any }>('/documents/:id/upload', { + preHandler: [requireAuth, tenantMiddleware as any], + handler: ctrl.upload.bind(ctrl) + }); + + fastify.get<{ Params: any }>('/documents/:id/download', { + preHandler: [requireAuth, tenantMiddleware as any], + handler: ctrl.download.bind(ctrl) + }); +}; diff --git a/backend/src/features/documents/api/documents.validation.ts b/backend/src/features/documents/api/documents.validation.ts new file mode 100644 index 0000000..3bec2b1 --- /dev/null +++ b/backend/src/features/documents/api/documents.validation.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { DocumentTypeSchema, CreateDocumentBodySchema, UpdateDocumentBodySchema } from '../domain/documents.types'; + +export const ListQuerySchema = z.object({ + vehicleId: z.string().uuid().optional(), + type: DocumentTypeSchema.optional(), + expiresBefore: z.string().optional(), +}); + +export const IdParamsSchema = z.object({ id: z.string().uuid() }); +export const VehicleParamsSchema = z.object({ vehicleId: z.string().uuid() }); + +export const CreateBodySchema = CreateDocumentBodySchema; +export const UpdateBodySchema = UpdateDocumentBodySchema; + +export type ListQuery = z.infer; +export type IdParams = z.infer; +export type VehicleParams = z.infer; +export type CreateBody = z.infer; +export type UpdateBody = z.infer; + diff --git a/backend/src/features/documents/data/documents.repository.ts b/backend/src/features/documents/data/documents.repository.ts new file mode 100644 index 0000000..24e57ae --- /dev/null +++ b/backend/src/features/documents/data/documents.repository.ts @@ -0,0 +1,94 @@ +import { Pool } from 'pg'; +import pool from '../../../core/config/database'; +import type { DocumentRecord, DocumentType } from '../domain/documents.types'; + +export class DocumentsRepository { + constructor(private readonly db: Pool = pool) {} + + async insert(doc: { + id: string; + user_id: string; + vehicle_id: string; + document_type: DocumentType; + title: string; + notes?: string | null; + details?: any; + issued_date?: string | null; + expiration_date?: string | null; + }): Promise { + const res = await this.db.query( + `INSERT INTO documents ( + id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, + [ + doc.id, + doc.user_id, + doc.vehicle_id, + doc.document_type, + doc.title, + doc.notes ?? null, + doc.details ?? null, + doc.issued_date ?? null, + doc.expiration_date ?? null, + ] + ); + return res.rows[0] as DocumentRecord; + } + + async findById(id: string, userId: string): Promise { + const res = await this.db.query(`SELECT * FROM documents WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, [id, userId]); + return res.rows[0] || null; + } + + async listByUser(userId: string, filters?: { vehicleId?: string; type?: DocumentType; expiresBefore?: string }): Promise { + const conds: string[] = ['user_id = $1', 'deleted_at IS NULL']; + const params: any[] = [userId]; + let i = 2; + if (filters?.vehicleId) { conds.push(`vehicle_id = $${i++}`); params.push(filters.vehicleId); } + if (filters?.type) { conds.push(`document_type = $${i++}`); params.push(filters.type); } + if (filters?.expiresBefore) { conds.push(`expiration_date <= $${i++}`); params.push(filters.expiresBefore); } + const sql = `SELECT * FROM documents WHERE ${conds.join(' AND ')} ORDER BY created_at DESC`; + const res = await this.db.query(sql, params); + return res.rows as DocumentRecord[]; + } + + async softDelete(id: string, userId: string): Promise { + await this.db.query(`UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2`, [id, userId]); + } + + async updateMetadata(id: string, userId: string, patch: Partial>): Promise { + const fields: string[] = []; + const params: any[] = []; + let i = 1; + if (patch.title !== undefined) { fields.push(`title = $${i++}`); params.push(patch.title); } + if (patch.notes !== undefined) { fields.push(`notes = $${i++}`); params.push(patch.notes); } + if (patch.details !== undefined) { fields.push(`details = $${i++}`); params.push(patch.details); } + if (patch.issued_date !== undefined) { fields.push(`issued_date = $${i++}`); params.push(patch.issued_date); } + if (patch.expiration_date !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expiration_date); } + if (!fields.length) return this.findById(id, userId); + params.push(id, userId); + const sql = `UPDATE documents SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} AND deleted_at IS NULL RETURNING *`; + const res = await this.db.query(sql, params); + return res.rows[0] || null; + } + + async updateStorageMeta(id: string, userId: string, meta: { + storage_bucket: string; storage_key: string; file_name: string; content_type: string; file_size: number; file_hash?: string | null; + }): Promise { + const res = await this.db.query( + `UPDATE documents SET + storage_bucket = $1, + storage_key = $2, + file_name = $3, + content_type = $4, + file_size = $5, + file_hash = $6 + WHERE id = $7 AND user_id = $8 AND deleted_at IS NULL + RETURNING *`, + [meta.storage_bucket, meta.storage_key, meta.file_name, meta.content_type, meta.file_size, meta.file_hash ?? null, id, userId] + ); + return res.rows[0] || null; + } +} + diff --git a/backend/src/features/documents/domain/documents.service.ts b/backend/src/features/documents/domain/documents.service.ts new file mode 100644 index 0000000..82d35e5 --- /dev/null +++ b/backend/src/features/documents/domain/documents.service.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'crypto'; +import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types'; +import { DocumentsRepository } from '../data/documents.repository'; +import pool from '../../../core/config/database'; + +export class DocumentsService { + private readonly repo = new DocumentsRepository(pool); + + async createDocument(userId: string, body: CreateDocumentBody): Promise { + await this.assertVehicleOwnership(userId, body.vehicle_id); + const id = randomUUID(); + return this.repo.insert({ + id, + user_id: userId, + vehicle_id: body.vehicle_id, + document_type: body.document_type as DocumentType, + title: body.title, + notes: body.notes ?? null, + details: body.details ?? null, + issued_date: body.issued_date ?? null, + expiration_date: body.expiration_date ?? null, + }); + } + + async getDocument(userId: string, id: string): Promise { + return this.repo.findById(id, userId); + } + + async listDocuments(userId: string, filters?: { vehicleId?: string; type?: DocumentType; expiresBefore?: string }) { + return this.repo.listByUser(userId, filters); + } + + async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) { + const existing = await this.repo.findById(id, userId); + if (!existing) return null; + if (patch && typeof patch === 'object') { + return this.repo.updateMetadata(id, userId, patch as any); + } + return existing; + } + + async deleteDocument(userId: string, id: string): Promise { + await this.repo.softDelete(id, userId); + } + + private async assertVehicleOwnership(userId: string, vehicleId: string) { + const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]); + if (!res.rows[0]) { + const err: any = new Error('Vehicle not found or not owned by user'); + err.statusCode = 403; + throw err; + } + } +} + diff --git a/backend/src/features/documents/domain/documents.types.ts b/backend/src/features/documents/domain/documents.types.ts new file mode 100644 index 0000000..8e6adb6 --- /dev/null +++ b/backend/src/features/documents/domain/documents.types.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +export const DocumentTypeSchema = z.enum(['insurance', 'registration']); +export type DocumentType = z.infer; + +export interface DocumentRecord { + id: string; + user_id: string; + vehicle_id: string; + document_type: DocumentType; + title: string; + notes?: string | null; + details?: Record | null; + storage_bucket?: string | null; + storage_key?: string | null; + file_name?: string | null; + content_type?: string | null; + file_size?: number | null; + file_hash?: string | null; + issued_date?: string | null; + expiration_date?: string | null; + created_at: string; + updated_at: string; + deleted_at?: string | null; +} + +export const CreateDocumentBodySchema = z.object({ + vehicle_id: z.string().uuid(), + document_type: DocumentTypeSchema, + title: z.string().min(1).max(200), + notes: z.string().max(10000).optional(), + details: z.record(z.any()).optional(), + issued_date: z.string().optional(), + expiration_date: z.string().optional(), +}); +export type CreateDocumentBody = z.infer; + +export const UpdateDocumentBodySchema = z.object({ + title: z.string().min(1).max(200).optional(), + notes: z.string().max(10000).nullable().optional(), + details: z.record(z.any()).optional(), + issued_date: z.string().nullable().optional(), + expiration_date: z.string().nullable().optional(), +}); +export type UpdateDocumentBody = z.infer; + diff --git a/backend/src/features/documents/index.ts b/backend/src/features/documents/index.ts new file mode 100644 index 0000000..d194192 --- /dev/null +++ b/backend/src/features/documents/index.ts @@ -0,0 +1,6 @@ +/** + * @ai-summary Public API for documents feature capsule + */ +export { documentsRoutes } from './api/documents.routes'; +export type { DocumentType, DocumentRecord, CreateDocumentBody, UpdateDocumentBody } from './domain/documents.types'; + diff --git a/backend/src/features/documents/migrations/001_create_documents_table.sql b/backend/src/features/documents/migrations/001_create_documents_table.sql new file mode 100644 index 0000000..d9b15f7 --- /dev/null +++ b/backend/src/features/documents/migrations/001_create_documents_table.sql @@ -0,0 +1,47 @@ +-- Documents feature schema +-- Depends on vehicles table and update_updated_at_column() from vehicles feature + +CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + vehicle_id UUID NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + document_type VARCHAR(32) NOT NULL CHECK (document_type IN ('insurance','registration')), + title VARCHAR(200) NOT NULL, + notes TEXT NULL, + details JSONB NULL, + + storage_bucket VARCHAR(128) NULL, + storage_key VARCHAR(512) NULL, + file_name VARCHAR(255) NULL, + content_type VARCHAR(128) NULL, + file_size BIGINT NULL, + file_hash VARCHAR(128) NULL, + + issued_date DATE NULL, + expiration_date DATE NULL, + + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + deleted_at TIMESTAMP WITHOUT TIME ZONE NULL +); + +-- Update trigger for updated_at +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_documents' + ) THEN + CREATE TRIGGER set_timestamp_documents + BEFORE UPDATE ON documents + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_documents_user_id ON documents(user_id); +CREATE INDEX IF NOT EXISTS idx_documents_vehicle_id ON documents(vehicle_id); +CREATE INDEX IF NOT EXISTS idx_documents_user_vehicle ON documents(user_id, vehicle_id); +CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(document_type); +CREATE INDEX IF NOT EXISTS idx_documents_expiration ON documents(expiration_date); + diff --git a/backend/src/features/documents/tests/integration/documents.integration.test.ts b/backend/src/features/documents/tests/integration/documents.integration.test.ts new file mode 100644 index 0000000..90cacae --- /dev/null +++ b/backend/src/features/documents/tests/integration/documents.integration.test.ts @@ -0,0 +1,435 @@ +/** + * @ai-summary Integration tests for Documents API endpoints + * @ai-context Tests full API flow with auth, database, and storage + */ + +import request from 'supertest'; +import { FastifyInstance } from 'fastify'; +import { build } from '../../../../app'; +import path from 'path'; +import fs from 'fs'; + +describe('Documents Integration Tests', () => { + let app: FastifyInstance; + let testUserId: string; + let testVehicleId: string; + let authToken: string; + + beforeAll(async () => { + app = build({ logger: false }); + await app.ready(); + + // Create test user context + testUserId = 'test-user-' + Date.now(); + authToken = 'Bearer test-token'; + + // Create test vehicle for document association + const vehicleData = { + vin: '1HGBH41JXMN109186', + nickname: 'Test Car', + color: 'Blue', + odometerReading: 50000, + }; + + const vehicleResponse = await request(app.server) + .post('/api/vehicles') + .set('Authorization', authToken) + .send(vehicleData); + + testVehicleId = vehicleResponse.body.id; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /api/documents', () => { + it('should create document metadata', async () => { + const documentData = { + vehicle_id: testVehicleId, + document_type: 'insurance', + title: 'Car Insurance Policy', + notes: 'Annual policy', + details: { provider: 'State Farm', policy_number: '12345' }, + issued_date: '2024-01-01', + expiration_date: '2024-12-31', + }; + + const response = await request(app.server) + .post('/api/documents') + .set('Authorization', authToken) + .send(documentData) + .expect(201); + + expect(response.body).toMatchObject({ + id: expect.any(String), + user_id: testUserId, + vehicle_id: testVehicleId, + document_type: 'insurance', + title: 'Car Insurance Policy', + notes: 'Annual policy', + details: { provider: 'State Farm', policy_number: '12345' }, + issued_date: '2024-01-01', + expiration_date: '2024-12-31', + created_at: expect.any(String), + updated_at: expect.any(String), + }); + + // Storage fields should be null initially + expect(response.body.storage_bucket).toBeNull(); + expect(response.body.storage_key).toBeNull(); + expect(response.body.file_name).toBeNull(); + }); + + it('should reject document for non-existent vehicle', async () => { + const documentData = { + vehicle_id: 'non-existent-vehicle', + document_type: 'registration', + title: 'Invalid Document', + }; + + await request(app.server) + .post('/api/documents') + .set('Authorization', authToken) + .send(documentData) + .expect(403); + }); + + it('should require authentication', async () => { + const documentData = { + vehicle_id: testVehicleId, + document_type: 'insurance', + title: 'Unauthorized Document', + }; + + await request(app.server) + .post('/api/documents') + .send(documentData) + .expect(401); + }); + }); + + describe('GET /api/documents', () => { + let testDocumentId: string; + + beforeEach(async () => { + // Create test document + const documentData = { + vehicle_id: testVehicleId, + document_type: 'registration', + title: 'Test Document for Listing', + }; + + const response = await request(app.server) + .post('/api/documents') + .set('Authorization', authToken) + .send(documentData); + + testDocumentId = response.body.id; + }); + + it('should list user documents', async () => { + const response = await request(app.server) + .get('/api/documents') + .set('Authorization', authToken) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + expect(response.body.some((doc: any) => doc.id === testDocumentId)).toBe(true); + }); + + it('should filter documents by vehicle', async () => { + const response = await request(app.server) + .get('/api/documents') + .query({ vehicleId: testVehicleId }) + .set('Authorization', authToken) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + response.body.forEach((doc: any) => { + expect(doc.vehicle_id).toBe(testVehicleId); + }); + }); + + it('should filter documents by type', async () => { + const response = await request(app.server) + .get('/api/documents') + .query({ type: 'registration' }) + .set('Authorization', authToken) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + response.body.forEach((doc: any) => { + expect(doc.document_type).toBe('registration'); + }); + }); + }); + + describe('GET /api/documents/:id', () => { + let testDocumentId: string; + + beforeEach(async () => { + const documentData = { + vehicle_id: testVehicleId, + document_type: 'insurance', + title: 'Single Document Test', + }; + + const response = await request(app.server) + .post('/api/documents') + .set('Authorization', authToken) + .send(documentData); + + testDocumentId = response.body.id; + }); + + it('should get single document', async () => { + const response = await request(app.server) + .get(`/api/documents/${testDocumentId}`) + .set('Authorization', authToken) + .expect(200); + + expect(response.body).toMatchObject({ + id: testDocumentId, + user_id: testUserId, + vehicle_id: testVehicleId, + title: 'Single Document Test', + }); + }); + + it('should return 404 for non-existent document', async () => { + await request(app.server) + .get('/api/documents/non-existent-id') + .set('Authorization', authToken) + .expect(404); + }); + }); + + describe('PUT /api/documents/:id', () => { + let testDocumentId: string; + + beforeEach(async () => { + const documentData = { + vehicle_id: testVehicleId, + document_type: 'insurance', + title: 'Document to Update', + notes: 'Original notes', + }; + + const response = await request(app.server) + .post('/api/documents') + .set('Authorization', authToken) + .send(documentData); + + testDocumentId = response.body.id; + }); + + it('should update document metadata', async () => { + const updateData = { + title: 'Updated Document Title', + notes: 'Updated notes', + details: { updated: true }, + }; + + const response = await request(app.server) + .put(`/api/documents/${testDocumentId}`) + .set('Authorization', authToken) + .send(updateData) + .expect(200); + + expect(response.body).toMatchObject({ + id: testDocumentId, + title: 'Updated Document Title', + notes: 'Updated notes', + details: { updated: true }, + updated_at: expect.any(String), + }); + }); + + it('should return 404 for non-existent document', async () => { + await request(app.server) + .put('/api/documents/non-existent-id') + .set('Authorization', authToken) + .send({ title: 'New Title' }) + .expect(404); + }); + }); + + describe('File Upload/Download Flow', () => { + let testDocumentId: string; + let testFilePath: string; + + beforeAll(() => { + // Create test file + testFilePath = path.join(__dirname, 'test-file.pdf'); + fs.writeFileSync(testFilePath, 'Fake PDF content for testing'); + }); + + afterAll(() => { + // Clean up test file + if (fs.existsSync(testFilePath)) { + fs.unlinkSync(testFilePath); + } + }); + + beforeEach(async () => { + const documentData = { + vehicle_id: testVehicleId, + document_type: 'insurance', + title: 'Document for Upload Test', + }; + + const response = await request(app.server) + .post('/api/documents') + .set('Authorization', authToken) + .send(documentData); + + testDocumentId = response.body.id; + }); + + it('should upload file to document', async () => { + const response = await request(app.server) + .post(`/api/documents/${testDocumentId}/upload`) + .set('Authorization', authToken) + .attach('file', testFilePath) + .expect(200); + + expect(response.body).toMatchObject({ + id: testDocumentId, + storage_bucket: expect.any(String), + storage_key: expect.any(String), + file_name: 'test-file.pdf', + content_type: expect.any(String), + file_size: expect.any(Number), + }); + + expect(response.body.storage_key).toMatch(/^documents\//); + }); + + it('should reject unsupported file types', async () => { + // Create temporary executable file + const execPath = path.join(__dirname, 'test.exe'); + fs.writeFileSync(execPath, 'fake executable'); + + try { + await request(app.server) + .post(`/api/documents/${testDocumentId}/upload`) + .set('Authorization', authToken) + .attach('file', execPath) + .expect(415); + } finally { + if (fs.existsSync(execPath)) { + fs.unlinkSync(execPath); + } + } + }); + + it('should download uploaded file', async () => { + // First upload a file + await request(app.server) + .post(`/api/documents/${testDocumentId}/upload`) + .set('Authorization', authToken) + .attach('file', testFilePath); + + // Then download it + const response = await request(app.server) + .get(`/api/documents/${testDocumentId}/download`) + .set('Authorization', authToken) + .expect(200); + + expect(response.headers['content-disposition']).toContain('test-file.pdf'); + expect(response.body.toString()).toBe('Fake PDF content for testing'); + }); + + it('should return 404 for download without uploaded file', async () => { + await request(app.server) + .get(`/api/documents/${testDocumentId}/download`) + .set('Authorization', authToken) + .expect(404); + }); + }); + + describe('DELETE /api/documents/:id', () => { + let testDocumentId: string; + + beforeEach(async () => { + const documentData = { + vehicle_id: testVehicleId, + document_type: 'registration', + title: 'Document to Delete', + }; + + const response = await request(app.server) + .post('/api/documents') + .set('Authorization', authToken) + .send(documentData); + + testDocumentId = response.body.id; + }); + + it('should soft delete document', async () => { + await request(app.server) + .delete(`/api/documents/${testDocumentId}`) + .set('Authorization', authToken) + .expect(204); + + // Verify document is no longer accessible + await request(app.server) + .get(`/api/documents/${testDocumentId}`) + .set('Authorization', authToken) + .expect(404); + }); + + it('should return 404 for already deleted document', async () => { + // Delete once + await request(app.server) + .delete(`/api/documents/${testDocumentId}`) + .set('Authorization', authToken) + .expect(204); + + // Try to delete again + await request(app.server) + .delete(`/api/documents/${testDocumentId}`) + .set('Authorization', authToken) + .expect(204); // Idempotent behavior + }); + }); + + describe('Authorization and Ownership', () => { + let otherUserDocumentId: string; + + beforeEach(async () => { + // Create document as different user + const documentData = { + vehicle_id: testVehicleId, + document_type: 'insurance', + title: 'Other User Document', + }; + + // Mock different user context + const otherUserToken = 'Bearer other-user-token'; + const response = await request(app.server) + .post('/api/documents') + .set('Authorization', otherUserToken) + .send(documentData); + + otherUserDocumentId = response.body.id; + }); + + it('should not allow access to other users documents', async () => { + await request(app.server) + .get(`/api/documents/${otherUserDocumentId}`) + .set('Authorization', authToken) + .expect(404); + }); + + it('should not allow update of other users documents', async () => { + await request(app.server) + .put(`/api/documents/${otherUserDocumentId}`) + .set('Authorization', authToken) + .send({ title: 'Hacked Title' }) + .expect(404); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/features/documents/tests/unit/documents.repository.test.ts b/backend/src/features/documents/tests/unit/documents.repository.test.ts new file mode 100644 index 0000000..64a4d54 --- /dev/null +++ b/backend/src/features/documents/tests/unit/documents.repository.test.ts @@ -0,0 +1,333 @@ +/** + * @ai-summary Unit tests for DocumentsRepository + * @ai-context Tests database layer with mocked pool + */ + +import { DocumentsRepository } from '../../data/documents.repository'; +import type { Pool } from 'pg'; + +describe('DocumentsRepository', () => { + let repository: DocumentsRepository; + let mockPool: jest.Mocked; + + beforeEach(() => { + mockPool = { + query: jest.fn(), + } as any; + repository = new DocumentsRepository(mockPool); + }); + + describe('insert', () => { + const mockDocumentData = { + id: 'doc-123', + user_id: 'user-123', + vehicle_id: 'vehicle-123', + document_type: 'insurance' as const, + title: 'Test Document', + notes: 'Test notes', + details: { provider: 'Test Provider' }, + issued_date: '2024-01-01', + expiration_date: '2024-12-31', + }; + + it('should insert document with all fields', async () => { + const mockResult = { rows: [{ ...mockDocumentData, created_at: '2024-01-01T00:00:00Z' }] }; + mockPool.query.mockResolvedValue(mockResult); + + const result = await repository.insert(mockDocumentData); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO documents'), + [ + 'doc-123', + 'user-123', + 'vehicle-123', + 'insurance', + 'Test Document', + 'Test notes', + { provider: 'Test Provider' }, + '2024-01-01', + '2024-12-31', + ] + ); + expect(result).toEqual(mockResult.rows[0]); + }); + + it('should insert document with null optional fields', async () => { + const minimalData = { + id: 'doc-123', + user_id: 'user-123', + vehicle_id: 'vehicle-123', + document_type: 'registration' as const, + title: 'Test Document', + }; + const mockResult = { rows: [{ ...minimalData, notes: null, details: null }] }; + mockPool.query.mockResolvedValue(mockResult); + + const result = await repository.insert(minimalData); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO documents'), + [ + 'doc-123', + 'user-123', + 'vehicle-123', + 'registration', + 'Test Document', + null, + null, + null, + null, + ] + ); + expect(result).toEqual(mockResult.rows[0]); + }); + }); + + describe('findById', () => { + it('should find document by id and user', async () => { + const mockDocument = { id: 'doc-123', user_id: 'user-123', title: 'Test' }; + mockPool.query.mockResolvedValue({ rows: [mockDocument] }); + + const result = await repository.findById('doc-123', 'user-123'); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT * FROM documents WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL', + ['doc-123', 'user-123'] + ); + expect(result).toEqual(mockDocument); + }); + + it('should return null if document not found', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + + const result = await repository.findById('doc-123', 'user-123'); + + expect(result).toBeNull(); + }); + }); + + describe('listByUser', () => { + const mockDocuments = [ + { id: 'doc-1', user_id: 'user-123', title: 'Doc 1' }, + { id: 'doc-2', user_id: 'user-123', title: 'Doc 2' }, + ]; + + it('should list all user documents without filters', async () => { + mockPool.query.mockResolvedValue({ rows: mockDocuments }); + + const result = await repository.listByUser('user-123'); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC', + ['user-123'] + ); + expect(result).toEqual(mockDocuments); + }); + + it('should list documents with vehicleId filter', async () => { + mockPool.query.mockResolvedValue({ rows: [mockDocuments[0]] }); + + const result = await repository.listByUser('user-123', { vehicleId: 'vehicle-123' }); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL AND vehicle_id = $2 ORDER BY created_at DESC', + ['user-123', 'vehicle-123'] + ); + expect(result).toEqual([mockDocuments[0]]); + }); + + it('should list documents with type filter', async () => { + mockPool.query.mockResolvedValue({ rows: [mockDocuments[0]] }); + + const result = await repository.listByUser('user-123', { type: 'insurance' }); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL AND document_type = $2 ORDER BY created_at DESC', + ['user-123', 'insurance'] + ); + expect(result).toEqual([mockDocuments[0]]); + }); + + it('should list documents with expiresBefore filter', async () => { + mockPool.query.mockResolvedValue({ rows: [mockDocuments[0]] }); + + const result = await repository.listByUser('user-123', { expiresBefore: '2024-12-31' }); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL AND expiration_date <= $2 ORDER BY created_at DESC', + ['user-123', '2024-12-31'] + ); + expect(result).toEqual([mockDocuments[0]]); + }); + + it('should list documents with multiple filters', async () => { + mockPool.query.mockResolvedValue({ rows: [mockDocuments[0]] }); + + const result = await repository.listByUser('user-123', { + vehicleId: 'vehicle-123', + type: 'insurance', + expiresBefore: '2024-12-31', + }); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT * FROM documents WHERE user_id = $1 AND deleted_at IS NULL AND vehicle_id = $2 AND document_type = $3 AND expiration_date <= $4 ORDER BY created_at DESC', + ['user-123', 'vehicle-123', 'insurance', '2024-12-31'] + ); + expect(result).toEqual([mockDocuments[0]]); + }); + }); + + describe('softDelete', () => { + it('should soft delete document', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + + await repository.softDelete('doc-123', 'user-123'); + + expect(mockPool.query).toHaveBeenCalledWith( + 'UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2', + ['doc-123', 'user-123'] + ); + }); + }); + + describe('updateMetadata', () => { + it('should update single field', async () => { + const mockUpdated = { id: 'doc-123', title: 'Updated Title' }; + mockPool.query.mockResolvedValue({ rows: [mockUpdated] }); + + const result = await repository.updateMetadata('doc-123', 'user-123', { title: 'Updated Title' }); + + expect(mockPool.query).toHaveBeenCalledWith( + 'UPDATE documents SET title = $1 WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL RETURNING *', + ['Updated Title', 'doc-123', 'user-123'] + ); + expect(result).toEqual(mockUpdated); + }); + + it('should update multiple fields', async () => { + const mockUpdated = { id: 'doc-123', title: 'Updated Title', notes: 'Updated notes' }; + mockPool.query.mockResolvedValue({ rows: [mockUpdated] }); + + const result = await repository.updateMetadata('doc-123', 'user-123', { + title: 'Updated Title', + notes: 'Updated notes', + details: { key: 'value' }, + }); + + expect(mockPool.query).toHaveBeenCalledWith( + 'UPDATE documents SET title = $1, notes = $2, details = $3 WHERE id = $4 AND user_id = $5 AND deleted_at IS NULL RETURNING *', + ['Updated Title', 'Updated notes', { key: 'value' }, 'doc-123', 'user-123'] + ); + expect(result).toEqual(mockUpdated); + }); + + it('should handle null values', async () => { + const mockUpdated = { id: 'doc-123', notes: null }; + mockPool.query.mockResolvedValue({ rows: [mockUpdated] }); + + const result = await repository.updateMetadata('doc-123', 'user-123', { notes: null }); + + expect(mockPool.query).toHaveBeenCalledWith( + 'UPDATE documents SET notes = $1 WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL RETURNING *', + [null, 'doc-123', 'user-123'] + ); + expect(result).toEqual(mockUpdated); + }); + + it('should return existing record if no fields to update', async () => { + const mockExisting = { id: 'doc-123', title: 'Existing' }; + mockPool.query.mockResolvedValue({ rows: [mockExisting] }); + + const result = await repository.updateMetadata('doc-123', 'user-123', {}); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT * FROM documents WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL', + ['doc-123', 'user-123'] + ); + expect(result).toEqual(mockExisting); + }); + + it('should return null if document not found', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + + const result = await repository.updateMetadata('doc-123', 'user-123', { title: 'New Title' }); + + expect(result).toBeNull(); + }); + }); + + describe('updateStorageMeta', () => { + it('should update storage metadata', async () => { + const storageMeta = { + storage_bucket: 'test-bucket', + storage_key: 'test-key', + file_name: 'test.pdf', + content_type: 'application/pdf', + file_size: 1024, + file_hash: 'hash123', + }; + const mockUpdated = { id: 'doc-123', ...storageMeta }; + mockPool.query.mockResolvedValue({ rows: [mockUpdated] }); + + const result = await repository.updateStorageMeta('doc-123', 'user-123', storageMeta); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE documents SET'), + [ + 'test-bucket', + 'test-key', + 'test.pdf', + 'application/pdf', + 1024, + 'hash123', + 'doc-123', + 'user-123', + ] + ); + expect(result).toEqual(mockUpdated); + }); + + it('should handle null file_hash', async () => { + const storageMeta = { + storage_bucket: 'test-bucket', + storage_key: 'test-key', + file_name: 'test.pdf', + content_type: 'application/pdf', + file_size: 1024, + }; + mockPool.query.mockResolvedValue({ rows: [{ id: 'doc-123', ...storageMeta, file_hash: null }] }); + + const result = await repository.updateStorageMeta('doc-123', 'user-123', storageMeta); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE documents SET'), + [ + 'test-bucket', + 'test-key', + 'test.pdf', + 'application/pdf', + 1024, + null, + 'doc-123', + 'user-123', + ] + ); + }); + + it('should return null if document not found', async () => { + const storageMeta = { + storage_bucket: 'test-bucket', + storage_key: 'test-key', + file_name: 'test.pdf', + content_type: 'application/pdf', + file_size: 1024, + }; + mockPool.query.mockResolvedValue({ rows: [] }); + + const result = await repository.updateStorageMeta('doc-123', 'user-123', storageMeta); + + expect(result).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/features/documents/tests/unit/documents.service.test.ts b/backend/src/features/documents/tests/unit/documents.service.test.ts new file mode 100644 index 0000000..22385fe --- /dev/null +++ b/backend/src/features/documents/tests/unit/documents.service.test.ts @@ -0,0 +1,261 @@ +/** + * @ai-summary Unit tests for DocumentsService + * @ai-context Tests business logic with mocked dependencies + */ + +import { DocumentsService } from '../../domain/documents.service'; +import { DocumentsRepository } from '../../data/documents.repository'; +import pool from '../../../../core/config/database'; + +// Mock dependencies +jest.mock('../../data/documents.repository'); +jest.mock('../../../../core/config/database'); + +const mockRepository = jest.mocked(DocumentsRepository); +const mockPool = jest.mocked(pool); + +describe('DocumentsService', () => { + let service: DocumentsService; + let repositoryInstance: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + repositoryInstance = { + insert: jest.fn(), + findById: jest.fn(), + listByUser: jest.fn(), + updateMetadata: jest.fn(), + updateStorageMeta: jest.fn(), + softDelete: jest.fn(), + } as any; + + mockRepository.mockImplementation(() => repositoryInstance); + service = new DocumentsService(); + }); + + describe('createDocument', () => { + const mockDocumentBody = { + vehicle_id: 'vehicle-123', + document_type: 'insurance' as const, + title: 'Car Insurance Policy', + notes: 'Annual insurance policy', + details: { provider: 'State Farm' }, + issued_date: '2024-01-01', + expiration_date: '2024-12-31', + }; + + const mockCreatedDocument = { + id: 'doc-123', + user_id: 'user-123', + vehicle_id: 'vehicle-123', + document_type: 'insurance' as const, + title: 'Car Insurance Policy', + notes: 'Annual insurance policy', + details: { provider: 'State Farm' }, + storage_bucket: null, + storage_key: null, + file_name: null, + content_type: null, + file_size: null, + file_hash: null, + issued_date: '2024-01-01', + expiration_date: '2024-12-31', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + deleted_at: null, + }; + + it('should create a document successfully', async () => { + // Mock vehicle ownership check + mockPool.query.mockResolvedValue({ rows: [{ id: 'vehicle-123' }] }); + repositoryInstance.insert.mockResolvedValue(mockCreatedDocument); + + const result = await service.createDocument('user-123', mockDocumentBody); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', + ['vehicle-123', 'user-123'] + ); + expect(repositoryInstance.insert).toHaveBeenCalledWith({ + id: expect.any(String), + user_id: 'user-123', + vehicle_id: 'vehicle-123', + document_type: 'insurance', + title: 'Car Insurance Policy', + notes: 'Annual insurance policy', + details: { provider: 'State Farm' }, + issued_date: '2024-01-01', + expiration_date: '2024-12-31', + }); + expect(result).toEqual(mockCreatedDocument); + }); + + it('should create document with minimal data', async () => { + const minimalBody = { + vehicle_id: 'vehicle-123', + document_type: 'registration' as const, + title: 'Vehicle Registration', + }; + + mockPool.query.mockResolvedValue({ rows: [{ id: 'vehicle-123' }] }); + repositoryInstance.insert.mockResolvedValue({ + ...mockCreatedDocument, + document_type: 'registration', + title: 'Vehicle Registration', + notes: null, + details: null, + issued_date: null, + expiration_date: null, + }); + + const result = await service.createDocument('user-123', minimalBody); + + expect(repositoryInstance.insert).toHaveBeenCalledWith({ + id: expect.any(String), + user_id: 'user-123', + vehicle_id: 'vehicle-123', + document_type: 'registration', + title: 'Vehicle Registration', + notes: null, + details: null, + issued_date: null, + expiration_date: null, + }); + }); + + it('should reject document for non-owned vehicle', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + + await expect(service.createDocument('user-123', mockDocumentBody)) + .rejects.toThrow('Vehicle not found or not owned by user'); + + expect(mockPool.query).toHaveBeenCalledWith( + 'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', + ['vehicle-123', 'user-123'] + ); + expect(repositoryInstance.insert).not.toHaveBeenCalled(); + }); + + it('should generate unique IDs for documents', async () => { + mockPool.query.mockResolvedValue({ rows: [{ id: 'vehicle-123' }] }); + repositoryInstance.insert.mockResolvedValue(mockCreatedDocument); + + await service.createDocument('user-123', mockDocumentBody); + await service.createDocument('user-123', mockDocumentBody); + + expect(repositoryInstance.insert).toHaveBeenCalledTimes(2); + const firstCall = repositoryInstance.insert.mock.calls[0][0]; + const secondCall = repositoryInstance.insert.mock.calls[1][0]; + expect(firstCall.id).not.toEqual(secondCall.id); + expect(firstCall.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + }); + + describe('getDocument', () => { + it('should return document if found', async () => { + const mockDocument = { + id: 'doc-123', + user_id: 'user-123', + title: 'Test Document', + }; + repositoryInstance.findById.mockResolvedValue(mockDocument as any); + + const result = await service.getDocument('user-123', 'doc-123'); + + expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123'); + expect(result).toEqual(mockDocument); + }); + + it('should return null if document not found', async () => { + repositoryInstance.findById.mockResolvedValue(null); + + const result = await service.getDocument('user-123', 'doc-123'); + + expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123'); + expect(result).toBeNull(); + }); + }); + + describe('listDocuments', () => { + const mockDocuments = [ + { id: 'doc-1', title: 'Insurance', document_type: 'insurance' }, + { id: 'doc-2', title: 'Registration', document_type: 'registration' }, + ]; + + it('should list all user documents without filters', async () => { + repositoryInstance.listByUser.mockResolvedValue(mockDocuments as any); + + const result = await service.listDocuments('user-123'); + + expect(repositoryInstance.listByUser).toHaveBeenCalledWith('user-123', undefined); + expect(result).toEqual(mockDocuments); + }); + + it('should list documents with filters', async () => { + const filters = { + vehicleId: 'vehicle-123', + type: 'insurance' as const, + expiresBefore: '2024-12-31', + }; + repositoryInstance.listByUser.mockResolvedValue([mockDocuments[0]] as any); + + const result = await service.listDocuments('user-123', filters); + + expect(repositoryInstance.listByUser).toHaveBeenCalledWith('user-123', filters); + expect(result).toEqual([mockDocuments[0]]); + }); + }); + + describe('updateDocument', () => { + const mockExistingDocument = { + id: 'doc-123', + user_id: 'user-123', + title: 'Original Title', + }; + + it('should update document successfully', async () => { + const updateData = { title: 'Updated Title', notes: 'Updated notes' }; + const updatedDocument = { ...mockExistingDocument, ...updateData }; + + repositoryInstance.findById.mockResolvedValue(mockExistingDocument as any); + repositoryInstance.updateMetadata.mockResolvedValue(updatedDocument as any); + + const result = await service.updateDocument('user-123', 'doc-123', updateData); + + expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123'); + expect(repositoryInstance.updateMetadata).toHaveBeenCalledWith('doc-123', 'user-123', updateData); + expect(result).toEqual(updatedDocument); + }); + + it('should return null if document not found', async () => { + repositoryInstance.findById.mockResolvedValue(null); + + const result = await service.updateDocument('user-123', 'doc-123', { title: 'New Title' }); + + expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123'); + expect(repositoryInstance.updateMetadata).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should return existing document if no valid patch provided', async () => { + repositoryInstance.findById.mockResolvedValue(mockExistingDocument as any); + + const result = await service.updateDocument('user-123', 'doc-123', null as any); + + expect(repositoryInstance.findById).toHaveBeenCalledWith('doc-123', 'user-123'); + expect(repositoryInstance.updateMetadata).not.toHaveBeenCalled(); + expect(result).toEqual(mockExistingDocument); + }); + }); + + describe('deleteDocument', () => { + it('should delete document successfully', async () => { + repositoryInstance.softDelete.mockResolvedValue(undefined); + + await service.deleteDocument('user-123', 'doc-123'); + + expect(repositoryInstance.softDelete).toHaveBeenCalledWith('doc-123', 'user-123'); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/features/documents/tests/unit/storage.adapter.test.ts b/backend/src/features/documents/tests/unit/storage.adapter.test.ts new file mode 100644 index 0000000..d423696 --- /dev/null +++ b/backend/src/features/documents/tests/unit/storage.adapter.test.ts @@ -0,0 +1,256 @@ +/** + * @ai-summary Unit tests for MinIO storage adapter + * @ai-context Tests storage layer with mocked MinIO client + */ + +import { createMinioAdapter } from '../../../../core/storage/adapters/minio.adapter'; +import { Client as MinioClient } from 'minio'; +import { appConfig } from '../../../../core/config/config-loader'; +import { Readable } from 'stream'; + +// Mock dependencies +jest.mock('minio'); +jest.mock('../../../../core/config/config-loader'); + +const mockMinioClient = jest.mocked(MinioClient); +const mockAppConfig = jest.mocked(appConfig); + +describe('MinIO Storage Adapter', () => { + let clientInstance: jest.Mocked; + let adapter: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + + clientInstance = { + putObject: jest.fn(), + getObject: jest.fn(), + removeObject: jest.fn(), + statObject: jest.fn(), + presignedGetObject: jest.fn(), + presignedPutObject: jest.fn(), + } as any; + + mockMinioClient.mockImplementation(() => clientInstance); + + mockAppConfig.getMinioConfig.mockReturnValue({ + endpoint: 'localhost', + port: 9000, + accessKey: 'testkey', + secretKey: 'testsecret', + bucket: 'test-bucket', + }); + + adapter = createMinioAdapter(); + }); + + describe('putObject', () => { + it('should upload Buffer with correct parameters', async () => { + const buffer = Buffer.from('test content'); + clientInstance.putObject.mockResolvedValue('etag-123'); + + await adapter.putObject('test-bucket', 'test-key', buffer, 'text/plain', { 'x-custom': 'value' }); + + expect(clientInstance.putObject).toHaveBeenCalledWith( + 'test-bucket', + 'test-key', + buffer, + buffer.length, + { + 'Content-Type': 'text/plain', + 'x-custom': 'value', + } + ); + }); + + it('should upload string with correct parameters', async () => { + const content = 'test content'; + clientInstance.putObject.mockResolvedValue('etag-123'); + + await adapter.putObject('test-bucket', 'test-key', content, 'text/plain'); + + expect(clientInstance.putObject).toHaveBeenCalledWith( + 'test-bucket', + 'test-key', + content, + content.length, + { 'Content-Type': 'text/plain' } + ); + }); + + it('should upload stream without size', async () => { + const stream = new Readable(); + clientInstance.putObject.mockResolvedValue('etag-123'); + + await adapter.putObject('test-bucket', 'test-key', stream, 'application/octet-stream'); + + expect(clientInstance.putObject).toHaveBeenCalledWith( + 'test-bucket', + 'test-key', + stream, + undefined, + { 'Content-Type': 'application/octet-stream' } + ); + }); + + it('should handle upload without content type', async () => { + const buffer = Buffer.from('test'); + clientInstance.putObject.mockResolvedValue('etag-123'); + + await adapter.putObject('test-bucket', 'test-key', buffer); + + expect(clientInstance.putObject).toHaveBeenCalledWith( + 'test-bucket', + 'test-key', + buffer, + buffer.length, + {} + ); + }); + }); + + describe('getObjectStream', () => { + it('should return object stream', async () => { + const mockStream = new Readable(); + clientInstance.getObject.mockResolvedValue(mockStream); + + const result = await adapter.getObjectStream('test-bucket', 'test-key'); + + expect(clientInstance.getObject).toHaveBeenCalledWith('test-bucket', 'test-key'); + expect(result).toBe(mockStream); + }); + }); + + describe('deleteObject', () => { + it('should remove object', async () => { + clientInstance.removeObject.mockResolvedValue(undefined); + + await adapter.deleteObject('test-bucket', 'test-key'); + + expect(clientInstance.removeObject).toHaveBeenCalledWith('test-bucket', 'test-key'); + }); + }); + + describe('headObject', () => { + it('should return object metadata', async () => { + const mockStat = { + size: 1024, + etag: 'test-etag', + lastModified: '2024-01-01T00:00:00Z', + metaData: { + 'content-type': 'application/pdf', + 'x-custom-header': 'custom-value', + }, + }; + clientInstance.statObject.mockResolvedValue(mockStat); + + const result = await adapter.headObject('test-bucket', 'test-key'); + + expect(clientInstance.statObject).toHaveBeenCalledWith('test-bucket', 'test-key'); + expect(result).toEqual({ + size: 1024, + etag: 'test-etag', + lastModified: new Date('2024-01-01T00:00:00Z'), + contentType: 'application/pdf', + metadata: mockStat.metaData, + }); + }); + + it('should handle metadata with Content-Type header', async () => { + const mockStat = { + size: 1024, + etag: 'test-etag', + lastModified: '2024-01-01T00:00:00Z', + metaData: { + 'Content-Type': 'image/jpeg', + }, + }; + clientInstance.statObject.mockResolvedValue(mockStat); + + const result = await adapter.headObject('test-bucket', 'test-key'); + + expect(result.contentType).toBe('image/jpeg'); + }); + + it('should handle missing optional fields', async () => { + const mockStat = { + size: 1024, + etag: 'test-etag', + }; + clientInstance.statObject.mockResolvedValue(mockStat); + + const result = await adapter.headObject('test-bucket', 'test-key'); + + expect(result).toEqual({ + size: 1024, + etag: 'test-etag', + lastModified: undefined, + contentType: undefined, + metadata: undefined, + }); + }); + }); + + describe('getSignedUrl', () => { + it('should generate GET signed URL with default expiry', async () => { + clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url'); + + const result = await adapter.getSignedUrl('test-bucket', 'test-key'); + + expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 300); + expect(result).toBe('https://example.com/signed-url'); + }); + + it('should generate GET signed URL with custom expiry', async () => { + clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url'); + + const result = await adapter.getSignedUrl('test-bucket', 'test-key', { + method: 'GET', + expiresSeconds: 600, + }); + + expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 600); + expect(result).toBe('https://example.com/signed-url'); + }); + + it('should generate PUT signed URL', async () => { + clientInstance.presignedPutObject.mockResolvedValue('https://example.com/put-url'); + + const result = await adapter.getSignedUrl('test-bucket', 'test-key', { + method: 'PUT', + expiresSeconds: 300, + }); + + expect(clientInstance.presignedPutObject).toHaveBeenCalledWith('test-bucket', 'test-key', 300); + expect(result).toBe('https://example.com/put-url'); + }); + + it('should enforce minimum expiry time', async () => { + clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url'); + + await adapter.getSignedUrl('test-bucket', 'test-key', { expiresSeconds: 0 }); + + expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 1); + }); + + it('should enforce maximum expiry time', async () => { + clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url'); + + await adapter.getSignedUrl('test-bucket', 'test-key', { expiresSeconds: 10000000 }); + + expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 604800); // 7 days max + }); + }); + + describe('MinioClient instantiation', () => { + it('should create client with correct configuration', () => { + expect(mockMinioClient).toHaveBeenCalledWith({ + endPoint: 'localhost', + port: 9000, + useSSL: false, + accessKey: 'testkey', + secretKey: 'testsecret', + }); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/features/maintenance/README.md b/backend/src/features/maintenance/README.md index 8accbe3..833c899 100644 --- a/backend/src/features/maintenance/README.md +++ b/backend/src/features/maintenance/README.md @@ -1,7 +1,7 @@ # Maintenance Feature Capsule ## Status -- Scaffolded; implementation pending. Endpoints and behavior to be defined. +- WIP: Scaffolded; implementation pending. Track updates in `docs/changes/MULTI-TENANT-REDESIGN.md` and related feature plans. ## Structure - **api/** - HTTP endpoints, routes, validators @@ -15,8 +15,8 @@ ## Dependencies - Internal: core/auth, core/cache -- External: (none defined yet) -- Database: maintenance table (see docs/DATABASE-SCHEMA.md) +- External: (none) +- Database: maintenance table (see `docs/DATABASE-SCHEMA.md`) ## Quick Commands ```bash @@ -27,8 +27,5 @@ npm test -- features/maintenance npm run migrate:feature maintenance ``` -## Clarifications Needed -- Entities/fields and validation rules (e.g., due date, mileage, completion criteria)? -- Planned endpoints and request/response shapes? -- Relationship to vehicles (required foreign keys, cascades)? -- Caching requirements (e.g., upcoming maintenance TTL)? +## API (planned) +- Endpoints and business rules to be finalized; depends on vehicles. See `docs/DATABASE-SCHEMA.md` for current table shape and indexes. diff --git a/backend/src/features/stations/README.md b/backend/src/features/stations/README.md index 821dc64..cfe7860 100644 --- a/backend/src/features/stations/README.md +++ b/backend/src/features/stations/README.md @@ -1,7 +1,7 @@ # Stations Feature Capsule -## Summary -Search nearby gas stations via Google Maps and manage users' saved stations. +## Quick Summary (50 tokens) +Search nearby gas stations via Google Maps and manage users' saved stations with user-owned saved lists. Caches search results for 1 hour. JWT required for all endpoints. ## API Endpoints (JWT required) - `POST /api/stations/search` — Search nearby stations @@ -22,7 +22,7 @@ Search nearby gas stations via Google Maps and manage users' saved stations. ## Dependencies - Internal: core/auth, core/cache - External: Google Maps API (Places) -- Database: stations table +- Database: stations table (see `docs/DATABASE-SCHEMA.md`) ## Quick Commands ```bash @@ -32,9 +32,6 @@ npm test -- features/stations # Run feature migrations npm run migrate:feature stations ``` - -## Clarifications Needed -- Search payload structure (required fields, radius/filters)? -- Saved station schema and required fields? -- Caching policy for searches (TTL, cache keys)? -- Rate limits or quotas for Google Maps calls? + +## Notes +- Search payload and saved schema to be finalized; align with Google Places best practices and platform quotas. Caching policy: 1 hour TTL (key `stations:search:{query}`). diff --git a/backend/src/features/stations/external/google-maps/google-maps.client.ts b/backend/src/features/stations/external/google-maps/google-maps.client.ts index a7105f3..016b581 100644 --- a/backend/src/features/stations/external/google-maps/google-maps.client.ts +++ b/backend/src/features/stations/external/google-maps/google-maps.client.ts @@ -4,14 +4,14 @@ */ import axios from 'axios'; -import { env } from '../../../../core/config/environment'; +import { appConfig } from '../../../../core/config/config-loader'; import { logger } from '../../../../core/logging/logger'; import { cacheService } from '../../../../core/config/redis'; import { GooglePlacesResponse, GooglePlace } from './google-maps.types'; import { Station } from '../../domain/stations.types'; export class GoogleMapsClient { - private readonly apiKey = env.GOOGLE_MAPS_API_KEY; + private readonly apiKey = appConfig.secrets.google_maps_api_key; private readonly baseURL = 'https://maps.googleapis.com/maps/api/place'; private readonly cacheTTL = 3600; // 1 hour diff --git a/backend/src/features/vehicles/domain/platform-integration.service.ts b/backend/src/features/vehicles/domain/platform-integration.service.ts index f03e481..15bb160 100644 --- a/backend/src/features/vehicles/domain/platform-integration.service.ts +++ b/backend/src/features/vehicles/domain/platform-integration.service.ts @@ -1,7 +1,7 @@ import { Logger } from 'winston'; import { PlatformVehiclesClient } from '../external/platform-vehicles/platform-vehicles.client'; import { VPICClient } from '../external/vpic/vpic.client'; -import { env } from '../../../core/config/environment'; +import { appConfig } from '../../../core/config/config-loader'; /** @@ -22,7 +22,7 @@ export class PlatformIntegrationService { this.vpicClient = vpicClient; // Feature flag - can be environment variable or runtime config - this.usePlatformService = env.NODE_ENV !== 'test'; // Use platform service except in tests + this.usePlatformService = appConfig.config.server.environment !== 'test'; // Use platform service except in tests this.logger.info(`Vehicle service integration initialized: usePlatformService=${this.usePlatformService}`); } diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index 69119d8..94c876b 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -16,7 +16,7 @@ import { import { logger } from '../../../core/logging/logger'; import { cacheService } from '../../../core/config/redis'; import { isValidVIN } from '../../../shared-minimal/utils/validators'; -import { env } from '../../../core/config/environment'; +import { appConfig } from '../../../core/config/config-loader'; import { normalizeMakeName, normalizeModelName } from './name-normalizer'; export class VehiclesService { @@ -26,10 +26,11 @@ export class VehiclesService { constructor(private repository: VehiclesRepository) { // Initialize platform vehicles client + const platformConfig = appConfig.getPlatformServiceConfig('vehicles'); const platformClient = new PlatformVehiclesClient({ - baseURL: env.PLATFORM_VEHICLES_API_URL, - apiKey: env.PLATFORM_VEHICLES_API_KEY, - tenantId: process.env.TENANT_ID, + baseURL: platformConfig.url, + apiKey: platformConfig.apiKey, + tenantId: appConfig.config.server.tenant_id, timeout: 3000, logger }); diff --git a/backend/src/features/vehicles/external/vpic/vpic.client.ts b/backend/src/features/vehicles/external/vpic/vpic.client.ts index 68d4a5f..1b90f1c 100644 --- a/backend/src/features/vehicles/external/vpic/vpic.client.ts +++ b/backend/src/features/vehicles/external/vpic/vpic.client.ts @@ -4,7 +4,7 @@ */ import axios from 'axios'; -import { env } from '../../../../core/config/environment'; +import { appConfig } from '../../../../core/config/config-loader'; import { logger } from '../../../../core/logging/logger'; import { cacheService } from '../../../../core/config/redis'; import { @@ -19,7 +19,7 @@ import { } from './vpic.types'; export class VPICClient { - private readonly baseURL = env.VPIC_API_URL; + private readonly baseURL = appConfig.config.external.vpic.url; private readonly cacheTTL = 30 * 24 * 60 * 60; // 30 days in seconds private readonly dropdownCacheTTL = 7 * 24 * 60 * 60; // 7 days for dropdown data diff --git a/backend/src/index.ts b/backend/src/index.ts index 6696bd7..f75a3d2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,10 +3,10 @@ * @ai-context Starts the Fastify server with all feature capsules */ import { buildApp } from './app'; -import { env } from './core/config/environment'; +import { appConfig } from './core/config/config-loader'; import { logger } from './core/logging/logger'; -const PORT = env.PORT || 3001; +const PORT = appConfig.config.server.port; async function start() { try { @@ -19,7 +19,7 @@ async function start() { logger.info(`MotoVaultPro backend running`, { port: PORT, - environment: env.NODE_ENV, + environment: appConfig.config.server.environment, nodeVersion: process.version, framework: 'Fastify' }); diff --git a/docker-compose.yml b/docker-compose.yml index 8e8dedf..df2c664 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,15 +77,13 @@ services: container_name: mvp-platform-tenants restart: unless-stopped environment: - # Core configuration loaded from files + # Core configuration (K8s pattern) NODE_ENV: production CONFIG_PATH: /app/config/production.yml SECRETS_DIR: /run/secrets - # Legacy environment variables (transitional) - DATABASE_URL: postgresql://platform_user:${PLATFORM_DB_PASSWORD:-platform123}@platform-postgres:5432/platform - AUTH0_DOMAIN: ${AUTH0_DOMAIN:-motovaultpro.us.auth0.com} - AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-https://api.motovaultpro.com} SERVICE_NAME: mvp-platform-tenants + # Database connection (temporary fix until k8s config loader implemented) + DATABASE_URL: postgresql://platform_user:platform123@platform-postgres:5432/platform volumes: # Configuration files (K8s ConfigMap equivalent) - ./config/platform/production.yml:/app/config/production.yml:ro @@ -132,15 +130,6 @@ services: NODE_ENV: production CONFIG_PATH: /app/config/production.yml SECRETS_DIR: /run/secrets - # Legacy environment variables (transitional) - POSTGRES_HOST: mvp-platform-vehicles-db - POSTGRES_PORT: 5432 - POSTGRES_DATABASE: vehicles - POSTGRES_USER: mvp_platform_user - REDIS_HOST: mvp-platform-vehicles-redis - REDIS_PORT: 6379 - DEBUG: false - CORS_ORIGINS: '["https://admin.motovaultpro.com", "https://motovaultpro.com"]' SERVICE_NAME: mvp-platform-vehicles-api volumes: # Configuration files (K8s ConfigMap equivalent) @@ -185,33 +174,10 @@ services: container_name: admin-backend restart: unless-stopped environment: - # Core environment for application startup + # Core configuration (K8s pattern) NODE_ENV: production CONFIG_PATH: /app/config/production.yml SECRETS_DIR: /run/secrets - # Force database configuration - DB_HOST: admin-postgres - DB_PORT: 5432 - DB_NAME: motovaultpro - DB_USER: postgres - DB_PASSWORD: localdev123 - # Essential environment variables (until file-based config is fully implemented) - DATABASE_URL: postgresql://postgres:localdev123@admin-postgres:5432/motovaultpro - REDIS_URL: redis://admin-redis:6379 - REDIS_HOST: admin-redis - REDIS_PORT: 6379 - MINIO_ENDPOINT: admin-minio - MINIO_PORT: 9000 - MINIO_BUCKET: motovaultpro - AUTH0_DOMAIN: ${AUTH0_DOMAIN:-motovaultpro.us.auth0.com} - AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-https://api.motovaultpro.com} - AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID:-your-auth0-client-id} - AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-your-auth0-client-secret} - GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-your-google-maps-api-key} - PLATFORM_VEHICLES_API_URL: http://mvp-platform-vehicles-api:8000 - PLATFORM_TENANTS_API_URL: http://mvp-platform-tenants:8000 - PLATFORM_VEHICLES_API_KEY: mvp-platform-vehicles-secret-key - PLATFORM_TENANTS_API_KEY: mvp-platform-tenants-secret-key volumes: # Configuration files (K8s ConfigMap equivalent) - ./config/app/production.yml:/app/config/production.yml:ro @@ -221,8 +187,6 @@ services: - ./secrets/app/minio-access-key.txt:/run/secrets/minio-access-key:ro - ./secrets/app/minio-secret-key.txt:/run/secrets/minio-secret-key:ro - ./secrets/app/platform-vehicles-api-key.txt:/run/secrets/platform-vehicles-api-key:ro - - ./secrets/app/platform-tenants-api-key.txt:/run/secrets/platform-tenants-api-key:ro - - ./secrets/app/service-auth-token.txt:/run/secrets/service-auth-token:ro - ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro - ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro networks: @@ -315,10 +279,12 @@ services: environment: POSTGRES_DB: motovaultpro POSTGRES_USER: postgres - POSTGRES_PASSWORD: localdev123 + POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password POSTGRES_INITDB_ARGS: --encoding=UTF8 volumes: - admin_postgres_data:/var/lib/postgresql/data + # Secrets (K8s Secrets equivalent) + - ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro networks: - database ports: @@ -355,10 +321,13 @@ services: restart: unless-stopped command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin123 + MINIO_ROOT_USER_FILE: /run/secrets/minio-access-key + MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio-secret-key volumes: - admin_minio_data:/data + # Secrets (K8s Secrets equivalent) + - ./secrets/app/minio-access-key.txt:/run/secrets/minio-access-key:ro + - ./secrets/app/minio-secret-key.txt:/run/secrets/minio-secret-key:ro networks: - database ports: @@ -378,11 +347,13 @@ services: environment: POSTGRES_DB: platform POSTGRES_USER: platform_user - POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD:-platform123} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password POSTGRES_INITDB_ARGS: --encoding=UTF8 volumes: - platform_postgres_data:/var/lib/postgresql/data - ./mvp-platform-services/tenants/sql/schema:/docker-entrypoint-initdb.d + # Secrets (K8s Secrets equivalent) + - ./secrets/platform/platform-db-password.txt:/run/secrets/postgres-password:ro networks: - platform ports: @@ -438,11 +409,13 @@ services: environment: POSTGRES_DB: vehicles POSTGRES_USER: mvp_platform_user - POSTGRES_PASSWORD: platform123 + POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password POSTGRES_INITDB_ARGS: --encoding=UTF8 volumes: - platform_vehicles_data:/var/lib/postgresql/data - ./mvp-platform-services/vehicles/sql/schema:/docker-entrypoint-initdb.d + # Secrets (K8s Secrets equivalent) + - ./secrets/platform/vehicles-db-password.txt:/run/secrets/postgres-password:ro networks: - platform ports: diff --git a/docker-compose.yml.backup b/docker-compose.yml.backup deleted file mode 100644 index 3d595a3..0000000 --- a/docker-compose.yml.backup +++ /dev/null @@ -1,368 +0,0 @@ -services: - mvp-platform-landing: - build: - context: ./mvp-platform-services/landing - dockerfile: Dockerfile - args: - VITE_AUTH0_DOMAIN: ${AUTH0_DOMAIN:-motovaultpro.us.auth0.com} - VITE_AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} - VITE_TENANTS_API_URL: http://mvp-platform-tenants:8000 - container_name: mvp-platform-landing - environment: - VITE_AUTH0_DOMAIN: ${AUTH0_DOMAIN:-motovaultpro.us.auth0.com} - VITE_AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} - VITE_TENANTS_API_URL: http://mvp-platform-tenants:8000 - volumes: - - ./certs:/etc/nginx/certs:ro - depends_on: - - mvp-platform-tenants - healthcheck: - test: - - CMD-SHELL - - curl -s http://localhost:3000 || exit 1 - interval: 30s - timeout: 10s - retries: 3 - start_period: 20s - mvp-platform-tenants: - build: - context: ./mvp-platform-services/tenants - dockerfile: docker/Dockerfile.api - container_name: mvp-platform-tenants - environment: - DATABASE_URL: postgresql://platform_user:${PLATFORM_DB_PASSWORD:-platform123}@platform-postgres:5432/platform - AUTH0_DOMAIN: ${AUTH0_DOMAIN:-motovaultpro.us.auth0.com} - AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-https://api.motovaultpro.com} - ports: - - 8001:8000 - depends_on: - - platform-postgres - - platform-redis - healthcheck: - test: - - CMD-SHELL - - "python -c \"import urllib.request,sys;\ntry:\n with urllib.request.urlopen('http://localhost:8000/health',\ - \ timeout=3) as r:\n sys.exit(0 if r.getcode()==200 else 1)\nexcept\ - \ Exception:\n sys.exit(1)\n\"" - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - platform-postgres: - image: postgres:15-alpine - container_name: platform-postgres - environment: - POSTGRES_DB: platform - POSTGRES_USER: platform_user - POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD:-platform123} - POSTGRES_INITDB_ARGS: --encoding=UTF8 - volumes: - - platform_postgres_data:/var/lib/postgresql/data - - ./mvp-platform-services/tenants/sql/schema:/docker-entrypoint-initdb.d - ports: - - 5434:5432 - healthcheck: - test: - - CMD-SHELL - - pg_isready -U platform_user -d platform - interval: 10s - timeout: 5s - retries: 5 - platform-redis: - image: redis:7-alpine - container_name: platform-redis - command: redis-server --appendonly yes - volumes: - - platform_redis_data:/data - ports: - - 6381:6379 - healthcheck: - test: - - CMD - - redis-cli - - ping - interval: 10s - timeout: 5s - retries: 5 - admin-postgres: - image: postgres:15-alpine - container_name: admin-postgres - environment: - POSTGRES_DB: motovaultpro - POSTGRES_USER: postgres - POSTGRES_PASSWORD: localdev123 - POSTGRES_INITDB_ARGS: --encoding=UTF8 - volumes: - - admin_postgres_data:/var/lib/postgresql/data - ports: - - 5432:5432 - healthcheck: - test: - - CMD-SHELL - - pg_isready -U postgres - interval: 10s - timeout: 5s - retries: 5 - admin-redis: - image: redis:7-alpine - container_name: admin-redis - command: redis-server --appendonly yes - volumes: - - admin_redis_data:/data - ports: - - 6379:6379 - healthcheck: - test: - - CMD - - redis-cli - - ping - interval: 10s - timeout: 5s - retries: 5 - admin-minio: - image: minio/minio:latest - container_name: admin-minio - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin123 - volumes: - - admin_minio_data:/data - ports: - - 9000:9000 - - 9001:9001 - healthcheck: - test: - - CMD - - curl - - -f - - http://localhost:9000/minio/health/live - interval: 30s - timeout: 20s - retries: 3 - admin-backend: - build: - context: ./backend - dockerfile: Dockerfile - cache_from: - - node:20-alpine - container_name: admin-backend - environment: - TENANT_ID: ${TENANT_ID:-admin} - PORT: 3001 - DB_HOST: admin-postgres - DB_PORT: 5432 - DB_NAME: motovaultpro - DB_USER: postgres - DB_PASSWORD: localdev123 - REDIS_HOST: admin-redis - REDIS_PORT: 6379 - MINIO_ENDPOINT: admin-minio - MINIO_PORT: 9000 - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin123 - MINIO_BUCKET: motovaultpro - AUTH0_DOMAIN: ${AUTH0_DOMAIN:-motovaultpro.us.auth0.com} - AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID:-your-client-id} - AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-your-client-secret} - AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-https://api.motovaultpro.com} - GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-your-google-maps-key} - VPIC_API_URL: https://vpic.nhtsa.dot.gov/api/vehicles - PLATFORM_VEHICLES_API_URL: http://mvp-platform-vehicles-api:8000 - PLATFORM_VEHICLES_API_KEY: mvp-platform-vehicles-secret-key - PLATFORM_TENANTS_API_URL: ${PLATFORM_TENANTS_API_URL:-http://mvp-platform-tenants:8000} - ports: - - 3001:3001 - depends_on: - - admin-postgres - - admin-redis - - admin-minio - - mvp-platform-vehicles-api - - mvp-platform-tenants - healthcheck: - test: - - CMD-SHELL - - node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', - () => process.exit(1))" - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - admin-frontend: - build: - context: ./frontend - dockerfile: Dockerfile - cache_from: - - node:20-alpine - - nginx:alpine - args: - VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com} - VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} - VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com} - VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} - container_name: admin-frontend - environment: - VITE_TENANT_ID: ${TENANT_ID:-admin} - VITE_API_BASE_URL: /api - VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com} - VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} - VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com} - volumes: - - ./certs:/etc/nginx/certs:ro - depends_on: - - admin-backend - healthcheck: - test: - - CMD-SHELL - - curl -s http://localhost:3000 || exit 1 - interval: 30s - timeout: 10s - retries: 3 - start_period: 20s - mvp-platform-vehicles-db: - image: postgres:15-alpine - container_name: mvp-platform-vehicles-db - command: 'postgres - - -c shared_buffers=4GB - - -c work_mem=256MB - - -c maintenance_work_mem=1GB - - -c effective_cache_size=12GB - - -c max_connections=100 - - -c checkpoint_completion_target=0.9 - - -c wal_buffers=256MB - - -c max_wal_size=8GB - - -c min_wal_size=2GB - - -c synchronous_commit=off - - -c full_page_writes=off - - -c fsync=off - - -c random_page_cost=1.1 - - -c seq_page_cost=1 - - -c max_worker_processes=8 - - -c max_parallel_workers=8 - - -c max_parallel_workers_per_gather=4 - - -c max_parallel_maintenance_workers=4 - - ' - environment: - POSTGRES_DB: vehicles - POSTGRES_USER: mvp_platform_user - POSTGRES_PASSWORD: platform123 - POSTGRES_INITDB_ARGS: --encoding=UTF8 - volumes: - - platform_vehicles_data:/var/lib/postgresql/data - - ./mvp-platform-services/vehicles/sql/schema:/docker-entrypoint-initdb.d - ports: - - 5433:5432 - deploy: - resources: - limits: - memory: 6G - cpus: '6.0' - reservations: - memory: 4G - cpus: '4.0' - healthcheck: - test: - - CMD-SHELL - - pg_isready -U mvp_platform_user -d vehicles - interval: 10s - timeout: 5s - retries: 5 - mvp-platform-vehicles-redis: - image: redis:7-alpine - container_name: mvp-platform-vehicles-redis - command: redis-server --appendonly yes - volumes: - - platform_vehicles_redis_data:/data - ports: - - 6380:6379 - healthcheck: - test: - - CMD - - redis-cli - - ping - interval: 10s - timeout: 5s - retries: 5 - mvp-platform-vehicles-api: - build: - context: ./mvp-platform-services/vehicles - dockerfile: docker/Dockerfile.api - container_name: mvp-platform-vehicles-api - environment: - POSTGRES_HOST: mvp-platform-vehicles-db - POSTGRES_PORT: 5432 - POSTGRES_DATABASE: vehicles - POSTGRES_USER: mvp_platform_user - POSTGRES_PASSWORD: platform123 - REDIS_HOST: mvp-platform-vehicles-redis - REDIS_PORT: 6379 - API_KEY: mvp-platform-vehicles-secret-key - DEBUG: true - CORS_ORIGINS: '["http://localhost:3000", "https://motovaultpro.com", "http://localhost:3001"]' - ports: - - 8000:8000 - depends_on: - - mvp-platform-vehicles-db - - mvp-platform-vehicles-redis - healthcheck: - test: - - CMD - - wget - - --quiet - - --tries=1 - - --spider - - http://localhost:8000/health - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - nginx-proxy: - image: nginx:alpine - container_name: nginx-proxy - ports: - - 80:80 - - 443:443 - volumes: - - ./nginx-proxy/nginx.conf:/etc/nginx/nginx.conf:ro - - ./certs:/etc/nginx/certs:ro - depends_on: - - mvp-platform-landing - - admin-frontend - - admin-backend - restart: unless-stopped - healthcheck: - test: - - CMD - - nginx - - -t - interval: 30s - timeout: 10s - retries: 3 -volumes: - platform_postgres_data: null - platform_redis_data: null - admin_postgres_data: null - admin_redis_data: null - admin_minio_data: null - platform_vehicles_data: null - platform_vehicles_redis_data: null - platform_vehicles_mssql_data: null diff --git a/docs/DOCUMENTATION-AUDIT-REPORT.md b/docs/DOCUMENTATION-AUDIT-REPORT.md new file mode 100644 index 0000000..7ad0d89 --- /dev/null +++ b/docs/DOCUMENTATION-AUDIT-REPORT.md @@ -0,0 +1,221 @@ +# MotoVaultPro Documentation Audit Report + +**Date**: 2025-09-28 +**Auditor**: Claude AI Assistant +**Scope**: Technical accuracy, consistency, and alignment with actual codebase architecture + +## Executive Summary + +I have conducted a comprehensive audit of the MotoVaultPro project documentation and identified **14 significant issues** across 4 priority levels. The audit revealed critical infrastructure mismatches, architectural contradictions, misleading security claims, and inconsistent testing information that could cause system failures or developer confusion. + +## Audit Methodology + +### Research Scope +- All major documentation files (PLATFORM-SERVICES.md, TESTING.md, DATABASE-SCHEMA.md, SECURITY.md, VEHICLES-API.md, README files) +- Docker configuration and container architecture +- Migration system and database schemas +- Makefile commands and actual implementations +- Package.json dependencies and scripts +- Actual API endpoints and service implementations +- Testing structure and coverage claims +- Authentication and security implementations + +### Evidence Standards +- Every finding includes specific file references and line numbers +- Cross-referenced documentation claims with actual codebase implementation +- Prioritized issues by potential impact on system functionality +- Provided actionable recommendations for each issue + +## Audit Findings + +### CRITICAL Priority Issues (Will Cause Failures) + +#### 1. Platform Services Port Mismatch +**FILE**: `docs/PLATFORM-SERVICES.md` +**SECTION**: Line 78 - MVP Platform Tenants Service +**ISSUE TYPE**: Inaccuracy +**DESCRIPTION**: Claims tenants API runs on "port 8001" +**PROBLEM**: docker-compose.yml shows both platform services on port 8000, no service on 8001 +**EVIDENCE**: PLATFORM-SERVICES.md:78 vs docker-compose.yml:lines 72-120 +**RECOMMENDATION**: Correct documentation to show port 8000 for both services + +#### 2. Database Password Contradiction +**FILE**: `docs/DATABASE-SCHEMA.md` +**SECTION**: Line 200 - Database Connection +**ISSUE TYPE**: Inaccuracy +**DESCRIPTION**: Claims development password is "localdev123" +**PROBLEM**: docker-compose.yml uses secrets files, not hardcoded passwords +**EVIDENCE**: DATABASE-SCHEMA.md:200 vs docker-compose.yml:282-287 +**RECOMMENDATION**: Update to reflect secrets-based credential management + +#### 3. Migration Idempotency Contradiction +**FILE**: `docs/DATABASE-SCHEMA.md` +**SECTION**: Lines 15-16 - Migration Tracking +**ISSUE TYPE**: Contradiction +**DESCRIPTION**: Claims migrations are tracked as "idempotent" but warns "may fail on indexes without IF NOT EXISTS" +**PROBLEM**: Cannot be both idempotent and prone to failure +**EVIDENCE**: docs/VEHICLES-API.md:84 claims "idempotent" vs DATABASE-SCHEMA.md:16 warns of failures +**RECOMMENDATION**: Clarify actual migration behavior and safety guarantees + +#### 4. Health Check Endpoint Mismatch +**FILE**: `docs/PLATFORM-SERVICES.md` +**SECTION**: Lines 243-244 - Health Checks +**ISSUE TYPE**: Inaccuracy +**DESCRIPTION**: Claims health endpoints at "localhost:8001/health" +**PROBLEM**: No service running on port 8001 based on docker-compose.yml +**EVIDENCE**: PLATFORM-SERVICES.md:244 vs docker-compose.yml service definitions +**RECOMMENDATION**: Correct health check URLs to match actual service ports + +### HIGH Priority Issues (Significant Confusion) + +#### 5. Platform Service Independence Claims +**FILE**: `docs/PLATFORM-SERVICES.md` +**SECTION**: Line 98 - Service Communication +**ISSUE TYPE**: Misleading +**DESCRIPTION**: Claims platform services are "completely independent" +**PROBLEM**: Services share config files (./config/shared/production.yml) and secret directories +**EVIDENCE**: PLATFORM-SERVICES.md:98 vs docker-compose.yml:90,137,184 +**RECOMMENDATION**: Clarify actual dependency relationships and shared resources + +#### 6. Test Coverage Misrepresentation +**FILE**: `docs/README.md` +**SECTION**: Line 24 - Feature test coverage +**ISSUE TYPE**: Misleading +**DESCRIPTION**: Claims "vehicles has full coverage" +**PROBLEM**: Only 7 test files exist across all features, minimal actual coverage +**EVIDENCE**: docs/README.md:24 vs find results showing 7 total .test.ts files +**RECOMMENDATION**: Provide accurate coverage metrics or remove coverage claims + +#### 7. API Script Reference Error +**FILE**: `backend/README.md` +**SECTION**: Line 46 - Test Commands +**ISSUE TYPE**: Inaccuracy +**DESCRIPTION**: Documents command syntax as "--feature=vehicles" with equals sign +**PROBLEM**: Actual npm script uses positional argument ${npm_config_feature} +**EVIDENCE**: backend/README.md:46 vs backend/package.json:12 script definition +**RECOMMENDATION**: Correct command syntax documentation + +#### 8. Cache TTL Value Conflicts +**FILE**: `docs/VEHICLES-API.md` vs `mvp-platform-services/vehicles/api/config.py` +**SECTION**: Line 41 vs Line 35 +**ISSUE TYPE**: Contradiction +**DESCRIPTION**: Documentation claims "6 hours" default TTL, code shows 3600 (1 hour) +**PROBLEM**: Inconsistent caching behavior documentation +**EVIDENCE**: VEHICLES-API.md:41 "6 hours" vs config.py:35 "3600 (1 hour default)" +**RECOMMENDATION**: Synchronize TTL values in documentation and code + +### MEDIUM Priority Issues (Inconsistencies) + +#### 9. Architecture Pattern Confusion +**FILE**: `docs/PLATFORM-SERVICES.md` +**SECTION**: Multiple references to "4-tier isolation" +**ISSUE TYPE**: Unclear +**DESCRIPTION**: Claims "4-tier network isolation" but implementation details are unclear +**PROBLEM**: docker-compose.yml shows services sharing networks, not clear isolation +**EVIDENCE**: Makefile:57,146-149 mentions tiers vs actual network sharing in docker-compose.yml +**RECOMMENDATION**: Clarify actual network topology and isolation boundaries + +#### 10. Container Name Inconsistencies +**FILE**: Multiple documentation files +**SECTION**: Various service references +**ISSUE TYPE**: Inaccuracy +**DESCRIPTION**: Documentation uses inconsistent container naming patterns +**PROBLEM**: Makes service discovery and debugging instructions unreliable +**EVIDENCE**: Mix of "admin-backend", "backend", "mvp-platform-*" naming across docs +**RECOMMENDATION**: Standardize container name references across all documentation + +#### 11. Authentication Method Confusion +**FILE**: `docs/SECURITY.md` vs `docs/PLATFORM-SERVICES.md` +**SECTION**: Authentication sections +**ISSUE TYPE**: Contradiction +**DESCRIPTION**: Mixed claims about JWT vs API key authentication +**PROBLEM**: Unclear which auth method applies where +**EVIDENCE**: SECURITY.md mentions Auth0 JWT, PLATFORM-SERVICES.md mentions API keys +**RECOMMENDATION**: Create clear authentication flow diagram showing all methods + +#### 12. Development Workflow Claims +**FILE**: `README.md` +**SECTION**: Line 7 - Docker-first requirements +**ISSUE TYPE**: Misleading +**DESCRIPTION**: Claims "production-only" development but allows development database access +**PROBLEM**: Contradicts stated "production-only" methodology +**EVIDENCE**: README.md:7 vs docker-compose.yml:291,310,360,378,422,440 (dev ports) +**RECOMMENDATION**: Clarify actual development vs production boundaries + +### LOW Priority Issues (Minor Issues) + +#### 13. Makefile Command Documentation Gaps +**FILE**: Multiple files referencing make commands +**SECTION**: Various command references +**ISSUE TYPE**: Unclear +**DESCRIPTION**: Some documented make commands have unclear purposes +**PROBLEM**: Developers may use wrong commands for tasks +**EVIDENCE**: Makefile contains commands not well documented in usage guides +**RECOMMENDATION**: Add comprehensive command documentation + +#### 14. Feature Documentation Inconsistency +**FILE**: `backend/src/features/*/README.md` files +**SECTION**: Feature-specific documentation +**ISSUE TYPE**: Inconsistency +**DESCRIPTION**: Different documentation standards across features +**PROBLEM**: Makes onboarding and maintenance inconsistent +**EVIDENCE**: Varying detail levels and structures across feature README files +**RECOMMENDATION**: Standardize feature documentation templates + +## Analysis Summary + +### Issue Type Distribution +- **Inaccuracies**: 6 issues (43% - ports, passwords, commands, endpoints) +- **Contradictions**: 4 issues (29% - idempotency, TTL, authentication, independence) +- **Misleading**: 3 issues (21% - coverage, independence, development methodology) +- **Unclear**: 1 issue (7% - network architecture) + +### Priority Distribution +- **CRITICAL**: 4 issues (29% - will cause failures) +- **HIGH**: 4 issues (29% - significant confusion) +- **MEDIUM**: 4 issues (29% - inconsistencies) +- **LOW**: 2 issues (14% - minor issues) + +### Root Causes Analysis +1. **Documentation Drift**: Code evolved but documentation wasn't updated +2. **Multiple Sources of Truth**: Same information documented differently in multiple places +3. **Aspirational Documentation**: Documents intended behavior rather than actual implementation +4. **Incomplete Implementation**: Features documented before full implementation + +## Recommendations + +### Immediate Actions (Critical Issues) +1. **Fix Port Mismatches**: Update all port references to match docker-compose.yml +2. **Correct Database Documentation**: Reflect actual secrets-based credential management +3. **Clarify Migration Behavior**: Document actual safety guarantees and failure modes +4. **Fix Health Check URLs**: Ensure all health check examples use correct endpoints + +### Short-term Actions (High Priority) +1. **Service Dependency Audit**: Document actual shared resources and dependencies +2. **Test Coverage Analysis**: Conduct real coverage analysis and update claims +3. **Command Syntax Verification**: Validate all documented commands and examples +4. **Cache Configuration Sync**: Align all TTL documentation with actual values + +### Long-term Actions (Medium/Low Priority) +1. **Architecture Documentation Overhaul**: Create accurate diagrams of actual vs claimed isolation +2. **Naming Convention Standardization**: Establish and enforce consistent naming across docs +3. **Authentication Flow Documentation**: Develop comprehensive auth flow diagrams +4. **Documentation Standards**: Establish review processes and templates + +### Process Improvements +1. **Documentation Review Process**: Require documentation updates with code changes +2. **Automated Validation**: Create scripts to validate documented commands and endpoints +3. **Single Source of Truth**: Identify authoritative sources for each type of information +4. **Regular Audits**: Schedule periodic documentation accuracy reviews + +## Conclusion + +This audit reveals that while the MotoVaultPro project has extensive documentation, there are significant gaps between documented behavior and actual implementation. These issues range from critical infrastructure mismatches that will cause system failures to misleading architectural claims that could confuse developers and AI agents. + +The 14 identified issues provide a clear roadmap for bringing documentation in line with reality. Addressing the 4 critical issues should be the immediate priority, as these will prevent system failures when following documented procedures. + +The findings suggest implementing stronger processes to keep documentation synchronized with code changes, particularly around infrastructure configuration, API endpoints, and architectural claims. + +--- + +**Audit Completion**: All major documentation files reviewed and cross-referenced with actual codebase implementation. Evidence-based findings with specific file references and actionable recommendations provided. \ No newline at end of file diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md new file mode 100644 index 0000000..2b33ee6 --- /dev/null +++ b/docs/PROMPTS.md @@ -0,0 +1,71 @@ +*** CRITICAL ***: Never read this file. Stop and move on. + +*** STARTING PROMPT *** +## Key Commands +- Start: `make start` +- Rebuild: `make rebuild` +- Logs: `make logs` +- Test: `make test` + +## Development Rules +1. NEVER use emojis in code or documentation +2. Every feature MUST be responsive (mobile + desktop) +3. Testing and debugging can be done locally. +4. All testing and debugging needs to be verified in containers. +5. Each backend feature is self-contained in src/features/[name]/ +6. Delete old code when replacing (no commented code) +7. Use meaningful variable names (userID not id) + +## Making Changes + +### Frontend Changes (React) +- Components: `frontend/src/features/[feature]/components/` +- Types: `frontend/src/features/[feature]/types/` +- After changes: `make rebuild` then test at https://admin.motovaultpro.com + +### Backend Changes (Node.js) +- API: `backend/src/features/[feature]/api/` +- Business logic: `backend/src/features/[feature]/domain/` +- Database: `backend/src/features/[feature]/data/` +- After changes: `make rebuild` then check logs + +### Database Changes +- Add migration: `backend/src/features/[feature]/migrations/00X_description.sql` +- Run: `make migrate` + +### Adding NPM Packages +- Edit package.json (frontend or backend) +- Run `make rebuild` (no local npm install) + +## Common Tasks + +### Add a form field: +1. Update types in frontend/backend +2. Add to database migration if needed +3. Update React form component +4. Update backend validation +5. Test with `make rebuild` + +### Add new API endpoint: +1. Create route in `backend/src/features/[feature]/api/` +2. Add service method in `domain/` +3. Add repository method in `data/` +4. Test with `make rebuild` + +### Fix UI responsiveness: +1. Use Tailwind classes: `sm:`, `md:`, `lg:` +2. Test on mobile viewport (375px) and desktop (1920px) +3. Ensure touch targets are 44px minimum + +## Current Task +[Describe your specific task here - e.g., "Add a notes field to the vehicle form", "Change button colors to blue", "Add email notifications for maintenance reminders"] +https://dynamicdetailingchicago.com +https://exoticcarcolors.com/car-companies/ferrari + +## Important Context +- Auth: Frontend uses Auth0, backend validates JWTs +- Database: PostgreSQL with user-isolated data (user_id scoping) +- Platform APIs: Authenticated via API keys +- File uploads: MinIO S3-compatible storage + +What changes do you need help with today? \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 2153bc8..5059e07 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,149 +1,24 @@ # MotoVaultPro Documentation -Complete documentation for the MotoVaultPro distributed microservices platform with Modified Feature Capsule application layer and MVP Platform Services. +Project documentation hub for the hybrid platform (platform microservices) and modular monolith application. -## Quick Navigation +## Navigation -### 🚀 Getting Started -- **[AI Project Guide](../AI_PROJECT_GUIDE.md)** - Complete AI-friendly project overview and navigation -- **[Security Architecture](SECURITY.md)** - Authentication, authorization, and security considerations +- Architecture: `docs/PLATFORM-SERVICES.md` +- Security: `docs/SECURITY.md` +- Vehicles API (authoritative): `docs/VEHICLES-API.md` +- Database schema: `docs/DATABASE-SCHEMA.md` +- Testing (containers only): `docs/TESTING.md` +- Development commands: `Makefile`, `docker-compose.yml` +- Application features (start at each README): + - `backend/src/features/vehicles/README.md` + - `backend/src/features/fuel-logs/README.md` + - `backend/src/features/maintenance/README.md` + - `backend/src/features/stations/README.md` + - `backend/src/features/documents/README.md` -### 🏗️ Architecture -- **[Architecture Directory](architecture/)** - Detailed architectural documentation -- **[Platform Services Guide](PLATFORM-SERVICES.md)** - MVP Platform Services architecture and development -- **[Vehicles API (Authoritative)](VEHICLES-API.md)** - Vehicles platform service + app integration -- **Application Feature Capsules** - Each feature has complete documentation in `backend/src/features/[name]/README.md`: - - **[Vehicles](../backend/src/features/vehicles/README.md)** - Platform service consumer for vehicle management - - **[Fuel Logs](../backend/src/features/fuel-logs/README.md)** - Fuel tracking and analytics - - **[Maintenance](../backend/src/features/maintenance/README.md)** - Vehicle maintenance scheduling - - **[Stations](../backend/src/features/stations/README.md)** - Gas station location services +## Notes -### 🔧 Development -- **[Docker Setup](../docker-compose.yml)** - Complete containerized development environment -- **[Makefile Commands](../Makefile)** - All available development commands -- **[Backend Package](../backend/package.json)** - Scripts and dependencies -- **[Frontend Package](../frontend/package.json)** - React app configuration - -### 🧪 Testing -Each feature contains complete test suites: -- **Unit Tests**: `backend/src/features/[name]/tests/unit/` -- **Integration Tests**: `backend/src/features/[name]/tests/integration/` -- **Test Commands**: `npm test -- features/[feature-name]` - -### 🗄️ Database -- **[Migration System](../backend/src/_system/migrations/)** - Database schema management -- **Feature Migrations**: Each feature manages its own schema in `migrations/` directory -- **Migration Order**: vehicles → fuel-logs → maintenance → stations - -### 🔐 Security -- **[Security Overview](SECURITY.md)** - Complete security architecture -- **Authentication**: Auth0 JWT for all protected endpoints -- **Authorization**: User-scoped data access -- **External APIs**: Rate limiting and caching strategies - -### 📦 Services & Integrations - -#### MVP Platform Services -- See **Vehicles API (Authoritative)**: [VEHICLES-API.md](VEHICLES-API.md) -- Future Platform Services: Analytics, notifications, payments, document management - -#### Application Services -- **PostgreSQL**: Application data storage -- **Redis**: Application caching layer -- **MinIO**: Object storage for files - -#### External APIs -- **Google Maps API**: Station location services (1-hour cache) -- **Auth0**: Authentication and authorization - -### 🚀 Deployment -- **[Kubernetes](../k8s/)** - Production deployment manifests -- **Environment**: Ensure a valid `.env` exists at project root -- **Services**: All services containerized with health checks - -## Documentation Standards - -### Feature Documentation -Each feature capsule maintains comprehensive documentation: -- **README.md**: Complete API reference, business rules, caching strategy -- **docs/**: Additional feature-specific documentation -- **API Examples**: Request/response samples with authentication -- **Error Handling**: Complete error code documentation - -### Code Documentation -- **Self-documenting code**: Meaningful names and clear structure -- **Minimal comments**: Code should be self-explanatory -- **Type definitions**: Complete TypeScript types in `domain/types.ts` - -## Getting Help - -### Quick Commands -```bash -# Start full microservices environment -make start - -# View all logs -make logs - -# View platform service logs -make logs-platform-vehicles - -# Run all tests -make test - -# Rebuild after changes -make rebuild - -# Access container shells -make shell-backend # Application service -make shell-frontend -make shell-platform-vehicles # Platform service -``` - -### Health Checks -#### Application Services -- **Frontend**: http://localhost:3000 -- **Backend API**: http://localhost:3001/health -- **MinIO Console**: http://localhost:9001 - -#### Platform Services -- **Platform Vehicles API**: http://localhost:8000/health -- **Platform Vehicles Docs**: http://localhost:8000/docs - -### Troubleshooting -1. **Container Issues**: `make clean && make start` -2. **Database Issues**: Check `make logs-backend` for migration errors -3. **Permission Issues**: Verify USER_ID/GROUP_ID in `.env` -4. **Port Conflicts**: Ensure ports 3000, 3001, 5432, 6379, 9000, 9001 are available - -## Contributing - -### Adding New Features -1. **Generate**: `./scripts/generate-feature-capsule.sh [feature-name]` -2. **Implement**: Fill out all capsule directories (api, domain, data, etc.) -3. **Document**: Complete README.md following existing patterns -4. **Test**: Add comprehensive unit and integration tests -5. **Migrate**: Create and test database migrations - -### Code Standards -- **Service Independence**: Platform services are completely independent -- **Feature Independence**: No shared business logic between application features -- **Docker-First**: All development in containers -- **Test Coverage**: Unit and integration tests required -- **Documentation**: AI-friendly documentation for all services and features - -## Architecture Benefits - -### For AI Maintenance -- **Service-Level Context**: Load platform service docs OR feature directory for complete understanding -- **Self-Contained Components**: No need to trace dependencies across service boundaries -- **Consistent Patterns**: Platform services and application features follow consistent structures -- **Complete Documentation**: All information needed is co-located with service/feature code -- **Clear Boundaries**: Explicit separation between platform and application concerns - -### For Developers -- **Service Independence**: Work on platform services and application features independently -- **Microservices Benefits**: Independent deployment, scaling, and technology choices -- **Predictable Structure**: Same organization patterns across services and features -- **Easy Testing**: Service-level and feature-level test isolation -- **Clear Dependencies**: Explicit service communication patterns +- Canonical URLs: Frontend `https://admin.motovaultpro.com`, Backend health `http://localhost:3001/health`. +- Hosts entry required: `127.0.0.1 motovaultpro.com admin.motovaultpro.com`. +- Feature test coverage varies; vehicles has full coverage, others are in progress. diff --git a/docs/TESTING.md b/docs/TESTING.md index 5be371e..4fcb2b5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -28,7 +28,7 @@ make test ``` This executes: -- Backend: `docker compose exec backend npm test` +- Backend: `docker compose exec admin-backend npm test` - Frontend: runs Jest in a disposable Node container mounting `./frontend` ### Feature-Specific Testing @@ -134,7 +134,8 @@ make test-frontend npm test -- features/vehicles --coverage # View coverage report -open coverage/lcov-report/index.html +# Inside the container, open using your OS tooling, +# or copy the report out of the container as needed ``` ### Container Management @@ -153,7 +154,7 @@ make clean && make start ### Jest Configuration - Backend: `backend/jest.config.js` -- Frontend: `frontend/jest.config.cjs` +- Frontend: `frontend/jest.config.ts` - React + TypeScript via `ts-jest` - jsdom environment - Testing Library setup in `frontend/setupTests.ts` diff --git a/docs/changes/DOCUMENTS.md b/docs/changes/DOCUMENTS.md new file mode 100644 index 0000000..a0c5b5c --- /dev/null +++ b/docs/changes/DOCUMENTS.md @@ -0,0 +1,299 @@ +# Documents Feature Plan (S3-Compatible, Phased) + +This plan aligns with the current codebase: MinIO is running (`admin-minio`), object storage credentials are mounted as secrets, and `appConfig.getMinioConfig()` is available. We will implement a generic S3-compatible storage surface with a MinIO-backed adapter first, following the Docker‑first, production‑only workflow and mobile+desktop requirements. + +— Read me quick — +- Storage: Start with MinIO SDK via `getMinioConfig()`. Keep the interface S3‑generic to support AWS S3 later without changing features. +- Auth/Tenant: All endpoints use `[fastify.authenticate, tenantMiddleware]`. +- Testing: Use Jest; run via containers with `make test`. +- Mobile+Desktop: Follow existing Zustand nav, React Router routes, GlassCard components, and React Query offlineFirst. + +Handoff markers are provided at the end of each phase. If work pauses, pick up from the next “Done when” checklist. + +## Phase 0 — Baseline Verification + +Objectives +- Confirm configuration and dependencies to avoid rework. + +Tasks +- Verify MinIO configuration in `config/app/production.yml` → `minio.endpoint`, `minio.port`, `minio.bucket`. +- Verify mounted secrets exist for MinIO (`secrets/app/minio-access-key.txt`, `secrets/app/minio-secret-key.txt`). +- Verify backend dependency presence: + - Present: `minio@^7.1.3` + - Missing: `@fastify/multipart` (add to `backend/package.json`) +- Rebuild and tail logs + - `make rebuild` + - `make logs` + +Done when +- Containers start cleanly and backend logs show no missing module errors. + +Status +- MinIO configuration verified in repo (endpoint/port/bucket present) ✓ +- MinIO secrets present in repo (mounted paths defined) ✓ +- Package check: `minio` present ✓, `@fastify/multipart` added to backend/package.json ✓ +- Rebuild/logs runtime verification: pending (perform via `make rebuild && make logs`) + +## Phase 1 — Storage Foundation (S3-Compatible, MinIO-Backed) + +Objectives +- Create a generic storage façade used by features; implement first adapter using MinIO SDK. + +Design +- Interface `StorageService` methods: + - `putObject(bucket, key, bodyOrStream, contentType, metadata?)` + - `getObjectStream(bucket, key)` + - `deleteObject(bucket, key)` + - `headObject(bucket, key)` + - `getSignedUrl(bucket, key, { method: 'GET'|'PUT', expiresSeconds })` +- Key scheme: `documents/{userId}/{vehicleId}/{documentId}/{version}/{uuid}.{ext}` +- Security: Private objects only; short‑lived signed URLs when needed. + +Files +- `backend/src/core/storage/storage.service.ts` — façade and factory. +- `backend/src/core/storage/adapters/minio.adapter.ts` — uses MinIO SDK and `appConfig.getMinioConfig()`. + +Tasks +- Implement MinIO client using endpoint/port/accessKey/secretKey/bucket from `appConfig.getMinioConfig()`. +- Ensure streaming APIs are used for uploads/downloads. +- Implement signed URL generation for downloads with short TTL (e.g., 60–300s). + +Done when +- Service can put/head/get/delete and generate signed URLs against `admin-minio` bucket from inside the backend container. + +Status +- Storage facade added: `backend/src/core/storage/storage.service.ts` ✓ +- MinIO adapter implemented: `backend/src/core/storage/adapters/minio.adapter.ts` ✓ +- Runtime validation against MinIO: pending (validate post-rebuild) ☐ + +## Phase 2 — Backend HTTP Foundation + +Objectives +- Enable file uploads and wire security. + +Tasks +- Add `@fastify/multipart` to `backend/package.json`. +- In `backend/src/app.ts`, register multipart with config‑based limits: + - `limits.fileSize` sourced from `appConfig.config.performance.max_request_size`. +- Confirm authentication plugin and tenant middleware are active (already implemented). + +Done when +- Backend accepts multipart requests and enforces size limits without errors. + +Status +- Dependency added: `@fastify/multipart` ✓ +- Registered in `backend/src/app.ts` with byte-limit parser ✓ +- Runtime verification via container: pending ☐ + +## Phase 3 — Documents Feature Capsule (Backend) + +Objectives +- Create the feature capsule with schema, repository, service, routes, and validators, following existing patterns (see vehicles and fuel‑logs). + +Structure (backend) +``` +backend/src/features/documents/ +├── README.md +├── index.ts +├── api/ +│ ├── documents.routes.ts +│ ├── documents.controller.ts +│ └── documents.validation.ts +├── domain/ +│ ├── documents.service.ts +│ └── documents.types.ts +├── data/ +│ └── documents.repository.ts +├── migrations/ +│ └── 001_create_documents_table.sql +└── tests/ + ├── unit/ + └── integration/ +``` + +Database schema +- Table `documents`: + - `id UUID PK` + - `user_id VARCHAR(255)` + - `vehicle_id UUID` FK → `vehicles(id)` + - `document_type VARCHAR(32)` CHECK IN ('insurance','registration') + - `title VARCHAR(200)`; `notes TEXT NULL`; `details JSONB` + - `storage_bucket VARCHAR(128)`; `storage_key VARCHAR(512)` + - `file_name VARCHAR(255)`; `content_type VARCHAR(128)`; `file_size BIGINT`; `file_hash VARCHAR(128) NULL` + - `issued_date DATE NULL`; `expiration_date DATE NULL` + - `created_at TIMESTAMP DEFAULT now()`; `updated_at TIMESTAMP DEFAULT now()` with `update_updated_at_column()` trigger + - `deleted_at TIMESTAMP NULL` +- Indexes: `(user_id)`, `(vehicle_id)`, `(user_id, vehicle_id)`, `(document_type)`, `(expiration_date)`; optional GIN on `details` if needed. + +API endpoints +``` +POST /api/documents # Create metadata (with/without file) +GET /api/documents # List (filters: vehicleId, type, expiresBefore) +GET /api/documents/:id # Get metadata +PUT /api/documents/:id # Update metadata/details +DELETE /api/documents/:id # Soft delete (and delete object) +GET /api/documents/vehicle/:vehicleId # List by vehicle +POST /api/documents/:id/upload # Upload/replace file (multipart) +GET /api/documents/:id/download # Download (proxy stream or signed URL) +``` +- Pre‑handlers: `[fastify.authenticate, tenantMiddleware]` for all routes. +- Validation: Zod schemas for params/query/body in `documents.validation.ts`. +- Ownership: Validate `vehicle_id` belongs to `user_id` using vehicles pattern (like fuel‑logs). + +Wire‑up +- Register in `backend/src/app.ts`: + - `import { documentsRoutes } from './features/documents/api/documents.routes'` + - `await app.register(documentsRoutes, { prefix: '/api' })` +- Health: Update `/health` feature list to include `documents`. +- Migrations: Add `'features/documents'` to `MIGRATION_ORDER` in `backend/src/_system/migrations/run-all.ts` after `'features/vehicles'`. + +Done when +- CRUD + upload/download endpoints are reachable and secured; migrations run in correct order; ownership enforced. + +Status +- Capsule scaffolded (api/domain/data/tests/migrations/README) ✓ +- Migration added `backend/src/features/documents/migrations/001_create_documents_table.sql` ✓ +- Registered routes in `backend/src/app.ts` with `/api` prefix ✓ +- Health feature list updated to include `documents` ✓ +- Migration order updated in `backend/src/_system/migrations/run-all.ts` ✓ +- CRUD handlers for metadata implemented ✓ +- Upload endpoint implemented with multipart streaming, MIME allowlist, and storage meta update ✓ +- Download endpoint implemented with proxy streaming and inline/attachment disposition ✓ +- Ownership validation on create via vehicles check ✓ +- Runtime verification in container: pending ☐ + +## Phase 4 — Frontend Feature (Mobile + Desktop) + +Objectives +- Implement documents UI following existing navigation, layout, and data patterns. + +Structure (frontend) +``` +frontend/src/features/documents/ +├── pages/ +├── components/ +├── hooks/ +└── types/ +``` + +Navigation +- Mobile: Add “Documents” to bottom nav (Zustand store in `frontend/src/core/store/navigation.ts`). +- Desktop: Add routes in `frontend/src/App.tsx` for list/detail/upload. +- Sub‑screens (mobile): list → detail → upload; wrap content with `GlassCard`. + +Upload UX +- Mobile camera/gallery: ``. +- Desktop drag‑and‑drop with progress. +- Progress tracking: React Query mutation with progress events; optimistic updates and cache invalidation. +- Offline: Use existing React Query `offlineFirst` config; queue uploads and retry on reconnect. + +Viewer +- Inline image/PDF preview; `Content-Disposition` inline for images/PDF; gestures (pinch/zoom) for mobile images. + +Done when +- Users can list, upload, view, and delete documents on both mobile and desktop with responsive UI and progress. + +Status +- Add Documents to mobile bottom nav (Zustand): completed ✓ +- Add desktop routes in `App.tsx` (list/detail/upload): completed ✓ +- Scaffold pages/components/hooks structure: completed ✓ +- Hook list/detail CRUD endpoints with React Query: completed ✓ +- Implement upload with progress UI: completed ✓ (hooks with onUploadProgress; UI in mobile/detail) +- Optimistic updates: partial (invalidate queries on success) ◐ +- Offline queuing/retry via React Query networkMode: configured via hooks ✓ +- Previews: basic image/PDF preview implemented ✓ (DocumentPreview) +- Gesture-friendly viewer: pending ☐ +- Desktop navigation: sidebar now defaults open and includes Documents ✓ +- Build hygiene: resolved TS unused import error in frontend documents hooks ✓ + +## Phase 5 — Security, Validation, and Policies + +Objectives +- Enforce safe file handling and consistent deletion semantics. + +Tasks +- MIME allowlist: `application/pdf`, `image/jpeg`, `image/png`; reject executables. +- Upload size: Enforce via multipart limit tied to `performance.max_request_size`. +- Deletion: Soft delete DB first; delete object after. Consider retention policy later if required. +- Logging: Create/update/delete/upload/download events include `user_id`, `document_id`, `vehicle_id` (use existing logger). +- Optional rate limiting for upload route (defer dependency until needed). + +Done when +- Unsafe files rejected; logs record document events; deletions are consistent. + +Status +- MIME allowlist enforced for uploads (PDF, JPEG, PNG) ✓ +- Upload size enforced via multipart limit (config-driven) ✓ +- Deletion semantics: DB soft-delete and best-effort storage object deletion ✓ +- Event logging for document actions: pending ☐ + +## Phase 6 — Testing (Docker-First) + +Objectives +- Achieve green tests and linting across backend and frontend. + +Backend tests +- Unit: repository/service/storage adapter (mock MinIO), validators. +- Integration: API with test DB + MinIO container, stream upload/download, auth/tenant checks. + +Frontend tests +- Unit: components/forms, upload interactions, previews. +- Integration: hooks with mocked API; navigation flows for list/detail/upload. + +Commands +- `make test` (backend + frontend) +- `make shell-backend` then `npm test -- features/documents` +- `make test-frontend` + +Done when +- All tests/linters pass with zero issues; upload/download E2E verified in containers. + +Status +- Backend unit tests (service/repo/storage; validators): pending ☐ +- Backend integration tests (upload/download/auth/tenant): pending ☐ +- Frontend unit tests (components/forms/uploads/previews): pending ☐ +- Frontend integration tests (hooks + navigation flows): pending ☐ +- CI via `make test` and linters green: pending ☐ + +## Phase 7 — Reality Checkpoints and Handoff + +Checkpoints +- After each phase: `make rebuild && make logs`. +- Before moving on: Verify auth + tenant pre‑handlers, ownership checks, and mobile responsiveness. +- When interrupted: Commit current status and annotate the “Current Handoff Status” section below. + +Handoff fields (update as you go) +- Storage façade: [x] implemented [ ] validated against MinIO +- Multipart plugin: [x] registered [x] enforcing limits +- Documents migrations: [x] added [ ] executed [ ] indexes verified +- Repo/service/routes: [x] implemented [x] ownership checks +- Frontend routes/nav: [x] added [x] mobile [x] desktop +- Upload/download flows: backend [x] implemented UI [x] progress [x] preview [ ] signed URLs (optional) +- Tests: [ ] unit backend [ ] int backend [ ] unit frontend [ ] int frontend + +Diagnostics Notes +- Added `/api/health` endpoint in backend to validate Traefik routing to admin-backend for API paths. +- Fixed Fastify schema boot error by removing Zod schemas from documents routes (align with existing patterns). This prevented route registration and caused 404 on `/api/*` while server crashed/restarted. + +## S3 Compatibility Notes + +- The interface is provider‑agnostic. MinIO adapter speaks S3‑compatible API using custom endpoint and credentials from `getMinioConfig()`. +- Adding AWS S3 later: Implement `backend/src/core/storage/adapters/s3.adapter.ts` using `@aws-sdk/client-s3`, wire via a simple provider flag (e.g., `storage.provider: 'minio' | 's3'`). No feature code changes expected. +- Security parity: Keep private objects by default; consider server‑side encryption when adding AWS S3. + +## Reference Pointers + +- MinIO config: `backend/src/core/config/config-loader.ts` (`getMinioConfig()`) and `config/app/production.yml`. +- Auth plugin: `backend/src/core/plugins/auth.plugin.ts`. +- Tenant middleware: `backend/src/core/middleware/tenant.ts`. +- Migration runner: `backend/src/_system/migrations/run-all.ts` (edit `MIGRATION_ORDER`). +- Feature registration: `backend/src/app.ts` (register `documentsRoutes` and update `/health`). +- Frontend nav and layout: `frontend/src/App.tsx`, `frontend/src/core/store/navigation.ts`, `frontend/src/shared-minimal/components/mobile/GlassCard`. + +## Success Criteria + +- Documents CRUD with upload/download works on mobile and desktop. +- Ownership and tenant enforcement on every request; private object storage; safe file types. +- S3‑compatible storage layer with MinIO adapter; S3 adapter can be added without feature changes. +- All tests and linters green; migrations idempotent and ordered after vehicles. +- Build hygiene: backend TS errors fixed (unused import, override modifier, union narrowing) ✓ diff --git a/docs/MULTI-TENANT-REDESIGN.md b/docs/changes/MULTI-TENANT-REDESIGN.md similarity index 100% rename from docs/MULTI-TENANT-REDESIGN.md rename to docs/changes/MULTI-TENANT-REDESIGN.md diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..3f4688a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,34 @@ +# Frontend Quickload + +## Overview +- Tech: React 18, Vite, TypeScript, MUI, Tailwind, React Query, Zustand. +- Auth: Auth0 via `src/core/auth/Auth0Provider.tsx`. +- Data: API client in `src/core/api/client.ts` with React Query config. + +## Commands (containers) +- Build: `make rebuild` +- Tests: `make test-frontend` +- Logs: `make logs-frontend` + +## Structure +- `src/App.tsx`, `src/main.tsx` — app entry. +- `src/features/*` — feature pages/components/hooks. +- `src/core/*` — auth, api, store, hooks, query config, utils. +- `src/shared-minimal/*` — shared UI components and theme. + +## Mobile + Desktop (required) +- Layouts responsive by default; validate on small/large viewports. +- Verify Suspense fallbacks and navigation flows on both form factors. +- Test key screens: Vehicles, Fuel Logs, Documents, Settings. +- Ensure touch interactions and keyboard navigation work equivalently. + +## Testing +- Jest config: `frontend/jest.config.ts` (jsdom). +- Setup: `frontend/setupTests.ts` (Testing Library). +- Run: `make test-frontend` (containerized). + +## Patterns +- State: co-locate feature state in `src/core/store` (Zustand) and React Query for server state. +- Forms: `react-hook-form` + Zod resolvers. +- UI: MUI components; Tailwind for utility styling. + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96671aa..8ec40bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ * @ai-summary Main app component with routing and mobile navigation */ -import { useState, useEffect, useTransition, useCallback, lazy } from 'react'; +import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth0 } from '@auth0/auth0-react'; @@ -13,6 +13,7 @@ import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; +import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import { md3Theme } from './shared-minimal/theme/md3Theme'; import { Layout } from './components/Layout'; import { UnitsProvider } from './core/units/UnitsContext'; @@ -22,8 +23,11 @@ const VehiclesPage = lazy(() => import('./features/vehicles/pages/VehiclesPage') const VehicleDetailPage = lazy(() => import('./features/vehicles/pages/VehicleDetailPage').then(m => ({ default: m.VehicleDetailPage }))); const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage }))); const FuelLogsPage = lazy(() => import('./features/fuel-logs/pages/FuelLogsPage').then(m => ({ default: m.FuelLogsPage }))); +const DocumentsPage = lazy(() => import('./features/documents/pages/DocumentsPage').then(m => ({ default: m.DocumentsPage }))); +const DocumentDetailPage = lazy(() => import('./features/documents/pages/DocumentDetailPage').then(m => ({ default: m.DocumentDetailPage }))); const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen }))); const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile }))); +const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen')); import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation'; import { GlassCard } from './shared-minimal/components/mobile/GlassCard'; import { Button } from './shared-minimal/components/Button'; @@ -303,6 +307,7 @@ function App() { { key: "Dashboard", label: "Dashboard", icon: }, { key: "Vehicles", label: "Vehicles", icon: }, { key: "Log Fuel", label: "Log Fuel", icon: }, + { key: "Documents", label: "Documents", icon: }, { key: "Settings", label: "Settings", icon: }, ]; @@ -475,6 +480,34 @@ function App() { )} + {activeScreen === "Documents" && ( + + + + +
+
+ {(() => { + console.log('[App] Documents Suspense fallback triggered'); + return 'Loading documents screen...'; + })()} +
+
+
+ + }> + +
+
+
+ )} @@ -516,6 +549,8 @@ function App() { } /> } /> } /> + } /> + } /> Maintenance (TODO)} /> Stations (TODO)} /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 92a41ff..2cc8409 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -12,6 +12,7 @@ import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRound import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; +import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import MenuIcon from '@mui/icons-material/Menu'; import CloseIcon from '@mui/icons-material/Close'; import { useAppStore } from '../core/store'; @@ -25,14 +26,23 @@ interface LayoutProps { export const Layout: React.FC = ({ children, mobileMode = false }) => { const { user, logout } = useAuth0(); const { sidebarOpen, toggleSidebar } = useAppStore(); + const { setSidebarOpen } = useAppStore.getState(); const location = useLocation(); const theme = useTheme(); + // Ensure desktop has a visible navigation by default + React.useEffect(() => { + if (!mobileMode && !sidebarOpen) { + setSidebarOpen(true); + } + }, [mobileMode, sidebarOpen]); + const navigation = [ { name: 'Vehicles', href: '/vehicles', icon: }, { name: 'Fuel Logs', href: '/fuel-logs', icon: }, { name: 'Maintenance', href: '/maintenance', icon: }, { name: 'Gas Stations', href: '/stations', icon: }, + { name: 'Documents', href: '/documents', icon: }, { name: 'Settings', href: '/settings', icon: }, ]; diff --git a/frontend/src/core/error-boundaries/MobileErrorBoundary.tsx b/frontend/src/core/error-boundaries/MobileErrorBoundary.tsx index dcd1428..8c43ffe 100644 --- a/frontend/src/core/error-boundaries/MobileErrorBoundary.tsx +++ b/frontend/src/core/error-boundaries/MobileErrorBoundary.tsx @@ -33,8 +33,13 @@ export class MobileErrorBoundary extends React.Component { diff --git a/frontend/src/core/store/navigation.ts b/frontend/src/core/store/navigation.ts index e526834..dfe8fae 100644 --- a/frontend/src/core/store/navigation.ts +++ b/frontend/src/core/store/navigation.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { safeStorage } from '../utils/safe-storage'; -export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Settings'; +export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Documents' | 'Settings'; export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit'; interface NavigationHistory { @@ -210,4 +210,4 @@ export const useNavigationStore = create()( }, } ) -); \ No newline at end of file +); diff --git a/frontend/src/features/documents/api/documents.api.ts b/frontend/src/features/documents/api/documents.api.ts new file mode 100644 index 0000000..a6d7ef8 --- /dev/null +++ b/frontend/src/features/documents/api/documents.api.ts @@ -0,0 +1,51 @@ +import { apiClient } from '../../../core/api/client'; +import type { CreateDocumentRequest, DocumentRecord, UpdateDocumentRequest } from '../types/documents.types'; + +export const documentsApi = { + async list(params?: { vehicleId?: string; type?: string; expiresBefore?: string }) { + const res = await apiClient.get('/documents', { params }); + return res.data; + }, + async get(id: string) { + const res = await apiClient.get(`/documents/${id}`); + return res.data; + }, + async create(payload: CreateDocumentRequest) { + const res = await apiClient.post('/documents', payload); + return res.data; + }, + async update(id: string, payload: UpdateDocumentRequest) { + const res = await apiClient.put(`/documents/${id}`, payload); + return res.data; + }, + async remove(id: string) { + await apiClient.delete(`/documents/${id}`); + }, + async upload(id: string, file: File) { + const form = new FormData(); + form.append('file', file); + const res = await apiClient.post(`/documents/${id}/upload`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return res.data; + }, + async uploadWithProgress(id: string, file: File, onProgress?: (percent: number) => void) { + const form = new FormData(); + form.append('file', file); + const res = await apiClient.post(`/documents/${id}/upload`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (evt) => { + if (evt.total) { + const pct = Math.round((evt.loaded / evt.total) * 100); + onProgress?.(pct); + } + }, + }); + return res.data; + }, + async download(id: string) { + // Return a blob for inline preview / download + const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' }); + return res.data as Blob; + } +}; diff --git a/frontend/src/features/documents/components/AddDocumentDialog.tsx b/frontend/src/features/documents/components/AddDocumentDialog.tsx new file mode 100644 index 0000000..4dfd861 --- /dev/null +++ b/frontend/src/features/documents/components/AddDocumentDialog.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Dialog, DialogTitle, DialogContent, useMediaQuery } from '@mui/material'; +import { DocumentForm } from './DocumentForm'; + +interface AddDocumentDialogProps { + open: boolean; + onClose: () => void; +} + +export const AddDocumentDialog: React.FC = ({ open, onClose }) => { + const isSmall = useMediaQuery('(max-width:600px)'); + + return ( + + Add Document + +
+ +
+
+
+ ); +}; + +export default AddDocumentDialog; + diff --git a/frontend/src/features/documents/components/DocumentForm.tsx b/frontend/src/features/documents/components/DocumentForm.tsx new file mode 100644 index 0000000..b31498e --- /dev/null +++ b/frontend/src/features/documents/components/DocumentForm.tsx @@ -0,0 +1,342 @@ +import React from 'react'; +import { Button } from '../../../shared-minimal/components/Button'; +import { useCreateDocument } from '../hooks/useDocuments'; +import { documentsApi } from '../api/documents.api'; +import type { DocumentType } from '../types/documents.types'; +import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import type { Vehicle } from '../../vehicles/types/vehicles.types'; + +interface DocumentFormProps { + onSuccess?: () => void; + onCancel?: () => void; +} + +export const DocumentForm: React.FC = ({ onSuccess, onCancel }) => { + const [documentType, setDocumentType] = React.useState('insurance'); + const [vehicleID, setVehicleID] = React.useState(''); + const [title, setTitle] = React.useState(''); + const [notes, setNotes] = React.useState(''); + + // Insurance fields + const [insuranceCompany, setInsuranceCompany] = React.useState(''); + const [policyNumber, setPolicyNumber] = React.useState(''); + const [effectiveDate, setEffectiveDate] = React.useState(''); + const [expirationDate, setExpirationDate] = React.useState(''); + const [bodilyInjuryPerson, setBodilyInjuryPerson] = React.useState(''); + const [bodilyInjuryIncident, setBodilyInjuryIncident] = React.useState(''); + const [propertyDamage, setPropertyDamage] = React.useState(''); + const [premium, setPremium] = React.useState(''); + + // Registration fields + const [licensePlate, setLicensePlate] = React.useState(''); + const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState(''); + const [registrationCost, setRegistrationCost] = React.useState(''); + + const [file, setFile] = React.useState(null); + const [uploadProgress, setUploadProgress] = React.useState(0); + const [error, setError] = React.useState(null); + + const { data: vehicles } = useVehicles(); + const create = useCreateDocument(); + + const resetForm = () => { + setTitle(''); + setNotes(''); + setInsuranceCompany(''); + setPolicyNumber(''); + setEffectiveDate(''); + setExpirationDate(''); + setBodilyInjuryPerson(''); + setBodilyInjuryIncident(''); + setPropertyDamage(''); + setPremium(''); + setLicensePlate(''); + setRegistrationExpirationDate(''); + setRegistrationCost(''); + setFile(null); + setUploadProgress(0); + setError(null); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!vehicleID) { + setError('Please select a vehicle.'); + return; + } + if (!title.trim()) { + setError('Please enter a title.'); + return; + } + + try { + const details: Record = {}; + let issued_date: string | undefined; + let expiration_date: string | undefined; + + if (documentType === 'insurance') { + details.insuranceCompany = insuranceCompany || undefined; + details.policyNumber = policyNumber || undefined; + details.bodilyInjuryPerson = bodilyInjuryPerson || undefined; + details.bodilyInjuryIncident = bodilyInjuryIncident || undefined; + details.propertyDamage = propertyDamage || undefined; + details.premium = premium ? parseFloat(premium) : undefined; + issued_date = effectiveDate || undefined; + expiration_date = expirationDate || undefined; + } else if (documentType === 'registration') { + details.licensePlate = licensePlate || undefined; + details.cost = registrationCost ? parseFloat(registrationCost) : undefined; + expiration_date = registrationExpirationDate || undefined; + } + + const created = await create.mutateAsync({ + vehicle_id: vehicleID, + document_type: documentType, + title: title.trim(), + notes: notes.trim() || undefined, + details, + issued_date, + expiration_date, + }); + + if (file) { + const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']); + if (!file.type || !allowed.has(file.type)) { + setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); + return; + } + try { + await documentsApi.uploadWithProgress(created.id, file, (pct) => setUploadProgress(pct)); + } catch (uploadErr: any) { + const status = uploadErr?.response?.status; + if (status === 415) { + setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); + return; + } + setError(uploadErr?.message || 'Failed to upload file'); + return; + } + } + + resetForm(); + onSuccess?.(); + } catch (err: any) { + const status = err?.response?.status; + if (status === 415) { + setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); + } else { + setError(err?.message || 'Failed to create document'); + } + } finally { + setUploadProgress(0); + } + }; + + const vehicleLabel = (v: Vehicle) => { + if (v.nickname && v.nickname.trim().length > 0) return v.nickname.trim(); + const parts = [v.year, v.make, v.model, v.trimLevel].filter(Boolean); + const primary = parts.join(' ').trim(); + if (primary.length > 0) return primary; + if (v.vin && v.vin.length > 0) return v.vin; + return v.id.slice(0, 8) + '...'; + }; + + return ( +
+
+
+ + +
+ +
+ + +
+ +
+ + setTitle(e.target.value)} + required + /> +
+ + {documentType === 'insurance' && ( + <> +
+ + setInsuranceCompany(e.target.value)} + /> +
+
+ + setPolicyNumber(e.target.value)} + /> +
+ +
+ + setEffectiveDate(e.target.value)} + /> +
+
+ + setExpirationDate(e.target.value)} + /> +
+ +
+ + setBodilyInjuryPerson(e.target.value)} + /> +
+
+ + setBodilyInjuryIncident(e.target.value)} + /> +
+ +
+ + setPropertyDamage(e.target.value)} + /> +
+
+ + setPremium(e.target.value)} + /> +
+ + )} + + {documentType === 'registration' && ( + <> +
+ + setLicensePlate(e.target.value)} + /> +
+
+ + setRegistrationExpirationDate(e.target.value)} + /> +
+
+ + setRegistrationCost(e.target.value)} + /> +
+ + )} + +
+ +