Added Documents Feature

This commit is contained in:
Eric Gullickson
2025-09-28 20:35:46 -05:00
parent 2e1b588270
commit 775a1ff69e
66 changed files with 5655 additions and 944 deletions

22
.env.development Normal file
View File

@@ -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)

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
node_modules/
.env
.env.local
.env.backup
dist/
*.log
.DS_Store

24
AI-INDEX.md Normal file
View File

@@ -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`

View File

@@ -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.
See `Makefile` for authoritative commands and `docs/README.md` for navigation.

View File

@@ -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"
@sudo sh -c "truncate -s 0 /var/lib/docker/containers/**/*-json.log"

206
README.md
View File

@@ -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 <token>`
- **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
```
## 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`

View File

@@ -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:

View File

@@ -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",

View File

@@ -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

View File

@@ -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<FastifyInstance> {
const app = Fastify({
@@ -27,6 +30,36 @@ async function buildApp(): Promise<FastifyInstance> {
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<FastifyInstance> {
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<FastifyInstance> {
// 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);

View File

@@ -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

View File

@@ -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<typeof configSchema>;
type Secrets = z.infer<typeof secretsSchema>;
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<Secrets> = {};
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 };

View File

@@ -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<typeof envSchema>;
// Validate and export - now with defaults for build-time compilation
export const env = envSchema.parse(process.env);
// Environment configuration validated and exported

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { appConfig } from './config-loader';
// Simple in-memory cache for tenant validation
const tenantValidityCache = new Map<string, { ok: boolean; ts: number }>();
@@ -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<boolean> => {
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;

View File

@@ -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', {

View File

@@ -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<string, string>) => {
const meta: Record<string, string> = { ...(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<HeadObjectResult> {
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;
}

View File

@@ -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<string, string>;
}
export interface StorageService {
putObject(
bucket: string,
key: string,
body: ObjectBody,
contentType?: string,
metadata?: Record<string, string>
): Promise<void>;
getObjectStream(bucket: string, key: string): Promise<Readable>;
deleteObject(bucket: string, key: string): Promise<void>;
headObject(bucket: string, key: string): Promise<HeadObjectResult>;
getSignedUrl(bucket: string, key: string, options?: SignedUrlOptions): Promise<string>;
}
// 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;
}

View File

@@ -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
```

View File

@@ -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<import('../../../core/storage/storage.service').HeadObjectResult> = {};
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);
}

View File

@@ -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)
});
};

View File

@@ -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<typeof ListQuerySchema>;
export type IdParams = z.infer<typeof IdParamsSchema>;
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
export type CreateBody = z.infer<typeof CreateBodySchema>;
export type UpdateBody = z.infer<typeof UpdateBodySchema>;

View File

@@ -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<DocumentRecord> {
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<DocumentRecord | null> {
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<DocumentRecord[]> {
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<void> {
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<Pick<DocumentRecord, 'title'|'notes'|'details'|'issued_date'|'expiration_date'>>): Promise<DocumentRecord | null> {
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<DocumentRecord | null> {
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;
}
}

View File

@@ -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<DocumentRecord> {
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<DocumentRecord | null> {
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<void> {
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;
}
}
}

View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
export const DocumentTypeSchema = z.enum(['insurance', 'registration']);
export type DocumentType = z.infer<typeof DocumentTypeSchema>;
export interface DocumentRecord {
id: string;
user_id: string;
vehicle_id: string;
document_type: DocumentType;
title: string;
notes?: string | null;
details?: Record<string, any> | 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<typeof CreateDocumentBodySchema>;
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<typeof UpdateDocumentBodySchema>;

View File

@@ -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';

View File

@@ -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);

View File

@@ -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);
});
});
});

View File

@@ -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<Pool>;
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();
});
});
});

View File

@@ -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<DocumentsRepository>;
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');
});
});
});

View File

@@ -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<MinioClient>;
let adapter: ReturnType<typeof createMinioAdapter>;
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',
});
});
});
});

View File

@@ -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.

View File

@@ -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}`).

View File

@@ -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

View File

@@ -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}`);
}

View File

@@ -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
});

View File

@@ -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

View File

@@ -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'
});

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

71
docs/PROMPTS.md Normal file
View File

@@ -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?

View File

@@ -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.

View File

@@ -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`

299
docs/changes/DOCUMENTS.md Normal file
View File

@@ -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 Dockerfirst, productiononly workflow and mobile+desktop requirements.
— Read me quick —
- Storage: Start with MinIO SDK via `getMinioConfig()`. Keep the interface S3generic 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; shortlived 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., 60300s).
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 configbased 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 fuellogs).
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)
```
- Prehandlers: `[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 fuellogs).
Wireup
- 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.
- Subscreens (mobile): list → detail → upload; wrap content with `GlassCard`.
Upload UX
- Mobile camera/gallery: `<input type="file" accept="image/*" capture="environment" />`.
- Desktop draganddrop 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 prehandlers, 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 provideragnostic. MinIO adapter speaks S3compatible 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 serverside 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.
- S3compatible 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) ✓

34
frontend/README.md Normal file
View File

@@ -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.

View File

@@ -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: <HomeRoundedIcon /> },
{ key: "Vehicles", label: "Vehicles", icon: <DirectionsCarRoundedIcon /> },
{ key: "Log Fuel", label: "Log Fuel", icon: <LocalGasStationRoundedIcon /> },
{ key: "Documents", label: "Documents", icon: <DescriptionRoundedIcon /> },
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
];
@@ -475,6 +480,34 @@ function App() {
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "Documents" && (
<motion.div
key="documents"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="Documents">
<React.Suspense fallback={
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="text-slate-500 py-6 text-center">
{(() => {
console.log('[App] Documents Suspense fallback triggered');
return 'Loading documents screen...';
})()}
</div>
</div>
</GlassCard>
</div>
}>
<DocumentsMobileScreen />
</React.Suspense>
</MobileErrorBoundary>
</motion.div>
)}
</AnimatePresence>
<DebugInfo />
</Layout>
@@ -516,6 +549,8 @@ function App() {
<Route path="/vehicles" element={<VehiclesPage />} />
<Route path="/vehicles/:id" element={<VehicleDetailPage />} />
<Route path="/fuel-logs" element={<FuelLogsPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="/settings" element={<SettingsPage />} />

View File

@@ -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<LayoutProps> = ({ 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: <DirectionsCarRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Fuel Logs', href: '/fuel-logs', icon: <LocalGasStationRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Maintenance', href: '/maintenance', icon: <BuildRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Gas Stations', href: '/stations', icon: <PlaceRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Documents', href: '/documents', icon: <DescriptionRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Settings', href: '/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> },
];

View File

@@ -33,8 +33,13 @@ export class MobileErrorBoundary extends React.Component<MobileErrorBoundaryProp
errorInfo
});
// Log error for debugging
console.error(`Mobile screen error in ${this.props.screenName}:`, error, errorInfo);
// Enhanced logging for debugging (temporary)
console.error(`[Mobile Error Boundary] Screen: ${this.props.screenName}`);
console.error(`[Mobile Error Boundary] Error message:`, error.message);
console.error(`[Mobile Error Boundary] Error stack:`, error.stack);
console.error(`[Mobile Error Boundary] Component stack:`, errorInfo.componentStack);
console.error(`[Mobile Error Boundary] Full error object:`, error);
console.error(`[Mobile Error Boundary] Full errorInfo object:`, errorInfo);
}
handleRetry = () => {

View File

@@ -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<NavigationState>()(
},
}
)
);
);

View File

@@ -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<DocumentRecord[]>('/documents', { params });
return res.data;
},
async get(id: string) {
const res = await apiClient.get<DocumentRecord>(`/documents/${id}`);
return res.data;
},
async create(payload: CreateDocumentRequest) {
const res = await apiClient.post<DocumentRecord>('/documents', payload);
return res.data;
},
async update(id: string, payload: UpdateDocumentRequest) {
const res = await apiClient.put<DocumentRecord>(`/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<DocumentRecord>(`/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<DocumentRecord>(`/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;
}
};

View File

@@ -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<AddDocumentDialogProps> = ({ open, onClose }) => {
const isSmall = useMediaQuery('(max-width:600px)');
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth fullScreen={isSmall} PaperProps={{ sx: { maxHeight: '90vh' } }}>
<DialogTitle>Add Document</DialogTitle>
<DialogContent>
<div className="mt-2">
<DocumentForm onSuccess={onClose} onCancel={onClose} />
</div>
</DialogContent>
</Dialog>
);
};
export default AddDocumentDialog;

View File

@@ -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<DocumentFormProps> = ({ onSuccess, onCancel }) => {
const [documentType, setDocumentType] = React.useState<DocumentType>('insurance');
const [vehicleID, setVehicleID] = React.useState<string>('');
const [title, setTitle] = React.useState<string>('');
const [notes, setNotes] = React.useState<string>('');
// Insurance fields
const [insuranceCompany, setInsuranceCompany] = React.useState<string>('');
const [policyNumber, setPolicyNumber] = React.useState<string>('');
const [effectiveDate, setEffectiveDate] = React.useState<string>('');
const [expirationDate, setExpirationDate] = React.useState<string>('');
const [bodilyInjuryPerson, setBodilyInjuryPerson] = React.useState<string>('');
const [bodilyInjuryIncident, setBodilyInjuryIncident] = React.useState<string>('');
const [propertyDamage, setPropertyDamage] = React.useState<string>('');
const [premium, setPremium] = React.useState<string>('');
// Registration fields
const [licensePlate, setLicensePlate] = React.useState<string>('');
const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState<string>('');
const [registrationCost, setRegistrationCost] = React.useState<string>('');
const [file, setFile] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
const [error, setError] = React.useState<string | null>(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<string, any> = {};
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 (
<form onSubmit={handleSubmit} className="w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Vehicle</label>
<select
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
value={vehicleID}
onChange={(e) => setVehicleID(e.target.value)}
required
>
<option value="">Select vehicle...</option>
{(vehicles || []).map((v: Vehicle) => (
<option key={v.id} value={v.id}>{vehicleLabel(v)}</option>
))}
</select>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Document Type</label>
<select
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
value={documentType}
onChange={(e) => setDocumentType(e.target.value as DocumentType)}
>
<option value="insurance">Insurance</option>
<option value="registration">Registration</option>
</select>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Title</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
value={title}
placeholder={documentType === 'insurance' ? 'e.g., Progressive Policy 2025' : 'e.g., Registration 2025'}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
{documentType === 'insurance' && (
<>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Insurance company</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
value={insuranceCompany}
onChange={(e) => setInsuranceCompany(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Policy number</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
value={policyNumber}
onChange={(e) => setPolicyNumber(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Effective Date</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="date"
value={effectiveDate}
onChange={(e) => setEffectiveDate(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Expiration Date</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Bodily Injury (Person)</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
placeholder="$25,000"
value={bodilyInjuryPerson}
onChange={(e) => setBodilyInjuryPerson(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Bodily Injury (Incident)</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
placeholder="$50,000"
value={bodilyInjuryIncident}
onChange={(e) => setBodilyInjuryIncident(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Property Damage</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
placeholder="$25,000"
value={propertyDamage}
onChange={(e) => setPropertyDamage(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Premium</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="number"
step="0.01"
placeholder="0.00"
value={premium}
onChange={(e) => setPremium(e.target.value)}
/>
</div>
</>
)}
{documentType === 'registration' && (
<>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">License Plate</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
value={licensePlate}
onChange={(e) => setLicensePlate(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Expiration Date</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="date"
value={registrationExpirationDate}
onChange={(e) => setRegistrationExpirationDate(e.target.value)}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Cost</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="number"
step="0.01"
placeholder="0.00"
value={registrationCost}
onChange={(e) => setRegistrationCost(e.target.value)}
/>
</div>
</>
)}
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Notes</label>
<textarea
className="min-h-[88px] rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Upload image/PDF</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="file"
accept="image/jpeg,image/png,application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="text-sm text-slate-600 mt-1">Uploading... {uploadProgress}%</div>
)}
</div>
</div>
{error && (
<div className="text-red-600 text-sm mt-3">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-2 mt-4">
<Button type="submit" className="min-h-[44px]">Create Document</Button>
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
</div>
</form>
);
};
export default DocumentForm;

View File

@@ -0,0 +1,258 @@
/**
* @ai-summary Unit tests for DocumentPreview component
* @ai-context Tests image/PDF preview with mocked API calls
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { DocumentPreview } from './DocumentPreview';
import { documentsApi } from '../api/documents.api';
import type { DocumentRecord } from '../types/documents.types';
// Mock the documents API
jest.mock('../api/documents.api');
const mockDocumentsApi = jest.mocked(documentsApi);
// Mock URL.createObjectURL and revokeObjectURL
const mockCreateObjectURL = jest.fn();
const mockRevokeObjectURL = jest.fn();
Object.defineProperty(global.URL, 'createObjectURL', {
value: mockCreateObjectURL,
});
Object.defineProperty(global.URL, 'revokeObjectURL', {
value: mockRevokeObjectURL,
});
describe('DocumentPreview', () => {
const mockPdfDocument: DocumentRecord = {
id: 'doc-1',
user_id: 'user-1',
vehicle_id: 'vehicle-1',
document_type: 'insurance',
title: 'Insurance Document',
content_type: 'application/pdf',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const mockImageDocument: DocumentRecord = {
id: 'doc-2',
user_id: 'user-1',
vehicle_id: 'vehicle-1',
document_type: 'registration',
title: 'Registration Photo',
content_type: 'image/jpeg',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const mockNonPreviewableDocument: DocumentRecord = {
id: 'doc-3',
user_id: 'user-1',
vehicle_id: 'vehicle-1',
document_type: 'insurance',
title: 'Text Document',
content_type: 'text/plain',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
beforeEach(() => {
jest.clearAllMocks();
mockCreateObjectURL.mockReturnValue('blob:http://localhost/test-blob');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('PDF Preview', () => {
it('should render PDF preview for PDF documents', async () => {
const mockBlob = new Blob(['fake pdf content'], { type: 'application/pdf' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={mockPdfDocument} />);
// Should show loading initially
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
// Wait for the PDF object to appear
await waitFor(() => {
const pdfObject = screen.getByRole('application', { name: 'PDF Preview' });
expect(pdfObject).toBeInTheDocument();
expect(pdfObject).toHaveAttribute('data', 'blob:http://localhost/test-blob');
expect(pdfObject).toHaveAttribute('type', 'application/pdf');
});
expect(mockDocumentsApi.download).toHaveBeenCalledWith('doc-1');
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob);
});
it('should provide fallback link for PDF when object fails', async () => {
const mockBlob = new Blob(['fake pdf content'], { type: 'application/pdf' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={mockPdfDocument} />);
await waitFor(() => {
// Check that fallback link exists within the object element
const fallbackLink = screen.getByRole('link', { name: 'Open PDF' });
expect(fallbackLink).toBeInTheDocument();
expect(fallbackLink).toHaveAttribute('href', 'blob:http://localhost/test-blob');
expect(fallbackLink).toHaveAttribute('target', '_blank');
});
});
});
describe('Image Preview', () => {
it('should render image preview for image documents', async () => {
const mockBlob = new Blob(['fake image content'], { type: 'image/jpeg' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={mockImageDocument} />);
// Should show loading initially
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
// Wait for the image to appear
await waitFor(() => {
const image = screen.getByRole('img', { name: 'Registration Photo' });
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute('src', 'blob:http://localhost/test-blob');
});
expect(mockDocumentsApi.download).toHaveBeenCalledWith('doc-2');
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob);
});
it('should have proper image styling', async () => {
const mockBlob = new Blob(['fake image content'], { type: 'image/jpeg' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={mockImageDocument} />);
await waitFor(() => {
const image = screen.getByRole('img');
expect(image).toHaveClass('max-w-full', 'h-auto', 'rounded-lg', 'border');
});
});
});
describe('Non-previewable Documents', () => {
it('should show no preview message for non-previewable documents', () => {
render(<DocumentPreview doc={mockNonPreviewableDocument} />);
expect(screen.getByText('No preview available.')).toBeInTheDocument();
expect(mockDocumentsApi.download).not.toHaveBeenCalled();
});
it('should not create blob URL for non-previewable documents', () => {
render(<DocumentPreview doc={mockNonPreviewableDocument} />);
expect(mockCreateObjectURL).not.toHaveBeenCalled();
});
});
describe('Error Handling', () => {
it('should show error message when download fails', async () => {
mockDocumentsApi.download.mockRejectedValue(new Error('Download failed'));
render(<DocumentPreview doc={mockPdfDocument} />);
await waitFor(() => {
expect(screen.getByText('Failed to load preview')).toBeInTheDocument();
});
expect(mockDocumentsApi.download).toHaveBeenCalledWith('doc-1');
expect(mockCreateObjectURL).not.toHaveBeenCalled();
});
it('should handle network errors gracefully', async () => {
mockDocumentsApi.download.mockRejectedValue(new Error('Network error'));
render(<DocumentPreview doc={mockImageDocument} />);
await waitFor(() => {
expect(screen.getByText('Failed to load preview')).toBeInTheDocument();
});
});
});
describe('Content Type Detection', () => {
it('should detect PDF from content type', () => {
render(<DocumentPreview doc={mockPdfDocument} />);
// PDF should be considered previewable
expect(screen.queryByText('No preview available.')).not.toBeInTheDocument();
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
});
it('should detect images from content type', () => {
render(<DocumentPreview doc={mockImageDocument} />);
// Image should be considered previewable
expect(screen.queryByText('No preview available.')).not.toBeInTheDocument();
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
});
it('should handle PNG images', async () => {
const pngDocument = { ...mockImageDocument, content_type: 'image/png' };
const mockBlob = new Blob(['fake png content'], { type: 'image/png' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={pngDocument} />);
await waitFor(() => {
const image = screen.getByRole('img');
expect(image).toBeInTheDocument();
});
});
it('should handle documents with undefined content type', () => {
const undefinedTypeDoc = { ...mockPdfDocument, content_type: undefined };
render(<DocumentPreview doc={undefinedTypeDoc} />);
expect(screen.getByText('No preview available.')).toBeInTheDocument();
});
});
describe('Memory Management', () => {
it('should clean up blob URL on unmount', async () => {
const mockBlob = new Blob(['fake content'], { type: 'application/pdf' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
const { unmount } = render(<DocumentPreview doc={mockPdfDocument} />);
await waitFor(() => {
expect(mockCreateObjectURL).toHaveBeenCalled();
});
unmount();
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/test-blob');
});
it('should clean up blob URL when document changes', async () => {
const mockBlob1 = new Blob(['fake content 1'], { type: 'application/pdf' });
const mockBlob2 = new Blob(['fake content 2'], { type: 'image/jpeg' });
mockDocumentsApi.download
.mockResolvedValueOnce(mockBlob1)
.mockResolvedValueOnce(mockBlob2);
const { rerender } = render(<DocumentPreview doc={mockPdfDocument} />);
await waitFor(() => {
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob1);
});
// Change to different document
rerender(<DocumentPreview doc={mockImageDocument} />);
await waitFor(() => {
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/test-blob');
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob2);
});
});
});
});

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { DocumentRecord } from '../types/documents.types';
import { documentsApi } from '../api/documents.api';
import { GestureImageViewer } from './GestureImageViewer';
interface Props {
doc: DocumentRecord;
}
export const DocumentPreview: React.FC<Props> = ({ doc }) => {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const previewable = useMemo(() => {
return doc.content_type === 'application/pdf' || doc.content_type?.startsWith('image/');
}, [doc.content_type]);
useEffect(() => {
let revoked: string | null = null;
(async () => {
try {
if (!previewable) return;
const data = await documentsApi.download(doc.id);
const url = URL.createObjectURL(data);
setBlobUrl(url);
revoked = url;
} catch (e) {
setError('Failed to load preview');
}
})();
return () => {
if (revoked) URL.revokeObjectURL(revoked);
};
}, [doc.id, previewable]);
if (!previewable) return <div className="text-slate-500 text-sm">No preview available.</div>;
if (error) return <div className="text-red-600 text-sm">{error}</div>;
if (!blobUrl) return <div className="text-slate-500 text-sm">Loading preview...</div>;
if (doc.content_type === 'application/pdf') {
return (
<object data={blobUrl} type="application/pdf" className="w-full h-[60vh] rounded-lg border" aria-label="PDF Preview">
<a href={blobUrl} target="_blank" rel="noopener noreferrer">Open PDF</a>
</object>
);
}
return (
<div className="rounded-lg border overflow-hidden bg-gray-50">
<GestureImageViewer
src={blobUrl}
alt={doc.title}
className="max-w-full h-auto min-h-[300px] max-h-[80vh]"
/>
</div>
);
};
export default DocumentPreview;

View File

@@ -0,0 +1,282 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
interface Props {
src: string;
alt: string;
className?: string;
}
interface Transform {
scale: number;
translateX: number;
translateY: number;
}
export const GestureImageViewer: React.FC<Props> = ({ src, alt, className = '' }) => {
const imageRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [transform, setTransform] = useState<Transform>({
scale: 1,
translateX: 0,
translateY: 0,
});
const [isGestureActive, setIsGestureActive] = useState(false);
const lastTouchRef = useRef<{ touches: React.TouchList; time: number } | null>(null);
const initialTransformRef = useRef<Transform>({ scale: 1, translateX: 0, translateY: 0 });
const initialDistanceRef = useRef<number>(0);
const initialCenterRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
// Calculate distance between two touch points
const getDistance = useCallback((touches: React.TouchList): number => {
if (touches.length < 2) return 0;
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}, []);
// Calculate center point between two touches
const getCenter = useCallback((touches: React.TouchList): { x: number; y: number } => {
if (touches.length === 1) {
return { x: touches[0].clientX, y: touches[0].clientY };
}
if (touches.length >= 2) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
};
}
return { x: 0, y: 0 };
}, []);
// Get bounding box relative coordinates
const getRelativeCoordinates = useCallback((clientX: number, clientY: number) => {
if (!containerRef.current) return { x: 0, y: 0 };
const rect = containerRef.current.getBoundingClientRect();
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
}, []);
// Constrain transform to prevent image from moving too far out of bounds
const constrainTransform = useCallback((newTransform: Transform): Transform => {
if (!containerRef.current || !imageRef.current) return newTransform;
const containerRect = containerRef.current.getBoundingClientRect();
const imageRect = imageRef.current.getBoundingClientRect();
const scaledWidth = imageRect.width * newTransform.scale;
const scaledHeight = imageRect.height * newTransform.scale;
// Calculate maximum allowed translation
const maxTranslateX = Math.max(0, (scaledWidth - containerRect.width) / 2);
const maxTranslateY = Math.max(0, (scaledHeight - containerRect.height) / 2);
return {
scale: Math.max(0.5, Math.min(5, newTransform.scale)), // Constrain scale between 0.5x and 5x
translateX: Math.max(-maxTranslateX, Math.min(maxTranslateX, newTransform.translateX)),
translateY: Math.max(-maxTranslateY, Math.min(maxTranslateY, newTransform.translateY)),
};
}, []);
// Handle touch start
const handleTouchStart = useCallback((e: React.TouchEvent) => {
e.preventDefault();
setIsGestureActive(true);
const touches = e.touches;
initialTransformRef.current = transform;
if (touches.length === 2) {
// Pinch gesture
initialDistanceRef.current = getDistance(touches);
const center = getCenter(touches);
initialCenterRef.current = getRelativeCoordinates(center.x, center.y);
} else if (touches.length === 1) {
// Pan gesture or tap
const center = getCenter(touches);
initialCenterRef.current = getRelativeCoordinates(center.x, center.y);
// Track for double-tap detection
const now = Date.now();
const lastTouch = lastTouchRef.current;
if (lastTouch && now - lastTouch.time < 300 && touches.length === 1) {
// Double tap - toggle zoom
const isZoomedIn = transform.scale > 1.1;
const newScale = isZoomedIn ? 1 : 2;
if (isZoomedIn) {
// Reset to original
setTransform({ scale: 1, translateX: 0, translateY: 0 });
} else {
// Zoom to double-tap location
const center = getRelativeCoordinates(touches[0].clientX, touches[0].clientY);
setTransform({
scale: newScale,
translateX: (containerRef.current!.clientWidth / 2 - center.x) * (newScale - 1),
translateY: (containerRef.current!.clientHeight / 2 - center.y) * (newScale - 1),
});
}
lastTouchRef.current = null;
return;
}
lastTouchRef.current = { touches, time: now };
}
}, [transform, getDistance, getCenter, getRelativeCoordinates]);
// Handle touch move
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isGestureActive) return;
e.preventDefault();
const touches = e.touches;
if (touches.length === 2) {
// Pinch zoom
const currentDistance = getDistance(touches);
const center = getCenter(touches);
const currentCenter = getRelativeCoordinates(center.x, center.y);
if (initialDistanceRef.current > 0) {
const scaleChange = currentDistance / initialDistanceRef.current;
const newScale = initialTransformRef.current.scale * scaleChange;
// Calculate translation to keep zoom centered on pinch point
const scaleDiff = newScale - initialTransformRef.current.scale;
const centerOffsetX = currentCenter.x - containerRef.current!.clientWidth / 2;
const centerOffsetY = currentCenter.y - containerRef.current!.clientHeight / 2;
const newTransform: Transform = {
scale: newScale,
translateX: initialTransformRef.current.translateX - centerOffsetX * scaleDiff,
translateY: initialTransformRef.current.translateY - centerOffsetY * scaleDiff,
};
setTransform(constrainTransform(newTransform));
}
} else if (touches.length === 1 && transform.scale > 1) {
// Pan when zoomed in
const currentCenter = getRelativeCoordinates(touches[0].clientX, touches[0].clientY);
const deltaX = currentCenter.x - initialCenterRef.current.x;
const deltaY = currentCenter.y - initialCenterRef.current.y;
const newTransform: Transform = {
scale: transform.scale,
translateX: initialTransformRef.current.translateX + deltaX,
translateY: initialTransformRef.current.translateY + deltaY,
};
setTransform(constrainTransform(newTransform));
}
}, [isGestureActive, transform.scale, getDistance, getCenter, getRelativeCoordinates, constrainTransform]);
// Handle touch end
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 0) {
setIsGestureActive(false);
}
}, []);
// Handle wheel zoom for desktop
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const centerX = e.clientX - rect.left;
const centerY = e.clientY - rect.top;
const newScale = transform.scale * delta;
const scaleDiff = newScale - transform.scale;
const centerOffsetX = centerX - rect.width / 2;
const centerOffsetY = centerY - rect.height / 2;
const newTransform: Transform = {
scale: newScale,
translateX: transform.translateX - centerOffsetX * scaleDiff,
translateY: transform.translateY - centerOffsetY * scaleDiff,
};
setTransform(constrainTransform(newTransform));
}, [transform, constrainTransform]);
// Reset transform when image changes
useEffect(() => {
setTransform({ scale: 1, translateX: 0, translateY: 0 });
}, [src]);
// Set appropriate touch-action CSS to manage touch behavior
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Use CSS touch-action instead of global preventDefault
// This allows normal scrolling when not actively gesturing
if (isGestureActive) {
container.style.touchAction = 'none';
} else {
container.style.touchAction = 'manipulation';
}
return () => {
if (container) {
container.style.touchAction = '';
}
};
}, [isGestureActive]);
const transformStyle = {
transform: `scale(${transform.scale}) translate(${transform.translateX}px, ${transform.translateY}px)`,
transformOrigin: 'center center',
transition: isGestureActive ? 'none' : 'transform 0.2s ease-out',
};
return (
<div
ref={containerRef}
className={`relative overflow-hidden touch-none select-none ${className}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onWheel={handleWheel}
style={{
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: isGestureActive ? 'none' : 'manipulation',
}}
>
<img
ref={imageRef}
src={src}
alt={alt}
style={transformStyle}
className="w-full h-auto object-contain pointer-events-none"
draggable={false}
/>
{/* Reset button for zoomed images */}
{transform.scale > 1.1 && (
<button
onClick={() => setTransform({ scale: 1, translateX: 0, translateY: 0 })}
className="absolute top-4 right-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-sm font-medium backdrop-blur-sm hover:bg-opacity-70 transition-colors"
aria-label="Reset zoom"
>
Reset
</button>
)}
{/* Instructions overlay for first-time users */}
{transform.scale === 1 && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-xs backdrop-blur-sm pointer-events-none">
Pinch to zoom Double-tap to zoom Drag to pan
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,227 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { documentsApi } from '../api/documents.api';
import type { CreateDocumentRequest, UpdateDocumentRequest, DocumentRecord } from '../types/documents.types';
export function useDocumentsList(filters?: { vehicleId?: string; type?: string; expiresBefore?: string }) {
const queryKey = ['documents', filters];
const query = useQuery({
queryKey,
queryFn: () => documentsApi.list(filters),
networkMode: 'offlineFirst',
});
return query;
}
export function useDocument(id?: string) {
const query = useQuery({
queryKey: ['document', id],
queryFn: () => documentsApi.get(id!),
enabled: !!id,
networkMode: 'offlineFirst',
});
return query;
}
export function useCreateDocument() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateDocumentRequest) => documentsApi.create(payload),
onMutate: async (newDocument) => {
// Cancel any outgoing refetches to avoid overwriting optimistic update
await qc.cancelQueries({ queryKey: ['documents'] });
// Snapshot previous value
const previousDocuments = qc.getQueryData(['documents']);
// Create optimistic document record
const optimisticDocument: DocumentRecord = {
id: `temp-${Date.now()}`, // Temporary ID
user_id: '', // Will be filled by server
vehicle_id: newDocument.vehicle_id,
document_type: newDocument.document_type,
title: newDocument.title,
notes: newDocument.notes || null,
details: newDocument.details || null,
storage_bucket: null,
storage_key: null,
file_name: null,
content_type: null,
file_size: null,
file_hash: null,
issued_date: newDocument.issued_date || null,
expiration_date: newDocument.expiration_date || null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted_at: null,
};
// Optimistically update cache
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
return old ? [optimisticDocument, ...old] : [optimisticDocument];
});
// Return context object with rollback data
return { previousDocuments };
},
onError: (_err, _newDocument, context) => {
// Rollback to previous state on error
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
onSettled: () => {
// Always refetch to ensure consistency
qc.invalidateQueries({ queryKey: ['documents'] });
},
networkMode: 'offlineFirst',
});
}
export function useUpdateDocument(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateDocumentRequest) => documentsApi.update(id, payload),
onMutate: async (updateData) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['document', id] });
await qc.cancelQueries({ queryKey: ['documents'] });
// Snapshot previous values
const previousDocument = qc.getQueryData(['document', id]);
const previousDocuments = qc.getQueryData(['documents']);
// Optimistically update individual document
qc.setQueryData(['document', id], (old: DocumentRecord | undefined) => {
if (!old) return old;
return {
...old,
...updateData,
updated_at: new Date().toISOString(),
};
});
// Optimistically update documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.map(doc =>
doc.id === id
? { ...doc, ...updateData, updated_at: new Date().toISOString() }
: doc
);
});
return { previousDocument, previousDocuments };
},
onError: (_err, _updateData, context) => {
// Rollback on error
if (context?.previousDocument) {
qc.setQueryData(['document', id], context.previousDocument);
}
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
onSettled: () => {
// Refetch to ensure consistency
qc.invalidateQueries({ queryKey: ['document', id] });
qc.invalidateQueries({ queryKey: ['documents'] });
},
networkMode: 'offlineFirst',
});
}
export function useDeleteDocument() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => documentsApi.remove(id),
onMutate: async (id) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['documents'] });
await qc.cancelQueries({ queryKey: ['document', id] });
// Snapshot previous values
const previousDocuments = qc.getQueryData(['documents']);
const previousDocument = qc.getQueryData(['document', id]);
// Optimistically remove from documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.filter(doc => doc.id !== id);
});
// Remove individual document from cache
qc.removeQueries({ queryKey: ['document', id] });
return { previousDocuments, previousDocument, deletedId: id };
},
onError: (_err, _id, context) => {
// Rollback on error
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
if (context?.previousDocument && context.deletedId) {
qc.setQueryData(['document', context.deletedId], context.previousDocument);
}
},
onSettled: () => {
// Refetch to ensure consistency
qc.invalidateQueries({ queryKey: ['documents'] });
},
networkMode: 'offlineFirst',
});
}
export function useUploadDocument(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (file: File) => documentsApi.upload(id, file),
onMutate: async (file) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['document', id] });
await qc.cancelQueries({ queryKey: ['documents'] });
// Snapshot previous values
const previousDocument = qc.getQueryData(['document', id]);
const previousDocuments = qc.getQueryData(['documents']);
// Optimistically update with upload in progress state
const optimisticUpdate = {
file_name: file.name,
content_type: file.type,
file_size: file.size,
updated_at: new Date().toISOString(),
};
// Update individual document
qc.setQueryData(['document', id], (old: DocumentRecord | undefined) => {
if (!old) return old;
return { ...old, ...optimisticUpdate };
});
// Update documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.map(doc =>
doc.id === id ? { ...doc, ...optimisticUpdate } : doc
);
});
return { previousDocument, previousDocuments };
},
onError: (_err, _file, context) => {
// Rollback on error
if (context?.previousDocument) {
qc.setQueryData(['document', id], context.previousDocument);
}
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
onSettled: () => {
// Refetch to get server state (including storage_bucket, storage_key, etc.)
qc.invalidateQueries({ queryKey: ['document', id] });
qc.invalidateQueries({ queryKey: ['documents'] });
},
networkMode: 'offlineFirst',
});
}

View File

@@ -0,0 +1,69 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { documentsApi } from '../api/documents.api';
import type { DocumentRecord } from '../types/documents.types';
export function useUploadWithProgress(documentId: string) {
const qc = useQueryClient();
const [progress, setProgress] = useState<number>(0);
const mutation = useMutation({
mutationFn: (file: File) => documentsApi.uploadWithProgress(documentId, file, setProgress),
onMutate: async (file) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['document', documentId] });
await qc.cancelQueries({ queryKey: ['documents'] });
// Snapshot previous values
const previousDocument = qc.getQueryData(['document', documentId]);
const previousDocuments = qc.getQueryData(['documents']);
// Optimistically update with upload in progress state
const optimisticUpdate = {
file_name: file.name,
content_type: file.type,
file_size: file.size,
updated_at: new Date().toISOString(),
};
// Update individual document
qc.setQueryData(['document', documentId], (old: DocumentRecord | undefined) => {
if (!old) return old;
return { ...old, ...optimisticUpdate };
});
// Update documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.map(doc =>
doc.id === documentId ? { ...doc, ...optimisticUpdate } : doc
);
});
return { previousDocument, previousDocuments };
},
onSuccess: () => {
setProgress(0);
// Refetch to get complete server state
qc.invalidateQueries({ queryKey: ['document', documentId] });
qc.invalidateQueries({ queryKey: ['documents'] });
},
onError: (_err, _file, context) => {
setProgress(0);
// Rollback on error
if (context?.previousDocument) {
qc.setQueryData(['document', documentId], context.previousDocument);
}
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
networkMode: 'offlineFirst',
});
return {
...mutation,
progress,
resetProgress: () => setProgress(0),
};
}

View File

@@ -0,0 +1,409 @@
/**
* @ai-summary Unit tests for DocumentsMobileScreen component
* @ai-context Tests mobile UI with mocked hooks and navigation
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DocumentsMobileScreen } from './DocumentsMobileScreen';
import { useDocumentsList } from '../hooks/useDocuments';
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
import { useNavigate } from 'react-router-dom';
import type { DocumentRecord } from '../types/documents.types';
// Mock dependencies
jest.mock('../hooks/useDocuments');
jest.mock('../hooks/useUploadWithProgress');
jest.mock('react-router-dom');
const mockUseDocumentsList = jest.mocked(useDocumentsList);
const mockUseUploadWithProgress = jest.mocked(useUploadWithProgress);
const mockUseNavigate = jest.mocked(useNavigate);
describe('DocumentsMobileScreen', () => {
const mockNavigate = jest.fn();
const mockUploadMutate = jest.fn();
const mockDocuments: DocumentRecord[] = [
{
id: 'doc-1',
user_id: 'user-1',
vehicle_id: 'vehicle-1',
document_type: 'insurance',
title: 'Car Insurance',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
{
id: 'doc-2',
user_id: 'user-1',
vehicle_id: 'vehicle-2',
document_type: 'registration',
title: 'Vehicle Registration',
created_at: '2024-01-02T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
},
];
beforeEach(() => {
jest.clearAllMocks();
mockUseNavigate.mockReturnValue(mockNavigate);
mockUseUploadWithProgress.mockReturnValue({
mutate: mockUploadMutate,
isPending: false,
progress: 0,
isSuccess: false,
isError: false,
error: null,
resetProgress: jest.fn(),
data: undefined,
variables: undefined,
isIdle: true,
status: 'idle',
mutateAsync: jest.fn(),
reset: jest.fn(),
} as any);
mockUseDocumentsList.mockReturnValue({
data: mockDocuments,
isLoading: false,
error: null,
refetch: jest.fn(),
isError: false,
isPending: false,
isSuccess: true,
status: 'success',
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPlaceholderData: false,
isPaused: false,
isRefetching: false,
isRefetchError: false,
isLoadingError: false,
isStale: false,
} as any);
});
describe('Document List Display', () => {
it('should render documents list', () => {
render(<DocumentsMobileScreen />);
expect(screen.getByText('Documents')).toBeInTheDocument();
expect(screen.getByText('Car Insurance')).toBeInTheDocument();
expect(screen.getByText('Vehicle Registration')).toBeInTheDocument();
});
it('should display document metadata', () => {
render(<DocumentsMobileScreen />);
// Check document types and vehicle IDs are displayed
expect(screen.getByText(/insurance/)).toBeInTheDocument();
expect(screen.getByText(/registration/)).toBeInTheDocument();
expect(screen.getByText(/vehicle-1/)).toBeInTheDocument();
expect(screen.getByText(/vehicle-2/)).toBeInTheDocument();
});
it('should truncate long vehicle IDs', () => {
const longVehicleId = 'very-long-vehicle-id-that-should-be-truncated';
const documentsWithLongId = [
{
...mockDocuments[0],
vehicle_id: longVehicleId,
},
];
mockUseDocumentsList.mockReturnValue({
data: documentsWithLongId,
isLoading: false,
error: null,
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
// Should show truncated version
expect(screen.getByText(/very-lon\.\.\./)).toBeInTheDocument();
expect(screen.queryByText(longVehicleId)).not.toBeInTheDocument();
});
});
describe('Loading States', () => {
it('should show loading message', () => {
mockUseDocumentsList.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should show error message', () => {
mockUseDocumentsList.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load'),
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
expect(screen.getByText('Failed to load documents')).toBeInTheDocument();
});
it('should handle empty documents list', () => {
mockUseDocumentsList.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
expect(screen.getByText('Documents')).toBeInTheDocument();
// Should not crash with empty list
});
});
describe('Navigation', () => {
it('should navigate to document detail when Open is clicked', async () => {
const user = userEvent.setup();
render(<DocumentsMobileScreen />);
const openButtons = screen.getAllByText('Open');
await user.click(openButtons[0]);
expect(mockNavigate).toHaveBeenCalledWith('/documents/doc-1');
});
it('should navigate to correct document for each Open button', async () => {
const user = userEvent.setup();
render(<DocumentsMobileScreen />);
const openButtons = screen.getAllByText('Open');
await user.click(openButtons[0]);
expect(mockNavigate).toHaveBeenCalledWith('/documents/doc-1');
await user.click(openButtons[1]);
expect(mockNavigate).toHaveBeenCalledWith('/documents/doc-2');
});
});
describe('File Upload', () => {
let mockFileInput: HTMLInputElement;
beforeEach(() => {
// Mock file input element
mockFileInput = document.createElement('input');
mockFileInput.type = 'file';
mockFileInput.click = jest.fn();
jest.spyOn(document, 'createElement').mockReturnValue(mockFileInput as any);
});
it('should trigger file upload when Upload button is clicked', async () => {
const user = userEvent.setup();
render(<DocumentsMobileScreen />);
const uploadButtons = screen.getAllByText('Upload');
await user.click(uploadButtons[0]);
// Should clear and click the hidden file input
expect(mockFileInput.value).toBe('');
expect(mockFileInput.click).toHaveBeenCalled();
});
it('should set correct document ID when upload button is clicked', async () => {
const user = userEvent.setup();
render(<DocumentsMobileScreen />);
const uploadButtons = screen.getAllByText('Upload');
await user.click(uploadButtons[1]); // Click second document's upload
// Verify the component tracks the current document ID
// This is tested indirectly through the file change handler
});
it('should handle file selection and upload', async () => {
render(<DocumentsMobileScreen />);
const uploadButtons = screen.getAllByText('Upload');
fireEvent.click(uploadButtons[0]);
// Simulate file selection
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const fileInput = screen.getByRole('textbox', { hidden: true }) ||
document.querySelector('input[type="file"]') as HTMLInputElement;
if (fileInput) {
Object.defineProperty(fileInput, 'files', {
value: [file],
writable: false,
});
fireEvent.change(fileInput);
expect(mockUploadMutate).toHaveBeenCalledWith(file);
}
});
it('should show upload progress during upload', () => {
mockUseUploadWithProgress.mockReturnValue({
mutate: mockUploadMutate,
isPending: true,
progress: 45,
isSuccess: false,
isError: false,
error: null,
resetProgress: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
expect(screen.getByText('45%')).toBeInTheDocument();
});
it('should show progress only for the uploading document', async () => {
const user = userEvent.setup();
// Mock upload in progress for first document
mockUseUploadWithProgress.mockImplementation((docId: string) => ({
mutate: mockUploadMutate,
isPending: docId === 'doc-1',
progress: docId === 'doc-1' ? 75 : 0,
isSuccess: false,
isError: false,
error: null,
resetProgress: jest.fn(),
} as any));
render(<DocumentsMobileScreen />);
// Click upload for first document
const uploadButtons = screen.getAllByText('Upload');
await user.click(uploadButtons[0]);
// Should show progress for first document only
expect(screen.getByText('75%')).toBeInTheDocument();
// Should not show progress for other documents
const progressElements = screen.getAllByText(/\d+%/);
expect(progressElements).toHaveLength(1);
});
});
describe('File Input Configuration', () => {
it('should configure file input with correct accept types', () => {
render(<DocumentsMobileScreen />);
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).toBeTruthy();
expect(fileInput!).toHaveAttribute('accept', 'image/*,application/pdf');
});
it('should hide file input from UI', () => {
render(<DocumentsMobileScreen />);
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).toBeTruthy();
expect(fileInput!).toHaveClass('hidden');
});
});
describe('Document Cards Layout', () => {
it('should render documents in individual cards', () => {
render(<DocumentsMobileScreen />);
// Each document should be in its own bordered container
const documentCards = screen.getAllByRole('generic').filter(el =>
el.className.includes('border') && el.className.includes('rounded-xl')
);
expect(documentCards.length).toBeGreaterThan(0);
});
it('should display action buttons for each document', () => {
render(<DocumentsMobileScreen />);
const openButtons = screen.getAllByText('Open');
const uploadButtons = screen.getAllByText('Upload');
expect(openButtons).toHaveLength(mockDocuments.length);
expect(uploadButtons).toHaveLength(mockDocuments.length);
});
});
describe('Error Handling', () => {
it('should handle missing vehicle_id gracefully', () => {
const documentsWithMissingVehicle = [
{
...mockDocuments[0],
vehicle_id: null as any,
},
];
mockUseDocumentsList.mockReturnValue({
data: documentsWithMissingVehicle,
isLoading: false,
error: null,
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
// Should show placeholder for missing vehicle ID
expect(screen.getByText('—')).toBeInTheDocument();
});
it('should handle upload errors gracefully', () => {
mockUseUploadWithProgress.mockReturnValue({
mutate: mockUploadMutate,
isPending: false,
progress: 0,
isSuccess: false,
isError: true,
error: new Error('Upload failed'),
resetProgress: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
// Component should still render without crashing
expect(screen.getByText('Documents')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper heading structure', () => {
render(<DocumentsMobileScreen />);
const heading = screen.getByRole('heading', { name: 'Documents' });
expect(heading).toBeInTheDocument();
expect(heading.tagName).toBe('H2');
});
it('should have accessible buttons', () => {
render(<DocumentsMobileScreen />);
const openButtons = screen.getAllByRole('button', { name: 'Open' });
const uploadButtons = screen.getAllByRole('button', { name: 'Upload' });
expect(openButtons).toHaveLength(mockDocuments.length);
expect(uploadButtons).toHaveLength(mockDocuments.length);
});
});
});

View File

@@ -0,0 +1,211 @@
import React, { useRef } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { isAxiosError } from 'axios';
import { useNavigate } from 'react-router-dom';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { useDocumentsList } from '../hooks/useDocuments';
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
import { Button } from '../../../shared-minimal/components/Button';
import { AddDocumentDialog } from '../components/AddDocumentDialog';
export const DocumentsMobileScreen: React.FC = () => {
console.log('[DocumentsMobileScreen] Component initializing');
// Auth is managed at App level; keep hook to support session-expired UI.
// In test environments without provider, fall back gracefully.
let auth = { isAuthenticated: true, isLoading: false, loginWithRedirect: () => {} } as any;
try {
auth = useAuth0();
} catch {
// Tests render without Auth0Provider; assume authenticated for unit tests.
}
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = auth;
// Data hooks (unconditional per React rules)
const { data, isLoading, error } = useDocumentsList();
const inputRef = useRef<HTMLInputElement | null>(null);
const [currentId, setCurrentId] = React.useState<string | null>(null);
const upload = useUploadWithProgress(currentId || '');
const navigate = useNavigate();
const [isAddOpen, setIsAddOpen] = React.useState(false);
const triggerUpload = (docId: string) => {
try {
setCurrentId(docId);
if (!inputRef.current) return;
inputRef.current.value = '';
inputRef.current.click();
} catch (error) {
console.error('[Documents Mobile] Upload trigger error:', error);
}
};
const onFileChange = () => {
try {
const file = inputRef.current?.files?.[0];
if (file && currentId) {
const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']);
if (!file.type || !allowed.has(file.type)) {
alert('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
return;
}
upload.mutate(file);
}
} catch (error) {
console.error('[Documents Mobile] File change error:', error);
}
};
// Show loading while auth is initializing
if (authLoading) {
return (
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<h2 className="text-lg font-semibold text-slate-800 mb-2">Documents</h2>
<div className="text-slate-500 py-6 text-center">Loading...</div>
</div>
</GlassCard>
</div>
);
}
// Show login prompt when not authenticated
if (!isAuthenticated) {
return (
<div className="space-y-4">
<GlassCard>
<div className="p-6 text-center">
<div className="mb-4">
<div className="mx-auto w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Login Required</h3>
<p className="text-slate-600 text-sm mb-4">Please log in to view your documents</p>
<button
onClick={() => loginWithRedirect()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Login to Continue
</button>
</div>
</GlassCard>
</div>
);
}
// Check for authentication error (401)
const isAuthError = error && isAxiosError(error) && error.response?.status === 401;
const hasError = !!error;
if (isAuthError) {
return (
<div className="space-y-4">
<GlassCard>
<div className="p-6 text-center">
<div className="mb-4">
<div className="mx-auto w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Session Expired</h3>
<p className="text-slate-600 text-sm mb-4">Your session has expired. Please log in again.</p>
<button
onClick={() => loginWithRedirect()}
className="w-full px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
>
Login Again
</button>
</div>
</GlassCard>
</div>
);
}
return (
<div className="space-y-4">
<input ref={inputRef} type="file" accept="image/jpeg,image/png,application/pdf" capture="environment" className="hidden" onChange={onFileChange} />
<AddDocumentDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
<GlassCard>
<div className="p-4">
<h2 className="text-lg font-semibold text-slate-800 mb-2">Documents</h2>
<div className="flex justify-end mb-2">
<Button onClick={() => setIsAddOpen(true)} className="min-h-[44px]">Add Document</Button>
</div>
{isLoading && <div className="text-slate-500 py-6 text-center">Loading...</div>}
{hasError && !isAuthError && (
<div className="py-6 text-center">
<div className="mb-4">
<div className="mx-auto w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
</div>
<p className="text-red-600 text-sm mb-3">Failed to load documents</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm"
>
Retry
</button>
</div>
)}
{!isLoading && !hasError && data && data.length === 0 && (
<div className="py-8 text-center">
<div className="mb-4">
<div className="mx-auto w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<p className="text-slate-600 text-sm mb-3">No documents yet</p>
<p className="text-slate-500 text-xs">Documents will appear here once you create them</p>
</div>
)}
{!isLoading && !hasError && data && data.length > 0 && (
<div className="space-y-3">
{data.map((doc) => {
const vehicleLabel = doc.vehicle_id ? `${doc.vehicle_id.slice(0, 8)}...` : '—';
return (
<div key={doc.id} className="flex items-center justify-between border rounded-xl p-3">
<div>
<div className="font-medium text-slate-800">{doc.title}</div>
<div className="text-xs text-slate-500">{doc.document_type} {vehicleLabel}</div>
</div>
<div className="flex gap-2 items-center">
<Button onClick={() => navigate(`/documents/${doc.id}`)}>Open</Button>
<Button onClick={() => triggerUpload(doc.id)}>Upload</Button>
{upload.isPending && currentId === doc.id && (
<span className="text-xs text-slate-500">{upload.progress}%</span>
)}
{upload.isError && currentId === doc.id && (
<span className="text-xs text-red-600">
{((upload.error as any)?.response?.status === 415)
? 'Unsupported file type. Use PDF, JPG/JPEG, PNG.'
: 'Upload failed'}
</span>
)}
</div>
</div>
);})}
</div>
)}
</div>
</GlassCard>
</div>
);
};
export default DocumentsMobileScreen;

View File

@@ -0,0 +1,168 @@
import React, { useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { isAxiosError } from 'axios';
import { Card } from '../../../shared-minimal/components/Card';
import { Button } from '../../../shared-minimal/components/Button';
import { useDocument } from '../hooks/useDocuments';
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
import { documentsApi } from '../api/documents.api';
import { DocumentPreview } from '../components/DocumentPreview';
export const DocumentDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
const { data: doc, isLoading, error } = useDocument(id);
const inputRef = useRef<HTMLInputElement | null>(null);
const upload = useUploadWithProgress(id!);
const handleDownload = async () => {
if (!id) return;
const blob = await documentsApi.download(id);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
};
const handleUpload = () => {
if (!inputRef.current) return;
inputRef.current.onchange = () => {
const file = inputRef.current?.files?.[0];
if (file && id) {
const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']);
if (!file.type || !allowed.has(file.type)) {
alert('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
return;
}
upload.mutate(file);
}
};
inputRef.current.click();
};
// Show loading while auth is initializing
if (authLoading) {
return (
<div className="container mx-auto p-4">
<div className="text-slate-500">Checking authentication...</div>
</div>
);
}
// Show login prompt when not authenticated
if (!isAuthenticated) {
return (
<div className="container mx-auto p-4">
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Authentication Required</h3>
<p className="text-slate-600 mb-6">Please log in to view this document</p>
<div className="space-x-3">
<Button onClick={() => loginWithRedirect()}>Login to Continue</Button>
<Button variant="secondary" onClick={() => navigate('/documents')}>Back to Documents</Button>
</div>
</div>
</Card>
</div>
);
}
// Check for authentication error (401)
const isAuthError = error && isAxiosError(error) && error.response?.status === 401;
if (isAuthError) {
return (
<div className="container mx-auto p-4">
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Session Expired</h3>
<p className="text-slate-600 mb-6">Your session has expired. Please log in again to continue.</p>
<div className="space-x-3">
<Button onClick={() => loginWithRedirect()}>Login Again</Button>
<Button variant="secondary" onClick={() => navigate('/documents')}>Back to Documents</Button>
</div>
</div>
</Card>
</div>
);
}
if (isLoading) return <div className="container mx-auto p-4">Loading document...</div>;
if (error && !isAuthError) {
return (
<div className="container mx-auto p-4">
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Document Not Found</h3>
<p className="text-slate-600 mb-6">The document you're looking for could not be found.</p>
<div className="space-x-3">
<Button onClick={() => window.location.reload()}>Retry</Button>
<Button variant="secondary" onClick={() => navigate('/documents')}>Back to Documents</Button>
</div>
</div>
</Card>
</div>
);
}
if (!doc) {
return (
<div className="container mx-auto p-4">
<Card>
<div className="p-8 text-center">
<h3 className="text-lg font-semibold text-slate-800 mb-2">Document Not Found</h3>
<p className="text-slate-600 mb-6">The document you're looking for does not exist.</p>
<Button onClick={() => navigate('/documents')}>Back to Documents</Button>
</div>
</Card>
</div>
);
}
return (
<div className="container mx-auto p-4">
<input ref={inputRef} type="file" accept="image/jpeg,image/png,application/pdf" capture="environment" className="hidden" />
<Card>
<div className="p-4 space-y-2">
<h2 className="text-xl font-semibold">{doc.title}</h2>
<div className="text-sm text-slate-500">Type: {doc.document_type}</div>
<div className="text-sm text-slate-500">Vehicle: {doc.vehicle_id}</div>
<div className="pt-2">
<DocumentPreview doc={doc} />
</div>
<div className="flex gap-2 pt-2">
<Button onClick={handleDownload}>Download</Button>
<Button onClick={handleUpload}>Upload/Replace</Button>
</div>
{upload.isPending && (
<div className="text-sm text-slate-600">Uploading... {upload.progress}%</div>
)}
{upload.isError && (
<div className="text-sm text-red-600">
{((upload.error as any)?.response?.status === 415)
? 'Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'
: 'Failed to upload file. Please try again.'}
</div>
)}
</div>
</Card>
</div>
);
};
export default DocumentDetailPage;

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useDocumentsList, useDeleteDocument } from '../hooks/useDocuments';
import { Card } from '../../../shared-minimal/components/Card';
import { Button } from '../../../shared-minimal/components/Button';
import { useNavigate } from 'react-router-dom';
import { AddDocumentDialog } from '../components/AddDocumentDialog';
export const DocumentsPage: React.FC = () => {
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
const { data, isLoading, error } = useDocumentsList();
const navigate = useNavigate();
const removeDoc = useDeleteDocument();
const [isAddOpen, setIsAddOpen] = React.useState(false);
// Show loading while auth is initializing
if (authLoading) {
return (
<div className="container mx-auto p-4 space-y-4">
<h1 className="text-2xl font-semibold">Documents</h1>
<div className="flex items-center justify-center py-12">
<div className="text-slate-500">Checking authentication...</div>
</div>
</div>
);
}
// Show login prompt when not authenticated
if (!isAuthenticated) {
return (
<div className="container mx-auto p-4 space-y-4">
<h1 className="text-2xl font-semibold">Documents</h1>
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Authentication Required</h3>
<p className="text-slate-600 mb-6">Please log in to view your documents</p>
<Button onClick={() => loginWithRedirect()}>
Login to Continue
</Button>
</div>
</Card>
</div>
);
}
// Check for authentication error (401)
const isAuthError = error && (error as any).response?.status === 401;
if (isAuthError) {
return (
<div className="container mx-auto p-4 space-y-4">
<h1 className="text-2xl font-semibold">Documents</h1>
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Session Expired</h3>
<p className="text-slate-600 mb-6">Your session has expired. Please log in again to continue.</p>
<Button onClick={() => loginWithRedirect()}>
Login Again
</Button>
</div>
</Card>
</div>
);
}
return (
<div className="container mx-auto p-4 space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h1 className="text-2xl font-semibold">Documents</h1>
<div className="flex gap-2">
<Button onClick={() => setIsAddOpen(true)} className="min-h-[44px]">Add Document</Button>
</div>
</div>
<AddDocumentDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="text-slate-500">Loading documents...</div>
</div>
)}
{error && !isAuthError && (
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Error Loading Documents</h3>
<p className="text-slate-600 mb-6">Failed to load documents. Please try again.</p>
<Button onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</Card>
)}
{!isLoading && !error && data && data.length === 0 && (
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">No Documents Yet</h3>
<p className="text-slate-600 mb-6">You haven't added any documents yet. Documents will appear here once you create them.</p>
<Button onClick={() => navigate('/vehicles')}>
Go to Vehicles
</Button>
</div>
</Card>
)}
{!isLoading && !error && data && data.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.map((doc) => (
<Card key={doc.id}>
<div className="p-4 space-y-2">
<div className="font-medium">{doc.title}</div>
<div className="text-sm text-slate-500">Type: {doc.document_type}</div>
<div className="text-sm text-slate-500">Vehicle: {doc.vehicle_id}</div>
<div className="flex gap-2 pt-2">
<Button onClick={() => navigate(`/documents/${doc.id}`)}>Open</Button>
<Button variant="danger" onClick={() => removeDoc.mutate(doc.id)}>Delete</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
};
export default DocumentsPage;

View File

@@ -0,0 +1,41 @@
export type DocumentType = 'insurance' | 'registration';
export interface DocumentRecord {
id: string;
user_id: string;
vehicle_id: string;
document_type: DocumentType;
title: string;
notes?: string | null;
details?: Record<string, any> | 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 interface CreateDocumentRequest {
vehicle_id: string;
document_type: DocumentType;
title: string;
notes?: string;
details?: Record<string, any>;
issued_date?: string;
expiration_date?: string;
}
export interface UpdateDocumentRequest {
title?: string;
notes?: string | null;
details?: Record<string, any>;
issued_date?: string | null;
expiration_date?: string | null;
}

View File

@@ -4,5 +4,13 @@
"exactOptionalPropertyTypes": false,
"noUncheckedIndexedAccess": false,
"noPropertyAccessFromIndexSignature": false
}
},
"exclude": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"setupTests.ts",
"jest.config.ts"
]
}

View File

@@ -1,5 +1,7 @@
# MVP Platform Vehicles Service
For full platform architecture and integration patterns, see `docs/PLATFORM-SERVICES.md`.
## Schema Bootstrapping (Docker-First)
- Database: PostgreSQL, service `mvp-platform-vehicles-db`.
- On first start, schema files from `mvp-platform-services/vehicles/sql/schema` are executed automatically because the folder is mounted to `/docker-entrypoint-initdb.d` in `docker-compose.yml`.

View File

@@ -2,18 +2,28 @@ import os
from pydantic_settings import BaseSettings
from typing import List
# Docker-first: load secrets from mounted files when env vars are absent
_PG_SECRET_FILE = os.getenv("POSTGRES_PASSWORD_FILE", "/run/secrets/postgres-password")
if not os.getenv("POSTGRES_PASSWORD"):
try:
with open(_PG_SECRET_FILE, 'r') as f:
os.environ["POSTGRES_PASSWORD"] = f.read().strip()
except Exception:
# Leave as-is; connection will fail loudly if missing
pass
class Settings(BaseSettings):
"""Application configuration"""
# Database settings
POSTGRES_HOST: str = os.getenv("POSTGRES_HOST", "localhost")
POSTGRES_HOST: str = os.getenv("POSTGRES_HOST", "mvp-platform-vehicles-db")
POSTGRES_PORT: int = int(os.getenv("POSTGRES_PORT", "5432"))
POSTGRES_USER: str = os.getenv("POSTGRES_USER", "mvp_platform_user")
POSTGRES_PASSWORD: str = os.getenv("POSTGRES_PASSWORD", "platform123")
POSTGRES_DATABASE: str = os.getenv("POSTGRES_DATABASE", "vpic")
POSTGRES_PASSWORD: str = os.getenv("POSTGRES_PASSWORD", "")
POSTGRES_DATABASE: str = os.getenv("POSTGRES_DATABASE", "vehicles")
# Redis settings
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
REDIS_HOST: str = os.getenv("REDIS_HOST", "mvp-platform-vehicles-redis")
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
@@ -40,4 +50,4 @@ class Settings(BaseSettings):
def get_settings() -> Settings:
"""Get application settings"""
return Settings()
return Settings()