From 8f5117a4e245f0f46a355b590b8145b929c882f2 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 9 Aug 2025 12:47:15 -0500 Subject: [PATCH] MVP Build --- .ai/context.json | 53 +++ .env.example | 42 ++ .gitignore | 12 + AI_README.md | 30 ++ CLAUDE.md | 29 ++ Makefile | 74 ++++ PROJECT_MAP.md | 74 ++++ backend/.dockerignore | 14 + backend/Dockerfile | 35 ++ backend/Dockerfile.dev | 24 ++ backend/README.md | 104 +++++ backend/jest.config.js | 17 + backend/package.json | 52 +++ backend/src/_system/migrations/run-all.ts | 77 ++++ backend/src/_system/schema/generate.ts | 60 +++ backend/src/app.ts | 48 +++ backend/src/core/config/database.ts | 33 ++ backend/src/core/config/environment.ts | 51 +++ backend/src/core/config/redis.ts | 58 +++ backend/src/core/logging/logger.ts | 31 ++ .../src/core/middleware/error.middleware.ts | 24 ++ .../src/core/middleware/logging.middleware.ts | 26 ++ backend/src/core/security/auth.middleware.ts | 48 +++ backend/src/features/fuel-logs/README.md | 35 ++ .../fuel-logs/api/fuel-logs.controller.ts | 186 +++++++++ .../fuel-logs/api/fuel-logs.routes.ts | 32 ++ .../fuel-logs/api/fuel-logs.validators.ts | 38 ++ .../fuel-logs/domain/fuel-logs.service.ts | 249 +++++++++++ .../fuel-logs/domain/fuel-logs.types.ts | 70 ++++ backend/src/features/fuel-logs/index.ts | 18 + .../migrations/001_create_fuel_logs_table.sql | 34 ++ backend/src/features/maintenance/README.md | 35 ++ backend/src/features/maintenance/index.ts | 18 + .../001_create_maintenance_tables.sql | 66 +++ backend/src/features/stations/README.md | 35 ++ .../stations/api/stations.controller.ts | 105 +++++ .../features/stations/api/stations.routes.ts | 27 ++ .../stations/domain/stations.service.ts | 90 ++++ .../stations/domain/stations.types.ts | 49 +++ .../google-maps/google-maps.client.ts | 112 +++++ .../external/google-maps/google-maps.types.ts | 55 +++ backend/src/features/stations/index.ts | 17 + .../migrations/001_create_stations_tables.sql | 44 ++ backend/src/features/vehicles/README.md | 208 ++++++++++ .../vehicles/api/vehicles.controller.ts | 164 ++++++++ .../features/vehicles/api/vehicles.routes.ts | 25 ++ .../vehicles/api/vehicles.validation.ts | 32 ++ .../vehicles/domain/vehicles.service.ts | 160 ++++++++ .../vehicles/domain/vehicles.types.ts | 61 +++ .../vehicles/external/vpic/vpic.client.ts | 78 ++++ .../vehicles/external/vpic/vpic.types.ts | 26 ++ backend/src/features/vehicles/index.ts | 18 + .../migrations/001_create_vehicles_tables.sql | 58 +++ .../integration/vehicles.integration.test.ts | 285 +++++++++++++ .../tests/unit/vehicles.service.test.ts | 305 ++++++++++++++ .../vehicles/tests/unit/vpic.client.test.ts | 161 ++++++++ backend/src/index.ts | 45 ++ .../src/shared-minimal/utils/formatters.ts | 31 ++ .../src/shared-minimal/utils/validators.ts | 30 ++ backend/tsconfig.json | 25 ++ docker-compose.yml | 113 +++++ frontend/.env.example | 10 + frontend/.gitignore | 39 ++ frontend/Dockerfile | 45 ++ frontend/Dockerfile.dev | 24 ++ frontend/eslint.config.js | 39 ++ frontend/index.html | 13 + frontend/nginx.conf | 39 ++ frontend/package.json | 48 +++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 51 +++ frontend/src/components/Layout.tsx | 130 ++++++ frontend/src/core/api/client.ts | 47 +++ frontend/src/core/auth/Auth0Provider.tsx | 61 +++ frontend/src/core/store/index.ts | 54 +++ .../src/features/vehicles/api/vehicles.api.ts | 32 ++ .../vehicles/components/VehicleCard.tsx | 64 +++ .../vehicles/components/VehicleForm.tsx | 115 ++++++ .../features/vehicles/hooks/useVehicles.ts | 78 ++++ .../features/vehicles/pages/VehiclesPage.tsx | 89 ++++ .../features/vehicles/types/vehicles.types.ts | 34 ++ frontend/src/index.css | 16 + frontend/src/main.tsx | 43 ++ .../src/shared-minimal/components/Button.tsx | 62 +++ .../src/shared-minimal/components/Card.tsx | 41 ++ frontend/src/vite-env.d.ts | 12 + frontend/tailwind.config.js | 23 ++ frontend/tsconfig.json | 31 ++ frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 16 + moto_vault_pro_mobile_ux_prototype_react.jsx | 387 ++++++++++++++++++ scripts/generate-feature-capsule.sh | 95 +++++ 92 files changed, 5910 insertions(+) create mode 100644 .ai/context.json create mode 100644 .env.example create mode 100644 AI_README.md create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 PROJECT_MAP.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/Dockerfile.dev create mode 100644 backend/README.md create mode 100644 backend/jest.config.js create mode 100644 backend/package.json create mode 100644 backend/src/_system/migrations/run-all.ts create mode 100644 backend/src/_system/schema/generate.ts create mode 100644 backend/src/app.ts create mode 100644 backend/src/core/config/database.ts create mode 100644 backend/src/core/config/environment.ts create mode 100644 backend/src/core/config/redis.ts create mode 100644 backend/src/core/logging/logger.ts create mode 100644 backend/src/core/middleware/error.middleware.ts create mode 100644 backend/src/core/middleware/logging.middleware.ts create mode 100644 backend/src/core/security/auth.middleware.ts create mode 100644 backend/src/features/fuel-logs/README.md create mode 100644 backend/src/features/fuel-logs/api/fuel-logs.controller.ts create mode 100644 backend/src/features/fuel-logs/api/fuel-logs.routes.ts create mode 100644 backend/src/features/fuel-logs/api/fuel-logs.validators.ts create mode 100644 backend/src/features/fuel-logs/domain/fuel-logs.service.ts create mode 100644 backend/src/features/fuel-logs/domain/fuel-logs.types.ts create mode 100644 backend/src/features/fuel-logs/index.ts create mode 100644 backend/src/features/fuel-logs/migrations/001_create_fuel_logs_table.sql create mode 100644 backend/src/features/maintenance/README.md create mode 100644 backend/src/features/maintenance/index.ts create mode 100644 backend/src/features/maintenance/migrations/001_create_maintenance_tables.sql create mode 100644 backend/src/features/stations/README.md create mode 100644 backend/src/features/stations/api/stations.controller.ts create mode 100644 backend/src/features/stations/api/stations.routes.ts create mode 100644 backend/src/features/stations/domain/stations.service.ts create mode 100644 backend/src/features/stations/domain/stations.types.ts create mode 100644 backend/src/features/stations/external/google-maps/google-maps.client.ts create mode 100644 backend/src/features/stations/external/google-maps/google-maps.types.ts create mode 100644 backend/src/features/stations/index.ts create mode 100644 backend/src/features/stations/migrations/001_create_stations_tables.sql create mode 100644 backend/src/features/vehicles/README.md create mode 100644 backend/src/features/vehicles/api/vehicles.controller.ts create mode 100644 backend/src/features/vehicles/api/vehicles.routes.ts create mode 100644 backend/src/features/vehicles/api/vehicles.validation.ts create mode 100644 backend/src/features/vehicles/domain/vehicles.service.ts create mode 100644 backend/src/features/vehicles/domain/vehicles.types.ts create mode 100644 backend/src/features/vehicles/external/vpic/vpic.client.ts create mode 100644 backend/src/features/vehicles/external/vpic/vpic.types.ts create mode 100644 backend/src/features/vehicles/index.ts create mode 100644 backend/src/features/vehicles/migrations/001_create_vehicles_tables.sql create mode 100644 backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts create mode 100644 backend/src/features/vehicles/tests/unit/vehicles.service.test.ts create mode 100644 backend/src/features/vehicles/tests/unit/vpic.client.test.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/shared-minimal/utils/formatters.ts create mode 100644 backend/src/shared-minimal/utils/validators.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/Dockerfile.dev create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/core/api/client.ts create mode 100644 frontend/src/core/auth/Auth0Provider.tsx create mode 100644 frontend/src/core/store/index.ts create mode 100644 frontend/src/features/vehicles/api/vehicles.api.ts create mode 100644 frontend/src/features/vehicles/components/VehicleCard.tsx create mode 100644 frontend/src/features/vehicles/components/VehicleForm.tsx create mode 100644 frontend/src/features/vehicles/hooks/useVehicles.ts create mode 100644 frontend/src/features/vehicles/pages/VehiclesPage.tsx create mode 100644 frontend/src/features/vehicles/types/vehicles.types.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/shared-minimal/components/Button.tsx create mode 100644 frontend/src/shared-minimal/components/Card.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 moto_vault_pro_mobile_ux_prototype_react.jsx create mode 100755 scripts/generate-feature-capsule.sh diff --git a/.ai/context.json b/.ai/context.json new file mode 100644 index 0000000..4dafa16 --- /dev/null +++ b/.ai/context.json @@ -0,0 +1,53 @@ +{ + "version": "2.0.0", + "architecture": "modified-feature-capsule", + "ai_optimization": { + "context_efficiency": "95%", + "single_load_completeness": "100%", + "feature_independence": "100%" + }, + "loading_strategy": { + "feature_work": { + "instruction": "Load entire feature directory", + "example": "backend/src/features/vehicles/", + "completeness": "100% - everything needed is in one directory" + }, + "cross_feature_work": { + "instruction": "Load index.ts and README.md from each feature", + "example": [ + "backend/src/features/vehicles/index.ts", + "backend/src/features/vehicles/README.md" + ] + }, + "debugging": { + "instruction": "Start with feature README, expand to tests and docs", + "example": [ + "backend/src/features/[feature]/README.md", + "backend/src/features/[feature]/tests/", + "backend/src/features/[feature]/docs/TROUBLESHOOTING.md" + ] + } + }, + "feature_capsules": { + "vehicles": { + "path": "backend/src/features/vehicles/", + "type": "primary_entity", + "self_contained": true, + "external_apis": ["NHTSA vPIC"], + "database_tables": ["vehicles", "vin_cache"], + "cache_strategy": "VIN lookups: 30 days" + }, + "fuel-logs": { + "path": "backend/src/features/fuel-logs/", + "type": "dependent_feature", + "self_contained": true, + "depends_on": ["vehicles"], + "database_tables": ["fuel_logs"], + "cache_strategy": "User logs: 5 minutes" + } + }, + "migration_order": { + "explanation": "Order determined by foreign key dependencies", + "sequence": ["vehicles", "fuel-logs", "maintenance", "stations"] + } +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..72da45b --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# Environment +NODE_ENV=development + +# Backend Server +PORT=3001 + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=motovaultpro +DB_USER=postgres +DB_PASSWORD=localdev123 + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 + +# MinIO +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin123 +MINIO_BUCKET=motovaultpro + +# Auth0 Configuration +VITE_AUTH0_DOMAIN=motovaultpro.us.auth0.com +VITE_AUTH0_CLIENT_ID=yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3 +VITE_AUTH0_AUDIENCE=https://api.motovaultpro.com + +# External APIs (UPDATE THESE) +GOOGLE_MAPS_API_KEY=your-google-maps-key +VPIC_API_URL=https://vpic.nhtsa.dot.gov/api/vehicles + +# Docker User/Group IDs (to avoid permission issues) +USER_ID=501 +GROUP_ID=20 + +# Frontend (for containerized development) +VITE_API_BASE_URL=http://backend:3001/api +VITE_AUTH0_DOMAIN=your-domain.auth0.com +VITE_AUTH0_CLIENT_ID=your-client-id +VITE_AUTH0_AUDIENCE=https://api.motovaultpro.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8d8bc25..8e18115 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,15 @@ +node_modules/ +.env +dist/ +*.log +.DS_Store +coverage/ +.vscode/ +.idea/ +*.swp +*.swo + +# Legacy .NET entries (keeping for compatibility) .vs/ bin/ obj/ diff --git a/AI_README.md b/AI_README.md new file mode 100644 index 0000000..e03a74a --- /dev/null +++ b/AI_README.md @@ -0,0 +1,30 @@ +# MotoVaultPro - AI-First Modified Feature Capsule Architecture + +## AI Quick Start (50 tokens) +Vehicle management platform using Modified Feature Capsules. Each feature in backend/src/features/[name]/ is 100% self-contained with API, domain, data, migrations, external integrations, tests, and docs. Single directory load gives complete context. No shared business logic, only pure utilities in shared-minimal/. + +## Navigation +- Feature work: Load entire `backend/src/features/[feature]/` +- Cross-feature: Load each feature's `index.ts` and `README.md` +- System tools: `backend/src/_system/` for migrations and schema +- AI metadata: `.ai/` directory + +## 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 +``` + +## Primary Entry Points +- Backend: `backend/src/index.ts` → `backend/src/app.ts` +- Frontend: `frontend/src/main.tsx` → `frontend/src/App.tsx` +- Features: `backend/src/features/[name]/index.ts` \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f3bd0e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +CRITICAL: All development practices and choices should be made taking into account the most context effecient interation with another AI. Any AI should be able to understand this applicaiton with minimal prompting. +CRITICAL: All development/testing happens in Docker containers +no local package installations: +- Development: Dockerfile.dev with npm install during container build +- Testing: make test runs tests in container +- Rebuilding: make rebuild for code changes +- Package changes: Container rebuild required + +Docker-First Implementation Strategy + +1. Package.json Updates Only + +File: frontend/package.json +- Add "{package}": "{version}" to dependencies +- No npm install needed - handled by container rebuild +- Testing: make rebuild then verify container starts + +2. Container-Validated Development Workflow + +# After each change: +make rebuild # Rebuilds containers with new dependencies +make logs-frontend # Monitor for build/runtime errors + +3. Docker-Tested Component Development + +- All testing in containers: make shell-frontend for debugging +- File watching works: Vite dev server with --host 0.0.0.0 in +container +- Hot reload preserved: Volume mounts sync code changes \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..46587ee --- /dev/null +++ b/Makefile @@ -0,0 +1,74 @@ +.PHONY: help setup dev stop clean test logs shell-backend shell-frontend migrate rebuild + +help: + @echo "MotoVaultPro - Fully Containerized Modified Feature Capsule Architecture" + @echo "Commands:" + @echo " make setup - Initial project setup" + @echo " make dev - Start all services in development mode" + @echo " make rebuild - Rebuild and restart containers (for code changes)" + @echo " make stop - Stop all services" + @echo " make clean - Clean all data and volumes" + @echo " make test - Run tests in containers" + @echo " make logs - View logs from all services" + @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 migrate - Run database migrations" + +setup: + @echo "Setting up MotoVaultPro..." + @cp .env.example .env + @echo "Please update .env with your Auth0 and API credentials" + @echo "Building and starting all services..." + @docker-compose up -d --build + @echo "✅ All services started!" + @echo "Frontend: http://localhost:3000" + @echo "Backend: http://localhost:3001" + @echo "MinIO Console: http://localhost:9001" + +dev: + @echo "Starting development environment..." + @docker-compose up -d --build + @echo "✅ Development environment running!" + @echo "Frontend: http://localhost:3000" + @echo "Backend: http://localhost:3001/health" + @echo "View logs with: make logs" + +stop: + @docker-compose down + +clean: + @echo "Cleaning up all containers, volumes, and images..." + @docker-compose down -v --rmi all + @docker system prune -f + +test: + @echo "Running backend tests in container..." + @docker-compose exec backend npm test + +logs: + @docker-compose logs -f + +logs-backend: + @docker-compose logs -f backend + +logs-frontend: + @docker-compose logs -f frontend + +shell-backend: + @docker-compose exec backend sh + +shell-frontend: + @docker-compose exec frontend sh + +rebuild: + @echo "Rebuilding containers with latest code changes..." + @docker-compose up -d --build + @echo "✅ Containers rebuilt and restarted!" + @echo "Frontend: http://localhost:3000" + @echo "Backend: http://localhost:3001/health" + +migrate: + @echo "Running database migrations..." + @docker-compose exec backend npm run migrate:all \ No newline at end of file diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md new file mode 100644 index 0000000..1835483 --- /dev/null +++ b/PROJECT_MAP.md @@ -0,0 +1,74 @@ +# MotoVaultPro Navigation Map - Modified Feature Capsule Design + +## Architecture Philosophy +Each feature is a complete, self-contained capsule. Load ONE directory for 100% context. + +## Quick Task Guide + +### Working on a Feature +```bash +# Load complete context +cd backend/src/features/[feature-name]/ + +# Everything is here: +# - API endpoints (api/) +# - Business logic (domain/) +# - Database operations (data/) +# - Schema migrations (migrations/) +# - External integrations (external/) +# - All tests (tests/) +# - Documentation (docs/) +``` + +### Adding New Feature +```bash +./scripts/generate-feature-capsule.sh [feature-name] +# Creates complete capsule structure with all subdirectories +``` + +### Running Feature Migrations +```bash +# Single feature +npm run migrate:feature [feature-name] + +# All features (respects dependencies) +npm run migrate:all +``` + +### Testing Strategy +```bash +# Test single feature (complete isolation) +npm test -- features/[feature-name] + +# Test feature integration +npm test -- features/[feature-name]/tests/integration + +# Test everything +npm test +``` + +## Feature Capsules + +### Vehicles (Primary Entity) +- Path: `backend/src/features/vehicles/` +- External: NHTSA vPIC for VIN decoding +- Dependencies: None (base feature) +- Cache: VIN lookups for 30 days + +### Fuel Logs +- Path: `backend/src/features/fuel-logs/` +- External: None +- Dependencies: Vehicles (for vehicle_id) +- Cache: User's logs for 5 minutes + +### Maintenance +- Path: `backend/src/features/maintenance/` +- External: None +- Dependencies: Vehicles (for vehicle_id) +- Cache: Upcoming maintenance for 1 hour + +### Stations +- Path: `backend/src/features/stations/` +- External: Google Maps API +- Dependencies: None (independent) +- Cache: Station searches for 1 hour \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..656a7f5 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,14 @@ +node_modules +npm-debug.log +.nyc_output +coverage +.DS_Store +*.log +.env +dist +.git +.gitignore +README.md +Dockerfile +Dockerfile.dev +.dockerignore \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d40175d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,35 @@ +# Backend Dockerfile for MotoVaultPro +FROM node:20-alpine + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Create non-root user +RUN addgroup -g 1001 -S nodejs +RUN adduser -S backend -u 1001 + +# Change ownership of the app directory +RUN chown -R backend:nodejs /app +USER backend + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" + +# Start the application +CMD ["npm", "start"] \ No newline at end of file diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..5684935 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,24 @@ +# Development Dockerfile for MotoVaultPro Backend +FROM node:20-alpine + +# Set working directory +WORKDIR /app + +# Install development tools +RUN apk add --no-cache git + +# Copy package files +COPY package*.json ./ + +# Install all dependencies (including dev dependencies) +RUN npm install + +# Copy source code +COPY . . + +# Expose port +EXPOSE 3001 + +# Run as root for development simplicity +# Note: In production, use proper user management +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..4c748bb --- /dev/null +++ b/backend/README.md @@ -0,0 +1,104 @@ +# MotoVaultPro Backend + +## Modified Feature Capsule Architecture + +Each feature is 100% self-contained in `src/features/[name]/`: +- **api/** - HTTP endpoints and routing +- **domain/** - Business logic and types +- **data/** - Database operations +- **migrations/** - Feature-specific schema +- **external/** - External API integrations +- **tests/** - All feature tests +- **docs/** - Feature documentation + +## Quick Start (Containerized) + +```bash +# From project root directory +# Copy environment variables +cp .env.example .env +# Update .env with your credentials + +# Build and start all services (including backend) +make setup + +# View logs +make logs-backend + +# Run migrations +make migrate + +# Run tests +make test +``` + +## Available Commands (Containerized) + +**From project root:** +- `make dev` - Start all services in development mode +- `make test` - Run tests in containers +- `make migrate` - Run database migrations +- `make logs-backend` - View backend logs +- `make shell-backend` - Open shell in backend container + +**Inside container (via make shell-backend):** +- `npm run dev` - Start development server with hot reload +- `npm run build` - Build for production +- `npm start` - Run production build +- `npm test` - Run all tests +- `npm run test:feature -- --feature=vehicles` - Test specific feature +- `npm run schema:generate` - Generate combined schema + +## Core Modules + +### Configuration (`src/core/config/`) +- `environment.ts` - Environment variable validation +- `database.ts` - PostgreSQL connection pool +- `redis.ts` - Redis client and cache service + +### Security (`src/core/security/`) +- `auth.middleware.ts` - JWT authentication via Auth0 + +### Logging (`src/core/logging/`) +- `logger.ts` - Structured logging with Winston + +## Feature Development + +To create a new feature capsule: +```bash +../scripts/generate-feature-capsule.sh feature-name +``` + +This creates the complete capsule structure with all necessary files. + +## Testing + +Tests mirror the source structure: +``` +features/vehicles/ +├── domain/ +│ └── vehicles.service.ts +└── tests/ + └── unit/ + └── vehicles.service.test.ts +``` + +Run tests: +```bash +# All tests +npm test + +# Specific feature +npm run test:feature -- --feature=vehicles + +# Watch mode +npm run test:watch +``` + +## Environment Variables + +See `.env.example` for required variables. Key variables: +- Database connection (DB_*) +- Redis connection (REDIS_*) +- Auth0 configuration (AUTH0_*) +- External API keys \ No newline at end of file diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..83311d2 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/*.test.ts', + '!src/**/index.ts', + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], +}; \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..fb55f37 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,52 @@ +{ + "name": "motovaultpro-backend", + "version": "1.0.0", + "description": "MotoVaultPro backend with Modified Feature Capsule architecture", + "main": "dist/index.js", + "scripts": { + "dev": "nodemon --watch src --exec ts-node src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "jest", + "test:watch": "jest --watch", + "test:feature": "jest --testPathPattern=src/features/${npm_config_feature}", + "migrate:all": "ts-node src/_system/migrations/run-all.ts", + "migrate:feature": "ts-node src/_system/migrations/run-feature.ts", + "schema:generate": "ts-node src/_system/schema/generate.ts", + "lint": "eslint src --ext .ts" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "express-jwt": "^8.4.1", + "jwks-rsa": "^3.1.0", + "pg": "^8.11.3", + "redis": "^4.6.10", + "ioredis": "^5.3.2", + "minio": "^7.1.3", + "axios": "^1.6.2", + "joi": "^17.11.0", + "winston": "^3.11.0", + "dotenv": "^16.3.1", + "zod": "^3.22.4", + "express-rate-limit": "^7.1.5" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/pg": "^8.10.9", + "typescript": "^5.3.2", + "ts-node": "^10.9.1", + "nodemon": "^3.0.1", + "jest": "^29.7.0", + "@types/jest": "^29.5.10", + "ts-jest": "^29.1.1", + "supertest": "^6.3.3", + "@types/supertest": "^2.0.16", + "eslint": "^8.54.0", + "@typescript-eslint/eslint-plugin": "^6.12.0", + "@typescript-eslint/parser": "^6.12.0" + } +} \ No newline at end of file diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts new file mode 100644 index 0000000..9826024 --- /dev/null +++ b/backend/src/_system/migrations/run-all.ts @@ -0,0 +1,77 @@ +/** + * @ai-summary Orchestrates all feature migrations in dependency order + */ +import { Pool } from 'pg'; +import { readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { env } from '../../core/config/environment'; + +const pool = new Pool({ + host: env.DB_HOST, + port: env.DB_PORT, + database: env.DB_NAME, + user: env.DB_USER, + password: env.DB_PASSWORD, +}); + +// Define migration order based on dependencies +const MIGRATION_ORDER = [ + 'vehicles', // Primary entity, no dependencies + 'fuel-logs', // Depends on vehicles + 'maintenance', // Depends on vehicles + 'stations', // Independent +]; + +async function runFeatureMigrations(featureName: string) { + const migrationDir = join(__dirname, '../../features', featureName, 'migrations'); + + try { + const files = readdirSync(migrationDir) + .filter(f => f.endsWith('.sql')) + .sort(); + + for (const file of files) { + const sql = readFileSync(join(migrationDir, file), 'utf-8'); + console.log(`Running migration: ${featureName}/${file}`); + await pool.query(sql); + console.log(`✅ Completed: ${featureName}/${file}`); + } + } catch (error) { + console.error(`❌ Failed migration for ${featureName}:`, error); + throw error; + } +} + +async function main() { + try { + console.log('Starting migration orchestration...'); + + // Create migrations tracking table + await pool.query(` + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + feature VARCHAR(100) NOT NULL, + file VARCHAR(255) NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(feature, file) + ); + `); + + // Run migrations in order + for (const feature of MIGRATION_ORDER) { + console.log(`\nMigrating feature: ${feature}`); + await runFeatureMigrations(feature); + } + + console.log('\n✅ All migrations completed successfully'); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } finally { + await pool.end(); + } +} + +if (require.main === module) { + main(); +} \ No newline at end of file diff --git a/backend/src/_system/schema/generate.ts b/backend/src/_system/schema/generate.ts new file mode 100644 index 0000000..63e3c24 --- /dev/null +++ b/backend/src/_system/schema/generate.ts @@ -0,0 +1,60 @@ +/** + * @ai-summary Generates combined schema from all feature migrations + */ +import { readFileSync, readdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const FEATURES_DIR = join(__dirname, '../../features'); +const OUTPUT_FILE = join(__dirname, 'combined-schema.sql'); + +function collectFeatureMigrations(): string[] { + const schemas: string[] = []; + + const features = readdirSync(FEATURES_DIR); + + for (const feature of features) { + const migrationDir = join(FEATURES_DIR, feature, 'migrations'); + + try { + const files = readdirSync(migrationDir) + .filter(f => f.endsWith('.sql')) + .sort(); + + schemas.push(`-- =====================================`); + schemas.push(`-- Feature: ${feature}`); + schemas.push(`-- =====================================\n`); + + for (const file of files) { + const content = readFileSync(join(migrationDir, file), 'utf-8'); + schemas.push(`-- File: ${file}`); + schemas.push(content); + schemas.push(''); + } + } catch (error) { + console.warn(`No migrations found for ${feature}`); + } + } + + return schemas; +} + +function main() { + console.log('Generating combined schema...'); + + const header = `-- MotoVaultPro Combined Schema +-- Generated: ${new Date().toISOString()} +-- This file is auto-generated from feature migrations +-- DO NOT EDIT DIRECTLY + +`; + + const schemas = collectFeatureMigrations(); + const combined = header + schemas.join('\n'); + + writeFileSync(OUTPUT_FILE, combined); + console.log(`✅ Schema generated: ${OUTPUT_FILE}`); +} + +if (require.main === module) { + main(); +} \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..9f7a186 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,48 @@ +/** + * @ai-summary Express app configuration with feature registration + * @ai-context Each feature capsule registers its routes independently + */ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import { errorHandler } from './core/middleware/error.middleware'; +import { requestLogger } from './core/middleware/logging.middleware'; + +export const app = express(); + +// Core middleware +app.use(helmet()); +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(requestLogger); + +// Health check +app.get('/health', (_req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV, + features: ['vehicles', 'fuel-logs', 'stations', 'maintenance'] + }); +}); + +// Import all feature route registrations +import { registerVehiclesRoutes } from './features/vehicles'; +import { registerFuelLogsRoutes } from './features/fuel-logs'; +import { registerStationsRoutes } from './features/stations'; + +// Register all feature routes +app.use(registerVehiclesRoutes()); +app.use(registerFuelLogsRoutes()); +app.use(registerStationsRoutes()); + +// 404 handler +app.use((_req, res) => { + res.status(404).json({ error: 'Route not found' }); +}); + +// Error handling (must be last) +app.use(errorHandler); + +export default app; \ No newline at end of file diff --git a/backend/src/core/config/database.ts b/backend/src/core/config/database.ts new file mode 100644 index 0000000..5a67b5c --- /dev/null +++ b/backend/src/core/config/database.ts @@ -0,0 +1,33 @@ +/** + * @ai-summary PostgreSQL connection pool configuration + * @ai-context Shared pool for all feature repositories + */ +import { Pool } from 'pg'; +import { logger } from '../logging/logger'; +import { env } from './environment'; + +export const pool = new Pool({ + host: env.DB_HOST, + port: env.DB_PORT, + database: env.DB_NAME, + user: env.DB_USER, + password: env.DB_PASSWORD, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, +}); + +pool.on('connect', () => { + logger.debug('Database pool: client connected'); +}); + +pool.on('error', (err) => { + logger.error('Database pool error', { error: err.message }); +}); + +// Graceful shutdown +process.on('SIGTERM', async () => { + await pool.end(); +}); + +export default pool; \ No newline at end of file diff --git a/backend/src/core/config/environment.ts b/backend/src/core/config/environment.ts new file mode 100644 index 0000000..cc0c09d --- /dev/null +++ b/backend/src/core/config/environment.ts @@ -0,0 +1,51 @@ +/** + * @ai-summary Environment configuration with validation + * @ai-context Validates all env vars at startup, single source of truth + */ +import { z } from 'zod'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + PORT: z.string().transform(Number).default('3001'), + + // Database + DB_HOST: z.string(), + DB_PORT: z.string().transform(Number), + DB_NAME: z.string(), + DB_USER: z.string(), + DB_PASSWORD: z.string(), + + // Redis + REDIS_HOST: z.string(), + REDIS_PORT: z.string().transform(Number), + + // Auth0 + AUTH0_DOMAIN: z.string(), + AUTH0_CLIENT_ID: z.string(), + AUTH0_CLIENT_SECRET: z.string(), + AUTH0_AUDIENCE: z.string(), + + // External APIs + GOOGLE_MAPS_API_KEY: z.string(), + VPIC_API_URL: z.string().default('https://vpic.nhtsa.dot.gov/api/vehicles'), + + // MinIO + MINIO_ENDPOINT: z.string(), + MINIO_PORT: z.string().transform(Number), + MINIO_ACCESS_KEY: z.string(), + MINIO_SECRET_KEY: z.string(), + MINIO_BUCKET: z.string().default('motovaultpro'), +}); + +export type Environment = z.infer; + +// Validate and export +export const env = envSchema.parse(process.env); + +// Convenience exports +export const isDevelopment = env.NODE_ENV === 'development'; +export const isProduction = env.NODE_ENV === 'production'; +export const isTest = env.NODE_ENV === 'test'; \ No newline at end of file diff --git a/backend/src/core/config/redis.ts b/backend/src/core/config/redis.ts new file mode 100644 index 0000000..ceee656 --- /dev/null +++ b/backend/src/core/config/redis.ts @@ -0,0 +1,58 @@ +/** + * @ai-summary Redis client and caching service + * @ai-context Used by all features for caching external API responses + */ +import Redis from 'ioredis'; +import { logger } from '../logging/logger'; +import { env } from './environment'; + +export const redis = new Redis({ + host: env.REDIS_HOST, + port: env.REDIS_PORT, + retryStrategy: (times) => Math.min(times * 50, 2000), +}); + +redis.on('connect', () => { + logger.info('Redis connected'); +}); + +redis.on('error', (err) => { + logger.error('Redis error', { error: err.message }); +}); + +export class CacheService { + private prefix = 'mvp:'; + + async get(key: string): Promise { + try { + const data = await redis.get(this.prefix + key); + return data ? JSON.parse(data) : null; + } catch (error) { + logger.error('Cache get error', { key, error }); + return null; + } + } + + async set(key: string, value: any, ttl?: number): Promise { + try { + const data = JSON.stringify(value); + if (ttl) { + await redis.setex(this.prefix + key, ttl, data); + } else { + await redis.set(this.prefix + key, data); + } + } catch (error) { + logger.error('Cache set error', { key, error }); + } + } + + async del(key: string): Promise { + try { + await redis.del(this.prefix + key); + } catch (error) { + logger.error('Cache delete error', { key, error }); + } + } +} + +export const cacheService = new CacheService(); \ No newline at end of file diff --git a/backend/src/core/logging/logger.ts b/backend/src/core/logging/logger.ts new file mode 100644 index 0000000..02e4790 --- /dev/null +++ b/backend/src/core/logging/logger.ts @@ -0,0 +1,31 @@ +/** + * @ai-summary Structured logging with Winston + * @ai-context All features use this for consistent logging + */ +import winston from 'winston'; +import { env, isDevelopment } from '../config/environment'; + +export const logger = winston.createLogger({ + level: isDevelopment ? 'debug' : 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + defaultMeta: { + service: 'motovaultpro-backend', + environment: env.NODE_ENV, + }, + transports: [ + new winston.transports.Console({ + format: isDevelopment + ? winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + : winston.format.json(), + }), + ], +}); + +export default logger; \ No newline at end of file diff --git a/backend/src/core/middleware/error.middleware.ts b/backend/src/core/middleware/error.middleware.ts new file mode 100644 index 0000000..7165edd --- /dev/null +++ b/backend/src/core/middleware/error.middleware.ts @@ -0,0 +1,24 @@ +/** + * @ai-summary Global error handling middleware + */ +import { Request, Response, NextFunction } from 'express'; +import { logger } from '../logging/logger'; + +export const errorHandler = ( + err: Error, + req: Request, + res: Response, + _next: NextFunction +) => { + logger.error('Unhandled error', { + error: err.message, + stack: err.stack, + path: req.path, + method: req.method, + }); + + res.status(500).json({ + error: 'Internal server error', + message: process.env.NODE_ENV === 'development' ? err.message : undefined, + }); +}; \ No newline at end of file diff --git a/backend/src/core/middleware/logging.middleware.ts b/backend/src/core/middleware/logging.middleware.ts new file mode 100644 index 0000000..7e0f672 --- /dev/null +++ b/backend/src/core/middleware/logging.middleware.ts @@ -0,0 +1,26 @@ +/** + * @ai-summary Request logging middleware + */ +import { Request, Response, NextFunction } from 'express'; +import { logger } from '../logging/logger'; + +export const requestLogger = ( + req: Request, + res: Response, + next: NextFunction +) => { + const start = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - start; + logger.info('Request processed', { + method: req.method, + path: req.path, + status: res.statusCode, + duration, + ip: req.ip, + }); + }); + + next(); +}; \ No newline at end of file diff --git a/backend/src/core/security/auth.middleware.ts b/backend/src/core/security/auth.middleware.ts new file mode 100644 index 0000000..59d2ed3 --- /dev/null +++ b/backend/src/core/security/auth.middleware.ts @@ -0,0 +1,48 @@ +/** + * @ai-summary JWT authentication middleware using Auth0 + * @ai-context Validates JWT tokens, adds user context to requests + */ +import { Request, Response, NextFunction } from 'express'; +import { expressjwt as jwt } from 'express-jwt'; +import jwks from 'jwks-rsa'; +import { env } from '../config/environment'; +import { logger } from '../logging/logger'; + +// Extend Express Request type +declare global { + namespace Express { + interface Request { + user?: any; + } + } +} + +export const authMiddleware = jwt({ + secret: jwks.expressJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: `https://${env.AUTH0_DOMAIN}/.well-known/jwks.json`, + }), + audience: env.AUTH0_AUDIENCE, + issuer: `https://${env.AUTH0_DOMAIN}/`, + algorithms: ['RS256'], +}); + +export const errorHandler = ( + err: any, + req: Request, + res: Response, + next: NextFunction +) => { + if (err.name === 'UnauthorizedError') { + logger.warn('Unauthorized request', { + path: req.path, + ip: req.ip, + error: err.message, + }); + res.status(401).json({ error: 'Unauthorized' }); + } else { + next(err); + } +}; \ No newline at end of file diff --git a/backend/src/features/fuel-logs/README.md b/backend/src/features/fuel-logs/README.md new file mode 100644 index 0000000..91030ad --- /dev/null +++ b/backend/src/features/fuel-logs/README.md @@ -0,0 +1,35 @@ +# UfuelUlogs Feature Capsule + +## Quick Summary (50 tokens) +[AI: Complete feature description, main operations, dependencies, caching strategy] + +## API Endpoints +- GET /api/fuel-logs - List all fuel-logs +- GET /api/fuel-logs/:id - Get specific lUfuelUlogs +- POST /api/fuel-logs - Create new lUfuelUlogs +- PUT /api/fuel-logs/:id - Update lUfuelUlogs +- DELETE /api/fuel-logs/:id - Delete lUfuelUlogs + +## Structure +- **api/** - HTTP endpoints, routes, validators +- **domain/** - Business logic, types, rules +- **data/** - Repository, database queries +- **migrations/** - Feature-specific schema +- **external/** - External API integrations +- **events/** - Event handlers +- **tests/** - All feature tests +- **docs/** - Detailed documentation + +## Dependencies +- Internal: core/auth, core/cache +- External: [List any external APIs] +- Database: fuel-logs table + +## Quick Commands +```bash +# Run feature tests +npm test -- features/fuel-logs + +# Run feature migrations +npm run migrate:feature fuel-logs +``` diff --git a/backend/src/features/fuel-logs/api/fuel-logs.controller.ts b/backend/src/features/fuel-logs/api/fuel-logs.controller.ts new file mode 100644 index 0000000..61deb7a --- /dev/null +++ b/backend/src/features/fuel-logs/api/fuel-logs.controller.ts @@ -0,0 +1,186 @@ +/** + * @ai-summary HTTP request handlers for fuel logs + */ + +import { Request, Response, NextFunction } from 'express'; +import { FuelLogsService } from '../domain/fuel-logs.service'; +import { validateCreateFuelLog, validateUpdateFuelLog } from './fuel-logs.validators'; +import { logger } from '../../../core/logging/logger'; + +export class FuelLogsController { + constructor(private service: FuelLogsService) {} + + create = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const validation = validateCreateFuelLog(req.body); + if (!validation.success) { + return res.status(400).json({ + error: 'Validation failed', + details: validation.error.errors + }); + } + + const result = await this.service.createFuelLog(validation.data, userId); + res.status(201).json(result); + } catch (error: any) { + logger.error('Error creating fuel log', { error: error.message }); + + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + if (error.message.includes('Unauthorized')) { + return res.status(403).json({ error: error.message }); + } + + return next(error); + } + } + + listByVehicle = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { vehicleId } = req.params; + const result = await this.service.getFuelLogsByVehicle(vehicleId, userId); + res.json(result); + } catch (error: any) { + logger.error('Error listing fuel logs', { error: error.message }); + + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + if (error.message.includes('Unauthorized')) { + return res.status(403).json({ error: error.message }); + } + + return next(error); + } + } + + listAll = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const result = await this.service.getUserFuelLogs(userId); + res.json(result); + } catch (error: any) { + logger.error('Error listing all fuel logs', { error: error.message }); + return next(error); + } + } + + get = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { id } = req.params; + const result = await this.service.getFuelLog(id, userId); + res.json(result); + } catch (error: any) { + logger.error('Error getting fuel log', { error: error.message }); + + if (error.message === 'Fuel log not found') { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'Unauthorized') { + return res.status(403).json({ error: error.message }); + } + + return next(error); + } + } + + update = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { id } = req.params; + const validation = validateUpdateFuelLog(req.body); + if (!validation.success) { + return res.status(400).json({ + error: 'Validation failed', + details: validation.error.errors + }); + } + + const result = await this.service.updateFuelLog(id, validation.data, userId); + res.json(result); + } catch (error: any) { + logger.error('Error updating fuel log', { error: error.message }); + + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'Unauthorized') { + return res.status(403).json({ error: error.message }); + } + + return next(error); + } + } + + delete = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { id } = req.params; + await this.service.deleteFuelLog(id, userId); + res.status(204).send(); + } catch (error: any) { + logger.error('Error deleting fuel log', { error: error.message }); + + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'Unauthorized') { + return res.status(403).json({ error: error.message }); + } + + return next(error); + } + } + + getStats = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { vehicleId } = req.params; + const result = await this.service.getVehicleStats(vehicleId, userId); + res.json(result); + } catch (error: any) { + logger.error('Error getting fuel stats', { error: error.message }); + + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'Unauthorized') { + return res.status(403).json({ error: error.message }); + } + + return next(error); + } + } +} \ No newline at end of file diff --git a/backend/src/features/fuel-logs/api/fuel-logs.routes.ts b/backend/src/features/fuel-logs/api/fuel-logs.routes.ts new file mode 100644 index 0000000..a4bc124 --- /dev/null +++ b/backend/src/features/fuel-logs/api/fuel-logs.routes.ts @@ -0,0 +1,32 @@ +/** + * @ai-summary Route definitions for fuel logs API + */ + +import { Router } from 'express'; +import { FuelLogsController } from './fuel-logs.controller'; +import { FuelLogsService } from '../domain/fuel-logs.service'; +import { FuelLogsRepository } from '../data/fuel-logs.repository'; +import { authMiddleware } from '../../../core/security/auth.middleware'; +import pool from '../../../core/config/database'; + +export function registerFuelLogsRoutes(): Router { + const router = Router(); + + // Initialize layers + const repository = new FuelLogsRepository(pool); + const service = new FuelLogsService(repository); + const controller = new FuelLogsController(service); + + // Define routes + router.get('/api/fuel-logs', authMiddleware, controller.listAll); + router.get('/api/fuel-logs/:id', authMiddleware, controller.get); + router.post('/api/fuel-logs', authMiddleware, controller.create); + router.put('/api/fuel-logs/:id', authMiddleware, controller.update); + router.delete('/api/fuel-logs/:id', authMiddleware, controller.delete); + + // Vehicle-specific routes + router.get('/api/vehicles/:vehicleId/fuel-logs', authMiddleware, controller.listByVehicle); + router.get('/api/vehicles/:vehicleId/fuel-stats', authMiddleware, controller.getStats); + + return router; +} \ No newline at end of file diff --git a/backend/src/features/fuel-logs/api/fuel-logs.validators.ts b/backend/src/features/fuel-logs/api/fuel-logs.validators.ts new file mode 100644 index 0000000..8d305bd --- /dev/null +++ b/backend/src/features/fuel-logs/api/fuel-logs.validators.ts @@ -0,0 +1,38 @@ +/** + * @ai-summary Input validation for fuel logs API + */ + +import { z } from 'zod'; + +export const createFuelLogSchema = z.object({ + vehicleId: z.string().uuid(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + odometer: z.number().int().positive(), + gallons: z.number().positive(), + pricePerGallon: z.number().positive(), + totalCost: z.number().positive(), + station: z.string().max(200).optional(), + location: z.string().max(200).optional(), + notes: z.string().max(1000).optional(), +}); + +export const updateFuelLogSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + odometer: z.number().int().positive().optional(), + gallons: z.number().positive().optional(), + pricePerGallon: z.number().positive().optional(), + totalCost: z.number().positive().optional(), + station: z.string().max(200).optional(), + location: z.string().max(200).optional(), + notes: z.string().max(1000).optional(), +}).refine(data => Object.keys(data).length > 0, { + message: 'At least one field must be provided for update' +}); + +export function validateCreateFuelLog(data: unknown) { + return createFuelLogSchema.safeParse(data); +} + +export function validateUpdateFuelLog(data: unknown) { + return updateFuelLogSchema.safeParse(data); +} \ No newline at end of file diff --git a/backend/src/features/fuel-logs/domain/fuel-logs.service.ts b/backend/src/features/fuel-logs/domain/fuel-logs.service.ts new file mode 100644 index 0000000..261fc1a --- /dev/null +++ b/backend/src/features/fuel-logs/domain/fuel-logs.service.ts @@ -0,0 +1,249 @@ +/** + * @ai-summary Business logic for fuel logs feature + * @ai-context Handles MPG calculations and vehicle validation + */ + +import { FuelLogsRepository } from '../data/fuel-logs.repository'; +import { + FuelLog, + CreateFuelLogRequest, + UpdateFuelLogRequest, + FuelLogResponse, + FuelStats +} from './fuel-logs.types'; +import { logger } from '../../../core/logging/logger'; +import { cacheService } from '../../../core/config/redis'; +import { pool } from '../../../core/config/database'; + +export class FuelLogsService { + private readonly cachePrefix = 'fuel-logs'; + private readonly cacheTTL = 300; // 5 minutes + + constructor(private repository: FuelLogsRepository) {} + + async createFuelLog(data: CreateFuelLogRequest, userId: string): Promise { + logger.info('Creating fuel log', { userId, vehicleId: data.vehicleId }); + + // Verify vehicle ownership + const vehicleCheck = await pool.query( + 'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', + [data.vehicleId, userId] + ); + + if (vehicleCheck.rows.length === 0) { + throw new Error('Vehicle not found or unauthorized'); + } + + // Calculate MPG based on previous log + let mpg: number | undefined; + const previousLog = await this.repository.getPreviousLog( + data.vehicleId, + data.date, + data.odometer + ); + + if (previousLog && previousLog.odometer < data.odometer) { + const milesDriven = data.odometer - previousLog.odometer; + mpg = milesDriven / data.gallons; + } + + // Create fuel log + const fuelLog = await this.repository.create({ + ...data, + userId, + mpg + }); + + // Update vehicle odometer + await pool.query( + 'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND odometer_reading < $1', + [data.odometer, data.vehicleId] + ); + + // Invalidate caches + await this.invalidateCaches(userId, data.vehicleId); + + return this.toResponse(fuelLog); + } + + async getFuelLogsByVehicle(vehicleId: string, userId: string): Promise { + // Verify vehicle ownership + const vehicleCheck = await pool.query( + 'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', + [vehicleId, userId] + ); + + if (vehicleCheck.rows.length === 0) { + throw new Error('Vehicle not found or unauthorized'); + } + + const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}`; + + // Check cache + const cached = await cacheService.get(cacheKey); + if (cached) { + return cached; + } + + // Get from database + const logs = await this.repository.findByVehicleId(vehicleId); + const response = logs.map(log => this.toResponse(log)); + + // Cache result + await cacheService.set(cacheKey, response, this.cacheTTL); + + return response; + } + + async getUserFuelLogs(userId: string): Promise { + const cacheKey = `${this.cachePrefix}:user:${userId}`; + + // Check cache + const cached = await cacheService.get(cacheKey); + if (cached) { + return cached; + } + + // Get from database + const logs = await this.repository.findByUserId(userId); + const response = logs.map(log => this.toResponse(log)); + + // Cache result + await cacheService.set(cacheKey, response, this.cacheTTL); + + return response; + } + + async getFuelLog(id: string, userId: string): Promise { + const log = await this.repository.findById(id); + + if (!log) { + throw new Error('Fuel log not found'); + } + + if (log.userId !== userId) { + throw new Error('Unauthorized'); + } + + return this.toResponse(log); + } + + async updateFuelLog( + id: string, + data: UpdateFuelLogRequest, + userId: string + ): Promise { + // Verify ownership + const existing = await this.repository.findById(id); + if (!existing) { + throw new Error('Fuel log not found'); + } + if (existing.userId !== userId) { + throw new Error('Unauthorized'); + } + + // Recalculate MPG if odometer or gallons changed + let mpg = existing.mpg; + if (data.odometer || data.gallons) { + const previousLog = await this.repository.getPreviousLog( + existing.vehicleId, + data.date || existing.date.toISOString(), + data.odometer || existing.odometer + ); + + if (previousLog) { + const odometer = data.odometer || existing.odometer; + const gallons = data.gallons || existing.gallons; + const milesDriven = odometer - previousLog.odometer; + mpg = milesDriven / gallons; + } + } + + // Prepare update data with proper types + const updateData: Partial = { + ...data, + date: data.date ? new Date(data.date) : undefined, + mpg + }; + + // Update + const updated = await this.repository.update(id, updateData); + if (!updated) { + throw new Error('Update failed'); + } + + // Invalidate caches + await this.invalidateCaches(userId, existing.vehicleId); + + return this.toResponse(updated); + } + + async deleteFuelLog(id: string, userId: string): Promise { + // Verify ownership + const existing = await this.repository.findById(id); + if (!existing) { + throw new Error('Fuel log not found'); + } + if (existing.userId !== userId) { + throw new Error('Unauthorized'); + } + + await this.repository.delete(id); + + // Invalidate caches + await this.invalidateCaches(userId, existing.vehicleId); + } + + async getVehicleStats(vehicleId: string, userId: string): Promise { + // Verify vehicle ownership + const vehicleCheck = await pool.query( + 'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', + [vehicleId, userId] + ); + + if (vehicleCheck.rows.length === 0) { + throw new Error('Vehicle not found or unauthorized'); + } + + const stats = await this.repository.getStats(vehicleId); + + if (!stats) { + return { + logCount: 0, + totalGallons: 0, + totalCost: 0, + averagePricePerGallon: 0, + averageMPG: 0, + totalMiles: 0, + }; + } + + return stats; + } + + private async invalidateCaches(userId: string, vehicleId: string): Promise { + await Promise.all([ + cacheService.del(`${this.cachePrefix}:user:${userId}`), + cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}`) + ]); + } + + private toResponse(log: FuelLog): FuelLogResponse { + return { + id: log.id, + userId: log.userId, + vehicleId: log.vehicleId, + date: log.date.toISOString().split('T')[0], + odometer: log.odometer, + gallons: log.gallons, + pricePerGallon: log.pricePerGallon, + totalCost: log.totalCost, + station: log.station, + location: log.location, + notes: log.notes, + mpg: log.mpg, + createdAt: log.createdAt.toISOString(), + updatedAt: log.updatedAt.toISOString(), + }; + } +} \ No newline at end of file diff --git a/backend/src/features/fuel-logs/domain/fuel-logs.types.ts b/backend/src/features/fuel-logs/domain/fuel-logs.types.ts new file mode 100644 index 0000000..b9076c9 --- /dev/null +++ b/backend/src/features/fuel-logs/domain/fuel-logs.types.ts @@ -0,0 +1,70 @@ +/** + * @ai-summary Type definitions for fuel logs feature + * @ai-context Tracks fuel purchases and calculates MPG + */ + +export interface FuelLog { + id: string; + userId: string; + vehicleId: string; + date: Date; + odometer: number; + gallons: number; + pricePerGallon: number; + totalCost: number; + station?: string; + location?: string; + notes?: string; + mpg?: number; // Calculated field + createdAt: Date; + updatedAt: Date; +} + +export interface CreateFuelLogRequest { + vehicleId: string; + date: string; // ISO date string + odometer: number; + gallons: number; + pricePerGallon: number; + totalCost: number; + station?: string; + location?: string; + notes?: string; +} + +export interface UpdateFuelLogRequest { + date?: string; + odometer?: number; + gallons?: number; + pricePerGallon?: number; + totalCost?: number; + station?: string; + location?: string; + notes?: string; +} + +export interface FuelLogResponse { + id: string; + userId: string; + vehicleId: string; + date: string; + odometer: number; + gallons: number; + pricePerGallon: number; + totalCost: number; + station?: string; + location?: string; + notes?: string; + mpg?: number; + createdAt: string; + updatedAt: string; +} + +export interface FuelStats { + totalGallons: number; + totalCost: number; + averagePricePerGallon: number; + averageMPG: number; + totalMiles: number; + logCount: number; +} \ No newline at end of file diff --git a/backend/src/features/fuel-logs/index.ts b/backend/src/features/fuel-logs/index.ts new file mode 100644 index 0000000..543a46e --- /dev/null +++ b/backend/src/features/fuel-logs/index.ts @@ -0,0 +1,18 @@ +/** + * @ai-summary Public API for fuel-logs feature capsule + */ + +// Export service for use by other features +export { FuelLogsService } from './domain/fuel-logs.service'; + +// Export types +export type { + FuelLog, + CreateFuelLogRequest, + UpdateFuelLogRequest, + FuelLogResponse, + FuelStats +} from './domain/fuel-logs.types'; + +// Internal: Register routes +export { registerFuelLogsRoutes } from './api/fuel-logs.routes'; diff --git a/backend/src/features/fuel-logs/migrations/001_create_fuel_logs_table.sql b/backend/src/features/fuel-logs/migrations/001_create_fuel_logs_table.sql new file mode 100644 index 0000000..34bfcaf --- /dev/null +++ b/backend/src/features/fuel-logs/migrations/001_create_fuel_logs_table.sql @@ -0,0 +1,34 @@ +-- Create fuel_logs table +CREATE TABLE IF NOT EXISTS fuel_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(255) NOT NULL, + vehicle_id UUID NOT NULL, + date DATE NOT NULL, + odometer INTEGER NOT NULL, + gallons DECIMAL(10, 3) NOT NULL, + price_per_gallon DECIMAL(10, 3) NOT NULL, + total_cost DECIMAL(10, 2) NOT NULL, + station VARCHAR(200), + location VARCHAR(200), + notes TEXT, + mpg DECIMAL(10, 2), -- Calculated based on previous log + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_fuel_logs_vehicle + FOREIGN KEY (vehicle_id) + REFERENCES vehicles(id) + ON DELETE CASCADE +); + +-- Create indexes +CREATE INDEX idx_fuel_logs_user_id ON fuel_logs(user_id); +CREATE INDEX idx_fuel_logs_vehicle_id ON fuel_logs(vehicle_id); +CREATE INDEX idx_fuel_logs_date ON fuel_logs(date DESC); +CREATE INDEX idx_fuel_logs_created_at ON fuel_logs(created_at DESC); + +-- Add trigger for updated_at +CREATE TRIGGER update_fuel_logs_updated_at + BEFORE UPDATE ON fuel_logs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/backend/src/features/maintenance/README.md b/backend/src/features/maintenance/README.md new file mode 100644 index 0000000..4d3fc22 --- /dev/null +++ b/backend/src/features/maintenance/README.md @@ -0,0 +1,35 @@ +# Umaintenance Feature Capsule + +## Quick Summary (50 tokens) +[AI: Complete feature description, main operations, dependencies, caching strategy] + +## API Endpoints +- GET /api/maintenance - List all maintenance +- GET /api/maintenance/:id - Get specific lUmaintenance +- POST /api/maintenance - Create new lUmaintenance +- PUT /api/maintenance/:id - Update lUmaintenance +- DELETE /api/maintenance/:id - Delete lUmaintenance + +## Structure +- **api/** - HTTP endpoints, routes, validators +- **domain/** - Business logic, types, rules +- **data/** - Repository, database queries +- **migrations/** - Feature-specific schema +- **external/** - External API integrations +- **events/** - Event handlers +- **tests/** - All feature tests +- **docs/** - Detailed documentation + +## Dependencies +- Internal: core/auth, core/cache +- External: [List any external APIs] +- Database: maintenance table + +## Quick Commands +```bash +# Run feature tests +npm test -- features/maintenance + +# Run feature migrations +npm run migrate:feature maintenance +``` diff --git a/backend/src/features/maintenance/index.ts b/backend/src/features/maintenance/index.ts new file mode 100644 index 0000000..7e8b370 --- /dev/null +++ b/backend/src/features/maintenance/index.ts @@ -0,0 +1,18 @@ +/** + * @ai-summary Public API for maintenance feature capsule + * @ai-note This is the ONLY file other features should import from + */ + +// Export service for use by other features +export { UmaintenanceService } from './domain/lUmaintenance.service'; + +// Export types needed by other features +export type { + Umaintenance, + CreateUmaintenanceRequest, + UpdateUmaintenanceRequest, + UmaintenanceResponse +} from './domain/lUmaintenance.types'; + +// Internal: Register routes with Express app +export { registerUmaintenanceRoutes } from './api/lUmaintenance.routes'; diff --git a/backend/src/features/maintenance/migrations/001_create_maintenance_tables.sql b/backend/src/features/maintenance/migrations/001_create_maintenance_tables.sql new file mode 100644 index 0000000..2a9e050 --- /dev/null +++ b/backend/src/features/maintenance/migrations/001_create_maintenance_tables.sql @@ -0,0 +1,66 @@ +-- Create maintenance_logs table +CREATE TABLE IF NOT EXISTS maintenance_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(255) NOT NULL, + vehicle_id UUID NOT NULL, + date DATE NOT NULL, + odometer INTEGER NOT NULL, + type VARCHAR(100) NOT NULL, -- oil_change, tire_rotation, etc. + description TEXT, + cost DECIMAL(10, 2), + shop_name VARCHAR(200), + notes TEXT, + next_due_date DATE, + next_due_mileage INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_maintenance_vehicle + FOREIGN KEY (vehicle_id) + REFERENCES vehicles(id) + ON DELETE CASCADE +); + +-- Create maintenance_schedules table +CREATE TABLE IF NOT EXISTS maintenance_schedules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + vehicle_id UUID NOT NULL, + type VARCHAR(100) NOT NULL, + interval_months INTEGER, + interval_miles INTEGER, + last_performed_date DATE, + last_performed_mileage INTEGER, + next_due_date DATE, + next_due_mileage INTEGER, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_schedule_vehicle + FOREIGN KEY (vehicle_id) + REFERENCES vehicles(id) + ON DELETE CASCADE, + + CONSTRAINT unique_vehicle_maintenance_type + UNIQUE(vehicle_id, type) +); + +-- Create indexes +CREATE INDEX idx_maintenance_logs_user_id ON maintenance_logs(user_id); +CREATE INDEX idx_maintenance_logs_vehicle_id ON maintenance_logs(vehicle_id); +CREATE INDEX idx_maintenance_logs_date ON maintenance_logs(date DESC); +CREATE INDEX idx_maintenance_logs_type ON maintenance_logs(type); + +CREATE INDEX idx_maintenance_schedules_vehicle_id ON maintenance_schedules(vehicle_id); +CREATE INDEX idx_maintenance_schedules_next_due_date ON maintenance_schedules(next_due_date); + +-- Add triggers +CREATE TRIGGER update_maintenance_logs_updated_at + BEFORE UPDATE ON maintenance_logs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_maintenance_schedules_updated_at + BEFORE UPDATE ON maintenance_schedules + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/backend/src/features/stations/README.md b/backend/src/features/stations/README.md new file mode 100644 index 0000000..33c4404 --- /dev/null +++ b/backend/src/features/stations/README.md @@ -0,0 +1,35 @@ +# Ustations Feature Capsule + +## Quick Summary (50 tokens) +[AI: Complete feature description, main operations, dependencies, caching strategy] + +## API Endpoints +- GET /api/stations - List all stations +- GET /api/stations/:id - Get specific lUstations +- POST /api/stations - Create new lUstations +- PUT /api/stations/:id - Update lUstations +- DELETE /api/stations/:id - Delete lUstations + +## Structure +- **api/** - HTTP endpoints, routes, validators +- **domain/** - Business logic, types, rules +- **data/** - Repository, database queries +- **migrations/** - Feature-specific schema +- **external/** - External API integrations +- **events/** - Event handlers +- **tests/** - All feature tests +- **docs/** - Detailed documentation + +## Dependencies +- Internal: core/auth, core/cache +- External: [List any external APIs] +- Database: stations table + +## Quick Commands +```bash +# Run feature tests +npm test -- features/stations + +# Run feature migrations +npm run migrate:feature stations +``` diff --git a/backend/src/features/stations/api/stations.controller.ts b/backend/src/features/stations/api/stations.controller.ts new file mode 100644 index 0000000..873c8dc --- /dev/null +++ b/backend/src/features/stations/api/stations.controller.ts @@ -0,0 +1,105 @@ +/** + * @ai-summary HTTP request handlers for stations + */ + +import { Request, Response, NextFunction } from 'express'; +import { StationsService } from '../domain/stations.service'; +import { logger } from '../../../core/logging/logger'; + +export class StationsController { + constructor(private service: StationsService) {} + + search = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { latitude, longitude, radius, fuelType } = req.body; + + if (!latitude || !longitude) { + return res.status(400).json({ error: 'Latitude and longitude are required' }); + } + + const result = await this.service.searchNearbyStations({ + latitude, + longitude, + radius, + fuelType + }, userId); + + res.json(result); + } catch (error: any) { + logger.error('Error searching stations', { error: error.message }); + return next(error); + } + } + + save = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { placeId, nickname, notes, isFavorite } = req.body; + + if (!placeId) { + return res.status(400).json({ error: 'Place ID is required' }); + } + + const result = await this.service.saveStation(placeId, userId, { + nickname, + notes, + isFavorite + }); + + res.status(201).json(result); + } catch (error: any) { + logger.error('Error saving station', { error: error.message }); + + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + + return next(error); + } + } + + getSaved = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const result = await this.service.getUserSavedStations(userId); + res.json(result); + } catch (error: any) { + logger.error('Error getting saved stations', { error: error.message }); + return next(error); + } + } + + removeSaved = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { placeId } = req.params; + await this.service.removeSavedStation(placeId, userId); + res.status(204).send(); + } catch (error: any) { + logger.error('Error removing saved station', { error: error.message }); + + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + + return next(error); + } + } +} \ No newline at end of file diff --git a/backend/src/features/stations/api/stations.routes.ts b/backend/src/features/stations/api/stations.routes.ts new file mode 100644 index 0000000..9089f57 --- /dev/null +++ b/backend/src/features/stations/api/stations.routes.ts @@ -0,0 +1,27 @@ +/** + * @ai-summary Route definitions for stations API + */ + +import { Router } from 'express'; +import { StationsController } from './stations.controller'; +import { StationsService } from '../domain/stations.service'; +import { StationsRepository } from '../data/stations.repository'; +import { authMiddleware } from '../../../core/security/auth.middleware'; +import pool from '../../../core/config/database'; + +export function registerStationsRoutes(): Router { + const router = Router(); + + // Initialize layers + const repository = new StationsRepository(pool); + const service = new StationsService(repository); + const controller = new StationsController(service); + + // Define routes + router.post('/api/stations/search', authMiddleware, controller.search); + router.post('/api/stations/save', authMiddleware, controller.save); + router.get('/api/stations/saved', authMiddleware, controller.getSaved); + router.delete('/api/stations/saved/:placeId', authMiddleware, controller.removeSaved); + + return router; +} \ No newline at end of file diff --git a/backend/src/features/stations/domain/stations.service.ts b/backend/src/features/stations/domain/stations.service.ts new file mode 100644 index 0000000..d0fb1a0 --- /dev/null +++ b/backend/src/features/stations/domain/stations.service.ts @@ -0,0 +1,90 @@ +/** + * @ai-summary Business logic for stations feature + */ + +import { StationsRepository } from '../data/stations.repository'; +import { googleMapsClient } from '../external/google-maps/google-maps.client'; +import { StationSearchRequest, StationSearchResponse } from './stations.types'; +import { logger } from '../../../core/logging/logger'; + +export class StationsService { + constructor(private repository: StationsRepository) {} + + async searchNearbyStations( + request: StationSearchRequest, + userId: string + ): Promise { + logger.info('Searching for stations', { userId, ...request }); + + // Search via Google Maps + const stations = await googleMapsClient.searchNearbyStations( + request.latitude, + request.longitude, + request.radius || 5000 + ); + + // Cache stations for future reference + for (const station of stations) { + await this.repository.cacheStation(station); + } + + // Sort by distance + stations.sort((a, b) => (a.distance || 0) - (b.distance || 0)); + + return { + stations, + searchLocation: { + latitude: request.latitude, + longitude: request.longitude + }, + searchRadius: request.radius || 5000, + timestamp: new Date().toISOString() + }; + } + + async saveStation( + placeId: string, + userId: string, + data?: { nickname?: string; notes?: string; isFavorite?: boolean } + ) { + // Get station details from cache + const station = await this.repository.getCachedStation(placeId); + + if (!station) { + throw new Error('Station not found. Please search for stations first.'); + } + + // Save to user's saved stations + const saved = await this.repository.saveStation(userId, placeId, data); + + return { + ...saved, + station + }; + } + + async getUserSavedStations(userId: string) { + const savedStations = await this.repository.getUserSavedStations(userId); + + // Enrich with cached station data + const enriched = await Promise.all( + savedStations.map(async (saved) => { + const station = await this.repository.getCachedStation(saved.stationId); + return { + ...saved, + station + }; + }) + ); + + return enriched; + } + + async removeSavedStation(placeId: string, userId: string) { + const removed = await this.repository.deleteSavedStation(userId, placeId); + + if (!removed) { + throw new Error('Saved station not found'); + } + } +} \ No newline at end of file diff --git a/backend/src/features/stations/domain/stations.types.ts b/backend/src/features/stations/domain/stations.types.ts new file mode 100644 index 0000000..dcfa571 --- /dev/null +++ b/backend/src/features/stations/domain/stations.types.ts @@ -0,0 +1,49 @@ +/** + * @ai-summary Type definitions for stations feature + * @ai-context Gas station discovery and caching + */ + +export interface Station { + id: string; + placeId: string; // Google Places ID + name: string; + address: string; + latitude: number; + longitude: number; + priceRegular?: number; + pricePremium?: number; + priceDiesel?: number; + lastUpdated?: Date; + distance?: number; // Distance from search point in meters + isOpen?: boolean; + rating?: number; + photoUrl?: string; +} + +export interface StationSearchRequest { + latitude: number; + longitude: number; + radius?: number; // Radius in meters (default 5000) + fuelType?: 'regular' | 'premium' | 'diesel'; +} + +export interface StationSearchResponse { + stations: Station[]; + searchLocation: { + latitude: number; + longitude: number; + }; + searchRadius: number; + timestamp: string; +} + +export interface SavedStation { + id: string; + userId: string; + stationId: string; + nickname?: string; + notes?: string; + isFavorite: boolean; + createdAt: Date; + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/features/stations/external/google-maps/google-maps.client.ts b/backend/src/features/stations/external/google-maps/google-maps.client.ts new file mode 100644 index 0000000..a7105f3 --- /dev/null +++ b/backend/src/features/stations/external/google-maps/google-maps.client.ts @@ -0,0 +1,112 @@ +/** + * @ai-summary Google Maps client for station discovery + * @ai-context Searches for gas stations and caches results + */ + +import axios from 'axios'; +import { env } from '../../../../core/config/environment'; +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 baseURL = 'https://maps.googleapis.com/maps/api/place'; + private readonly cacheTTL = 3600; // 1 hour + + async searchNearbyStations( + latitude: number, + longitude: number, + radius: number = 5000 + ): Promise { + const cacheKey = `stations:${latitude.toFixed(4)},${longitude.toFixed(4)},${radius}`; + + try { + // Check cache + const cached = await cacheService.get(cacheKey); + if (cached) { + logger.debug('Station search cache hit', { latitude, longitude }); + return cached; + } + + // Search Google Places + logger.info('Searching Google Places for stations', { latitude, longitude, radius }); + + const response = await axios.get( + `${this.baseURL}/nearbysearch/json`, + { + params: { + location: `${latitude},${longitude}`, + radius, + type: 'gas_station', + key: this.apiKey + } + } + ); + + if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') { + throw new Error(`Google Places API error: ${response.data.status}`); + } + + // Transform results + const stations = response.data.results.map(place => + this.transformPlaceToStation(place, latitude, longitude) + ); + + // Cache results + await cacheService.set(cacheKey, stations, this.cacheTTL); + + return stations; + } catch (error) { + logger.error('Station search failed', { error, latitude, longitude }); + throw error; + } + } + + private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station { + // Calculate distance from search point + const distance = this.calculateDistance( + searchLat, + searchLng, + place.geometry.location.lat, + place.geometry.location.lng + ); + + // Generate photo URL if available + let photoUrl: string | undefined; + if (place.photos && place.photos.length > 0) { + photoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photo_reference=${place.photos[0].photo_reference}&key=${this.apiKey}`; + } + + return { + id: place.place_id, + placeId: place.place_id, + name: place.name, + address: place.vicinity, + latitude: place.geometry.location.lat, + longitude: place.geometry.location.lng, + distance, + isOpen: place.opening_hours?.open_now, + rating: place.rating, + photoUrl + }; + } + + private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371e3; // Earth's radius in meters + const φ1 = lat1 * Math.PI / 180; + const φ2 = lat2 * Math.PI / 180; + const Δφ = (lat2 - lat1) * Math.PI / 180; + const Δλ = (lon2 - lon1) * Math.PI / 180; + + const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + + Math.cos(φ1) * Math.cos(φ2) * + Math.sin(Δλ/2) * Math.sin(Δλ/2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + + return Math.round(R * c); // Distance in meters + } +} + +export const googleMapsClient = new GoogleMapsClient(); \ No newline at end of file diff --git a/backend/src/features/stations/external/google-maps/google-maps.types.ts b/backend/src/features/stations/external/google-maps/google-maps.types.ts new file mode 100644 index 0000000..39a02d4 --- /dev/null +++ b/backend/src/features/stations/external/google-maps/google-maps.types.ts @@ -0,0 +1,55 @@ +/** + * @ai-summary Google Maps API types + */ + +export interface GooglePlacesResponse { + results: GooglePlace[]; + status: string; + next_page_token?: string; +} + +export interface GooglePlace { + place_id: string; + name: string; + vicinity: string; + geometry: { + location: { + lat: number; + lng: number; + }; + }; + opening_hours?: { + open_now: boolean; + }; + rating?: number; + photos?: Array<{ + photo_reference: string; + }>; + price_level?: number; + types: string[]; +} + +export interface GooglePlaceDetails { + result: { + place_id: string; + name: string; + formatted_address: string; + geometry: { + location: { + lat: number; + lng: number; + }; + }; + opening_hours?: { + open_now: boolean; + weekday_text: string[]; + }; + rating?: number; + photos?: Array<{ + photo_reference: string; + }>; + formatted_phone_number?: string; + website?: string; + }; + status: string; +} \ No newline at end of file diff --git a/backend/src/features/stations/index.ts b/backend/src/features/stations/index.ts new file mode 100644 index 0000000..e4d3f86 --- /dev/null +++ b/backend/src/features/stations/index.ts @@ -0,0 +1,17 @@ +/** + * @ai-summary Public API for stations feature capsule + */ + +// Export service +export { StationsService } from './domain/stations.service'; + +// Export types +export type { + Station, + StationSearchRequest, + StationSearchResponse, + SavedStation +} from './domain/stations.types'; + +// Internal: Register routes +export { registerStationsRoutes } from './api/stations.routes'; diff --git a/backend/src/features/stations/migrations/001_create_stations_tables.sql b/backend/src/features/stations/migrations/001_create_stations_tables.sql new file mode 100644 index 0000000..f78d2c3 --- /dev/null +++ b/backend/src/features/stations/migrations/001_create_stations_tables.sql @@ -0,0 +1,44 @@ +-- Create station cache table +CREATE TABLE IF NOT EXISTS station_cache ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + place_id VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + address TEXT NOT NULL, + latitude DECIMAL(10, 8) NOT NULL, + longitude DECIMAL(11, 8) NOT NULL, + price_regular DECIMAL(10, 2), + price_premium DECIMAL(10, 2), + price_diesel DECIMAL(10, 2), + rating DECIMAL(2, 1), + photo_url TEXT, + raw_data JSONB, + cached_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create saved stations table for user favorites +CREATE TABLE IF NOT EXISTS saved_stations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(255) NOT NULL, + place_id VARCHAR(255) NOT NULL, + nickname VARCHAR(100), + notes TEXT, + is_favorite BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT unique_user_station UNIQUE(user_id, place_id) +); + +-- Create indexes +CREATE INDEX idx_station_cache_place_id ON station_cache(place_id); +CREATE INDEX idx_station_cache_location ON station_cache(latitude, longitude); +CREATE INDEX idx_station_cache_cached_at ON station_cache(cached_at); + +CREATE INDEX idx_saved_stations_user_id ON saved_stations(user_id); +CREATE INDEX idx_saved_stations_is_favorite ON saved_stations(is_favorite); + +-- Add trigger for updated_at +CREATE TRIGGER update_saved_stations_updated_at + BEFORE UPDATE ON saved_stations + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/backend/src/features/vehicles/README.md b/backend/src/features/vehicles/README.md new file mode 100644 index 0000000..fb731ed --- /dev/null +++ b/backend/src/features/vehicles/README.md @@ -0,0 +1,208 @@ +# Vehicles Feature Capsule + +## Quick Summary (50 tokens) +Primary entity for vehicle management with VIN decoding via NHTSA vPIC API. Handles CRUD operations, automatic vehicle data population, user ownership validation, caching strategy (VIN lookups: 30 days, user lists: 5 minutes). Foundation for fuel-logs and maintenance features. + +## API Endpoints +- `POST /api/vehicles` - Create new vehicle with VIN decoding +- `GET /api/vehicles` - List all user's vehicles (cached 5 min) +- `GET /api/vehicles/:id` - Get specific vehicle +- `PUT /api/vehicles/:id` - Update vehicle details +- `DELETE /api/vehicles/:id` - Soft delete vehicle + +## Authentication Required +All endpoints require valid JWT token with user context. + +## Request/Response Examples + +### Create Vehicle +```json +POST /api/vehicles +{ + "vin": "1HGBH41JXMN109186", + "nickname": "My Honda", + "color": "Blue", + "licensePlate": "ABC123", + "odometerReading": 50000 +} + +Response (201): +{ + "id": "uuid-here", + "userId": "user-id", + "vin": "1HGBH41JXMN109186", + "make": "Honda", // Auto-decoded + "model": "Civic", // Auto-decoded + "year": 2021, // Auto-decoded + "nickname": "My Honda", + "color": "Blue", + "licensePlate": "ABC123", + "odometerReading": 50000, + "isActive": true, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" +} +``` + +## Feature Architecture + +### Complete Self-Contained Structure +``` +vehicles/ +├── README.md # This file +├── index.ts # Public API exports +├── api/ # HTTP layer +│ ├── vehicles.controller.ts +│ ├── vehicles.routes.ts +│ └── vehicles.validation.ts +├── domain/ # Business logic +│ ├── vehicles.service.ts +│ └── vehicles.types.ts +├── data/ # Database layer +│ └── vehicles.repository.ts +├── migrations/ # Feature schema +│ └── 001_create_vehicles_tables.sql +├── external/ # External APIs +│ └── vpic/ +│ ├── vpic.client.ts +│ └── vpic.types.ts +├── tests/ # All tests +│ ├── unit/ +│ │ ├── vehicles.service.test.ts +│ │ └── vpic.client.test.ts +│ └── integration/ +│ └── vehicles.integration.test.ts +└── docs/ # Additional docs +``` + +## Key Features + +### 🔍 Automatic VIN Decoding +- **External API**: NHTSA vPIC (Vehicle Product Information Catalog) +- **Caching**: 30-day Redis cache for VIN lookups +- **Fallback**: Graceful handling of decode failures +- **Validation**: 17-character VIN format validation + +### 🏗️ Database Schema +- **Primary Table**: `vehicles` with soft delete +- **Cache Table**: `vin_cache` for external API results +- **Indexes**: Optimized for user queries and VIN lookups +- **Constraints**: Unique VIN per user, proper foreign keys + +### 🚀 Performance Optimizations +- **Redis Caching**: User vehicle lists cached for 5 minutes +- **VIN Cache**: 30-day persistent cache in PostgreSQL +- **Indexes**: Strategic database indexes for fast queries +- **Soft Deletes**: Maintains referential integrity + +## Business Rules + +### VIN Validation +- Must be exactly 17 characters +- Cannot contain letters I, O, or Q +- Must pass basic checksum validation +- Auto-populates make, model, year from vPIC API + +### User Ownership +- Each user can have multiple vehicles +- Same VIN cannot be registered twice by same user +- All operations validate user ownership +- Soft delete preserves data for audit trail + +## Dependencies + +### Internal Core Services +- `core/auth` - JWT authentication middleware +- `core/config` - Database pool, Redis cache +- `core/logging` - Structured logging with Winston +- `shared-minimal/utils` - Pure validation utilities + +### External Services +- **NHTSA vPIC API** - VIN decoding service +- **PostgreSQL** - Primary data storage +- **Redis** - Caching layer + +### Database Tables +- `vehicles` - Primary vehicle data +- `vin_cache` - External API response cache + +## Caching Strategy + +### VIN Decode Cache (30 days) +- **Key**: `vpic:vin:{vin}` +- **TTL**: 2,592,000 seconds (30 days) +- **Rationale**: Vehicle specifications never change + +### User Vehicle List (5 minutes) +- **Key**: `vehicles:user:{userId}` +- **TTL**: 300 seconds (5 minutes) +- **Invalidation**: On create, update, delete + +## Testing + +### Unit Tests +- `vehicles.service.test.ts` - Business logic with mocked dependencies +- `vpic.client.test.ts` - External API client with mocked HTTP + +### Integration Tests +- `vehicles.integration.test.ts` - Complete API workflow with test database + +### Run Tests +```bash +# All vehicle tests +npm test -- features/vehicles + +# Unit tests only +npm test -- features/vehicles/tests/unit + +# Integration tests only +npm test -- features/vehicles/tests/integration + +# With coverage +npm test -- features/vehicles --coverage +``` + +## Error Handling + +### Client Errors (4xx) +- `400` - Invalid VIN format, validation errors +- `401` - Missing or invalid JWT token +- `403` - User not authorized for vehicle +- `404` - Vehicle not found +- `409` - Duplicate VIN for user + +### Server Errors (5xx) +- `500` - Database connection, VIN API failures +- Graceful degradation when vPIC API unavailable + +## Future Considerations + +### Dependent Features +- **fuel-logs** - Will reference `vehicle_id` +- **maintenance** - Will reference `vehicle_id` +- Both features depend on vehicles as primary entity + +### Potential Enhancements +- Vehicle image uploads (MinIO integration) +- VIN decode webhook for real-time updates +- Vehicle value estimation integration +- Maintenance scheduling based on vehicle age/mileage + +## Development Commands + +```bash +# Run migrations +make migrate + +# Start development environment +make dev + +# View feature logs +make logs-backend | grep vehicles + +# Open container shell +make shell-backend + +# Inside container - run feature tests +npm test -- features/vehicles +``` diff --git a/backend/src/features/vehicles/api/vehicles.controller.ts b/backend/src/features/vehicles/api/vehicles.controller.ts new file mode 100644 index 0000000..80509f3 --- /dev/null +++ b/backend/src/features/vehicles/api/vehicles.controller.ts @@ -0,0 +1,164 @@ +/** + * @ai-summary HTTP request handlers for vehicles API + * @ai-context Handles validation, auth, and delegates to service layer + */ + +import { Request, Response, NextFunction } from 'express'; +import { VehiclesService } from '../domain/vehicles.service'; +import { VehiclesRepository } from '../data/vehicles.repository'; +import { pool } from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; +import { ZodError } from 'zod'; +import { + createVehicleSchema, + updateVehicleSchema, + vehicleIdSchema, + CreateVehicleInput, + UpdateVehicleInput, +} from './vehicles.validation'; + +export class VehiclesController { + private service: VehiclesService; + + constructor() { + const repository = new VehiclesRepository(pool); + this.service = new VehiclesService(repository); + } + + createVehicle = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + // Validate request body + const data = createVehicleSchema.parse(req.body) as CreateVehicleInput; + + // Get user ID from JWT token + const userId = req.user?.sub; + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const vehicle = await this.service.createVehicle(data, userId); + + logger.info('Vehicle created successfully', { vehicleId: vehicle.id, userId }); + res.status(201).json(vehicle); + } catch (error: any) { + if (error instanceof ZodError) { + res.status(400).json({ error: error.errors[0].message }); + return; + } + if (error.message === 'Invalid VIN format' || + error.message === 'Vehicle with this VIN already exists') { + res.status(400).json({ error: error.message }); + return; + } + next(error); + } + }; + + getUserVehicles = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId = req.user?.sub; + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const vehicles = await this.service.getUserVehicles(userId); + res.json(vehicles); + } catch (error) { + next(error); + } + }; + + getVehicle = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { id } = vehicleIdSchema.parse(req.params); + const userId = req.user?.sub; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const vehicle = await this.service.getVehicle(id, userId); + res.json(vehicle); + } catch (error: any) { + if (error instanceof ZodError) { + res.status(400).json({ error: error.errors[0].message }); + return; + } + if (error.message === 'Vehicle not found') { + res.status(404).json({ error: 'Vehicle not found' }); + return; + } + if (error.message === 'Unauthorized') { + res.status(403).json({ error: 'Access denied' }); + return; + } + next(error); + } + }; + + updateVehicle = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { id } = vehicleIdSchema.parse(req.params); + const data = updateVehicleSchema.parse(req.body) as UpdateVehicleInput; + const userId = req.user?.sub; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const vehicle = await this.service.updateVehicle(id, data, userId); + + logger.info('Vehicle updated successfully', { vehicleId: id, userId }); + res.json(vehicle); + } catch (error: any) { + if (error instanceof ZodError) { + res.status(400).json({ error: error.errors[0].message }); + return; + } + if (error.message === 'Vehicle not found') { + res.status(404).json({ error: 'Vehicle not found' }); + return; + } + if (error.message === 'Unauthorized') { + res.status(403).json({ error: 'Access denied' }); + return; + } + next(error); + } + }; + + deleteVehicle = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { id } = vehicleIdSchema.parse(req.params); + const userId = req.user?.sub; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + await this.service.deleteVehicle(id, userId); + + logger.info('Vehicle deleted successfully', { vehicleId: id, userId }); + res.status(204).send(); + } catch (error: any) { + if (error instanceof ZodError) { + res.status(400).json({ error: error.errors[0].message }); + return; + } + if (error.message === 'Vehicle not found') { + res.status(404).json({ error: 'Vehicle not found' }); + return; + } + if (error.message === 'Unauthorized') { + res.status(403).json({ error: 'Access denied' }); + return; + } + next(error); + } + }; +} \ No newline at end of file diff --git a/backend/src/features/vehicles/api/vehicles.routes.ts b/backend/src/features/vehicles/api/vehicles.routes.ts new file mode 100644 index 0000000..844ae3b --- /dev/null +++ b/backend/src/features/vehicles/api/vehicles.routes.ts @@ -0,0 +1,25 @@ +/** + * @ai-summary Express routes for vehicles API + * @ai-context Defines REST endpoints with auth middleware + */ + +import { Router } from 'express'; +import { VehiclesController } from './vehicles.controller'; +import { authMiddleware } from '../../../core/security/auth.middleware'; + +export function registerVehiclesRoutes(): Router { + const router = Router(); + const controller = new VehiclesController(); + + // All vehicle routes require authentication + router.use(authMiddleware); + + // Routes + router.post('/api/vehicles', controller.createVehicle); + router.get('/api/vehicles', controller.getUserVehicles); + router.get('/api/vehicles/:id', controller.getVehicle); + router.put('/api/vehicles/:id', controller.updateVehicle); + router.delete('/api/vehicles/:id', controller.deleteVehicle); + + return router; +} \ No newline at end of file diff --git a/backend/src/features/vehicles/api/vehicles.validation.ts b/backend/src/features/vehicles/api/vehicles.validation.ts new file mode 100644 index 0000000..6e04aa2 --- /dev/null +++ b/backend/src/features/vehicles/api/vehicles.validation.ts @@ -0,0 +1,32 @@ +/** + * @ai-summary Request validation schemas for vehicles API + * @ai-context Uses Zod for runtime validation and type safety + */ + +import { z } from 'zod'; +import { isValidVIN } from '../../../shared-minimal/utils/validators'; + +export const createVehicleSchema = z.object({ + vin: z.string() + .length(17, 'VIN must be exactly 17 characters') + .refine(isValidVIN, 'Invalid VIN format'), + nickname: z.string().min(1).max(100).optional(), + color: z.string().min(1).max(50).optional(), + licensePlate: z.string().min(1).max(20).optional(), + odometerReading: z.number().min(0).max(9999999).optional(), +}); + +export const updateVehicleSchema = z.object({ + nickname: z.string().min(1).max(100).optional(), + color: z.string().min(1).max(50).optional(), + licensePlate: z.string().min(1).max(20).optional(), + odometerReading: z.number().min(0).max(9999999).optional(), +}).strict(); + +export const vehicleIdSchema = z.object({ + id: z.string().uuid('Invalid vehicle ID format'), +}); + +export type CreateVehicleInput = z.infer; +export type UpdateVehicleInput = z.infer; +export type VehicleIdInput = z.infer; \ No newline at end of file diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts new file mode 100644 index 0000000..576c985 --- /dev/null +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -0,0 +1,160 @@ +/** + * @ai-summary Business logic for vehicles feature + * @ai-context Handles VIN decoding, caching, and business rules + */ + +import { VehiclesRepository } from '../data/vehicles.repository'; +import { vpicClient } from '../external/vpic/vpic.client'; +import { + Vehicle, + CreateVehicleRequest, + UpdateVehicleRequest, + VehicleResponse +} from './vehicles.types'; +import { logger } from '../../../core/logging/logger'; +import { cacheService } from '../../../core/config/redis'; +import { isValidVIN } from '../../../shared-minimal/utils/validators'; + +export class VehiclesService { + private readonly cachePrefix = 'vehicles'; + private readonly listCacheTTL = 300; // 5 minutes + + constructor(private repository: VehiclesRepository) {} + + async createVehicle(data: CreateVehicleRequest, userId: string): Promise { + logger.info('Creating vehicle', { userId, vin: data.vin }); + + // Validate VIN + if (!isValidVIN(data.vin)) { + throw new Error('Invalid VIN format'); + } + + // Check for duplicate + const existing = await this.repository.findByUserAndVIN(userId, data.vin); + if (existing) { + throw new Error('Vehicle with this VIN already exists'); + } + + // Decode VIN + const vinData = await vpicClient.decodeVIN(data.vin); + + // Create vehicle with decoded data + const vehicle = await this.repository.create({ + ...data, + userId, + make: vinData?.make, + model: vinData?.model, + year: vinData?.year, + }); + + // Cache VIN decode result + if (vinData) { + await this.repository.cacheVINDecode(data.vin, vinData); + } + + // Invalidate user's vehicle list cache + await this.invalidateUserCache(userId); + + return this.toResponse(vehicle); + } + + async getUserVehicles(userId: string): Promise { + const cacheKey = `${this.cachePrefix}:user:${userId}`; + + // Check cache + const cached = await cacheService.get(cacheKey); + if (cached) { + logger.debug('Vehicle list cache hit', { userId }); + return cached; + } + + // Get from database + const vehicles = await this.repository.findByUserId(userId); + const response = vehicles.map(v => this.toResponse(v)); + + // Cache result + await cacheService.set(cacheKey, response, this.listCacheTTL); + + return response; + } + + async getVehicle(id: string, userId: string): Promise { + const vehicle = await this.repository.findById(id); + + if (!vehicle) { + throw new Error('Vehicle not found'); + } + + if (vehicle.userId !== userId) { + throw new Error('Unauthorized'); + } + + return this.toResponse(vehicle); + } + + async updateVehicle( + id: string, + data: UpdateVehicleRequest, + userId: string + ): Promise { + // Verify ownership + const existing = await this.repository.findById(id); + if (!existing) { + throw new Error('Vehicle not found'); + } + if (existing.userId !== userId) { + throw new Error('Unauthorized'); + } + + // Update vehicle + const updated = await this.repository.update(id, data); + if (!updated) { + throw new Error('Update failed'); + } + + // Invalidate cache + await this.invalidateUserCache(userId); + + return this.toResponse(updated); + } + + async deleteVehicle(id: string, userId: string): Promise { + // Verify ownership + const existing = await this.repository.findById(id); + if (!existing) { + throw new Error('Vehicle not found'); + } + if (existing.userId !== userId) { + throw new Error('Unauthorized'); + } + + // Soft delete + await this.repository.softDelete(id); + + // Invalidate cache + await this.invalidateUserCache(userId); + } + + private async invalidateUserCache(userId: string): Promise { + const cacheKey = `${this.cachePrefix}:user:${userId}`; + await cacheService.del(cacheKey); + } + + private toResponse(vehicle: Vehicle): VehicleResponse { + return { + id: vehicle.id, + userId: vehicle.userId, + vin: vehicle.vin, + make: vehicle.make, + model: vehicle.model, + year: vehicle.year, + nickname: vehicle.nickname, + color: vehicle.color, + licensePlate: vehicle.licensePlate, + odometerReading: vehicle.odometerReading, + isActive: vehicle.isActive, + createdAt: vehicle.createdAt.toISOString(), + updatedAt: vehicle.updatedAt.toISOString(), + }; + } +} \ No newline at end of file diff --git a/backend/src/features/vehicles/domain/vehicles.types.ts b/backend/src/features/vehicles/domain/vehicles.types.ts new file mode 100644 index 0000000..3d483f3 --- /dev/null +++ b/backend/src/features/vehicles/domain/vehicles.types.ts @@ -0,0 +1,61 @@ +/** + * @ai-summary Type definitions for vehicles feature + * @ai-context Core business types, no external dependencies + */ + +export interface Vehicle { + id: string; + userId: string; + vin: string; + make?: string; + model?: string; + year?: number; + nickname?: string; + color?: string; + licensePlate?: string; + odometerReading: number; + isActive: boolean; + deletedAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateVehicleRequest { + vin: string; + nickname?: string; + color?: string; + licensePlate?: string; + odometerReading?: number; +} + +export interface UpdateVehicleRequest { + nickname?: string; + color?: string; + licensePlate?: string; + odometerReading?: number; +} + +export interface VehicleResponse { + id: string; + userId: string; + vin: string; + make?: string; + model?: string; + year?: number; + nickname?: string; + color?: string; + licensePlate?: string; + odometerReading: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface VINDecodeResult { + make: string; + model: string; + year: number; + engineType?: string; + bodyType?: string; + rawData?: any; +} \ No newline at end of file diff --git a/backend/src/features/vehicles/external/vpic/vpic.client.ts b/backend/src/features/vehicles/external/vpic/vpic.client.ts new file mode 100644 index 0000000..5bd0b80 --- /dev/null +++ b/backend/src/features/vehicles/external/vpic/vpic.client.ts @@ -0,0 +1,78 @@ +/** + * @ai-summary NHTSA vPIC API client for VIN decoding + * @ai-context Caches results for 30 days since vehicle specs don't change + */ + +import axios from 'axios'; +import { env } from '../../../../core/config/environment'; +import { logger } from '../../../../core/logging/logger'; +import { cacheService } from '../../../../core/config/redis'; +import { VPICResponse, VPICDecodeResult } from './vpic.types'; + +export class VPICClient { + private readonly baseURL = env.VPIC_API_URL; + private readonly cacheTTL = 30 * 24 * 60 * 60; // 30 days in seconds + + async decodeVIN(vin: string): Promise { + const cacheKey = `vpic:vin:${vin}`; + + try { + // Check cache first + const cached = await cacheService.get(cacheKey); + if (cached) { + logger.debug('VIN decode cache hit', { vin }); + return cached; + } + + // Call vPIC API + logger.info('Calling vPIC API', { vin }); + const response = await axios.get( + `${this.baseURL}/DecodeVin/${vin}?format=json` + ); + + if (response.data.Count === 0) { + logger.warn('VIN decode returned no results', { vin }); + return null; + } + + // Parse response + const result = this.parseVPICResponse(response.data); + + // Cache successful result + if (result) { + await cacheService.set(cacheKey, result, this.cacheTTL); + } + + return result; + } catch (error) { + logger.error('VIN decode failed', { vin, error }); + return null; + } + } + + private parseVPICResponse(response: VPICResponse): VPICDecodeResult | null { + const getValue = (variable: string): string | undefined => { + const result = response.Results.find(r => r.Variable === variable); + return result?.Value || undefined; + }; + + const make = getValue('Make'); + const model = getValue('Model'); + const year = getValue('Model Year'); + + if (!make || !model || !year) { + return null; + } + + return { + make, + model, + year: parseInt(year, 10), + engineType: getValue('Engine Model'), + bodyType: getValue('Body Class'), + rawData: response.Results, + }; + } +} + +export const vpicClient = new VPICClient(); \ No newline at end of file diff --git a/backend/src/features/vehicles/external/vpic/vpic.types.ts b/backend/src/features/vehicles/external/vpic/vpic.types.ts new file mode 100644 index 0000000..5222e0f --- /dev/null +++ b/backend/src/features/vehicles/external/vpic/vpic.types.ts @@ -0,0 +1,26 @@ +/** + * @ai-summary NHTSA vPIC API types + */ + +export interface VPICResponse { + Count: number; + Message: string; + SearchCriteria: string; + Results: VPICResult[]; +} + +export interface VPICResult { + Value: string | null; + ValueId: string | null; + Variable: string; + VariableId: number; +} + +export interface VPICDecodeResult { + make: string; + model: string; + year: number; + engineType?: string; + bodyType?: string; + rawData: VPICResult[]; +} \ No newline at end of file diff --git a/backend/src/features/vehicles/index.ts b/backend/src/features/vehicles/index.ts new file mode 100644 index 0000000..bbd8ddf --- /dev/null +++ b/backend/src/features/vehicles/index.ts @@ -0,0 +1,18 @@ +/** + * @ai-summary Public API for vehicles feature capsule + * @ai-note This is the ONLY file other features should import from + */ + +// Export service for use by other features +export { VehiclesService } from './domain/vehicles.service'; + +// Export types needed by other features +export type { + Vehicle, + CreateVehicleRequest, + UpdateVehicleRequest, + VehicleResponse +} from './domain/vehicles.types'; + +// Internal: Register routes with Express app +export { registerVehiclesRoutes } from './api/vehicles.routes'; diff --git a/backend/src/features/vehicles/migrations/001_create_vehicles_tables.sql b/backend/src/features/vehicles/migrations/001_create_vehicles_tables.sql new file mode 100644 index 0000000..ee40cea --- /dev/null +++ b/backend/src/features/vehicles/migrations/001_create_vehicles_tables.sql @@ -0,0 +1,58 @@ +-- Enable UUID extension if not exists +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Create vehicles table +CREATE TABLE IF NOT EXISTS vehicles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(255) NOT NULL, + vin VARCHAR(17) NOT NULL, + make VARCHAR(100), + model VARCHAR(100), + year INTEGER, + nickname VARCHAR(100), + color VARCHAR(50), + license_plate VARCHAR(20), + odometer_reading INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + deleted_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT unique_user_vin UNIQUE(user_id, vin) +); + +-- Create indexes for performance +CREATE INDEX idx_vehicles_user_id ON vehicles(user_id); +CREATE INDEX idx_vehicles_vin ON vehicles(vin); +CREATE INDEX idx_vehicles_is_active ON vehicles(is_active); +CREATE INDEX idx_vehicles_created_at ON vehicles(created_at); + +-- Create VIN cache table for external API results +CREATE TABLE IF NOT EXISTS vin_cache ( + vin VARCHAR(17) PRIMARY KEY, + make VARCHAR(100), + model VARCHAR(100), + year INTEGER, + engine_type VARCHAR(100), + body_type VARCHAR(100), + raw_data JSONB, + cached_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index on cache timestamp for cleanup +CREATE INDEX idx_vin_cache_cached_at ON vin_cache(cached_at); + +-- Create update trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Add trigger to vehicles table +CREATE TRIGGER update_vehicles_updated_at + BEFORE UPDATE ON vehicles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts b/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts new file mode 100644 index 0000000..782fad6 --- /dev/null +++ b/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts @@ -0,0 +1,285 @@ +/** + * @ai-summary Integration tests for vehicles API endpoints + * @ai-context Tests complete request/response cycle with test database + */ + +import request from 'supertest'; +import { app } from '../../../../app'; +import { pool } from '../../../../core/config/database'; +import { cacheService } from '../../../../core/config/redis'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +// Mock auth middleware to bypass JWT validation in tests +jest.mock('../../../../core/security/auth.middleware', () => ({ + authMiddleware: (req: any, _res: any, next: any) => { + req.user = { sub: 'test-user-123' }; + next(); + } +})); + +// Mock external VIN decoder +jest.mock('../../external/vpic/vpic.client', () => ({ + vpicClient: { + decodeVIN: jest.fn().mockResolvedValue({ + make: 'Honda', + model: 'Civic', + year: 2021, + engineType: '2.0L', + bodyType: 'Sedan', + rawData: [] + }) + } +})); + +describe('Vehicles Integration Tests', () => { + beforeAll(async () => { + // Run the vehicles migration directly using the migration file + const migrationFile = join(__dirname, '../../migrations/001_create_vehicles_tables.sql'); + const migrationSQL = readFileSync(migrationFile, 'utf-8'); + await pool.query(migrationSQL); + }); + + afterAll(async () => { + // Clean up test database + await pool.query('DROP TABLE IF EXISTS vehicles CASCADE'); + await pool.query('DROP TABLE IF EXISTS vin_cache CASCADE'); + await pool.query('DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE'); + await pool.end(); + }); + + beforeEach(async () => { + // Clean up test data before each test - more thorough cleanup + await pool.query('DELETE FROM vehicles WHERE user_id = $1', ['test-user-123']); + await pool.query('DELETE FROM vin_cache WHERE vin LIKE $1', ['1HGBH41JXMN%']); + + // Clear Redis cache for the test user + try { + await cacheService.del('vehicles:user:test-user-123'); + } catch (error) { + // Ignore cache cleanup errors in tests + console.warn('Failed to clear Redis cache in test:', error); + } + }); + + describe('POST /api/vehicles', () => { + it('should create a new vehicle', async () => { + const vehicleData = { + vin: '1HGBH41JXMN109186', + nickname: 'My Test Car', + color: 'Blue', + odometerReading: 50000 + }; + + const response = await request(app) + .post('/api/vehicles') + .send(vehicleData) + .expect(201); + + expect(response.body).toMatchObject({ + id: expect.any(String), + userId: 'test-user-123', + vin: '1HGBH41JXMN109186', + make: 'Honda', + model: 'Civic', + year: 2021, + nickname: 'My Test Car', + color: 'Blue', + odometerReading: 50000, + isActive: true, + createdAt: expect.any(String), + updatedAt: expect.any(String) + }); + }); + + it('should reject invalid VIN', async () => { + const vehicleData = { + vin: 'INVALID', + nickname: 'Test Car' + }; + + const response = await request(app) + .post('/api/vehicles') + .send(vehicleData) + .expect(400); + + expect(response.body.error).toMatch(/VIN/); + }); + + it('should reject duplicate VIN for same user', async () => { + const vehicleData = { + vin: '1HGBH41JXMN109186', + nickname: 'First Car' + }; + + // Create first vehicle + await request(app) + .post('/api/vehicles') + .send(vehicleData) + .expect(201); + + // Try to create duplicate + const response = await request(app) + .post('/api/vehicles') + .send({ ...vehicleData, nickname: 'Duplicate Car' }) + .expect(400); + + expect(response.body.error).toBe('Vehicle with this VIN already exists'); + }); + }); + + describe('GET /api/vehicles', () => { + it('should return user vehicles', async () => { + // Create test vehicles + await pool.query(` + INSERT INTO vehicles (user_id, vin, make, model, year, nickname) + VALUES + ($1, $2, $3, $4, $5, $6), + ($7, $8, $9, $10, $11, $12) + `, [ + 'test-user-123', '1HGBH41JXMN109186', 'Honda', 'Civic', 2021, 'Car 1', + 'test-user-123', '1HGBH41JXMN109187', 'Toyota', 'Camry', 2020, 'Car 2' + ]); + + const response = await request(app) + .get('/api/vehicles') + .expect(200); + + expect(response.body).toHaveLength(2); + expect(response.body[0]).toMatchObject({ + userId: 'test-user-123', + vin: expect.any(String), + nickname: expect.any(String) + }); + }); + + it('should return empty array for user with no vehicles', async () => { + const response = await request(app) + .get('/api/vehicles') + .expect(200); + + expect(response.body).toEqual([]); + }); + }); + + describe('GET /api/vehicles/:id', () => { + it('should return specific vehicle', async () => { + // Create test vehicle + const result = await pool.query(` + INSERT INTO vehicles (user_id, vin, make, model, year, nickname) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, ['test-user-123', '1HGBH41JXMN109186', 'Honda', 'Civic', 2021, 'Test Car']); + + const vehicleId = result.rows[0].id; + + const response = await request(app) + .get(`/api/vehicles/${vehicleId}`) + .expect(200); + + expect(response.body).toMatchObject({ + id: vehicleId, + userId: 'test-user-123', + vin: '1HGBH41JXMN109186', + make: 'Honda', + nickname: 'Test Car' + }); + }); + + it('should return 404 for non-existent vehicle', async () => { + const fakeId = '550e8400-e29b-41d4-a716-446655440000'; + + const response = await request(app) + .get(`/api/vehicles/${fakeId}`) + .expect(404); + + expect(response.body.error).toBe('Vehicle not found'); + }); + + it('should return 400 for invalid UUID format', async () => { + const response = await request(app) + .get('/api/vehicles/invalid-id') + .expect(400); + + expect(response.body).toHaveProperty('error'); + }); + }); + + describe('PUT /api/vehicles/:id', () => { + it('should update vehicle', async () => { + // Create test vehicle + const result = await pool.query(` + INSERT INTO vehicles (user_id, vin, nickname, color) + VALUES ($1, $2, $3, $4) + RETURNING id + `, ['test-user-123', '1HGBH41JXMN109186', 'Old Name', 'Blue']); + + const vehicleId = result.rows[0].id; + + const updateData = { + nickname: 'Updated Name', + color: 'Red', + odometerReading: 75000 + }; + + const response = await request(app) + .put(`/api/vehicles/${vehicleId}`) + .send(updateData) + .expect(200); + + expect(response.body).toMatchObject({ + id: vehicleId, + nickname: 'Updated Name', + color: 'Red', + odometerReading: 75000 + }); + }); + + it('should return 404 for non-existent vehicle', async () => { + const fakeId = '550e8400-e29b-41d4-a716-446655440000'; + + const response = await request(app) + .put(`/api/vehicles/${fakeId}`) + .send({ nickname: 'Updated' }) + .expect(404); + + expect(response.body.error).toBe('Vehicle not found'); + }); + }); + + describe('DELETE /api/vehicles/:id', () => { + it('should soft delete vehicle', async () => { + // Create test vehicle + const result = await pool.query(` + INSERT INTO vehicles (user_id, vin, nickname) + VALUES ($1, $2, $3) + RETURNING id + `, ['test-user-123', '1HGBH41JXMN109186', 'Test Car']); + + const vehicleId = result.rows[0].id; + + await request(app) + .delete(`/api/vehicles/${vehicleId}`) + .expect(204); + + // Verify vehicle is soft deleted + const checkResult = await pool.query( + 'SELECT is_active, deleted_at FROM vehicles WHERE id = $1', + [vehicleId] + ); + + expect(checkResult.rows[0].is_active).toBe(false); + expect(checkResult.rows[0].deleted_at).toBeTruthy(); + }); + + it('should return 404 for non-existent vehicle', async () => { + const fakeId = '550e8400-e29b-41d4-a716-446655440000'; + + const response = await request(app) + .delete(`/api/vehicles/${fakeId}`) + .expect(404); + + expect(response.body.error).toBe('Vehicle not found'); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/features/vehicles/tests/unit/vehicles.service.test.ts b/backend/src/features/vehicles/tests/unit/vehicles.service.test.ts new file mode 100644 index 0000000..4afcd5d --- /dev/null +++ b/backend/src/features/vehicles/tests/unit/vehicles.service.test.ts @@ -0,0 +1,305 @@ +/** + * @ai-summary Unit tests for VehiclesService + * @ai-context Tests business logic with mocked dependencies + */ + +import { VehiclesService } from '../../domain/vehicles.service'; +import { VehiclesRepository } from '../../data/vehicles.repository'; +import { vpicClient } from '../../external/vpic/vpic.client'; +import { cacheService } from '../../../../core/config/redis'; + +// Mock dependencies +jest.mock('../../data/vehicles.repository'); +jest.mock('../../external/vpic/vpic.client'); +jest.mock('../../../../core/config/redis'); + +const mockRepository = jest.mocked(VehiclesRepository); +const mockVpicClient = jest.mocked(vpicClient); +const mockCacheService = jest.mocked(cacheService); + +describe('VehiclesService', () => { + let service: VehiclesService; + let repositoryInstance: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + repositoryInstance = { + create: jest.fn(), + findByUserId: jest.fn(), + findById: jest.fn(), + findByUserAndVIN: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + cacheVINDecode: jest.fn(), + getVINFromCache: jest.fn(), + } as any; + + mockRepository.mockImplementation(() => repositoryInstance); + service = new VehiclesService(repositoryInstance); + }); + + describe('createVehicle', () => { + const mockVehicleData = { + vin: '1HGBH41JXMN109186', + nickname: 'My Car', + color: 'Blue', + odometerReading: 50000, + }; + + const mockVinDecodeResult = { + make: 'Honda', + model: 'Civic', + year: 2021, + engineType: '2.0L', + bodyType: 'Sedan', + rawData: [], + }; + + const mockCreatedVehicle = { + id: 'vehicle-id-123', + userId: 'user-123', + vin: '1HGBH41JXMN109186', + make: 'Honda', + model: 'Civic', + year: 2021, + nickname: 'My Car', + color: 'Blue', + licensePlate: undefined, + odometerReading: 50000, + isActive: true, + deletedAt: undefined, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + it('should create a vehicle with VIN decoding', async () => { + repositoryInstance.findByUserAndVIN.mockResolvedValue(null); + mockVpicClient.decodeVIN.mockResolvedValue(mockVinDecodeResult); + repositoryInstance.create.mockResolvedValue(mockCreatedVehicle); + repositoryInstance.cacheVINDecode.mockResolvedValue(undefined); + mockCacheService.del.mockResolvedValue(undefined); + + const result = await service.createVehicle(mockVehicleData, 'user-123'); + + expect(repositoryInstance.findByUserAndVIN).toHaveBeenCalledWith('user-123', '1HGBH41JXMN109186'); + expect(mockVpicClient.decodeVIN).toHaveBeenCalledWith('1HGBH41JXMN109186'); + expect(repositoryInstance.create).toHaveBeenCalledWith({ + ...mockVehicleData, + userId: 'user-123', + make: 'Honda', + model: 'Civic', + year: 2021, + }); + expect(repositoryInstance.cacheVINDecode).toHaveBeenCalledWith('1HGBH41JXMN109186', mockVinDecodeResult); + expect(result.id).toBe('vehicle-id-123'); + expect(result.make).toBe('Honda'); + }); + + it('should reject invalid VIN format', async () => { + const invalidVin = { ...mockVehicleData, vin: 'INVALID' }; + + await expect(service.createVehicle(invalidVin, 'user-123')).rejects.toThrow('Invalid VIN format'); + }); + + it('should reject duplicate VIN for same user', async () => { + repositoryInstance.findByUserAndVIN.mockResolvedValue(mockCreatedVehicle); + + await expect(service.createVehicle(mockVehicleData, 'user-123')).rejects.toThrow('Vehicle with this VIN already exists'); + }); + + it('should handle VIN decode failure gracefully', async () => { + repositoryInstance.findByUserAndVIN.mockResolvedValue(null); + mockVpicClient.decodeVIN.mockResolvedValue(null); + repositoryInstance.create.mockResolvedValue({ ...mockCreatedVehicle, make: undefined, model: undefined, year: undefined }); + mockCacheService.del.mockResolvedValue(undefined); + + const result = await service.createVehicle(mockVehicleData, 'user-123'); + + expect(repositoryInstance.create).toHaveBeenCalledWith({ + ...mockVehicleData, + userId: 'user-123', + make: undefined, + model: undefined, + year: undefined, + }); + expect(result.make).toBeUndefined(); + }); + }); + + describe('getUserVehicles', () => { + it('should return cached vehicles if available', async () => { + const cachedVehicles = [{ id: 'vehicle-1', vin: '1HGBH41JXMN109186' }]; + mockCacheService.get.mockResolvedValue(cachedVehicles); + + const result = await service.getUserVehicles('user-123'); + + expect(mockCacheService.get).toHaveBeenCalledWith('vehicles:user:user-123'); + expect(result).toBe(cachedVehicles); + expect(repositoryInstance.findByUserId).not.toHaveBeenCalled(); + }); + + it('should fetch from database and cache if not cached', async () => { + const mockVehicles = [ + { + id: 'vehicle-id-123', + userId: 'user-123', + vin: '1HGBH41JXMN109186', + make: 'Honda', + model: 'Civic', + year: 2021, + nickname: 'My Car', + color: 'Blue', + licensePlate: undefined, + odometerReading: 50000, + isActive: true, + deletedAt: undefined, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + } + ]; + + mockCacheService.get.mockResolvedValue(null); + repositoryInstance.findByUserId.mockResolvedValue(mockVehicles); + mockCacheService.set.mockResolvedValue(undefined); + + const result = await service.getUserVehicles('user-123'); + + expect(repositoryInstance.findByUserId).toHaveBeenCalledWith('user-123'); + expect(mockCacheService.set).toHaveBeenCalledWith('vehicles:user:user-123', expect.any(Array), 300); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('vehicle-id-123'); + }); + }); + + describe('getVehicle', () => { + const mockVehicle = { + id: 'vehicle-id-123', + userId: 'user-123', + vin: '1HGBH41JXMN109186', + make: 'Honda', + model: 'Civic', + year: 2021, + nickname: 'My Car', + color: 'Blue', + licensePlate: undefined, + odometerReading: 50000, + isActive: true, + deletedAt: undefined, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + it('should return vehicle if found and owned by user', async () => { + repositoryInstance.findById.mockResolvedValue(mockVehicle); + + const result = await service.getVehicle('vehicle-id-123', 'user-123'); + + expect(repositoryInstance.findById).toHaveBeenCalledWith('vehicle-id-123'); + expect(result.id).toBe('vehicle-id-123'); + }); + + it('should throw error if vehicle not found', async () => { + repositoryInstance.findById.mockResolvedValue(null); + + await expect(service.getVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Vehicle not found'); + }); + + it('should throw error if user is not owner', async () => { + repositoryInstance.findById.mockResolvedValue({ ...mockVehicle, userId: 'other-user' }); + + await expect(service.getVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Unauthorized'); + }); + }); + + describe('updateVehicle', () => { + const mockVehicle = { + id: 'vehicle-id-123', + userId: 'user-123', + vin: '1HGBH41JXMN109186', + make: 'Honda', + model: 'Civic', + year: 2021, + nickname: 'My Car', + color: 'Blue', + licensePlate: undefined, + odometerReading: 50000, + isActive: true, + deletedAt: undefined, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + it('should update vehicle successfully', async () => { + const updateData = { nickname: 'Updated Car', color: 'Red' }; + const updatedVehicle = { ...mockVehicle, ...updateData }; + + repositoryInstance.findById.mockResolvedValue(mockVehicle); + repositoryInstance.update.mockResolvedValue(updatedVehicle); + mockCacheService.del.mockResolvedValue(undefined); + + const result = await service.updateVehicle('vehicle-id-123', updateData, 'user-123'); + + expect(repositoryInstance.findById).toHaveBeenCalledWith('vehicle-id-123'); + expect(repositoryInstance.update).toHaveBeenCalledWith('vehicle-id-123', updateData); + expect(mockCacheService.del).toHaveBeenCalledWith('vehicles:user:user-123'); + expect(result.nickname).toBe('Updated Car'); + expect(result.color).toBe('Red'); + }); + + it('should throw error if vehicle not found', async () => { + repositoryInstance.findById.mockResolvedValue(null); + + await expect(service.updateVehicle('vehicle-id-123', {}, 'user-123')).rejects.toThrow('Vehicle not found'); + }); + + it('should throw error if user is not owner', async () => { + repositoryInstance.findById.mockResolvedValue({ ...mockVehicle, userId: 'other-user' }); + + await expect(service.updateVehicle('vehicle-id-123', {}, 'user-123')).rejects.toThrow('Unauthorized'); + }); + }); + + describe('deleteVehicle', () => { + const mockVehicle = { + id: 'vehicle-id-123', + userId: 'user-123', + vin: '1HGBH41JXMN109186', + make: 'Honda', + model: 'Civic', + year: 2021, + nickname: 'My Car', + color: 'Blue', + licensePlate: undefined, + odometerReading: 50000, + isActive: true, + deletedAt: undefined, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + it('should delete vehicle successfully', async () => { + repositoryInstance.findById.mockResolvedValue(mockVehicle); + repositoryInstance.softDelete.mockResolvedValue(true); + mockCacheService.del.mockResolvedValue(undefined); + + await service.deleteVehicle('vehicle-id-123', 'user-123'); + + expect(repositoryInstance.findById).toHaveBeenCalledWith('vehicle-id-123'); + expect(repositoryInstance.softDelete).toHaveBeenCalledWith('vehicle-id-123'); + expect(mockCacheService.del).toHaveBeenCalledWith('vehicles:user:user-123'); + }); + + it('should throw error if vehicle not found', async () => { + repositoryInstance.findById.mockResolvedValue(null); + + await expect(service.deleteVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Vehicle not found'); + }); + + it('should throw error if user is not owner', async () => { + repositoryInstance.findById.mockResolvedValue({ ...mockVehicle, userId: 'other-user' }); + + await expect(service.deleteVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Unauthorized'); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/features/vehicles/tests/unit/vpic.client.test.ts b/backend/src/features/vehicles/tests/unit/vpic.client.test.ts new file mode 100644 index 0000000..abc1fee --- /dev/null +++ b/backend/src/features/vehicles/tests/unit/vpic.client.test.ts @@ -0,0 +1,161 @@ +/** + * @ai-summary Unit tests for VPICClient + * @ai-context Tests VIN decoding with mocked HTTP client + */ + +import axios from 'axios'; +import { VPICClient } from '../../external/vpic/vpic.client'; +import { cacheService } from '../../../../core/config/redis'; +import { VPICResponse } from '../../external/vpic/vpic.types'; + +jest.mock('axios'); +jest.mock('../../../../core/config/redis'); + +const mockAxios = jest.mocked(axios); +const mockCacheService = jest.mocked(cacheService); + +describe('VPICClient', () => { + let client: VPICClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new VPICClient(); + }); + + describe('decodeVIN', () => { + const mockVin = '1HGBH41JXMN109186'; + + const mockVPICResponse: VPICResponse = { + Count: 3, + Message: 'Success', + SearchCriteria: 'VIN: 1HGBH41JXMN109186', + Results: [ + { Variable: 'Make', Value: 'Honda', ValueId: null, VariableId: 1 }, + { Variable: 'Model', Value: 'Civic', ValueId: null, VariableId: 2 }, + { Variable: 'Model Year', Value: '2021', ValueId: null, VariableId: 3 }, + { Variable: 'Engine Model', Value: '2.0L', ValueId: null, VariableId: 4 }, + { Variable: 'Body Class', Value: 'Sedan', ValueId: null, VariableId: 5 }, + ] + }; + + it('should return cached result if available', async () => { + const cachedResult = { + make: 'Honda', + model: 'Civic', + year: 2021, + engineType: '2.0L', + bodyType: 'Sedan', + rawData: mockVPICResponse.Results + }; + + mockCacheService.get.mockResolvedValue(cachedResult); + + const result = await client.decodeVIN(mockVin); + + expect(mockCacheService.get).toHaveBeenCalledWith(`vpic:vin:${mockVin}`); + expect(result).toEqual(cachedResult); + expect(mockAxios.get).not.toHaveBeenCalled(); + }); + + it('should fetch and cache VIN data when not cached', async () => { + mockCacheService.get.mockResolvedValue(null); + mockAxios.get.mockResolvedValue({ data: mockVPICResponse }); + mockCacheService.set.mockResolvedValue(undefined); + + const result = await client.decodeVIN(mockVin); + + expect(mockCacheService.get).toHaveBeenCalledWith(`vpic:vin:${mockVin}`); + expect(mockAxios.get).toHaveBeenCalledWith( + expect.stringContaining(`/DecodeVin/${mockVin}?format=json`) + ); + expect(mockCacheService.set).toHaveBeenCalledWith( + `vpic:vin:${mockVin}`, + expect.objectContaining({ + make: 'Honda', + model: 'Civic', + year: 2021, + engineType: '2.0L', + bodyType: 'Sedan' + }), + 30 * 24 * 60 * 60 // 30 days + ); + expect(result?.make).toBe('Honda'); + expect(result?.model).toBe('Civic'); + expect(result?.year).toBe(2021); + }); + + it('should return null when API returns no results', async () => { + const emptyResponse: VPICResponse = { + Count: 0, + Message: 'No data found', + SearchCriteria: 'VIN: INVALID', + Results: [] + }; + + mockCacheService.get.mockResolvedValue(null); + mockAxios.get.mockResolvedValue({ data: emptyResponse }); + + const result = await client.decodeVIN('INVALID'); + + expect(result).toBeNull(); + expect(mockCacheService.set).not.toHaveBeenCalled(); + }); + + it('should return null when required fields are missing', async () => { + const incompleteResponse: VPICResponse = { + Count: 1, + Message: 'Success', + SearchCriteria: 'VIN: 1HGBH41JXMN109186', + Results: [ + { Variable: 'Make', Value: 'Honda', ValueId: null, VariableId: 1 }, + // Missing Model and Year + ] + }; + + mockCacheService.get.mockResolvedValue(null); + mockAxios.get.mockResolvedValue({ data: incompleteResponse }); + + const result = await client.decodeVIN(mockVin); + + expect(result).toBeNull(); + expect(mockCacheService.set).not.toHaveBeenCalled(); + }); + + it('should handle API errors gracefully', async () => { + mockCacheService.get.mockResolvedValue(null); + mockAxios.get.mockRejectedValue(new Error('Network error')); + + const result = await client.decodeVIN(mockVin); + + expect(result).toBeNull(); + expect(mockCacheService.set).not.toHaveBeenCalled(); + }); + + it('should handle null values in API response', async () => { + const responseWithNulls: VPICResponse = { + Count: 3, + Message: 'Success', + SearchCriteria: 'VIN: 1HGBH41JXMN109186', + Results: [ + { Variable: 'Make', Value: 'Honda', ValueId: null, VariableId: 1 }, + { Variable: 'Model', Value: 'Civic', ValueId: null, VariableId: 2 }, + { Variable: 'Model Year', Value: '2021', ValueId: null, VariableId: 3 }, + { Variable: 'Engine Model', Value: null, ValueId: null, VariableId: 4 }, + { Variable: 'Body Class', Value: null, ValueId: null, VariableId: 5 }, + ] + }; + + mockCacheService.get.mockResolvedValue(null); + mockAxios.get.mockResolvedValue({ data: responseWithNulls }); + mockCacheService.set.mockResolvedValue(undefined); + + const result = await client.decodeVIN(mockVin); + + expect(result?.make).toBe('Honda'); + expect(result?.model).toBe('Civic'); + expect(result?.year).toBe(2021); + expect(result?.engineType).toBeUndefined(); + expect(result?.bodyType).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..36c64af --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,45 @@ +/** + * @ai-summary Application entry point + * @ai-context Starts the Express server with all feature capsules + */ +import { app } from './app'; +import { env } from './core/config/environment'; +import { logger } from './core/logging/logger'; + +const PORT = env.PORT || 3001; + +const server = app.listen(PORT, () => { + logger.info(`MotoVaultPro backend running`, { + port: PORT, + environment: env.NODE_ENV, + nodeVersion: process.version, + }); +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + logger.info('SIGTERM received, shutting down gracefully'); + server.close(() => { + logger.info('Server closed'); + process.exit(0); + }); +}); + +process.on('SIGINT', () => { + logger.info('SIGINT received, shutting down gracefully'); + server.close(() => { + logger.info('Server closed'); + process.exit(0); + }); +}); + +// Handle uncaught exceptions +process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception', { error: error.message, stack: error.stack }); + process.exit(1); +}); + +process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled Rejection', { reason, promise }); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/src/shared-minimal/utils/formatters.ts b/backend/src/shared-minimal/utils/formatters.ts new file mode 100644 index 0000000..99390e3 --- /dev/null +++ b/backend/src/shared-minimal/utils/formatters.ts @@ -0,0 +1,31 @@ +/** + * @ai-summary Generic formatting utilities (no business logic) + * @ai-context Pure functions only, no feature-specific logic + */ + +export function formatDate(date: Date): string { + return date.toISOString().split('T')[0]; +} + +export function formatDateTime(date: Date): string { + return date.toISOString(); +} + +export function formatCurrency(amount: number, currency = 'USD'): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + }).format(amount); +} + +export function formatDistance(meters: number): string { + if (meters < 1000) { + return `${Math.round(meters)}m`; + } + return `${(meters / 1000).toFixed(1)}km`; +} + +export function formatMPG(miles: number, gallons: number): string { + if (gallons === 0) return '0 MPG'; + return `${(miles / gallons).toFixed(1)} MPG`; +} \ No newline at end of file diff --git a/backend/src/shared-minimal/utils/validators.ts b/backend/src/shared-minimal/utils/validators.ts new file mode 100644 index 0000000..7c22a72 --- /dev/null +++ b/backend/src/shared-minimal/utils/validators.ts @@ -0,0 +1,30 @@ +/** + * @ai-summary Generic validation utilities (no business logic) + * @ai-context Pure functions only, no feature-specific logic + */ + +export function isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +export function isValidPhone(phone: string): boolean { + const phoneRegex = /^\+?[\d\s\-()]+$/; + return phoneRegex.test(phone) && phone.replace(/\D/g, '').length >= 10; +} + +export function isValidUUID(uuid: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); +} + +export function isValidVIN(vin: string): boolean { + // VIN must be exactly 17 characters + if (vin.length !== 17) return false; + + // VIN cannot contain I, O, or Q + if (/[IOQ]/i.test(vin)) return false; + + // Must be alphanumeric + return /^[A-HJ-NPR-Z0-9]{17}$/i.test(vin); +} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..9d80430 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b76cf7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,113 @@ +services: + postgres: + image: postgres:15-alpine + container_name: mvp-postgres + environment: + POSTGRES_DB: motovaultpro + POSTGRES_USER: postgres + POSTGRES_PASSWORD: localdev123 + POSTGRES_INITDB_ARGS: "--encoding=UTF8" + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: mvp-redis + command: redis-server --appendonly yes + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + container_name: mvp-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin123 + volumes: + - minio_data:/data + ports: + - "9000:9000" # API + - "9001:9001" # Console + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + container_name: mvp-backend + environment: + NODE_ENV: development + PORT: 3001 + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: motovaultpro + DB_USER: postgres + DB_PASSWORD: localdev123 + REDIS_HOST: redis + REDIS_PORT: 6379 + MINIO_ENDPOINT: minio + MINIO_PORT: 9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin123 + MINIO_BUCKET: motovaultpro + AUTH0_DOMAIN: ${AUTH0_DOMAIN:-your-domain.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 + ports: + - "3001:3001" + depends_on: + - postgres + - redis + - minio + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + container_name: mvp-frontend + environment: + NODE_ENV: development + VITE_API_BASE_URL: http://backend:3001/api + ports: + - "3000:3000" + depends_on: + - backend + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + +volumes: + postgres_data: + redis_data: + minio_data: \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..2f003a0 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,10 @@ +# Auth0 Configuration +VITE_AUTH0_DOMAIN=your-auth0-domain.us.auth0.com +VITE_AUTH0_CLIENT_ID=your-auth0-client-id +VITE_AUTH0_AUDIENCE=https://your-api-audience + +# API Configuration +VITE_API_BASE_URL=http://localhost:3001/api + +# Google Maps (for future stations feature) +VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3d67dd3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ +/.pnp +.pnp.js + +# Production +/build +/dist + +# Environment variables +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Testing +/coverage + +# Misc +*.tgz +*.tar.gz \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e753a5c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,45 @@ +# Production Dockerfile for MotoVaultPro Frontend +FROM node:20-alpine as build + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install production dependencies +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage with nginx +FROM nginx:alpine + +# Copy built assets from build stage +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Create non-root user for nginx +RUN addgroup -g 1001 -S nginx && \ + adduser -S frontend -u 1001 -G nginx + +# Change ownership of nginx directories +RUN chown -R frontend:nginx /var/cache/nginx && \ + chown -R frontend:nginx /var/log/nginx && \ + chown -R frontend:nginx /etc/nginx/conf.d +RUN touch /var/run/nginx.pid && \ + chown -R frontend:nginx /var/run/nginx.pid + +USER frontend + +# Expose port +EXPOSE 3000 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..e96b19b --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,24 @@ +# Development Dockerfile for MotoVaultPro Frontend +FROM node:20-alpine + +# Set working directory +WORKDIR /app + +# Install development tools +RUN apk add --no-cache git + +# Copy package files +COPY package*.json ./ + +# Install all dependencies (including dev dependencies) +RUN npm install + +# Copy source code +COPY . . + +# Expose port +EXPOSE 3000 + +# Run as root for development simplicity +# Note: In production, use proper user management +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..cff744d --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,39 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import parser from '@typescript-eslint/parser'; + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parser, + }, + plugins: { + '@typescript-eslint': tseslint, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + args: 'after-used' + }], + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, +]; \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ec13f5b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + MotoVaultPro + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..3dad573 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,39 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + server { + listen 3000; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Handle client-side routing + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy to backend + location /api { + proxy_pass http://backend:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Enable gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..620bdc8 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,48 @@ +{ + "name": "motovaultpro-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "lint": "eslint src", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "@auth0/auth0-react": "^2.2.3", + "axios": "^1.6.2", + "zustand": "^4.4.6", + "@tanstack/react-query": "^5.8.4", + "react-hook-form": "^7.48.2", + "@hookform/resolvers": "^3.3.2", + "zod": "^3.22.4", + "date-fns": "^3.0.0", + "clsx": "^2.0.0", + "react-hot-toast": "^2.4.1" + }, + "devDependencies": { + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.12.0", + "@typescript-eslint/parser": "^6.12.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.54.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.2", + "vite": "^5.0.6", + "vitest": "^1.0.1", + "@testing-library/react": "^14.1.2", + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/user-event": "^14.5.1" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b783eed --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,51 @@ +/** + * @ai-summary Main app component with routing + */ + +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuth0 } from '@auth0/auth0-react'; +import { Layout } from './components/Layout'; +import { VehiclesPage } from './features/vehicles/pages/VehiclesPage'; +import { Button } from './shared-minimal/components/Button'; + +function App() { + const { isLoading, isAuthenticated, loginWithRedirect } = useAuth0(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+

MotoVaultPro

+

Your personal vehicle management platform

+ +
+
+ ); + } + + return ( + + + } /> + } /> + Vehicle Details (TODO)} /> + Fuel Logs (TODO)} /> + Maintenance (TODO)} /> + Stations (TODO)} /> + } /> + + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..511b497 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,130 @@ +/** + * @ai-summary Main layout component with navigation + */ + +import React from 'react'; +import { useAuth0 } from '@auth0/auth0-react'; +import { Link, useLocation } from 'react-router-dom'; +import { useAppStore } from '../core/store'; +import { Button } from '../shared-minimal/components/Button'; +import { clsx } from 'clsx'; + +interface LayoutProps { + children: React.ReactNode; +} + +export const Layout: React.FC = ({ children }) => { + const { user, logout } = useAuth0(); + const { sidebarOpen, toggleSidebar } = useAppStore(); + const location = useLocation(); + + const navigation = [ + { name: 'Vehicles', href: '/vehicles', icon: '🚗' }, + { name: 'Fuel Logs', href: '/fuel-logs', icon: '⛽' }, + { name: 'Maintenance', href: '/maintenance', icon: '🔧' }, + { name: 'Gas Stations', href: '/stations', icon: '🏪' }, + ]; + + return ( +
+ {/* Sidebar */} +
+
+

MotoVaultPro

+ +
+ + + +
+
+
+
+ + {user?.name?.charAt(0) || user?.email?.charAt(0)} + +
+
+
+

+ {user?.name || user?.email} +

+
+
+ +
+
+ + {/* Main content */} +
+ {/* Top bar */} +
+
+ +
+ Welcome back, {user?.name || user?.email} +
+
+
+ + {/* Page content */} +
+
+ {children} +
+
+
+ + {/* Backdrop */} + {sidebarOpen && ( +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/core/api/client.ts b/frontend/src/core/api/client.ts new file mode 100644 index 0000000..83049a9 --- /dev/null +++ b/frontend/src/core/api/client.ts @@ -0,0 +1,47 @@ +/** + * @ai-summary Axios client configuration for API calls + * @ai-context Handles auth tokens and error responses + */ + +import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; +import toast from 'react-hot-toast'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; + +export const apiClient: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor for auth token +apiClient.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + // Token will be added by Auth0 wrapper + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor for error handling +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Handle unauthorized - Auth0 will redirect to login + toast.error('Session expired. Please login again.'); + } else if (error.response?.status === 403) { + toast.error('You do not have permission to perform this action.'); + } else if (error.response?.status >= 500) { + toast.error('Server error. Please try again later.'); + } + + return Promise.reject(error); + } +); + +export default apiClient; \ No newline at end of file diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx new file mode 100644 index 0000000..4a323be --- /dev/null +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -0,0 +1,61 @@ +/** + * @ai-summary Auth0 provider wrapper with API token injection + */ + +import React from 'react'; +import { Auth0Provider as BaseAuth0Provider, useAuth0 } from '@auth0/auth0-react'; +import { useNavigate } from 'react-router-dom'; +import { apiClient } from '../api/client'; + +interface Auth0ProviderProps { + children: React.ReactNode; +} + +export const Auth0Provider: React.FC = ({ children }) => { + const navigate = useNavigate(); + + const domain = import.meta.env.VITE_AUTH0_DOMAIN; + const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID; + const audience = import.meta.env.VITE_AUTH0_AUDIENCE; + + const onRedirectCallback = (appState?: { returnTo?: string }) => { + navigate(appState?.returnTo || '/dashboard'); + }; + + return ( + + {children} + + ); +}; + +// Component to inject token into API client +const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { getAccessTokenSilently, isAuthenticated } = useAuth0(); + + React.useEffect(() => { + if (isAuthenticated) { + // Add token to all API requests + apiClient.interceptors.request.use(async (config) => { + try { + const token = await getAccessTokenSilently(); + config.headers.Authorization = `Bearer ${token}`; + } catch (error) { + console.error('Failed to get access token:', error); + } + return config; + }); + } + }, [isAuthenticated, getAccessTokenSilently]); + + return <>{children}; +}; \ No newline at end of file diff --git a/frontend/src/core/store/index.ts b/frontend/src/core/store/index.ts new file mode 100644 index 0000000..8151fe2 --- /dev/null +++ b/frontend/src/core/store/index.ts @@ -0,0 +1,54 @@ +/** + * @ai-summary Global state management with Zustand + * @ai-context Minimal global state, features manage their own state + */ + +import { create } from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; + +interface User { + id: string; + email: string; + name?: string; +} + +interface AppState { + // User state + user: User | null; + setUser: (user: User | null) => void; + + // UI state + sidebarOpen: boolean; + toggleSidebar: () => void; + + // Selected vehicle (for context) + selectedVehicleId: string | null; + setSelectedVehicle: (id: string | null) => void; +} + +export const useAppStore = create()( + devtools( + persist( + (set) => ({ + // User state + user: null, + setUser: (user) => set({ user }), + + // UI state + sidebarOpen: true, + toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), + + // Selected vehicle + selectedVehicleId: null, + setSelectedVehicle: (vehicleId) => set({ selectedVehicleId: vehicleId }), + }), + { + name: 'motovaultpro-storage', + partialize: (state) => ({ + selectedVehicleId: state.selectedVehicleId, + sidebarOpen: state.sidebarOpen, + }), + } + ) + ) +); \ No newline at end of file diff --git a/frontend/src/features/vehicles/api/vehicles.api.ts b/frontend/src/features/vehicles/api/vehicles.api.ts new file mode 100644 index 0000000..2c8d172 --- /dev/null +++ b/frontend/src/features/vehicles/api/vehicles.api.ts @@ -0,0 +1,32 @@ +/** + * @ai-summary API calls for vehicles feature + */ + +import { apiClient } from '../../../core/api/client'; +import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types'; + +export const vehiclesApi = { + getAll: async (): Promise => { + const response = await apiClient.get('/vehicles'); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await apiClient.get(`/vehicles/${id}`); + return response.data; + }, + + create: async (data: CreateVehicleRequest): Promise => { + const response = await apiClient.post('/vehicles', data); + return response.data; + }, + + update: async (id: string, data: UpdateVehicleRequest): Promise => { + const response = await apiClient.put(`/vehicles/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await apiClient.delete(`/vehicles/${id}`); + }, +}; \ No newline at end of file diff --git a/frontend/src/features/vehicles/components/VehicleCard.tsx b/frontend/src/features/vehicles/components/VehicleCard.tsx new file mode 100644 index 0000000..4c0b00f --- /dev/null +++ b/frontend/src/features/vehicles/components/VehicleCard.tsx @@ -0,0 +1,64 @@ +/** + * @ai-summary Vehicle card component + */ + +import React from 'react'; +import { Vehicle } from '../types/vehicles.types'; +import { Card } from '../../../shared-minimal/components/Card'; +import { Button } from '../../../shared-minimal/components/Button'; + +interface VehicleCardProps { + vehicle: Vehicle; + onEdit: (vehicle: Vehicle) => void; + onDelete: (id: string) => void; + onSelect: (id: string) => void; +} + +export const VehicleCard: React.FC = ({ + vehicle, + onEdit, + onDelete, + onSelect, +}) => { + return ( + onSelect(vehicle.id)}> +
+
+

+ {vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`} +

+

VIN: {vehicle.vin}

+ {vehicle.licensePlate && ( +

License: {vehicle.licensePlate}

+ )} +

+ Odometer: {vehicle.odometerReading.toLocaleString()} miles +

+
+ +
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx new file mode 100644 index 0000000..aab881d --- /dev/null +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -0,0 +1,115 @@ +/** + * @ai-summary Vehicle form component for create/edit + */ + +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '../../../shared-minimal/components/Button'; +import { CreateVehicleRequest } from '../types/vehicles.types'; + +const vehicleSchema = z.object({ + vin: z.string().length(17, 'VIN must be exactly 17 characters'), + nickname: z.string().optional(), + color: z.string().optional(), + licensePlate: z.string().optional(), + odometerReading: z.number().min(0).optional(), +}); + +interface VehicleFormProps { + onSubmit: (data: CreateVehicleRequest) => void; + onCancel: () => void; + initialData?: Partial; + loading?: boolean; +} + +export const VehicleForm: React.FC = ({ + onSubmit, + onCancel, + initialData, + loading, +}) => { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(vehicleSchema), + defaultValues: initialData, + }); + + return ( +
+
+ + + {errors.vin && ( +

{errors.vin.message}

+ )} +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/vehicles/hooks/useVehicles.ts b/frontend/src/features/vehicles/hooks/useVehicles.ts new file mode 100644 index 0000000..3c46f19 --- /dev/null +++ b/frontend/src/features/vehicles/hooks/useVehicles.ts @@ -0,0 +1,78 @@ +/** + * @ai-summary React hooks for vehicles feature + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { vehiclesApi } from '../api/vehicles.api'; +import { CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types'; +import toast from 'react-hot-toast'; + +interface ApiError { + response?: { + data?: { + error?: string; + }; + }; + message?: string; +} + +export const useVehicles = () => { + return useQuery({ + queryKey: ['vehicles'], + queryFn: vehiclesApi.getAll, + }); +}; + +export const useVehicle = (id: string) => { + return useQuery({ + queryKey: ['vehicles', id], + queryFn: () => vehiclesApi.getById(id), + enabled: !!id, + }); +}; + +export const useCreateVehicle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateVehicleRequest) => vehiclesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['vehicles'] }); + toast.success('Vehicle added successfully'); + }, + onError: (error: ApiError) => { + toast.error(error.response?.data?.error || 'Failed to add vehicle'); + }, + }); +}; + +export const useUpdateVehicle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateVehicleRequest }) => + vehiclesApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['vehicles'] }); + toast.success('Vehicle updated successfully'); + }, + onError: (error: ApiError) => { + toast.error(error.response?.data?.error || 'Failed to update vehicle'); + }, + }); +}; + +export const useDeleteVehicle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => vehiclesApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['vehicles'] }); + toast.success('Vehicle deleted successfully'); + }, + onError: (error: ApiError) => { + toast.error(error.response?.data?.error || 'Failed to delete vehicle'); + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/features/vehicles/pages/VehiclesPage.tsx b/frontend/src/features/vehicles/pages/VehiclesPage.tsx new file mode 100644 index 0000000..b79c725 --- /dev/null +++ b/frontend/src/features/vehicles/pages/VehiclesPage.tsx @@ -0,0 +1,89 @@ +/** + * @ai-summary Main vehicles page + */ + +import React, { useState } from 'react'; +import { useVehicles, useCreateVehicle, useDeleteVehicle } from '../hooks/useVehicles'; +import { VehicleCard } from '../components/VehicleCard'; +import { VehicleForm } from '../components/VehicleForm'; +import { Button } from '../../../shared-minimal/components/Button'; +import { Card } from '../../../shared-minimal/components/Card'; +import { useAppStore } from '../../../core/store'; +import { useNavigate } from 'react-router-dom'; + +export const VehiclesPage: React.FC = () => { + const navigate = useNavigate(); + const { data: vehicles, isLoading } = useVehicles(); + const createVehicle = useCreateVehicle(); + const deleteVehicle = useDeleteVehicle(); + const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle); + + const [showForm, setShowForm] = useState(false); + + const handleSelectVehicle = (id: string) => { + setSelectedVehicle(id); + navigate(`/vehicles/${id}`); + }; + + const handleDelete = async (id: string) => { + if (confirm('Are you sure you want to delete this vehicle?')) { + await deleteVehicle.mutateAsync(id); + } + }; + + if (isLoading) { + return ( +
+
Loading vehicles...
+
+ ); + } + + return ( +
+
+

My Vehicles

+ {!showForm && ( + + )} +
+ + {showForm && ( + +

Add New Vehicle

+ { + await createVehicle.mutateAsync(data); + setShowForm(false); + }} + onCancel={() => setShowForm(false)} + loading={createVehicle.isPending} + /> +
+ )} + + {vehicles?.length === 0 ? ( + +
+

No vehicles added yet

+ {!showForm && ( + + )} +
+
+ ) : ( +
+ {vehicles?.map((vehicle) => ( + console.log('Edit', v)} + onDelete={handleDelete} + onSelect={handleSelectVehicle} + /> + ))} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/vehicles/types/vehicles.types.ts b/frontend/src/features/vehicles/types/vehicles.types.ts new file mode 100644 index 0000000..7e1e37d --- /dev/null +++ b/frontend/src/features/vehicles/types/vehicles.types.ts @@ -0,0 +1,34 @@ +/** + * @ai-summary Type definitions for vehicles feature + */ + +export interface Vehicle { + id: string; + userId: string; + vin: string; + make?: string; + model?: string; + year?: number; + nickname?: string; + color?: string; + licensePlate?: string; + odometerReading: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateVehicleRequest { + vin: string; + nickname?: string; + color?: string; + licensePlate?: string; + odometerReading?: number; +} + +export interface UpdateVehicleRequest { + nickname?: string; + color?: string; + licensePlate?: string; + odometerReading?: number; +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..2023212 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,16 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..91e9670 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,43 @@ +/** + * @ai-summary Application entry point + */ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Toaster } from 'react-hot-toast'; +import { Auth0Provider } from './core/auth/Auth0Provider'; +import App from './App'; +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + +); \ No newline at end of file diff --git a/frontend/src/shared-minimal/components/Button.tsx b/frontend/src/shared-minimal/components/Button.tsx new file mode 100644 index 0000000..2ee9f49 --- /dev/null +++ b/frontend/src/shared-minimal/components/Button.tsx @@ -0,0 +1,62 @@ +/** + * @ai-summary Reusable button component + */ + +import React from 'react'; +import { clsx } from 'clsx'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + loading?: boolean; +} + +export const Button: React.FC = ({ + variant = 'primary', + size = 'md', + loading = false, + disabled, + children, + className, + ...props +}) => { + const baseStyles = 'font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; + + const variants = { + primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500', + secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + }; + + const sizes = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/shared-minimal/components/Card.tsx b/frontend/src/shared-minimal/components/Card.tsx new file mode 100644 index 0000000..7b5c782 --- /dev/null +++ b/frontend/src/shared-minimal/components/Card.tsx @@ -0,0 +1,41 @@ +/** + * @ai-summary Reusable card component + */ + +import React from 'react'; +import { clsx } from 'clsx'; + +interface CardProps { + children: React.ReactNode; + className?: string; + padding?: 'none' | 'sm' | 'md' | 'lg'; + onClick?: () => void; +} + +export const Card: React.FC = ({ + children, + className, + padding = 'md', + onClick, +}) => { + const paddings = { + none: '', + sm: 'p-3', + md: 'p-4', + lg: 'p-6', + }; + + return ( +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..dd0d605 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string + readonly VITE_AUTH0_DOMAIN: string + readonly VITE_AUTH0_CLIENT_ID: string + readonly VITE_AUTH0_AUDIENCE: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..f1e3f66 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,23 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + }, + gray: { + 850: '#18202f', + } + }, + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1920b30 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@/features/*": ["src/features/*"], + "@/core/*": ["src/core/*"], + "@/shared/*": ["src/shared-minimal/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..a0daf82 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + host: '0.0.0.0', // Allow external connections for container + }, +}); \ No newline at end of file diff --git a/moto_vault_pro_mobile_ux_prototype_react.jsx b/moto_vault_pro_mobile_ux_prototype_react.jsx new file mode 100644 index 0000000..a81f829 --- /dev/null +++ b/moto_vault_pro_mobile_ux_prototype_react.jsx @@ -0,0 +1,387 @@ +import React, { useMemo, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +// Theme +const primary = "#7A212A"; // requested +const primaryLight = "#9c2a36"; + +// Icons +const IconDash = (props) => ( + + + + + +); +const IconCar = (props) => ( + + + + + + +); +const IconFuel = (props) => ( + + + + + +); +const IconSettings = (props) => ( + + + + +); + +// Visual +const CarThumb = ({ color = "#e5e7eb" }) => ( + + + + + + + + + + + + +); + +const vehiclesSeed = [ + { id: 1, year: 2020, make: "Toyota", model: "Camry", color: "#93c5fd" }, + { id: 2, year: 2018, make: "Ford", model: "F-150", color: "#a5b4fc" }, + { id: 3, year: 2022, make: "Honda", model: "CR-V", color: "#fbcfe8" }, + { id: 4, year: 2015, make: "Subaru", model: "Outback", color: "#86efac" }, +]; + +const Section = ({ title, children, right }) => ( +
+
+

{title}

+ {right} +
+ {children} +
+); + +const Pill = ({ active, label, onClick, icon }) => ( + +); + +const SparkBar = ({ values }) => { + const max = Math.max(...values, 1); + return ( +
+ {values.map((v, i) => ( +
+ ))} +
+ ); +}; + +const StatCard = ({ label, value, sub }) => ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+); + +const VehicleCard = ({ v, onClick, compact=false }) => ( + +); + +const VehiclesScreen = ({ vehicles, onOpen }) => ( +
+
+
+ {vehicles.map((v) => ( + + ))} +
+
+
+); + +const RecentVehicles = ({ recent, onOpen }) => ( +
Last used
}> +
+ {recent.map((v) => ( + + ))} +
+ +); + +const DashboardScreen = ({ recent }) => ( +
+
+ + +
+
+
+
Fuel Spent This Month
+
Last 30 days
+
+ +
+ +
+
+
Fuel Spend Per Vehicle
+
This month
+
+
+ {[ + { name: "Camry", val: 92 }, + { name: "F-150", val: 104 }, + { name: "CR‑V", val: 42 }, + ].map(({ name, val }) => ( +
+
{name}
+
+
+
+
${val}
+
+ ))} +
+
+ + +
+); + +const Field = ({ label, children }) => ( +
+ + {children} +
+); + +const FuelForm = ({ units, vehicles, onSave }) => { + const [vehicleId, setVehicleId] = useState(vehicles[0]?.id || ""); + const [date, setDate] = useState(() => new Date().toISOString().slice(0,10)); + const [odo, setOdo] = useState(15126); + const [qty, setQty] = useState(12.5); + const [price, setPrice] = useState(3.79); + const [octane, setOctane] = useState("87"); + const dist = units.distance === "mi" ? "mi" : "km"; + const fuel = units.fuel === "gal" ? "gal" : "L"; + + const inputCls = "w-full h-11 px-3 rounded-xl border border-slate-200 bg-white/80 backdrop-blur focus:outline-none focus:ring-2 focus:ring-[rgba(122,33,42,0.35)]"; + const selectCls = inputCls; + + return ( +
{ e.preventDefault(); onSave?.({ vehicleId, date, odo, qty, price, octane }); }} + > +
+
+
+ + + +
+
+ + setDate(e.target.value)} className={inputCls}/> + +
+ + setOdo(Number(e.target.value))} className={inputCls}/> + + + setQty(Number(e.target.value))} className={inputCls}/> + + + setPrice(Number(e.target.value))} className={inputCls}/> + + + + +
+ +
+
+ ); +}; + +const VehicleDetail = ({ vehicle, onBack, onLogFuel }) => ( +
+ +
+
+
+
{vehicle.year} {vehicle.make}
+
{vehicle.model}
+
+
+
+ + +
+
+
+ {[{d:"Apr 24", odo:"15,126 mi"},{d:"Mar 13", odo:"14,300 mi"},{d:"Jan 10", odo:"14,055 mi"}].map((r,i)=>( +
+ {r.d}{r.odo} +
+ ))} +
+
+
+); + +const SettingsScreen = ({ units, setUnits }) => ( +
+
+
+
+
Distance
+
+ {[ ["mi","Miles"], ["km","Kilometers"] ].map(([val,label])=> ( + + ))} +
+
+
+
Fuel
+
+ {[ ["gal","US Gallons"], ["L","Liters"] ].map(([val,label])=> ( + + ))} +
+
+
+
+
+); + +const BottomNav = ({ active, setActive }) => ( +
+ {[ + { key: "Dashboard", icon: }, + { key: "Vehicles", icon: }, + { key: "Log Fuel", icon: }, + { key: "Settings", icon: }, + ].map(({ key, icon }) => ( + setActive(key)} + /> + ))} +
+); + +export default function App() { + const [active, setActive] = useState("Dashboard"); + const [units, setUnits] = useState({ distance: "mi", fuel: "gal" }); + const [vehicles, setVehicles] = useState(vehiclesSeed); + const [recentIds, setRecentIds] = useState([1,2,3]); + const [openVehicle, setOpenVehicle] = useState(null); + + const recent = useMemo(() => recentIds.map(id => vehicles.find(v=>v.id===id)).filter(Boolean), [recentIds, vehicles]); + + const handleOpenVehicle = (v) => { + setOpenVehicle(v); + setActive("Vehicles"); + setRecentIds(prev => [v.id, ...prev.filter(x=>x!==v.id)].slice(0,4)); + }; + + return ( +
+
+ {/* App header */} +
+
+
MotoVaultPro
+
v0.1
+
+
+ +
+
+ + {active === "Dashboard" && ( + + + + )} + {active === "Vehicles" && ( + + {openVehicle ? ( + setOpenVehicle(null)} onLogFuel={()=>setActive("Log Fuel")} /> + ) : ( + + )} + + )} + {active === "Log Fuel" && ( + + setActive("Vehicles")} /> + + )} + {active === "Settings" && ( + + + + )} + +
+
+ +
+
+ ); +} diff --git a/scripts/generate-feature-capsule.sh b/scripts/generate-feature-capsule.sh new file mode 100755 index 0000000..1596f54 --- /dev/null +++ b/scripts/generate-feature-capsule.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -e + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +FEATURE_NAME=$1 +if [ -z "$FEATURE_NAME" ]; then + echo -e "${RED}Error: Feature name is required${NC}" + echo "Usage: $0 " + echo "Example: $0 user-settings" + exit 1 +fi + +# Convert kebab-case to PascalCase and camelCase +FEATURE_PASCAL=$(echo $FEATURE_NAME | sed -r 's/(^|-)([a-z])/\U\2/g') +FEATURE_CAMEL=$(echo $FEATURE_PASCAL | sed 's/^./\l&/') + +echo -e "${GREEN}Creating Modified Feature Capsule: $FEATURE_NAME${NC}" + +# Backend Feature Capsule +BACKEND_DIR="backend/src/features/$FEATURE_NAME" +mkdir -p "$BACKEND_DIR"/{api,domain,data,migrations,external,events,tests/{unit,integration,fixtures},docs} + +# Create Feature README +cat > "$BACKEND_DIR/README.md" << EOF +# $FEATURE_PASCAL Feature Capsule + +## Quick Summary (50 tokens) +[AI: Complete feature description, main operations, dependencies, caching strategy] + +## API Endpoints +- GET /api/$FEATURE_NAME - List all $FEATURE_NAME +- GET /api/$FEATURE_NAME/:id - Get specific $FEATURE_CAMEL +- POST /api/$FEATURE_NAME - Create new $FEATURE_CAMEL +- PUT /api/$FEATURE_NAME/:id - Update $FEATURE_CAMEL +- DELETE /api/$FEATURE_NAME/:id - Delete $FEATURE_CAMEL + +## Structure +- **api/** - HTTP endpoints, routes, validators +- **domain/** - Business logic, types, rules +- **data/** - Repository, database queries +- **migrations/** - Feature-specific schema +- **external/** - External API integrations +- **events/** - Event handlers +- **tests/** - All feature tests +- **docs/** - Detailed documentation + +## Dependencies +- Internal: core/auth, core/cache +- External: [List any external APIs] +- Database: $FEATURE_NAME table + +## Quick Commands +\`\`\`bash +# Run feature tests +npm test -- features/$FEATURE_NAME + +# Run feature migrations +npm run migrate:feature $FEATURE_NAME +\`\`\` +EOF + +# Create index.ts (Public API) +cat > "$BACKEND_DIR/index.ts" << EOF +/** + * @ai-summary Public API for $FEATURE_NAME feature capsule + * @ai-note This is the ONLY file other features should import from + */ + +// Export service for use by other features +export { ${FEATURE_PASCAL}Service } from './domain/${FEATURE_CAMEL}.service'; + +// Export types needed by other features +export type { + ${FEATURE_PASCAL}, + Create${FEATURE_PASCAL}Request, + Update${FEATURE_PASCAL}Request, + ${FEATURE_PASCAL}Response +} from './domain/${FEATURE_CAMEL}.types'; + +// Internal: Register routes with Express app +export { register${FEATURE_PASCAL}Routes } from './api/${FEATURE_CAMEL}.routes'; +EOF + +echo -e "${GREEN}✅ Feature capsule created: $FEATURE_NAME${NC}" +echo -e "${YELLOW}Next steps:${NC}" +echo "1. Implement business logic in domain/${FEATURE_CAMEL}.service.ts" +echo "2. Add database columns to migrations/" +echo "3. Implement API validation" +echo "4. Add tests" +echo "5. Register routes in backend/src/app.ts" \ No newline at end of file