MVP Build
This commit is contained in:
53
.ai/context.json
Normal file
53
.ai/context.json
Normal file
@@ -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"]
|
||||
}
|
||||
}
|
||||
42
.env.example
Normal file
42
.env.example
Normal file
@@ -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
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -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/
|
||||
|
||||
30
AI_README.md
Normal file
30
AI_README.md
Normal file
@@ -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`
|
||||
29
CLAUDE.md
Normal file
29
CLAUDE.md
Normal file
@@ -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
|
||||
74
Makefile
Normal file
74
Makefile
Normal file
@@ -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
|
||||
74
PROJECT_MAP.md
Normal file
74
PROJECT_MAP.md
Normal file
@@ -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
|
||||
14
backend/.dockerignore
Normal file
14
backend/.dockerignore
Normal file
@@ -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
|
||||
35
backend/Dockerfile
Normal file
35
backend/Dockerfile
Normal file
@@ -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"]
|
||||
24
backend/Dockerfile.dev
Normal file
24
backend/Dockerfile.dev
Normal file
@@ -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"]
|
||||
104
backend/README.md
Normal file
104
backend/README.md
Normal file
@@ -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
|
||||
17
backend/jest.config.js
Normal file
17
backend/jest.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/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'],
|
||||
};
|
||||
52
backend/package.json
Normal file
52
backend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
77
backend/src/_system/migrations/run-all.ts
Normal file
77
backend/src/_system/migrations/run-all.ts
Normal file
@@ -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();
|
||||
}
|
||||
60
backend/src/_system/schema/generate.ts
Normal file
60
backend/src/_system/schema/generate.ts
Normal file
@@ -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();
|
||||
}
|
||||
48
backend/src/app.ts
Normal file
48
backend/src/app.ts
Normal file
@@ -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;
|
||||
33
backend/src/core/config/database.ts
Normal file
33
backend/src/core/config/database.ts
Normal file
@@ -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;
|
||||
51
backend/src/core/config/environment.ts
Normal file
51
backend/src/core/config/environment.ts
Normal file
@@ -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<typeof envSchema>;
|
||||
|
||||
// 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';
|
||||
58
backend/src/core/config/redis.ts
Normal file
58
backend/src/core/config/redis.ts
Normal file
@@ -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<T>(key: string): Promise<T | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await redis.del(this.prefix + key);
|
||||
} catch (error) {
|
||||
logger.error('Cache delete error', { key, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cacheService = new CacheService();
|
||||
31
backend/src/core/logging/logger.ts
Normal file
31
backend/src/core/logging/logger.ts
Normal file
@@ -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;
|
||||
24
backend/src/core/middleware/error.middleware.ts
Normal file
24
backend/src/core/middleware/error.middleware.ts
Normal file
@@ -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,
|
||||
});
|
||||
};
|
||||
26
backend/src/core/middleware/logging.middleware.ts
Normal file
26
backend/src/core/middleware/logging.middleware.ts
Normal file
@@ -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();
|
||||
};
|
||||
48
backend/src/core/security/auth.middleware.ts
Normal file
48
backend/src/core/security/auth.middleware.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
35
backend/src/features/fuel-logs/README.md
Normal file
35
backend/src/features/fuel-logs/README.md
Normal file
@@ -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
|
||||
```
|
||||
186
backend/src/features/fuel-logs/api/fuel-logs.controller.ts
Normal file
186
backend/src/features/fuel-logs/api/fuel-logs.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
backend/src/features/fuel-logs/api/fuel-logs.routes.ts
Normal file
32
backend/src/features/fuel-logs/api/fuel-logs.routes.ts
Normal file
@@ -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;
|
||||
}
|
||||
38
backend/src/features/fuel-logs/api/fuel-logs.validators.ts
Normal file
38
backend/src/features/fuel-logs/api/fuel-logs.validators.ts
Normal file
@@ -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);
|
||||
}
|
||||
249
backend/src/features/fuel-logs/domain/fuel-logs.service.ts
Normal file
249
backend/src/features/fuel-logs/domain/fuel-logs.service.ts
Normal file
@@ -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<FuelLogResponse> {
|
||||
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<FuelLogResponse[]> {
|
||||
// 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<FuelLogResponse[]>(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<FuelLogResponse[]> {
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||
|
||||
// Check cache
|
||||
const cached = await cacheService.get<FuelLogResponse[]>(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<FuelLogResponse> {
|
||||
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<FuelLogResponse> {
|
||||
// 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<FuelLog> = {
|
||||
...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<void> {
|
||||
// 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<FuelStats> {
|
||||
// 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<void> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
70
backend/src/features/fuel-logs/domain/fuel-logs.types.ts
Normal file
70
backend/src/features/fuel-logs/domain/fuel-logs.types.ts
Normal file
@@ -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;
|
||||
}
|
||||
18
backend/src/features/fuel-logs/index.ts
Normal file
18
backend/src/features/fuel-logs/index.ts
Normal file
@@ -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';
|
||||
@@ -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();
|
||||
35
backend/src/features/maintenance/README.md
Normal file
35
backend/src/features/maintenance/README.md
Normal file
@@ -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
|
||||
```
|
||||
18
backend/src/features/maintenance/index.ts
Normal file
18
backend/src/features/maintenance/index.ts
Normal file
@@ -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';
|
||||
@@ -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();
|
||||
35
backend/src/features/stations/README.md
Normal file
35
backend/src/features/stations/README.md
Normal file
@@ -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
|
||||
```
|
||||
105
backend/src/features/stations/api/stations.controller.ts
Normal file
105
backend/src/features/stations/api/stations.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
backend/src/features/stations/api/stations.routes.ts
Normal file
27
backend/src/features/stations/api/stations.routes.ts
Normal file
@@ -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;
|
||||
}
|
||||
90
backend/src/features/stations/domain/stations.service.ts
Normal file
90
backend/src/features/stations/domain/stations.service.ts
Normal file
@@ -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<StationSearchResponse> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
49
backend/src/features/stations/domain/stations.types.ts
Normal file
49
backend/src/features/stations/domain/stations.types.ts
Normal file
@@ -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;
|
||||
}
|
||||
112
backend/src/features/stations/external/google-maps/google-maps.client.ts
vendored
Normal file
112
backend/src/features/stations/external/google-maps/google-maps.client.ts
vendored
Normal file
@@ -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<Station[]> {
|
||||
const cacheKey = `stations:${latitude.toFixed(4)},${longitude.toFixed(4)},${radius}`;
|
||||
|
||||
try {
|
||||
// Check cache
|
||||
const cached = await cacheService.get<Station[]>(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<GooglePlacesResponse>(
|
||||
`${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();
|
||||
55
backend/src/features/stations/external/google-maps/google-maps.types.ts
vendored
Normal file
55
backend/src/features/stations/external/google-maps/google-maps.types.ts
vendored
Normal file
@@ -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;
|
||||
}
|
||||
17
backend/src/features/stations/index.ts
Normal file
17
backend/src/features/stations/index.ts
Normal file
@@ -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';
|
||||
@@ -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();
|
||||
208
backend/src/features/vehicles/README.md
Normal file
208
backend/src/features/vehicles/README.md
Normal file
@@ -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
|
||||
```
|
||||
164
backend/src/features/vehicles/api/vehicles.controller.ts
Normal file
164
backend/src/features/vehicles/api/vehicles.controller.ts
Normal file
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
25
backend/src/features/vehicles/api/vehicles.routes.ts
Normal file
25
backend/src/features/vehicles/api/vehicles.routes.ts
Normal file
@@ -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;
|
||||
}
|
||||
32
backend/src/features/vehicles/api/vehicles.validation.ts
Normal file
32
backend/src/features/vehicles/api/vehicles.validation.ts
Normal file
@@ -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<typeof createVehicleSchema>;
|
||||
export type UpdateVehicleInput = z.infer<typeof updateVehicleSchema>;
|
||||
export type VehicleIdInput = z.infer<typeof vehicleIdSchema>;
|
||||
160
backend/src/features/vehicles/domain/vehicles.service.ts
Normal file
160
backend/src/features/vehicles/domain/vehicles.service.ts
Normal file
@@ -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<VehicleResponse> {
|
||||
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<VehicleResponse[]> {
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||
|
||||
// Check cache
|
||||
const cached = await cacheService.get<VehicleResponse[]>(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<VehicleResponse> {
|
||||
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<VehicleResponse> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
61
backend/src/features/vehicles/domain/vehicles.types.ts
Normal file
61
backend/src/features/vehicles/domain/vehicles.types.ts
Normal file
@@ -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;
|
||||
}
|
||||
78
backend/src/features/vehicles/external/vpic/vpic.client.ts
vendored
Normal file
78
backend/src/features/vehicles/external/vpic/vpic.client.ts
vendored
Normal file
@@ -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<VPICDecodeResult | null> {
|
||||
const cacheKey = `vpic:vin:${vin}`;
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cached = await cacheService.get<VPICDecodeResult>(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<VPICResponse>(
|
||||
`${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();
|
||||
26
backend/src/features/vehicles/external/vpic/vpic.types.ts
vendored
Normal file
26
backend/src/features/vehicles/external/vpic/vpic.types.ts
vendored
Normal file
@@ -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[];
|
||||
}
|
||||
18
backend/src/features/vehicles/index.ts
Normal file
18
backend/src/features/vehicles/index.ts
Normal file
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<VehiclesRepository>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
161
backend/src/features/vehicles/tests/unit/vpic.client.test.ts
Normal file
161
backend/src/features/vehicles/tests/unit/vpic.client.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
45
backend/src/index.ts
Normal file
45
backend/src/index.ts
Normal file
@@ -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);
|
||||
});
|
||||
31
backend/src/shared-minimal/utils/formatters.ts
Normal file
31
backend/src/shared-minimal/utils/formatters.ts
Normal file
@@ -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`;
|
||||
}
|
||||
30
backend/src/shared-minimal/utils/validators.ts
Normal file
30
backend/src/shared-minimal/utils/validators.ts
Normal file
@@ -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);
|
||||
}
|
||||
25
backend/tsconfig.json
Normal file
25
backend/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
113
docker-compose.yml
Normal file
113
docker-compose.yml
Normal file
@@ -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:
|
||||
10
frontend/.env.example
Normal file
10
frontend/.env.example
Normal file
@@ -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
|
||||
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal file
@@ -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
|
||||
45
frontend/Dockerfile
Normal file
45
frontend/Dockerfile
Normal file
@@ -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;"]
|
||||
24
frontend/Dockerfile.dev
Normal file
24
frontend/Dockerfile.dev
Normal file
@@ -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"]
|
||||
39
frontend/eslint.config.js
Normal file
39
frontend/eslint.config.js
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MotoVaultPro</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
frontend/nginx.conf
Normal file
39
frontend/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
48
frontend/package.json
Normal file
48
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
51
frontend/src/App.tsx
Normal file
51
frontend/src/App.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">MotoVaultPro</h1>
|
||||
<p className="text-gray-600 mb-8">Your personal vehicle management platform</p>
|
||||
<Button onClick={() => loginWithRedirect()}>
|
||||
Login to Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/vehicles" replace />} />
|
||||
<Route path="/vehicles" element={<VehiclesPage />} />
|
||||
<Route path="/vehicles/:id" element={<div>Vehicle Details (TODO)</div>} />
|
||||
<Route path="/fuel-logs" element={<div>Fuel Logs (TODO)</div>} />
|
||||
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
|
||||
<Route path="/stations" element={<div>Stations (TODO)</div>} />
|
||||
<Route path="*" element={<Navigate to="/vehicles" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
130
frontend/src/components/Layout.tsx
Normal file
130
frontend/src/components/Layout.tsx
Normal file
@@ -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<LayoutProps> = ({ 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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<div className={clsx(
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-200 ease-in-out',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-gray-900">MotoVaultPro</h1>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-6">
|
||||
<div className="px-3">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={clsx(
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-md mb-1 transition-colors',
|
||||
location.pathname.startsWith(item.href)
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
<span className="mr-3 text-lg">{item.icon}</span>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-primary-600 font-medium text-sm">
|
||||
{user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{user?.name || user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full mt-3"
|
||||
onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={clsx(
|
||||
'transition-all duration-200 ease-in-out',
|
||||
sidebarOpen ? 'ml-64' : 'ml-0'
|
||||
)}>
|
||||
{/* Top bar */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between h-16 px-6">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
☰
|
||||
</button>
|
||||
<div className="text-sm text-gray-500">
|
||||
Welcome back, {user?.name || user?.email}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
frontend/src/core/api/client.ts
Normal file
47
frontend/src/core/api/client.ts
Normal file
@@ -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;
|
||||
61
frontend/src/core/auth/Auth0Provider.tsx
Normal file
61
frontend/src/core/auth/Auth0Provider.tsx
Normal file
@@ -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<Auth0ProviderProps> = ({ 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 (
|
||||
<BaseAuth0Provider
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
authorizationParams={{
|
||||
redirect_uri: window.location.origin,
|
||||
audience: audience,
|
||||
}}
|
||||
onRedirectCallback={onRedirectCallback}
|
||||
cacheLocation="localstorage"
|
||||
>
|
||||
<TokenInjector>{children}</TokenInjector>
|
||||
</BaseAuth0Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 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}</>;
|
||||
};
|
||||
54
frontend/src/core/store/index.ts
Normal file
54
frontend/src/core/store/index.ts
Normal file
@@ -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<AppState>()(
|
||||
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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
32
frontend/src/features/vehicles/api/vehicles.api.ts
Normal file
32
frontend/src/features/vehicles/api/vehicles.api.ts
Normal file
@@ -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<Vehicle[]> => {
|
||||
const response = await apiClient.get('/vehicles');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Vehicle> => {
|
||||
const response = await apiClient.get(`/vehicles/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateVehicleRequest): Promise<Vehicle> => {
|
||||
const response = await apiClient.post('/vehicles', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateVehicleRequest): Promise<Vehicle> => {
|
||||
const response = await apiClient.put(`/vehicles/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/vehicles/${id}`);
|
||||
},
|
||||
};
|
||||
64
frontend/src/features/vehicles/components/VehicleCard.tsx
Normal file
64
frontend/src/features/vehicles/components/VehicleCard.tsx
Normal file
@@ -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<VehicleCardProps> = ({
|
||||
vehicle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => onSelect(vehicle.id)}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">VIN: {vehicle.vin}</p>
|
||||
{vehicle.licensePlate && (
|
||||
<p className="text-sm text-gray-500">License: {vehicle.licensePlate}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Odometer: {vehicle.odometerReading.toLocaleString()} miles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(vehicle);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(vehicle.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
115
frontend/src/features/vehicles/components/VehicleForm.tsx
Normal file
115
frontend/src/features/vehicles/components/VehicleForm.tsx
Normal file
@@ -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<CreateVehicleRequest>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
loading,
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<CreateVehicleRequest>({
|
||||
resolver: zodResolver(vehicleSchema),
|
||||
defaultValues: initialData,
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
VIN <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('vin')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Enter 17-character VIN"
|
||||
/>
|
||||
{errors.vin && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.vin.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nickname
|
||||
</label>
|
||||
<input
|
||||
{...register('nickname')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., Family Car"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
{...register('color')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., Blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
License Plate
|
||||
</label>
|
||||
<input
|
||||
{...register('licensePlate')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., ABC-123"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Odometer Reading
|
||||
</label>
|
||||
<input
|
||||
{...register('odometerReading', { valueAsNumber: true })}
|
||||
type="number"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., 50000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button variant="secondary" onClick={onCancel} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
78
frontend/src/features/vehicles/hooks/useVehicles.ts
Normal file
78
frontend/src/features/vehicles/hooks/useVehicles.ts
Normal file
@@ -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');
|
||||
},
|
||||
});
|
||||
};
|
||||
89
frontend/src/features/vehicles/pages/VehiclesPage.tsx
Normal file
89
frontend/src/features/vehicles/pages/VehiclesPage.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading vehicles...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Vehicles</h1>
|
||||
{!showForm && (
|
||||
<Button onClick={() => setShowForm(true)}>Add Vehicle</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Add New Vehicle</h2>
|
||||
<VehicleForm
|
||||
onSubmit={async (data) => {
|
||||
await createVehicle.mutateAsync(data);
|
||||
setShowForm(false);
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
loading={createVehicle.isPending}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{vehicles?.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">No vehicles added yet</p>
|
||||
{!showForm && (
|
||||
<Button onClick={() => setShowForm(true)}>Add Your First Vehicle</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{vehicles?.map((vehicle) => (
|
||||
<VehicleCard
|
||||
key={vehicle.id}
|
||||
vehicle={vehicle}
|
||||
onEdit={(v) => console.log('Edit', v)}
|
||||
onDelete={handleDelete}
|
||||
onSelect={handleSelectVehicle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
34
frontend/src/features/vehicles/types/vehicles.types.ts
Normal file
34
frontend/src/features/vehicles/types/vehicles.types.ts
Normal file
@@ -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;
|
||||
}
|
||||
16
frontend/src/index.css
Normal file
16
frontend/src/index.css
Normal file
@@ -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;
|
||||
}
|
||||
43
frontend/src/main.tsx
Normal file
43
frontend/src/main.tsx
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Auth0Provider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</Auth0Provider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
62
frontend/src/shared-minimal/components/Button.tsx
Normal file
62
frontend/src/shared-minimal/components/Button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @ai-summary Reusable button component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
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 (
|
||||
<button
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
(disabled || loading) && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
41
frontend/src/shared-minimal/components/Card.tsx
Normal file
41
frontend/src/shared-minimal/components/Card.tsx
Normal file
@@ -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<CardProps> = ({
|
||||
children,
|
||||
className,
|
||||
padding = 'md',
|
||||
onClick,
|
||||
}) => {
|
||||
const paddings = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white rounded-lg shadow-sm border border-gray-200',
|
||||
paddings[padding],
|
||||
onClick && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
frontend/src/vite-env.d.ts
vendored
Normal file
12
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
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
|
||||
}
|
||||
23
frontend/tailwind.config.js
Normal file
23
frontend/tailwind.config.js
Normal file
@@ -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: [],
|
||||
}
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -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
|
||||
},
|
||||
});
|
||||
387
moto_vault_pro_mobile_ux_prototype_react.jsx
Normal file
387
moto_vault_pro_mobile_ux_prototype_react.jsx
Normal file
@@ -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) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
|
||||
<rect x="3" y="3" width="18" height="7" rx="2" strokeWidth="1.6"/>
|
||||
<rect x="3" y="14" width="10" height="7" rx="2" strokeWidth="1.6"/>
|
||||
<rect x="15" y="14" width="6" height="7" rx="2" strokeWidth="1.6"/>
|
||||
</svg>
|
||||
);
|
||||
const IconCar = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
|
||||
<path d="M3 13l2-5a3 3 0 012.8-2h6.4A3 3 0 0117 8l2 5" strokeWidth="1.6"/>
|
||||
<rect x="2" y="11" width="20" height="6" rx="2" strokeWidth="1.6"/>
|
||||
<circle cx="7" cy="17" r="1.5"/>
|
||||
<circle cx="17" cy="17" r="1.5"/>
|
||||
</svg>
|
||||
);
|
||||
const IconFuel = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
|
||||
<path d="M4 5h8a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1z" strokeWidth="1.6"/>
|
||||
<path d="M12 8h2l3 3v7a2 2 0 01-2 2h0" strokeWidth="1.6"/>
|
||||
<circle cx="8" cy="9" r="1.2"/>
|
||||
</svg>
|
||||
);
|
||||
const IconSettings = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
|
||||
<path d="M12 8a4 4 0 100 8 4 4 0 000-8z" strokeWidth="1.6"/>
|
||||
<path d="M19.4 15a1.8 1.8 0 00.36 2l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.8 1.8 0 00-2-.36 1.8 1.8 0 00-1 1.6V22a2 2 0 01-4 0v-.06a1.8 1.8 0 00-1-1.6 1.8 1.8 0 00-2 .36l-.06.06A2 2 0 013.2 19.1l.06-.06a1.8 1.8 0 00.36-2 1.8 1.8 0 00-1.6-1H2a2 2 0 010-4h.06a1.8 1.8 0 001.6-1 1.8 1.8 0 00-.36-2l-.06-.06A2 2 0 013.8 4.1l.06.06a1.8 1.8 0 002 .36 1.8 1.8 0 001-1.6V2a2 2 0 014 0v.06a1.8 1.8 0 001 1.6 1.8 1.8 0 002-.36l.06-.06A2 2 0 0120.8 4.9l-.06.06a1.8 1.8 0 00-.36 2 1.8 1.8 0 001.6 1H22a2 2 0 010 4h-.06a1.8 1.8 0 00-1.6 1z" strokeWidth="1.2"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Visual
|
||||
const CarThumb = ({ color = "#e5e7eb" }) => (
|
||||
<svg viewBox="0 0 120 64" className="w-full h-24 rounded-2xl" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1">
|
||||
<stop offset="0" stopColor={color} stopOpacity="0.9" />
|
||||
<stop offset="1" stopColor="#ffffff" stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="120" height="64" rx="16" fill="url(#g)" />
|
||||
<rect x="16" y="28" width="72" height="20" rx="6" fill="#fff" opacity="0.9"/>
|
||||
<circle cx="38" cy="54" r="6" fill="#0f172a"/>
|
||||
<circle cx="78" cy="54" r="6" fill="#0f172a"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Pill = ({ active, label, onClick, icon }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group h-11 rounded-2xl text-sm font-medium border transition flex items-center justify-center gap-2 backdrop-blur ${
|
||||
active
|
||||
? "text-white border-transparent shadow-lg"
|
||||
: "bg-white/80 text-slate-800 border-slate-200 hover:bg-slate-50"
|
||||
}`}
|
||||
style={active ? { background: `linear-gradient(90deg, ${primary}, ${primaryLight})` } : {}}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const SparkBar = ({ values }) => {
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-1 h-16 w-full">
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: `${(v / max) * 100}%`, background: `linear-gradient(to top, ${primary}, ${primaryLight})` }}
|
||||
className="flex-1 rounded-t"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatCard = ({ label, value, sub }) => (
|
||||
<div className="rounded-3xl border border-slate-200/70 bg-white/80 p-4 shadow-sm backdrop-blur">
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<div className="text-2xl font-semibold mt-1 text-slate-900">{value}</div>
|
||||
{sub && <div className="text-xs mt-1 text-slate-500">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const VehicleCard = ({ v, onClick, compact=false }) => (
|
||||
<button
|
||||
onClick={() => onClick?.(v)}
|
||||
className={`rounded-3xl border border-slate-200/70 bg-white/80 text-left ${compact ? "w-44 flex-shrink-0" : "w-full"} hover:shadow-xl hover:-translate-y-0.5 transition shadow-sm backdrop-blur`}
|
||||
>
|
||||
<div className="p-3">
|
||||
<CarThumb color={v.color} />
|
||||
<div className="mt-3">
|
||||
<div className="text-base font-semibold tracking-tight text-slate-800">{v.year} {v.make}</div>
|
||||
<div className="text-sm text-slate-500">{v.model}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
const VehiclesScreen = ({ vehicles, onOpen }) => (
|
||||
<div className="space-y-4">
|
||||
<Section title="Vehicles">
|
||||
<div className="space-y-3">
|
||||
{vehicles.map((v) => (
|
||||
<VehicleCard key={v.id} v={v} onClick={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const RecentVehicles = ({ recent, onOpen }) => (
|
||||
<Section title="Recent Vehicles" right={<div className="text-xs text-slate-500">Last used</div>}>
|
||||
<div className="flex gap-3 overflow-x-auto no-scrollbar pb-1">
|
||||
{recent.map((v) => (
|
||||
<VehicleCard key={v.id} v={v} onClick={onOpen} compact />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
const DashboardScreen = ({ recent }) => (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard label="Fuel Spend (Mo)" value="$238" sub="↑ 6% vs last month" />
|
||||
<StatCard label="Avg Price / gal" value="$3.76" sub="US gallons" />
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200/70 bg-white/80 p-4 shadow-sm backdrop-blur">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-medium text-slate-800">Fuel Spent This Month</div>
|
||||
<div className="text-xs text-slate-500">Last 30 days</div>
|
||||
</div>
|
||||
<SparkBar values={[6,8,5,7,10,12,9,11,6,7,8,9]} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200/70 bg-white/80 p-4 shadow-sm backdrop-blur">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-medium text-slate-800">Fuel Spend Per Vehicle</div>
|
||||
<div className="text-xs text-slate-500">This month</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm text-slate-700">
|
||||
{[
|
||||
{ name: "Camry", val: 92 },
|
||||
{ name: "F-150", val: 104 },
|
||||
{ name: "CR‑V", val: 42 },
|
||||
].map(({ name, val }) => (
|
||||
<div key={name} className="space-y-1">
|
||||
<div className="text-xs text-slate-500">{name}</div>
|
||||
<div className="h-2 rounded bg-slate-200/70">
|
||||
<div className="h-2 rounded" style={{ width: `${Math.min(val, 100)}%`, background: `linear-gradient(90deg, ${primary}, ${primaryLight})` }} />
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">${val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecentVehicles recent={recent} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const Field = ({ label, children }) => (
|
||||
<div>
|
||||
<label className="text-xs text-slate-600">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => { e.preventDefault(); onSave?.({ vehicleId, date, odo, qty, price, octane }); }}
|
||||
>
|
||||
<Section title="Log Fuel">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Field label="Vehicle">
|
||||
<select value={vehicleId} onChange={(e)=>setVehicleId(e.target.value)} className={selectCls}>
|
||||
{vehicles.map(v => (
|
||||
<option key={v.id} value={v.id}>{`${v.year} ${v.make} ${v.model}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Field label="Date">
|
||||
<input type="date" value={date} onChange={(e)=>setDate(e.target.value)} className={inputCls}/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={`Odometer (${dist})`}>
|
||||
<input type="number" value={odo} onChange={(e)=>setOdo(Number(e.target.value))} className={inputCls}/>
|
||||
</Field>
|
||||
<Field label={`Quantity (${fuel})`}>
|
||||
<input type="number" step="0.01" value={qty} onChange={(e)=>setQty(Number(e.target.value))} className={inputCls}/>
|
||||
</Field>
|
||||
<Field label={`Price / ${fuel}`}>
|
||||
<input type="number" step="0.01" value={price} onChange={(e)=>setPrice(Number(e.target.value))} className={inputCls}/>
|
||||
</Field>
|
||||
<Field label="Octane (gasoline)">
|
||||
<select value={octane} onChange={(e)=>setOctane(e.target.value)} className={selectCls}>
|
||||
<option>85</option>
|
||||
<option>87</option>
|
||||
<option>89</option>
|
||||
<option>91</option>
|
||||
<option>93</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 h-12 w-full rounded-2xl text-white font-medium shadow-lg active:scale-[0.99] transition"
|
||||
style={{ background: `linear-gradient(90deg, ${primary}, ${primaryLight})` }}
|
||||
>
|
||||
Save Fuel Log
|
||||
</button>
|
||||
</Section>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const VehicleDetail = ({ vehicle, onBack, onLogFuel }) => (
|
||||
<div className="space-y-4">
|
||||
<button onClick={onBack} className="text-sm text-slate-500">← Back</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-28"><CarThumb color={vehicle.color} /></div>
|
||||
<div>
|
||||
<div className="text-xl font-semibold">{vehicle.year} {vehicle.make}</div>
|
||||
<div className="text-slate-500">{vehicle.model}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onLogFuel} className="h-10 px-4 rounded-xl text-white text-sm font-medium shadow" style={{ background: `linear-gradient(90deg, ${primary}, ${primaryLight})` }}>Add Fuel</button>
|
||||
<button className="h-10 px-4 rounded-xl border border-slate-200 text-sm font-medium bg-white/80 backdrop-blur">Maintenance</button>
|
||||
</div>
|
||||
<Section title="Fuel Logs">
|
||||
<div className="rounded-3xl border border-slate-200/70 divide-y bg-white/80 backdrop-blur shadow-sm">
|
||||
{[{d:"Apr 24", odo:"15,126 mi"},{d:"Mar 13", odo:"14,300 mi"},{d:"Jan 10", odo:"14,055 mi"}].map((r,i)=>(
|
||||
<div key={i} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<span>{r.d}</span><span className="text-slate-600">{r.odo}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsScreen = ({ units, setUnits }) => (
|
||||
<div className="space-y-6">
|
||||
<Section title="Units">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-slate-600 mb-1">Distance</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[ ["mi","Miles"], ["km","Kilometers"] ].map(([val,label])=> (
|
||||
<button key={val} onClick={()=>setUnits(u=>({...u, distance: val}))} className="h-10 rounded-xl border text-sm bg-white/80 border-slate-200" style={units.distance===val?{ background: `linear-gradient(90deg, ${primary}, ${primaryLight})`, color: '#fff', borderColor: 'transparent' }:{}}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-600 mb-1">Fuel</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[ ["gal","US Gallons"], ["L","Liters"] ].map(([val,label])=> (
|
||||
<button key={val} onClick={()=>setUnits(u=>({...u, fuel: val}))} className="h-10 rounded-xl border text-sm bg-white/80 border-slate-200" style={units.fuel===val?{ background: `linear-gradient(90deg, ${primary}, ${primaryLight})`, color: '#fff', borderColor: 'transparent' }:{}}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BottomNav = ({ active, setActive }) => (
|
||||
<div className="grid grid-cols-4 gap-3 w-full p-4 border-t border-slate-200 bg-white/70 backdrop-blur sticky bottom-0">
|
||||
{[
|
||||
{ key: "Dashboard", icon: <IconDash className="w-4 h-4"/> },
|
||||
{ key: "Vehicles", icon: <IconCar className="w-4 h-4"/> },
|
||||
{ key: "Log Fuel", icon: <IconFuel className="w-4 h-4"/> },
|
||||
{ key: "Settings", icon: <IconSettings className="w-4 h-4"/> },
|
||||
].map(({ key, icon }) => (
|
||||
<Pill
|
||||
key={key}
|
||||
label={key}
|
||||
icon={icon}
|
||||
active={active === key}
|
||||
onClick={() => setActive(key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="w-full h-full bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-start justify-center py-6">
|
||||
<div className="w-[380px] rounded-[32px] shadow-2xl flex flex-col border border-slate-200/70 bg-white/70 backdrop-blur-xl">
|
||||
{/* App header */}
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold tracking-tight">MotoVaultPro</div>
|
||||
<div className="text-xs text-slate-500">v0.1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-5 pb-5 space-y-5 overflow-y-auto">
|
||||
<div className="min-h-[560px]">
|
||||
<AnimatePresence mode="wait">
|
||||
{active === "Dashboard" && (
|
||||
<motion.div key="dashboard" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
||||
<DashboardScreen recent={recent} />
|
||||
</motion.div>
|
||||
)}
|
||||
{active === "Vehicles" && (
|
||||
<motion.div key="vehicles" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}} className="space-y-6">
|
||||
{openVehicle ? (
|
||||
<VehicleDetail vehicle={openVehicle} onBack={()=>setOpenVehicle(null)} onLogFuel={()=>setActive("Log Fuel")} />
|
||||
) : (
|
||||
<VehiclesScreen vehicles={vehicles} onOpen={handleOpenVehicle} />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
{active === "Log Fuel" && (
|
||||
<motion.div key="logfuel" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
||||
<FuelForm units={units} vehicles={vehicles} onSave={() => setActive("Vehicles")} />
|
||||
</motion.div>
|
||||
)}
|
||||
{active === "Settings" && (
|
||||
<motion.div key="settings" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
||||
<SettingsScreen units={units} setUnits={setUnits} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<BottomNav active={active} setActive={setActive} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
scripts/generate-feature-capsule.sh
Executable file
95
scripts/generate-feature-capsule.sh
Executable file
@@ -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 <feature-name>"
|
||||
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"
|
||||
Reference in New Issue
Block a user