MVP Build
This commit is contained in:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user