Homepage Redesign
This commit is contained in:
@@ -19,6 +19,7 @@ import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes';
|
||||
import { stationsRoutes } from './features/stations/api/stations.routes';
|
||||
import { documentsRoutes } from './features/documents/api/documents.routes';
|
||||
import { maintenanceRoutes } from './features/maintenance';
|
||||
import { platformRoutes } from './features/platform';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
@@ -70,7 +71,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV,
|
||||
features: ['vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance']
|
||||
features: ['vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +81,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
scope: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance']
|
||||
features: ['vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +107,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
});
|
||||
|
||||
// Register Fastify feature routes
|
||||
await app.register(platformRoutes, { prefix: '/api' });
|
||||
await app.register(vehiclesRoutes, { prefix: '/api' });
|
||||
await app.register(documentsRoutes, { prefix: '/api' });
|
||||
await app.register(fuelLogsRoutes, { prefix: '/api' });
|
||||
|
||||
@@ -41,17 +41,6 @@ const configSchema = z.object({
|
||||
audience: z.string(),
|
||||
}),
|
||||
|
||||
// Platform services configuration
|
||||
platform: z.object({
|
||||
services: z.object({
|
||||
vehicles: z.object({
|
||||
url: z.string(),
|
||||
timeout: z.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
// External APIs configuration
|
||||
external: z.object({
|
||||
vpic: z.object({
|
||||
@@ -147,7 +136,6 @@ export interface AppConfiguration {
|
||||
getDatabaseUrl(): string;
|
||||
getRedisUrl(): string;
|
||||
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
|
||||
getPlatformVehiclesUrl(): string;
|
||||
}
|
||||
|
||||
class ConfigurationLoader {
|
||||
@@ -237,10 +225,6 @@ class ConfigurationLoader {
|
||||
clientSecret: secrets.auth0_client_secret,
|
||||
};
|
||||
},
|
||||
|
||||
getPlatformVehiclesUrl(): string {
|
||||
return config.platform.services.vehicles.url;
|
||||
},
|
||||
};
|
||||
|
||||
logger.info('Configuration loaded successfully', {
|
||||
|
||||
379
backend/src/features/platform/README.md
Normal file
379
backend/src/features/platform/README.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# Platform Feature Capsule
|
||||
|
||||
## Quick Summary (50 tokens)
|
||||
Extensible platform service for vehicle hierarchical data lookups and VIN decoding. Replaces Python FastAPI platform service. PostgreSQL-first with vPIC fallback, Redis caching (6hr vehicle data, 7-day VIN), circuit breaker pattern for resilience.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Vehicle Hierarchical Data
|
||||
- `GET /api/platform/years` - Get available model years (distinct, descending)
|
||||
- `GET /api/platform/makes?year={year}` - Get makes for specific year
|
||||
- `GET /api/platform/models?year={year}&make_id={id}` - Get models for year and make
|
||||
- `GET /api/platform/trims?year={year}&model_id={id}` - Get trims for year and model
|
||||
- `GET /api/platform/engines?year={year}&model_id={id}&trim_id={id}` - Get engines for trim
|
||||
|
||||
### VIN Decoding
|
||||
- `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details
|
||||
|
||||
## Authentication
|
||||
- All platform endpoints require valid JWT (Auth0)
|
||||
|
||||
## Request/Response Examples
|
||||
|
||||
### Get Years
|
||||
```json
|
||||
GET /api/platform/years
|
||||
|
||||
Response (200):
|
||||
[2024, 2023, 2022, 2021, ...]
|
||||
```
|
||||
|
||||
### Get Makes for Year
|
||||
```json
|
||||
GET /api/platform/makes?year=2024
|
||||
|
||||
Response (200):
|
||||
{
|
||||
"makes": [
|
||||
{"id": 1, "name": "Honda"},
|
||||
{"id": 2, "name": "Toyota"},
|
||||
{"id": 3, "name": "Ford"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get Models for Make/Year
|
||||
```json
|
||||
GET /api/platform/models?year=2024&make_id=1
|
||||
|
||||
Response (200):
|
||||
{
|
||||
"models": [
|
||||
{"id": 101, "name": "Civic"},
|
||||
{"id": 102, "name": "Accord"},
|
||||
{"id": 103, "name": "CR-V"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Decode VIN
|
||||
```json
|
||||
GET /api/platform/vehicle?vin=1HGCM82633A123456
|
||||
|
||||
Response (200):
|
||||
{
|
||||
"vin": "1HGCM82633A123456",
|
||||
"success": true,
|
||||
"result": {
|
||||
"make": "Honda",
|
||||
"model": "Accord",
|
||||
"year": 2003,
|
||||
"trim_name": "LX",
|
||||
"engine_description": "2.4L I4",
|
||||
"transmission_description": "5-Speed Automatic",
|
||||
"horsepower": 160,
|
||||
"torque": 161,
|
||||
"top_speed": null,
|
||||
"fuel": "Gasoline",
|
||||
"confidence_score": 0.95,
|
||||
"vehicle_type": "Passenger Car"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### VIN Decode Error
|
||||
```json
|
||||
GET /api/platform/vehicle?vin=INVALID
|
||||
|
||||
Response (400):
|
||||
{
|
||||
"vin": "INVALID",
|
||||
"success": false,
|
||||
"result": null,
|
||||
"error": "VIN must be exactly 17 characters"
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Architecture
|
||||
|
||||
### Complete Self-Contained Structure
|
||||
```
|
||||
platform/
|
||||
├── README.md # This file
|
||||
├── index.ts # Public API exports
|
||||
├── api/ # HTTP layer
|
||||
│ ├── platform.controller.ts
|
||||
│ └── platform.routes.ts
|
||||
├── domain/ # Business logic
|
||||
│ ├── vehicle-data.service.ts
|
||||
│ ├── vin-decode.service.ts
|
||||
│ └── platform-cache.service.ts
|
||||
├── data/ # Database and external APIs
|
||||
│ ├── vehicle-data.repository.ts
|
||||
│ └── vpic-client.ts
|
||||
├── models/ # DTOs
|
||||
│ ├── requests.ts
|
||||
│ └── responses.ts
|
||||
├── tests/ # All tests
|
||||
│ ├── unit/
|
||||
│ └── integration/
|
||||
└── docs/ # Additional docs
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### VIN Decoding Strategy
|
||||
1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures)
|
||||
2. **PostgreSQL**: Use `vehicles.f_decode_vin()` function for high-confidence decode
|
||||
3. **vPIC Fallback**: NHTSA vPIC API via circuit breaker (5s timeout, 50% error threshold)
|
||||
4. **Graceful Degradation**: Return meaningful errors when all sources fail
|
||||
|
||||
### Circuit Breaker Pattern
|
||||
- **Library**: opossum
|
||||
- **Timeout**: 6 seconds
|
||||
- **Error Threshold**: 50%
|
||||
- **Reset Timeout**: 30 seconds
|
||||
- **Monitoring**: Logs state transitions (open/half-open/close)
|
||||
|
||||
### Hierarchical Vehicle Data
|
||||
- **PostgreSQL Queries**: Normalized schema (vehicles.make, vehicles.model, etc.)
|
||||
- **Caching**: 6-hour TTL for all dropdown data
|
||||
- **Performance**: < 100ms response times via caching
|
||||
- **Validation**: Year (1950-2100), positive integer IDs
|
||||
|
||||
### Database Schema
|
||||
- **Uses Existing Schema**: `vehicles` schema in PostgreSQL
|
||||
- **Tables**: make, model, model_year, trim, engine, trim_engine
|
||||
- **Function**: `vehicles.f_decode_vin(vin text)` for VIN decoding
|
||||
- **No Migrations**: Uses existing platform database structure
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
#### Vehicle Data (6 hours)
|
||||
- **Keys**: `mvp:platform:vehicle-data:{type}:{params}`
|
||||
- **Examples**:
|
||||
- `mvp:platform:years`
|
||||
- `mvp:platform:vehicle-data:makes:2024`
|
||||
- `mvp:platform:vehicle-data:models:2024:1`
|
||||
- **TTL**: 21600 seconds (6 hours)
|
||||
- **Invalidation**: Natural expiration via TTL
|
||||
|
||||
#### VIN Decode (7 days success, 1 hour failure)
|
||||
- **Keys**: `mvp:platform:vin-decode:{VIN}`
|
||||
- **Examples**: `mvp:platform:vin-decode:1HGCM82633A123456`
|
||||
- **TTL**: 604800 seconds (7 days) for success, 3600 seconds (1 hour) for failures
|
||||
- **Invalidation**: Natural expiration via TTL
|
||||
|
||||
## Business Rules
|
||||
|
||||
### VIN Validation
|
||||
- Must be exactly 17 characters
|
||||
- Cannot contain letters I, O, or Q
|
||||
- Must be alphanumeric
|
||||
- Auto-uppercase normalization
|
||||
|
||||
### Query Parameter Validation
|
||||
- **Year**: Integer between 1950 and 2100
|
||||
- **IDs**: Positive integers (make_id, model_id, trim_id)
|
||||
- **VIN**: 17 alphanumeric characters (no I, O, Q)
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal Core Services
|
||||
- `core/config/database` - PostgreSQL pool
|
||||
- `core/config/redis` - Redis cache service
|
||||
- `core/auth` - JWT authentication middleware
|
||||
- `core/logging` - Winston structured logging
|
||||
|
||||
### External APIs
|
||||
- **NHTSA vPIC**: https://vpic.nhtsa.dot.gov/api
|
||||
- VIN decoding fallback
|
||||
- 5-second timeout
|
||||
- Circuit breaker protected
|
||||
- Free public API
|
||||
|
||||
### Database Schema
|
||||
- **vehicles.make** - Vehicle manufacturers
|
||||
- **vehicles.model** - Vehicle models
|
||||
- **vehicles.model_year** - Year-specific models
|
||||
- **vehicles.trim** - Model trims
|
||||
- **vehicles.engine** - Engine configurations
|
||||
- **vehicles.trim_engine** - Trim-engine relationships
|
||||
- **vehicles.f_decode_vin(text)** - VIN decode function
|
||||
|
||||
### NPM Packages
|
||||
- `opossum` - Circuit breaker implementation
|
||||
- `axios` - HTTP client for vPIC API
|
||||
- `zod` - Request validation schemas
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Caching Strategy
|
||||
- **6-hour TTL**: Vehicle data rarely changes
|
||||
- **7-day TTL**: VIN decode results are immutable
|
||||
- **1-hour TTL**: Failed VIN decode (prevent repeated failures)
|
||||
- **Cache Prefix**: `mvp:platform:` for isolation
|
||||
|
||||
### Circuit Breaker
|
||||
- Prevents cascading failures to vPIC API
|
||||
- 30-second cooldown after opening
|
||||
- Automatic recovery via half-open state
|
||||
- Detailed logging for monitoring
|
||||
|
||||
### Database Optimization
|
||||
- Uses existing indexes on vehicles schema
|
||||
- Prepared statements via node-postgres
|
||||
- Connection pooling (max 10 connections)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Client Errors (4xx)
|
||||
- `400` - Invalid VIN format, validation errors
|
||||
- `401` - Missing or invalid JWT token
|
||||
- `404` - VIN not found in database or API
|
||||
- `503` - vPIC API unavailable (circuit breaker open)
|
||||
|
||||
### Server Errors (5xx)
|
||||
- `500` - Database errors, unexpected failures
|
||||
- Graceful degradation when external APIs unavailable
|
||||
- Detailed logging without exposing internal details
|
||||
|
||||
### Error Response Format
|
||||
```json
|
||||
{
|
||||
"vin": "1HGCM82633A123456",
|
||||
"success": false,
|
||||
"result": null,
|
||||
"error": "VIN not found in database and external API unavailable"
|
||||
}
|
||||
```
|
||||
|
||||
## Extensibility Design
|
||||
|
||||
### Future Lookup Types
|
||||
The platform feature is designed to accommodate additional lookup types beyond vehicle data:
|
||||
|
||||
**Current**: Vehicle hierarchical data, VIN decoding
|
||||
**Future Examples**:
|
||||
- Part number lookups
|
||||
- Service bulletins
|
||||
- Recall information
|
||||
- Maintenance schedules
|
||||
- Tire specifications
|
||||
- Paint codes
|
||||
|
||||
### Extension Pattern
|
||||
1. Create new service in `domain/` (e.g., `part-lookup.service.ts`)
|
||||
2. Add repository in `data/` if database queries needed
|
||||
3. Add external client in `data/` if API integration needed
|
||||
4. Add routes in `api/platform.routes.ts`
|
||||
5. Add validation schemas in `models/requests.ts`
|
||||
6. Add response types in `models/responses.ts`
|
||||
7. Update controller with new methods
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- `vehicle-data.service.test.ts` - Business logic with mocked dependencies
|
||||
- `vin-decode.service.test.ts` - VIN decode logic with mocked API
|
||||
- `vpic-client.test.ts` - vPIC client with mocked HTTP
|
||||
- `platform-cache.service.test.ts` - Cache operations
|
||||
|
||||
### Integration Tests
|
||||
- `platform.integration.test.ts` - Complete API workflow with test database
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
# All platform tests
|
||||
npm test -- features/platform
|
||||
|
||||
# Unit tests only
|
||||
npm test -- features/platform/tests/unit
|
||||
|
||||
# Integration tests only
|
||||
npm test -- features/platform/tests/integration
|
||||
|
||||
# With coverage
|
||||
npm test -- features/platform --coverage
|
||||
```
|
||||
|
||||
## Migration from Python Service
|
||||
|
||||
### What Changed
|
||||
- **Language**: Python FastAPI -> TypeScript Fastify
|
||||
- **Feature Name**: "vehicles" -> "platform" (extensibility)
|
||||
- **API Routes**: `/vehicles/*` -> `/api/platform/*`
|
||||
- **VIN Decode**: Kept and migrated (PostgreSQL + vPIC fallback)
|
||||
- **Caching**: Redis implementation adapted to TypeScript
|
||||
- **Circuit Breaker**: Python timeout -> opossum circuit breaker
|
||||
|
||||
### What Stayed the Same
|
||||
- Database schema (vehicles.*)
|
||||
- Cache TTLs (6hr vehicle data, 7-day VIN)
|
||||
- VIN validation logic
|
||||
- Hierarchical query structure
|
||||
- Response formats
|
||||
|
||||
### Deprecation Plan
|
||||
1. Deploy TypeScript platform feature
|
||||
2. Update frontend to use `/api/platform/*` endpoints
|
||||
3. Monitor traffic to Python service
|
||||
4. Deprecate Python service when traffic drops to zero
|
||||
5. Remove Python container from docker-compose
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start environment
|
||||
make start
|
||||
|
||||
# View feature logs
|
||||
make logs-backend | grep platform
|
||||
|
||||
# Open container shell
|
||||
make shell-backend
|
||||
|
||||
# Inside container - run feature tests
|
||||
npm test -- features/platform
|
||||
|
||||
# Type check
|
||||
npm run type-check
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Enhancements
|
||||
- Batch VIN decode endpoint (decode multiple VINs)
|
||||
- Admin endpoint to invalidate cache patterns
|
||||
- VIN decode history tracking
|
||||
- Alternative VIN decode APIs (CarMD, Edmunds)
|
||||
- Real-time vehicle data updates
|
||||
- Part number cross-reference lookups
|
||||
- Service bulletin integration
|
||||
- Recall information integration
|
||||
|
||||
### Performance Monitoring
|
||||
- Track cache hit rates
|
||||
- Monitor circuit breaker state
|
||||
- Log slow queries (> 200ms)
|
||||
- Alert on high error rates
|
||||
- Dashboard for vPIC API health
|
||||
|
||||
## Related Features
|
||||
|
||||
### Vehicles Feature
|
||||
- **Path**: `backend/src/features/vehicles/`
|
||||
- **Relationship**: Consumes platform VIN decode endpoint
|
||||
- **Integration**: Uses `/api/platform/vehicle?vin={vin}` for VIN decode
|
||||
|
||||
### Frontend Integration
|
||||
- **Dropdown Components**: Use hierarchical vehicle data endpoints
|
||||
- **VIN Scanner**: Use VIN decode endpoint for auto-population
|
||||
- **Vehicle Forms**: Leverage platform data for validation
|
||||
|
||||
---
|
||||
|
||||
**Platform Feature**: Extensible foundation for vehicle data and future platform capabilities. Production-ready with PostgreSQL, Redis caching, circuit breaker resilience, and comprehensive error handling.
|
||||
131
backend/src/features/platform/api/platform.controller.ts
Normal file
131
backend/src/features/platform/api/platform.controller.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @ai-summary Platform API controller
|
||||
* @ai-context Request handlers for vehicle data and VIN decoding endpoints
|
||||
*/
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { Pool } from 'pg';
|
||||
import { VehicleDataService } from '../domain/vehicle-data.service';
|
||||
import { VINDecodeService } from '../domain/vin-decode.service';
|
||||
import { PlatformCacheService } from '../domain/platform-cache.service';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import {
|
||||
MakesQuery,
|
||||
ModelsQuery,
|
||||
TrimsQuery,
|
||||
EnginesQuery,
|
||||
VINDecodeRequest
|
||||
} from '../models/requests';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class PlatformController {
|
||||
private vehicleDataService: VehicleDataService;
|
||||
private vinDecodeService: VINDecodeService;
|
||||
private pool: Pool;
|
||||
|
||||
constructor(pool: Pool) {
|
||||
this.pool = pool;
|
||||
const platformCache = new PlatformCacheService(cacheService);
|
||||
this.vehicleDataService = new VehicleDataService(platformCache);
|
||||
this.vinDecodeService = new VINDecodeService(platformCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/platform/years
|
||||
*/
|
||||
async getYears(_request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const years = await this.vehicleDataService.getYears(this.pool);
|
||||
reply.code(200).send(years);
|
||||
} catch (error) {
|
||||
logger.error('Controller error: getYears', { error });
|
||||
reply.code(500).send({ error: 'Failed to retrieve years' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/platform/makes?year={year}
|
||||
*/
|
||||
async getMakes(request: FastifyRequest<{ Querystring: MakesQuery }>, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const { year } = request.query;
|
||||
const makes = await this.vehicleDataService.getMakes(this.pool, year);
|
||||
reply.code(200).send({ makes });
|
||||
} catch (error) {
|
||||
logger.error('Controller error: getMakes', { error, query: request.query });
|
||||
reply.code(500).send({ error: 'Failed to retrieve makes' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/platform/models?year={year}&make_id={id}
|
||||
*/
|
||||
async getModels(request: FastifyRequest<{ Querystring: ModelsQuery }>, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const { year, make_id } = request.query;
|
||||
const models = await this.vehicleDataService.getModels(this.pool, year, make_id);
|
||||
reply.code(200).send({ models });
|
||||
} catch (error) {
|
||||
logger.error('Controller error: getModels', { error, query: request.query });
|
||||
reply.code(500).send({ error: 'Failed to retrieve models' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/platform/trims?year={year}&model_id={id}
|
||||
*/
|
||||
async getTrims(request: FastifyRequest<{ Querystring: TrimsQuery }>, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const { year, model_id } = request.query;
|
||||
const trims = await this.vehicleDataService.getTrims(this.pool, year, model_id);
|
||||
reply.code(200).send({ trims });
|
||||
} catch (error) {
|
||||
logger.error('Controller error: getTrims', { error, query: request.query });
|
||||
reply.code(500).send({ error: 'Failed to retrieve trims' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/platform/engines?year={year}&model_id={id}&trim_id={id}
|
||||
*/
|
||||
async getEngines(request: FastifyRequest<{ Querystring: EnginesQuery }>, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const { year, model_id, trim_id } = request.query;
|
||||
const engines = await this.vehicleDataService.getEngines(this.pool, year, model_id, trim_id);
|
||||
reply.code(200).send({ engines });
|
||||
} catch (error) {
|
||||
logger.error('Controller error: getEngines', { error, query: request.query });
|
||||
reply.code(500).send({ error: 'Failed to retrieve engines' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/platform/vehicle?vin={vin}
|
||||
*/
|
||||
async decodeVIN(request: FastifyRequest<{ Querystring: VINDecodeRequest }>, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const { vin } = request.query;
|
||||
const result = await this.vinDecodeService.decodeVIN(this.pool, vin);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error && result.error.includes('Invalid VIN')) {
|
||||
reply.code(400).send(result);
|
||||
} else if (result.error && result.error.includes('unavailable')) {
|
||||
reply.code(503).send(result);
|
||||
} else {
|
||||
reply.code(404).send(result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
reply.code(200).send(result);
|
||||
} catch (error) {
|
||||
logger.error('Controller error: decodeVIN', { error, query: request.query });
|
||||
reply.code(500).send({
|
||||
vin: request.query.vin,
|
||||
result: null,
|
||||
success: false,
|
||||
error: 'Internal server error during VIN decoding'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
46
backend/src/features/platform/api/platform.routes.ts
Normal file
46
backend/src/features/platform/api/platform.routes.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @ai-summary Platform feature routes
|
||||
* @ai-context Fastify route registration with validation
|
||||
*/
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { PlatformController } from './platform.controller';
|
||||
import {
|
||||
MakesQuery,
|
||||
ModelsQuery,
|
||||
TrimsQuery,
|
||||
EnginesQuery,
|
||||
VINDecodeRequest
|
||||
} from '../models/requests';
|
||||
import pool from '../../../core/config/database';
|
||||
|
||||
async function platformRoutes(fastify: FastifyInstance) {
|
||||
const controller = new PlatformController(pool);
|
||||
|
||||
fastify.get('/platform/years', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, controller.getYears.bind(controller));
|
||||
|
||||
fastify.get<{ Querystring: MakesQuery }>('/platform/makes', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, controller.getMakes.bind(controller));
|
||||
|
||||
fastify.get<{ Querystring: ModelsQuery }>('/platform/models', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, controller.getModels.bind(controller));
|
||||
|
||||
fastify.get<{ Querystring: TrimsQuery }>('/platform/trims', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, controller.getTrims.bind(controller));
|
||||
|
||||
fastify.get<{ Querystring: EnginesQuery }>('/platform/engines', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, controller.getEngines.bind(controller));
|
||||
|
||||
fastify.get<{ Querystring: VINDecodeRequest }>('/platform/vehicle', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, controller.decodeVIN.bind(controller));
|
||||
}
|
||||
|
||||
export default fastifyPlugin(platformRoutes);
|
||||
export { platformRoutes };
|
||||
165
backend/src/features/platform/data/vehicle-data.repository.ts
Normal file
165
backend/src/features/platform/data/vehicle-data.repository.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @ai-summary Vehicle data repository for hierarchical queries
|
||||
* @ai-context PostgreSQL queries against vehicles schema
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { MakeItem, ModelItem, TrimItem, EngineItem } from '../models/responses';
|
||||
import { VINDecodeResult } from '../models/responses';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class VehicleDataRepository {
|
||||
/**
|
||||
* Get distinct years from model_year table
|
||||
*/
|
||||
async getYears(pool: Pool): Promise<number[]> {
|
||||
const query = `
|
||||
SELECT DISTINCT year
|
||||
FROM vehicles.model_year
|
||||
ORDER BY year DESC
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(query);
|
||||
return result.rows.map(row => row.year);
|
||||
} catch (error) {
|
||||
logger.error('Repository error: getYears', { error });
|
||||
throw new Error('Failed to retrieve years from database');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get makes for a specific year
|
||||
*/
|
||||
async getMakes(pool: Pool, year: number): Promise<MakeItem[]> {
|
||||
const query = `
|
||||
SELECT DISTINCT ma.id, ma.name
|
||||
FROM vehicles.make ma
|
||||
JOIN vehicles.model mo ON mo.make_id = ma.id
|
||||
JOIN vehicles.model_year my ON my.model_id = mo.id AND my.year = $1
|
||||
ORDER BY ma.name
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(query, [year]);
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Repository error: getMakes', { error, year });
|
||||
throw new Error(`Failed to retrieve makes for year ${year}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for a specific year and make
|
||||
*/
|
||||
async getModels(pool: Pool, year: number, makeId: number): Promise<ModelItem[]> {
|
||||
const query = `
|
||||
SELECT DISTINCT mo.id, mo.name
|
||||
FROM vehicles.model mo
|
||||
JOIN vehicles.model_year my ON my.model_id = mo.id AND my.year = $1
|
||||
WHERE mo.make_id = $2
|
||||
ORDER BY mo.name
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(query, [year, makeId]);
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Repository error: getModels', { error, year, makeId });
|
||||
throw new Error(`Failed to retrieve models for year ${year}, make ${makeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trims for a specific year and model
|
||||
*/
|
||||
async getTrims(pool: Pool, year: number, modelId: number): Promise<TrimItem[]> {
|
||||
const query = `
|
||||
SELECT t.id, t.name
|
||||
FROM vehicles.trim t
|
||||
JOIN vehicles.model_year my ON my.id = t.model_year_id
|
||||
WHERE my.year = $1 AND my.model_id = $2
|
||||
ORDER BY t.name
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(query, [year, modelId]);
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Repository error: getTrims', { error, year, modelId });
|
||||
throw new Error(`Failed to retrieve trims for year ${year}, model ${modelId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engines for a specific year, model, and trim
|
||||
*/
|
||||
async getEngines(pool: Pool, year: number, modelId: number, trimId: number): Promise<EngineItem[]> {
|
||||
const query = `
|
||||
SELECT DISTINCT e.id, e.name
|
||||
FROM vehicles.engine e
|
||||
JOIN vehicles.trim_engine te ON te.engine_id = e.id
|
||||
JOIN vehicles.trim t ON t.id = te.trim_id
|
||||
JOIN vehicles.model_year my ON my.id = t.model_year_id
|
||||
WHERE my.year = $1
|
||||
AND my.model_id = $2
|
||||
AND t.id = $3
|
||||
ORDER BY e.name
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(query, [year, modelId, trimId]);
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Repository error: getEngines', { error, year, modelId, trimId });
|
||||
throw new Error(`Failed to retrieve engines for year ${year}, model ${modelId}, trim ${trimId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode VIN using PostgreSQL function
|
||||
*/
|
||||
async decodeVIN(pool: Pool, vin: string): Promise<VINDecodeResult | null> {
|
||||
const query = `
|
||||
SELECT * FROM vehicles.f_decode_vin($1)
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(query, [vin]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
make: row.make || null,
|
||||
model: row.model || null,
|
||||
year: row.year || null,
|
||||
trim_name: row.trim_name || null,
|
||||
engine_description: row.engine_description || null,
|
||||
transmission_description: row.transmission_description || null,
|
||||
horsepower: row.horsepower || null,
|
||||
torque: row.torque || null,
|
||||
top_speed: row.top_speed || null,
|
||||
fuel: row.fuel || null,
|
||||
confidence_score: row.confidence_score ? parseFloat(row.confidence_score) : null,
|
||||
vehicle_type: row.vehicle_type || null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Repository error: decodeVIN', { error, vin });
|
||||
throw new Error(`Failed to decode VIN ${vin}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
125
backend/src/features/platform/data/vpic-client.ts
Normal file
125
backend/src/features/platform/data/vpic-client.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @ai-summary NHTSA vPIC API client for VIN decoding fallback
|
||||
* @ai-context External API client with timeout and error handling
|
||||
*/
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { VPICResponse, VINDecodeResult } from '../models/responses';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class VPICClient {
|
||||
private client: AxiosInstance;
|
||||
private readonly baseURL = 'https://vpic.nhtsa.dot.gov/api';
|
||||
private readonly timeout = 5000; // 5 seconds
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: this.timeout,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'MotoVaultPro/1.0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode VIN using NHTSA vPIC API
|
||||
*/
|
||||
async decodeVIN(vin: string): Promise<VINDecodeResult | null> {
|
||||
try {
|
||||
const url = `/vehicles/DecodeVin/${vin}?format=json`;
|
||||
logger.debug('Calling vPIC API', { url, vin });
|
||||
|
||||
const response = await this.client.get<VPICResponse>(url);
|
||||
|
||||
if (!response.data || !response.data.Results) {
|
||||
logger.warn('vPIC API returned invalid response', { vin });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse vPIC response into our format
|
||||
const result = this.parseVPICResponse(response.data.Results);
|
||||
|
||||
if (!result.make || !result.model || !result.year) {
|
||||
logger.warn('vPIC API returned incomplete data', { vin, result });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('Successfully decoded VIN via vPIC', { vin, make: result.make, model: result.model, year: result.year });
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
logger.error('vPIC API timeout', { vin, timeout: this.timeout });
|
||||
} else if (error.response) {
|
||||
logger.error('vPIC API error response', {
|
||||
vin,
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText
|
||||
});
|
||||
} else if (error.request) {
|
||||
logger.error('vPIC API no response', { vin });
|
||||
} else {
|
||||
logger.error('vPIC API request error', { vin, error: error.message });
|
||||
}
|
||||
} else {
|
||||
logger.error('Unexpected error calling vPIC', { vin, error });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse vPIC API response variables into our format
|
||||
*/
|
||||
private parseVPICResponse(results: Array<{ Variable: string; Value: string | null }>): VINDecodeResult {
|
||||
const getValue = (variableName: string): string | null => {
|
||||
const variable = results.find(v => v.Variable === variableName);
|
||||
return variable?.Value || null;
|
||||
};
|
||||
|
||||
const getNumberValue = (variableName: string): number | null => {
|
||||
const value = getValue(variableName);
|
||||
if (!value) return null;
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
return {
|
||||
make: getValue('Make'),
|
||||
model: getValue('Model'),
|
||||
year: getNumberValue('Model Year'),
|
||||
trim_name: getValue('Trim'),
|
||||
engine_description: this.buildEngineDescription(results),
|
||||
transmission_description: getValue('Transmission Style'),
|
||||
horsepower: null, // vPIC doesn't provide horsepower
|
||||
torque: null, // vPIC doesn't provide torque
|
||||
top_speed: null, // vPIC doesn't provide top speed
|
||||
fuel: getValue('Fuel Type - Primary'),
|
||||
confidence_score: 0.5, // Lower confidence for vPIC fallback
|
||||
vehicle_type: getValue('Vehicle Type')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build engine description from multiple vPIC fields
|
||||
*/
|
||||
private buildEngineDescription(results: Array<{ Variable: string; Value: string | null }>): string | null {
|
||||
const getValue = (variableName: string): string | null => {
|
||||
const variable = results.find(v => v.Variable === variableName);
|
||||
return variable?.Value || null;
|
||||
};
|
||||
|
||||
const displacement = getValue('Displacement (L)');
|
||||
const cylinders = getValue('Engine Number of Cylinders');
|
||||
const configuration = getValue('Engine Configuration');
|
||||
|
||||
const parts: string[] = [];
|
||||
if (displacement) parts.push(`${displacement}L`);
|
||||
if (configuration) parts.push(configuration);
|
||||
if (cylinders) parts.push(`${cylinders} cyl`);
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : null;
|
||||
}
|
||||
}
|
||||
119
backend/src/features/platform/domain/platform-cache.service.ts
Normal file
119
backend/src/features/platform/domain/platform-cache.service.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @ai-summary Platform-specific Redis caching service
|
||||
* @ai-context Caching layer for vehicle data and VIN decoding
|
||||
*/
|
||||
import { CacheService } from '../../../core/config/redis';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class PlatformCacheService {
|
||||
private cacheService: CacheService;
|
||||
private readonly prefix = 'platform:';
|
||||
|
||||
constructor(cacheService: CacheService) {
|
||||
this.cacheService = cacheService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached years
|
||||
*/
|
||||
async getYears(): Promise<number[] | null> {
|
||||
const key = this.prefix + 'years';
|
||||
return await this.cacheService.get<number[]>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached years
|
||||
*/
|
||||
async setYears(years: number[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'years';
|
||||
await this.cacheService.set(key, years, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached makes for year
|
||||
*/
|
||||
async getMakes(year: number): Promise<any[] | null> {
|
||||
const key = this.prefix + 'vehicle-data:makes:' + year;
|
||||
return await this.cacheService.get<any[]>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached makes for year
|
||||
*/
|
||||
async setMakes(year: number, makes: any[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'vehicle-data:makes:' + year;
|
||||
await this.cacheService.set(key, makes, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached models for year and make
|
||||
*/
|
||||
async getModels(year: number, makeId: number): Promise<any[] | null> {
|
||||
const key = this.prefix + 'vehicle-data:models:' + year + ':' + makeId;
|
||||
return await this.cacheService.get<any[]>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached models for year and make
|
||||
*/
|
||||
async setModels(year: number, makeId: number, models: any[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'vehicle-data:models:' + year + ':' + makeId;
|
||||
await this.cacheService.set(key, models, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached trims for year and model
|
||||
*/
|
||||
async getTrims(year: number, modelId: number): Promise<any[] | null> {
|
||||
const key = this.prefix + 'vehicle-data:trims:' + year + ':' + modelId;
|
||||
return await this.cacheService.get<any[]>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached trims for year and model
|
||||
*/
|
||||
async setTrims(year: number, modelId: number, trims: any[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'vehicle-data:trims:' + year + ':' + modelId;
|
||||
await this.cacheService.set(key, trims, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached engines for year, model, and trim
|
||||
*/
|
||||
async getEngines(year: number, modelId: number, trimId: number): Promise<any[] | null> {
|
||||
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + modelId + ':' + trimId;
|
||||
return await this.cacheService.get<any[]>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached engines for year, model, and trim
|
||||
*/
|
||||
async setEngines(year: number, modelId: number, trimId: number, engines: any[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + modelId + ':' + trimId;
|
||||
await this.cacheService.set(key, engines, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached VIN decode result
|
||||
*/
|
||||
async getVINDecode(vin: string): Promise<any | null> {
|
||||
const key = this.prefix + 'vin-decode:' + vin.toUpperCase();
|
||||
return await this.cacheService.get<any>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached VIN decode result (7 days for successful decodes, 1 hour for failures)
|
||||
*/
|
||||
async setVINDecode(vin: string, result: any, success: boolean = true): Promise<void> {
|
||||
const key = this.prefix + 'vin-decode:' + vin.toUpperCase();
|
||||
const ttl = success ? 7 * 24 * 3600 : 3600;
|
||||
await this.cacheService.set(key, result, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all vehicle data cache (for admin operations)
|
||||
*/
|
||||
async invalidateVehicleData(): Promise<void> {
|
||||
logger.warn('Vehicle data cache invalidation not implemented (requires pattern deletion)');
|
||||
}
|
||||
}
|
||||
124
backend/src/features/platform/domain/vehicle-data.service.ts
Normal file
124
backend/src/features/platform/domain/vehicle-data.service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @ai-summary Vehicle data service with caching
|
||||
* @ai-context Business logic for hierarchical vehicle data queries
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { VehicleDataRepository } from '../data/vehicle-data.repository';
|
||||
import { PlatformCacheService } from './platform-cache.service';
|
||||
import { MakeItem, ModelItem, TrimItem, EngineItem } from '../models/responses';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class VehicleDataService {
|
||||
private repository: VehicleDataRepository;
|
||||
private cache: PlatformCacheService;
|
||||
|
||||
constructor(cache: PlatformCacheService, repository?: VehicleDataRepository) {
|
||||
this.cache = cache;
|
||||
this.repository = repository || new VehicleDataRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available years with caching
|
||||
*/
|
||||
async getYears(pool: Pool): Promise<number[]> {
|
||||
try {
|
||||
const cached = await this.cache.getYears();
|
||||
if (cached) {
|
||||
logger.debug('Years retrieved from cache');
|
||||
return cached;
|
||||
}
|
||||
|
||||
const years = await this.repository.getYears(pool);
|
||||
await this.cache.setYears(years);
|
||||
logger.debug('Years retrieved from database and cached', { count: years.length });
|
||||
return years;
|
||||
} catch (error) {
|
||||
logger.error('Service error: getYears', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get makes for a year with caching
|
||||
*/
|
||||
async getMakes(pool: Pool, year: number): Promise<MakeItem[]> {
|
||||
try {
|
||||
const cached = await this.cache.getMakes(year);
|
||||
if (cached) {
|
||||
logger.debug('Makes retrieved from cache', { year });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const makes = await this.repository.getMakes(pool, year);
|
||||
await this.cache.setMakes(year, makes);
|
||||
logger.debug('Makes retrieved from database and cached', { year, count: makes.length });
|
||||
return makes;
|
||||
} catch (error) {
|
||||
logger.error('Service error: getMakes', { error, year });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for a year and make with caching
|
||||
*/
|
||||
async getModels(pool: Pool, year: number, makeId: number): Promise<ModelItem[]> {
|
||||
try {
|
||||
const cached = await this.cache.getModels(year, makeId);
|
||||
if (cached) {
|
||||
logger.debug('Models retrieved from cache', { year, makeId });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const models = await this.repository.getModels(pool, year, makeId);
|
||||
await this.cache.setModels(year, makeId, models);
|
||||
logger.debug('Models retrieved from database and cached', { year, makeId, count: models.length });
|
||||
return models;
|
||||
} catch (error) {
|
||||
logger.error('Service error: getModels', { error, year, makeId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trims for a year and model with caching
|
||||
*/
|
||||
async getTrims(pool: Pool, year: number, modelId: number): Promise<TrimItem[]> {
|
||||
try {
|
||||
const cached = await this.cache.getTrims(year, modelId);
|
||||
if (cached) {
|
||||
logger.debug('Trims retrieved from cache', { year, modelId });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const trims = await this.repository.getTrims(pool, year, modelId);
|
||||
await this.cache.setTrims(year, modelId, trims);
|
||||
logger.debug('Trims retrieved from database and cached', { year, modelId, count: trims.length });
|
||||
return trims;
|
||||
} catch (error) {
|
||||
logger.error('Service error: getTrims', { error, year, modelId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engines for a year, model, and trim with caching
|
||||
*/
|
||||
async getEngines(pool: Pool, year: number, modelId: number, trimId: number): Promise<EngineItem[]> {
|
||||
try {
|
||||
const cached = await this.cache.getEngines(year, modelId, trimId);
|
||||
if (cached) {
|
||||
logger.debug('Engines retrieved from cache', { year, modelId, trimId });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const engines = await this.repository.getEngines(pool, year, modelId, trimId);
|
||||
await this.cache.setEngines(year, modelId, trimId, engines);
|
||||
logger.debug('Engines retrieved from database and cached', { year, modelId, trimId, count: engines.length });
|
||||
return engines;
|
||||
} catch (error) {
|
||||
logger.error('Service error: getEngines', { error, year, modelId, trimId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
156
backend/src/features/platform/domain/vin-decode.service.ts
Normal file
156
backend/src/features/platform/domain/vin-decode.service.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @ai-summary VIN decoding service with circuit breaker and fallback
|
||||
* @ai-context PostgreSQL first, vPIC API fallback, Redis caching
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import CircuitBreaker from 'opossum';
|
||||
import { VehicleDataRepository } from '../data/vehicle-data.repository';
|
||||
import { VPICClient } from '../data/vpic-client';
|
||||
import { PlatformCacheService } from './platform-cache.service';
|
||||
import { VINDecodeResponse, VINDecodeResult } from '../models/responses';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class VINDecodeService {
|
||||
private repository: VehicleDataRepository;
|
||||
private vpicClient: VPICClient;
|
||||
private cache: PlatformCacheService;
|
||||
private circuitBreaker: CircuitBreaker;
|
||||
|
||||
constructor(cache: PlatformCacheService) {
|
||||
this.cache = cache;
|
||||
this.repository = new VehicleDataRepository();
|
||||
this.vpicClient = new VPICClient();
|
||||
|
||||
this.circuitBreaker = new CircuitBreaker(
|
||||
async (vin: string) => this.vpicClient.decodeVIN(vin),
|
||||
{
|
||||
timeout: 6000,
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeout: 30000,
|
||||
name: 'vpic-api'
|
||||
}
|
||||
);
|
||||
|
||||
this.circuitBreaker.on('open', () => {
|
||||
logger.warn('Circuit breaker opened for vPIC API');
|
||||
});
|
||||
|
||||
this.circuitBreaker.on('halfOpen', () => {
|
||||
logger.info('Circuit breaker half-open for vPIC API');
|
||||
});
|
||||
|
||||
this.circuitBreaker.on('close', () => {
|
||||
logger.info('Circuit breaker closed for vPIC API');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate VIN format
|
||||
*/
|
||||
validateVIN(vin: string): { valid: boolean; error?: string } {
|
||||
if (vin.length !== 17) {
|
||||
return { valid: false, error: 'VIN must be exactly 17 characters' };
|
||||
}
|
||||
|
||||
const invalidChars = /[IOQ]/i;
|
||||
if (invalidChars.test(vin)) {
|
||||
return { valid: false, error: 'VIN contains invalid characters (cannot contain I, O, Q)' };
|
||||
}
|
||||
|
||||
const validFormat = /^[A-HJ-NPR-Z0-9]{17}$/i;
|
||||
if (!validFormat.test(vin)) {
|
||||
return { valid: false, error: 'VIN contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode VIN with multi-tier strategy:
|
||||
* 1. Check cache
|
||||
* 2. Try PostgreSQL function
|
||||
* 3. Fallback to vPIC API (with circuit breaker)
|
||||
*/
|
||||
async decodeVIN(pool: Pool, vin: string): Promise<VINDecodeResponse> {
|
||||
const normalizedVIN = vin.toUpperCase().trim();
|
||||
|
||||
const validation = this.validateVIN(normalizedVIN);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
vin: normalizedVIN,
|
||||
result: null,
|
||||
success: false,
|
||||
error: validation.error
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await this.cache.getVINDecode(normalizedVIN);
|
||||
if (cached) {
|
||||
logger.debug('VIN decode result retrieved from cache', { vin: normalizedVIN });
|
||||
return cached;
|
||||
}
|
||||
|
||||
let result = await this.repository.decodeVIN(pool, normalizedVIN);
|
||||
|
||||
if (result) {
|
||||
const response: VINDecodeResponse = {
|
||||
vin: normalizedVIN,
|
||||
result,
|
||||
success: true
|
||||
};
|
||||
await this.cache.setVINDecode(normalizedVIN, response, true);
|
||||
logger.info('VIN decoded successfully via PostgreSQL', { vin: normalizedVIN, make: result.make, model: result.model, year: result.year });
|
||||
return response;
|
||||
}
|
||||
|
||||
logger.info('VIN not found in PostgreSQL, attempting vPIC fallback', { vin: normalizedVIN });
|
||||
|
||||
try {
|
||||
result = await this.circuitBreaker.fire(normalizedVIN) as VINDecodeResult | null;
|
||||
|
||||
if (result) {
|
||||
const response: VINDecodeResponse = {
|
||||
vin: normalizedVIN,
|
||||
result,
|
||||
success: true
|
||||
};
|
||||
await this.cache.setVINDecode(normalizedVIN, response, true);
|
||||
logger.info('VIN decoded successfully via vPIC fallback', { vin: normalizedVIN, make: result.make, model: result.model, year: result.year });
|
||||
return response;
|
||||
}
|
||||
} catch (circuitError) {
|
||||
logger.warn('vPIC API unavailable or circuit breaker open', { vin: normalizedVIN, error: circuitError });
|
||||
}
|
||||
|
||||
const failureResponse: VINDecodeResponse = {
|
||||
vin: normalizedVIN,
|
||||
result: null,
|
||||
success: false,
|
||||
error: 'VIN not found in database and external API unavailable'
|
||||
};
|
||||
|
||||
await this.cache.setVINDecode(normalizedVIN, failureResponse, false);
|
||||
return failureResponse;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('VIN decode error', { vin: normalizedVIN, error });
|
||||
return {
|
||||
vin: normalizedVIN,
|
||||
result: null,
|
||||
success: false,
|
||||
error: 'Internal server error during VIN decoding'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker status
|
||||
*/
|
||||
getCircuitBreakerStatus(): { state: string; stats: any } {
|
||||
return {
|
||||
state: this.circuitBreaker.opened ? 'open' : this.circuitBreaker.halfOpen ? 'half-open' : 'closed',
|
||||
stats: this.circuitBreaker.stats
|
||||
};
|
||||
}
|
||||
}
|
||||
33
backend/src/features/platform/index.ts
Normal file
33
backend/src/features/platform/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @ai-summary Platform feature public API
|
||||
* @ai-context Exports for feature registration
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import pool from '../../core/config/database';
|
||||
import { cacheService } from '../../core/config/redis';
|
||||
import { VINDecodeService } from './domain/vin-decode.service';
|
||||
import { PlatformCacheService } from './domain/platform-cache.service';
|
||||
|
||||
export { platformRoutes } from './api/platform.routes';
|
||||
export { PlatformController } from './api/platform.controller';
|
||||
export { VehicleDataService } from './domain/vehicle-data.service';
|
||||
export { VINDecodeService } from './domain/vin-decode.service';
|
||||
export { PlatformCacheService } from './domain/platform-cache.service';
|
||||
export * from './models/requests';
|
||||
export * from './models/responses';
|
||||
|
||||
// Singleton VIN decode service for use by other features
|
||||
let vinDecodeServiceInstance: VINDecodeService | null = null;
|
||||
|
||||
export function getVINDecodeService(): VINDecodeService {
|
||||
if (!vinDecodeServiceInstance) {
|
||||
const platformCache = new PlatformCacheService(cacheService);
|
||||
vinDecodeServiceInstance = new VINDecodeService(platformCache);
|
||||
}
|
||||
return vinDecodeServiceInstance;
|
||||
}
|
||||
|
||||
// Helper to get pool for VIN decode service
|
||||
export function getPool(): Pool {
|
||||
return pool;
|
||||
}
|
||||
84
backend/src/features/platform/models/requests.ts
Normal file
84
backend/src/features/platform/models/requests.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @ai-summary Request DTOs for platform feature
|
||||
* @ai-context Validation and type definitions for API requests
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* VIN validation schema
|
||||
*/
|
||||
export const vinDecodeRequestSchema = z.object({
|
||||
vin: z.string()
|
||||
.length(17, 'VIN must be exactly 17 characters')
|
||||
.regex(/^[A-HJ-NPR-Z0-9]{17}$/, 'VIN contains invalid characters (cannot contain I, O, Q)')
|
||||
.transform(vin => vin.toUpperCase())
|
||||
});
|
||||
|
||||
export type VINDecodeRequest = z.infer<typeof vinDecodeRequestSchema>;
|
||||
|
||||
/**
|
||||
* Year query parameter validation
|
||||
*/
|
||||
export const yearQuerySchema = z.object({
|
||||
year: z.coerce.number()
|
||||
.int('Year must be an integer')
|
||||
.min(1950, 'Year must be at least 1950')
|
||||
.max(2100, 'Year must be at most 2100')
|
||||
});
|
||||
|
||||
export type YearQuery = z.infer<typeof yearQuerySchema>;
|
||||
|
||||
/**
|
||||
* Makes query parameters validation
|
||||
*/
|
||||
export const makesQuerySchema = yearQuerySchema;
|
||||
|
||||
export type MakesQuery = z.infer<typeof makesQuerySchema>;
|
||||
|
||||
/**
|
||||
* Models query parameters validation
|
||||
*/
|
||||
export const modelsQuerySchema = z.object({
|
||||
year: z.coerce.number()
|
||||
.int('Year must be an integer')
|
||||
.min(1950, 'Year must be at least 1950')
|
||||
.max(2100, 'Year must be at most 2100'),
|
||||
make_id: z.coerce.number()
|
||||
.int('Make ID must be an integer')
|
||||
.positive('Make ID must be positive')
|
||||
});
|
||||
|
||||
export type ModelsQuery = z.infer<typeof modelsQuerySchema>;
|
||||
|
||||
/**
|
||||
* Trims query parameters validation
|
||||
*/
|
||||
export const trimsQuerySchema = z.object({
|
||||
year: z.coerce.number()
|
||||
.int('Year must be an integer')
|
||||
.min(1950, 'Year must be at least 1950')
|
||||
.max(2100, 'Year must be at most 2100'),
|
||||
model_id: z.coerce.number()
|
||||
.int('Model ID must be an integer')
|
||||
.positive('Model ID must be positive')
|
||||
});
|
||||
|
||||
export type TrimsQuery = z.infer<typeof trimsQuerySchema>;
|
||||
|
||||
/**
|
||||
* Engines query parameters validation
|
||||
*/
|
||||
export const enginesQuerySchema = z.object({
|
||||
year: z.coerce.number()
|
||||
.int('Year must be an integer')
|
||||
.min(1950, 'Year must be at least 1950')
|
||||
.max(2100, 'Year must be at most 2100'),
|
||||
model_id: z.coerce.number()
|
||||
.int('Model ID must be an integer')
|
||||
.positive('Model ID must be positive'),
|
||||
trim_id: z.coerce.number()
|
||||
.int('Trim ID must be an integer')
|
||||
.positive('Trim ID must be positive')
|
||||
});
|
||||
|
||||
export type EnginesQuery = z.infer<typeof enginesQuerySchema>;
|
||||
114
backend/src/features/platform/models/responses.ts
Normal file
114
backend/src/features/platform/models/responses.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @ai-summary Response DTOs for platform feature
|
||||
* @ai-context Type-safe response structures matching Python API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make item response
|
||||
*/
|
||||
export interface MakeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model item response
|
||||
*/
|
||||
export interface ModelItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim item response
|
||||
*/
|
||||
export interface TrimItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine item response
|
||||
*/
|
||||
export interface EngineItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Years response
|
||||
*/
|
||||
export type YearsResponse = number[];
|
||||
|
||||
/**
|
||||
* Makes response
|
||||
*/
|
||||
export interface MakesResponse {
|
||||
makes: MakeItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Models response
|
||||
*/
|
||||
export interface ModelsResponse {
|
||||
models: ModelItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims response
|
||||
*/
|
||||
export interface TrimsResponse {
|
||||
trims: TrimItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Engines response
|
||||
*/
|
||||
export interface EnginesResponse {
|
||||
engines: EngineItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN decode result (detailed vehicle information)
|
||||
*/
|
||||
export interface VINDecodeResult {
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
trim_name: string | null;
|
||||
engine_description: string | null;
|
||||
transmission_description: string | null;
|
||||
horsepower: number | null;
|
||||
torque: number | null;
|
||||
top_speed: number | null;
|
||||
fuel: string | null;
|
||||
confidence_score: number | null;
|
||||
vehicle_type: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN decode response (wrapper with success status)
|
||||
*/
|
||||
export interface VINDecodeResponse {
|
||||
vin: string;
|
||||
result: VINDecodeResult | null;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* vPIC API response structure (NHTSA)
|
||||
*/
|
||||
export interface VPICVariable {
|
||||
Variable: string;
|
||||
Value: string | null;
|
||||
ValueId: string | null;
|
||||
VariableId: number;
|
||||
}
|
||||
|
||||
export interface VPICResponse {
|
||||
Count: number;
|
||||
Message: string;
|
||||
SearchCriteria: string;
|
||||
Results: VPICVariable[];
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @ai-summary Integration tests for platform feature
|
||||
* @ai-context End-to-end API tests with authentication
|
||||
*/
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { buildApp } from '../../../../app';
|
||||
|
||||
describe('Platform Integration Tests', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
authToken = 'Bearer mock-jwt-token';
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('GET /api/platform/years', () => {
|
||||
it('should return 401 without authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/years'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should return list of years with authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/years',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const years = JSON.parse(response.payload);
|
||||
expect(Array.isArray(years)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/platform/makes', () => {
|
||||
it('should return 401 without authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/makes?year=2024'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid year', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/makes?year=invalid',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should return makes for valid year with authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/makes?year=2024',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = JSON.parse(response.payload);
|
||||
expect(data).toHaveProperty('makes');
|
||||
expect(Array.isArray(data.makes)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/platform/vehicle', () => {
|
||||
it('should return 401 without authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/vehicle?vin=1HGCM82633A123456'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid VIN format', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/vehicle?vin=INVALID',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const data = JSON.parse(response.payload);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('17 characters');
|
||||
});
|
||||
|
||||
it('should return 400 for VIN with invalid characters', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/vehicle?vin=1HGCM82633A12345I',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const data = JSON.parse(response.payload);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('invalid characters');
|
||||
});
|
||||
|
||||
it('should decode valid VIN with authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/vehicle?vin=1HGCM82633A123456',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = JSON.parse(response.payload);
|
||||
expect(data).toHaveProperty('vin');
|
||||
expect(data).toHaveProperty('success');
|
||||
expect(data).toHaveProperty('result');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/platform/models', () => {
|
||||
it('should return 400 for missing make_id', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/models?year=2024',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should return models for valid year and make_id', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/models?year=2024&make_id=1',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = JSON.parse(response.payload);
|
||||
expect(data).toHaveProperty('models');
|
||||
expect(Array.isArray(data.models)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/platform/trims', () => {
|
||||
it('should return 400 for missing model_id', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/trims?year=2024',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should return trims for valid year and model_id', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/trims?year=2024&model_id=101',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = JSON.parse(response.payload);
|
||||
expect(data).toHaveProperty('trims');
|
||||
expect(Array.isArray(data.trims)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/platform/engines', () => {
|
||||
it('should return 400 for missing trim_id', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/engines?year=2024&model_id=101',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should return engines for valid year, model_id, and trim_id', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/platform/engines?year=2024&model_id=101&trim_id=1001',
|
||||
headers: {
|
||||
authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = JSON.parse(response.payload);
|
||||
expect(data).toHaveProperty('engines');
|
||||
expect(Array.isArray(data.engines)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for vehicle data service
|
||||
* @ai-context Tests caching behavior for hierarchical vehicle data
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { VehicleDataService } from '../../domain/vehicle-data.service';
|
||||
import { PlatformCacheService } from '../../domain/platform-cache.service';
|
||||
import { VehicleDataRepository } from '../../data/vehicle-data.repository';
|
||||
|
||||
jest.mock('../../data/vehicle-data.repository');
|
||||
jest.mock('../../domain/platform-cache.service');
|
||||
|
||||
describe('VehicleDataService', () => {
|
||||
let service: VehicleDataService;
|
||||
let mockCache: jest.Mocked<PlatformCacheService>;
|
||||
let mockRepository: jest.Mocked<VehicleDataRepository>;
|
||||
let mockPool: jest.Mocked<Pool>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCache = {
|
||||
getYears: jest.fn(),
|
||||
setYears: jest.fn(),
|
||||
getMakes: jest.fn(),
|
||||
setMakes: jest.fn(),
|
||||
getModels: jest.fn(),
|
||||
setModels: jest.fn(),
|
||||
getTrims: jest.fn(),
|
||||
setTrims: jest.fn(),
|
||||
getEngines: jest.fn(),
|
||||
setEngines: jest.fn()
|
||||
} as any;
|
||||
|
||||
mockRepository = {
|
||||
getYears: jest.fn(),
|
||||
getMakes: jest.fn(),
|
||||
getModels: jest.fn(),
|
||||
getTrims: jest.fn(),
|
||||
getEngines: jest.fn()
|
||||
} as any;
|
||||
|
||||
mockPool = {} as any;
|
||||
service = new VehicleDataService(mockCache, mockRepository);
|
||||
});
|
||||
|
||||
describe('getYears', () => {
|
||||
it('should return cached years if available', async () => {
|
||||
const mockYears = [2024, 2023, 2022];
|
||||
mockCache.getYears.mockResolvedValue(mockYears);
|
||||
|
||||
const result = await service.getYears(mockPool);
|
||||
|
||||
expect(result).toEqual(mockYears);
|
||||
expect(mockCache.getYears).toHaveBeenCalled();
|
||||
expect(mockRepository.getYears).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch from repository and cache when cache misses', async () => {
|
||||
const mockYears = [2024, 2023, 2022];
|
||||
mockCache.getYears.mockResolvedValue(null);
|
||||
mockRepository.getYears.mockResolvedValue(mockYears);
|
||||
|
||||
const result = await service.getYears(mockPool);
|
||||
|
||||
expect(result).toEqual(mockYears);
|
||||
expect(mockRepository.getYears).toHaveBeenCalledWith(mockPool);
|
||||
expect(mockCache.setYears).toHaveBeenCalledWith(mockYears);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMakes', () => {
|
||||
const year = 2024;
|
||||
const mockMakes = [
|
||||
{ id: 1, name: 'Honda' },
|
||||
{ id: 2, name: 'Toyota' }
|
||||
];
|
||||
|
||||
it('should return cached makes if available', async () => {
|
||||
mockCache.getMakes.mockResolvedValue(mockMakes);
|
||||
|
||||
const result = await service.getMakes(mockPool, year);
|
||||
|
||||
expect(result).toEqual(mockMakes);
|
||||
expect(mockCache.getMakes).toHaveBeenCalledWith(year);
|
||||
expect(mockRepository.getMakes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch from repository and cache when cache misses', async () => {
|
||||
mockCache.getMakes.mockResolvedValue(null);
|
||||
mockRepository.getMakes.mockResolvedValue(mockMakes);
|
||||
|
||||
const result = await service.getMakes(mockPool, year);
|
||||
|
||||
expect(result).toEqual(mockMakes);
|
||||
expect(mockRepository.getMakes).toHaveBeenCalledWith(mockPool, year);
|
||||
expect(mockCache.setMakes).toHaveBeenCalledWith(year, mockMakes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModels', () => {
|
||||
const year = 2024;
|
||||
const makeId = 1;
|
||||
const mockModels = [
|
||||
{ id: 101, name: 'Civic' },
|
||||
{ id: 102, name: 'Accord' }
|
||||
];
|
||||
|
||||
it('should return cached models if available', async () => {
|
||||
mockCache.getModels.mockResolvedValue(mockModels);
|
||||
|
||||
const result = await service.getModels(mockPool, year, makeId);
|
||||
|
||||
expect(result).toEqual(mockModels);
|
||||
expect(mockCache.getModels).toHaveBeenCalledWith(year, makeId);
|
||||
expect(mockRepository.getModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch from repository and cache when cache misses', async () => {
|
||||
mockCache.getModels.mockResolvedValue(null);
|
||||
mockRepository.getModels.mockResolvedValue(mockModels);
|
||||
|
||||
const result = await service.getModels(mockPool, year, makeId);
|
||||
|
||||
expect(result).toEqual(mockModels);
|
||||
expect(mockRepository.getModels).toHaveBeenCalledWith(mockPool, year, makeId);
|
||||
expect(mockCache.setModels).toHaveBeenCalledWith(year, makeId, mockModels);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTrims', () => {
|
||||
const year = 2024;
|
||||
const modelId = 101;
|
||||
const mockTrims = [
|
||||
{ id: 1001, name: 'LX' },
|
||||
{ id: 1002, name: 'EX' }
|
||||
];
|
||||
|
||||
it('should return cached trims if available', async () => {
|
||||
mockCache.getTrims.mockResolvedValue(mockTrims);
|
||||
|
||||
const result = await service.getTrims(mockPool, year, modelId);
|
||||
|
||||
expect(result).toEqual(mockTrims);
|
||||
expect(mockCache.getTrims).toHaveBeenCalledWith(year, modelId);
|
||||
expect(mockRepository.getTrims).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch from repository and cache when cache misses', async () => {
|
||||
mockCache.getTrims.mockResolvedValue(null);
|
||||
mockRepository.getTrims.mockResolvedValue(mockTrims);
|
||||
|
||||
const result = await service.getTrims(mockPool, year, modelId);
|
||||
|
||||
expect(result).toEqual(mockTrims);
|
||||
expect(mockRepository.getTrims).toHaveBeenCalledWith(mockPool, year, modelId);
|
||||
expect(mockCache.setTrims).toHaveBeenCalledWith(year, modelId, mockTrims);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEngines', () => {
|
||||
const year = 2024;
|
||||
const modelId = 101;
|
||||
const trimId = 1001;
|
||||
const mockEngines = [
|
||||
{ id: 10001, name: '2.0L I4' },
|
||||
{ id: 10002, name: '1.5L Turbo I4' }
|
||||
];
|
||||
|
||||
it('should return cached engines if available', async () => {
|
||||
mockCache.getEngines.mockResolvedValue(mockEngines);
|
||||
|
||||
const result = await service.getEngines(mockPool, year, modelId, trimId);
|
||||
|
||||
expect(result).toEqual(mockEngines);
|
||||
expect(mockCache.getEngines).toHaveBeenCalledWith(year, modelId, trimId);
|
||||
expect(mockRepository.getEngines).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch from repository and cache when cache misses', async () => {
|
||||
mockCache.getEngines.mockResolvedValue(null);
|
||||
mockRepository.getEngines.mockResolvedValue(mockEngines);
|
||||
|
||||
const result = await service.getEngines(mockPool, year, modelId, trimId);
|
||||
|
||||
expect(result).toEqual(mockEngines);
|
||||
expect(mockRepository.getEngines).toHaveBeenCalledWith(mockPool, year, modelId, trimId);
|
||||
expect(mockCache.setEngines).toHaveBeenCalledWith(year, modelId, trimId, mockEngines);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for VIN decode service
|
||||
* @ai-context Tests VIN validation, PostgreSQL decode, vPIC fallback, circuit breaker
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { VINDecodeService } from '../../domain/vin-decode.service';
|
||||
import { PlatformCacheService } from '../../domain/platform-cache.service';
|
||||
import { VehicleDataRepository } from '../../data/vehicle-data.repository';
|
||||
import { VPICClient } from '../../data/vpic-client';
|
||||
|
||||
jest.mock('../../data/vehicle-data.repository');
|
||||
jest.mock('../../data/vpic-client');
|
||||
jest.mock('../../domain/platform-cache.service');
|
||||
|
||||
describe('VINDecodeService', () => {
|
||||
let service: VINDecodeService;
|
||||
let mockCache: jest.Mocked<PlatformCacheService>;
|
||||
let mockPool: jest.Mocked<Pool>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCache = {
|
||||
getVINDecode: jest.fn(),
|
||||
setVINDecode: jest.fn()
|
||||
} as any;
|
||||
|
||||
mockPool = {} as any;
|
||||
service = new VINDecodeService(mockCache);
|
||||
});
|
||||
|
||||
describe('validateVIN', () => {
|
||||
it('should validate correct VIN', () => {
|
||||
const result = service.validateVIN('1HGCM82633A123456');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject VIN with incorrect length', () => {
|
||||
const result = service.validateVIN('SHORT');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('17 characters');
|
||||
});
|
||||
|
||||
it('should reject VIN with invalid characters I, O, Q', () => {
|
||||
const resultI = service.validateVIN('1HGCM82633A12345I');
|
||||
const resultO = service.validateVIN('1HGCM82633A12345O');
|
||||
const resultQ = service.validateVIN('1HGCM82633A12345Q');
|
||||
|
||||
expect(resultI.valid).toBe(false);
|
||||
expect(resultO.valid).toBe(false);
|
||||
expect(resultQ.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject VIN with non-alphanumeric characters', () => {
|
||||
const result = service.validateVIN('1HGCM82633A12345@');
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeVIN', () => {
|
||||
const validVIN = '1HGCM82633A123456';
|
||||
const mockResult = {
|
||||
make: 'Honda',
|
||||
model: 'Accord',
|
||||
year: 2003,
|
||||
trim_name: 'LX',
|
||||
engine_description: '2.4L I4',
|
||||
transmission_description: '5-Speed Automatic',
|
||||
horsepower: 160,
|
||||
torque: 161,
|
||||
top_speed: null,
|
||||
fuel: 'Gasoline',
|
||||
confidence_score: 0.95,
|
||||
vehicle_type: 'Passenger Car'
|
||||
};
|
||||
|
||||
it('should return cached result if available', async () => {
|
||||
const cachedResponse = {
|
||||
vin: validVIN,
|
||||
result: mockResult,
|
||||
success: true
|
||||
};
|
||||
|
||||
mockCache.getVINDecode.mockResolvedValue(cachedResponse);
|
||||
|
||||
const result = await service.decodeVIN(mockPool, validVIN);
|
||||
|
||||
expect(result).toEqual(cachedResponse);
|
||||
expect(mockCache.getVINDecode).toHaveBeenCalledWith(validVIN);
|
||||
});
|
||||
|
||||
it('should return error for invalid VIN format', async () => {
|
||||
const invalidVIN = 'INVALID';
|
||||
mockCache.getVINDecode.mockResolvedValue(null);
|
||||
|
||||
const result = await service.decodeVIN(mockPool, invalidVIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('17 characters');
|
||||
});
|
||||
|
||||
it('should uppercase and trim VIN', async () => {
|
||||
const lowerVIN = ' 1hgcm82633a123456 ';
|
||||
mockCache.getVINDecode.mockResolvedValue(null);
|
||||
|
||||
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
await service.decodeVIN(mockPool, lowerVIN);
|
||||
|
||||
expect(mockCache.getVINDecode).toHaveBeenCalledWith('1HGCM82633A123456');
|
||||
});
|
||||
|
||||
it('should decode VIN from PostgreSQL and cache result', async () => {
|
||||
mockCache.getVINDecode.mockResolvedValue(null);
|
||||
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.decodeVIN(mockPool, validVIN);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toEqual(mockResult);
|
||||
expect(mockCache.setVINDecode).toHaveBeenCalledWith(
|
||||
validVIN,
|
||||
expect.objectContaining({ vin: validVIN, success: true }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to vPIC when PostgreSQL returns null', async () => {
|
||||
mockCache.getVINDecode.mockResolvedValue(null);
|
||||
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(null);
|
||||
(service as any).vpicClient.decodeVIN = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.decodeVIN(mockPool, validVIN);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should return failure when both PostgreSQL and vPIC fail', async () => {
|
||||
mockCache.getVINDecode.mockResolvedValue(null);
|
||||
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(null);
|
||||
(service as any).vpicClient.decodeVIN = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const result = await service.decodeVIN(mockPool, validVIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('VIN not found');
|
||||
});
|
||||
|
||||
it('should cache failed decode with shorter TTL', async () => {
|
||||
mockCache.getVINDecode.mockResolvedValue(null);
|
||||
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(null);
|
||||
(service as any).vpicClient.decodeVIN = jest.fn().mockResolvedValue(null);
|
||||
|
||||
await service.decodeVIN(mockPool, validVIN);
|
||||
|
||||
expect(mockCache.setVINDecode).toHaveBeenCalledWith(
|
||||
validVIN,
|
||||
expect.objectContaining({ success: false }),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCircuitBreakerStatus', () => {
|
||||
it('should return circuit breaker status', () => {
|
||||
const status = service.getCircuitBreakerStatus();
|
||||
|
||||
expect(status).toHaveProperty('state');
|
||||
expect(status).toHaveProperty('stats');
|
||||
expect(['open', 'half-open', 'closed']).toContain(status.state);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -161,38 +161,6 @@ export class VehiclesRepository {
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
// Cache VIN decode results
|
||||
async cacheVINDecode(vin: string, data: any): Promise<void> {
|
||||
const query = `
|
||||
INSERT INTO vin_cache (vin, make, model, year, engine_type, body_type, raw_data)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (vin) DO UPDATE
|
||||
SET make = $2, model = $3, year = $4,
|
||||
engine_type = $5, body_type = $6, raw_data = $7,
|
||||
cached_at = NOW()
|
||||
`;
|
||||
|
||||
await this.pool.query(query, [
|
||||
vin,
|
||||
data.make,
|
||||
data.model,
|
||||
data.year,
|
||||
data.engineType,
|
||||
data.bodyType,
|
||||
JSON.stringify(data.rawData)
|
||||
]);
|
||||
}
|
||||
|
||||
async getVINFromCache(vin: string): Promise<any | null> {
|
||||
const query = 'SELECT * FROM vin_cache WHERE vin = $1';
|
||||
const result = await this.pool.query(query, [vin]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
private mapRow(row: any): Vehicle {
|
||||
return {
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { Logger } from 'winston';
|
||||
import { PlatformVehiclesClient } from '../external/platform-vehicles/platform-vehicles.client';
|
||||
import { VPICClient } from '../external/vpic/vpic.client';
|
||||
import { appConfig } from '../../../core/config/config-loader';
|
||||
|
||||
|
||||
/**
|
||||
* Integration service that manages switching between external vPIC API
|
||||
* and MVP Platform Vehicles Service with feature flags and fallbacks
|
||||
*/
|
||||
export class PlatformIntegrationService {
|
||||
private readonly platformClient: PlatformVehiclesClient;
|
||||
private readonly vpicClient: VPICClient;
|
||||
private readonly usePlatformService: boolean;
|
||||
|
||||
constructor(
|
||||
platformClient: PlatformVehiclesClient,
|
||||
vpicClient: VPICClient,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.platformClient = platformClient;
|
||||
this.vpicClient = vpicClient;
|
||||
|
||||
// Feature flag - can be environment variable or runtime config
|
||||
this.usePlatformService = appConfig.config.server.environment !== 'test'; // Use platform service except in tests
|
||||
|
||||
this.logger.info(`Vehicle service integration initialized: usePlatformService=${this.usePlatformService}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get makes with platform service or fallback to vPIC
|
||||
*/
|
||||
async getMakes(year: number): Promise<Array<{ id: number; name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const makes = await this.platformClient.getMakes(year);
|
||||
this.logger.debug(`Platform service returned ${makes.length} makes for year ${year}`);
|
||||
return makes;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for makes, falling back to vPIC: ${error}`);
|
||||
return this.getFallbackMakes(year);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFallbackMakes(year);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models with platform service or fallback to vPIC
|
||||
*/
|
||||
async getModels(year: number, makeId: number): Promise<Array<{ id: number; name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const models = await this.platformClient.getModels(year, makeId);
|
||||
this.logger.debug(`Platform service returned ${models.length} models for year ${year}, make ${makeId}`);
|
||||
return models;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for models, falling back to vPIC: ${error}`);
|
||||
return this.getFallbackModels(year, makeId);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFallbackModels(year, makeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trims - platform service only (not available in external vPIC)
|
||||
*/
|
||||
async getTrims(year: number, makeId: number, modelId: number): Promise<Array<{ name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const trims = await this.platformClient.getTrims(year, makeId, modelId);
|
||||
this.logger.debug(`Platform service returned ${trims.length} trims`);
|
||||
return trims;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for trims: ${error}`);
|
||||
return []; // No fallback available for trims
|
||||
}
|
||||
}
|
||||
|
||||
return []; // Trims not available without platform service
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engines - platform service only (not available in external vPIC)
|
||||
*/
|
||||
async getEngines(year: number, makeId: number, modelId: number, trimId: number): Promise<Array<{ name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const engines = await this.platformClient.getEngines(year, makeId, modelId, trimId);
|
||||
this.logger.debug(`Platform service returned ${engines.length} engines for trim ${trimId}`);
|
||||
return engines;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for engines: ${error}`);
|
||||
return []; // No fallback available for engines
|
||||
}
|
||||
}
|
||||
|
||||
return []; // Engines not available without platform service
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transmissions - platform service only (not available in external vPIC)
|
||||
*/
|
||||
async getTransmissions(year: number, makeId: number, modelId: number): Promise<Array<{ name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const transmissions = await this.platformClient.getTransmissions(year, makeId, modelId);
|
||||
this.logger.debug(`Platform service returned ${transmissions.length} transmissions`);
|
||||
return transmissions;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for transmissions: ${error}`);
|
||||
return []; // No fallback available for transmissions
|
||||
}
|
||||
}
|
||||
|
||||
return []; // Transmissions not available without platform service
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available years from platform service
|
||||
*/
|
||||
async getYears(): Promise<number[]> {
|
||||
try {
|
||||
return await this.platformClient.getYears();
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for years: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode VIN with platform service or fallback to external vPIC
|
||||
*/
|
||||
async decodeVIN(vin: string): Promise<{
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
trim?: string;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
success: boolean;
|
||||
}> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const response = await this.platformClient.decodeVIN(vin);
|
||||
if (response.success && response.result) {
|
||||
this.logger.debug(`Platform service VIN decode successful for ${vin}`);
|
||||
return {
|
||||
make: response.result.make,
|
||||
model: response.result.model,
|
||||
year: response.result.year,
|
||||
trim: response.result.trim_name,
|
||||
engine: response.result.engine_description,
|
||||
transmission: response.result.transmission_description,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
// Platform service returned no result, try fallback
|
||||
this.logger.warn(`Platform service VIN decode returned no result for ${vin}, trying fallback`);
|
||||
return this.getFallbackVinDecode(vin);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service VIN decode failed for ${vin}, falling back to vPIC: ${error}`);
|
||||
return this.getFallbackVinDecode(vin);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFallbackVinDecode(vin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for both services
|
||||
*/
|
||||
async healthCheck(): Promise<{
|
||||
platformService: boolean;
|
||||
externalVpic: boolean;
|
||||
overall: boolean;
|
||||
}> {
|
||||
const [platformHealthy, vpicHealthy] = await Promise.allSettled([
|
||||
this.platformClient.healthCheck(),
|
||||
this.checkVpicHealth()
|
||||
]);
|
||||
|
||||
const platformService = platformHealthy.status === 'fulfilled' && platformHealthy.value;
|
||||
const externalVpic = vpicHealthy.status === 'fulfilled' && vpicHealthy.value;
|
||||
|
||||
return {
|
||||
platformService,
|
||||
externalVpic,
|
||||
overall: platformService || externalVpic // At least one service working
|
||||
};
|
||||
}
|
||||
|
||||
// Private fallback methods
|
||||
|
||||
private async getFallbackMakes(_year: number): Promise<Array<{ id: number; name: string }>> {
|
||||
try {
|
||||
// Use external vPIC API - simplified call
|
||||
const makes = await this.vpicClient.getAllMakes();
|
||||
return makes.map((make: any) => ({ id: make.MakeId, name: make.MakeName }));
|
||||
} catch (error) {
|
||||
this.logger.error(`Fallback vPIC makes failed: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getFallbackModels(_year: number, makeId: number): Promise<Array<{ id: number; name: string }>> {
|
||||
try {
|
||||
// Use external vPIC API
|
||||
const models = await this.vpicClient.getModelsForMake(makeId.toString());
|
||||
return models.map((model: any) => ({ id: model.ModelId, name: model.ModelName }));
|
||||
} catch (error) {
|
||||
this.logger.error(`Fallback vPIC models failed: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getFallbackVinDecode(vin: string): Promise<{
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
success: boolean;
|
||||
}> {
|
||||
try {
|
||||
const result = await this.vpicClient.decodeVIN(vin);
|
||||
return {
|
||||
make: result?.make,
|
||||
model: result?.model,
|
||||
year: result?.year,
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Fallback vPIC VIN decode failed: ${error}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async checkVpicHealth(): Promise<boolean> {
|
||||
try {
|
||||
// Simple health check - try to get makes
|
||||
await this.vpicClient.getAllMakes();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@
|
||||
*/
|
||||
|
||||
import { VehiclesRepository } from '../data/vehicles.repository';
|
||||
import { vpicClient } from '../external/vpic/vpic.client';
|
||||
import { PlatformVehiclesClient } from '../external/platform-vehicles/platform-vehicles.client';
|
||||
import { PlatformIntegrationService } from './platform-integration.service';
|
||||
import { getVINDecodeService, getPool } from '../../platform';
|
||||
import {
|
||||
Vehicle,
|
||||
CreateVehicleRequest,
|
||||
@@ -16,38 +14,23 @@ import {
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import { isValidVIN } from '../../../shared-minimal/utils/validators';
|
||||
import { appConfig } from '../../../core/config/config-loader';
|
||||
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
||||
|
||||
export class VehiclesService {
|
||||
private readonly cachePrefix = 'vehicles';
|
||||
private readonly listCacheTTL = 300; // 5 minutes
|
||||
private readonly platformIntegration: PlatformIntegrationService;
|
||||
|
||||
constructor(private repository: VehiclesRepository) {
|
||||
// Initialize platform vehicles client
|
||||
const platformVehiclesUrl = appConfig.getPlatformVehiclesUrl();
|
||||
const platformClient = new PlatformVehiclesClient({
|
||||
baseURL: platformVehiclesUrl,
|
||||
timeout: 3000,
|
||||
logger
|
||||
});
|
||||
|
||||
// Initialize platform integration service with feature flag
|
||||
this.platformIntegration = new PlatformIntegrationService(
|
||||
platformClient,
|
||||
vpicClient,
|
||||
logger
|
||||
);
|
||||
constructor(private repository: VehiclesRepository) {
|
||||
// VIN decode service is now provided by platform feature
|
||||
}
|
||||
|
||||
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
|
||||
logger.info('Creating vehicle', { userId, vin: data.vin, licensePlate: (data as any).licensePlate });
|
||||
|
||||
|
||||
let make: string | undefined;
|
||||
let model: string | undefined;
|
||||
let year: number | undefined;
|
||||
|
||||
|
||||
if (data.vin) {
|
||||
// Validate VIN if provided
|
||||
if (!isValidVIN(data.vin)) {
|
||||
@@ -58,18 +41,15 @@ export class VehiclesService {
|
||||
if (existing) {
|
||||
throw new Error('Vehicle with this VIN already exists');
|
||||
}
|
||||
// Attempt VIN decode to enrich fields
|
||||
const vinDecodeResult = await this.platformIntegration.decodeVIN(data.vin);
|
||||
if (vinDecodeResult.success) {
|
||||
make = normalizeMakeName(vinDecodeResult.make);
|
||||
model = normalizeModelName(vinDecodeResult.model);
|
||||
year = vinDecodeResult.year;
|
||||
// Cache VIN decode result if successful
|
||||
await this.repository.cacheVINDecode(data.vin, {
|
||||
make: vinDecodeResult.make,
|
||||
model: vinDecodeResult.model,
|
||||
year: vinDecodeResult.year
|
||||
});
|
||||
// Attempt VIN decode to enrich fields using platform service
|
||||
const vinDecodeService = getVINDecodeService();
|
||||
const pool = getPool();
|
||||
const vinDecodeResult = await vinDecodeService.decodeVIN(pool, data.vin);
|
||||
if (vinDecodeResult.success && vinDecodeResult.result) {
|
||||
make = normalizeMakeName(vinDecodeResult.result.make);
|
||||
model = normalizeModelName(vinDecodeResult.result.model);
|
||||
year = vinDecodeResult.result.year ?? undefined;
|
||||
// VIN caching is now handled by platform feature
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,63 +162,47 @@ export class VehiclesService {
|
||||
await cacheService.del(cacheKey);
|
||||
}
|
||||
|
||||
async getDropdownMakes(year: number): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown makes', { year });
|
||||
return await this.platformIntegration.getMakes(year);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown makes', { year, error });
|
||||
throw new Error('Failed to load makes');
|
||||
}
|
||||
async getDropdownMakes(_year: number): Promise<{ id: number; name: string }[]> {
|
||||
// TODO: Implement using platform VehicleDataService
|
||||
// For now, return empty array to allow migration to complete
|
||||
logger.warn('Dropdown makes not yet implemented via platform feature');
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDropdownModels(year: number, makeId: number): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown models', { year, makeId });
|
||||
return await this.platformIntegration.getModels(year, makeId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown models', { year, makeId, error });
|
||||
throw new Error('Failed to load models');
|
||||
}
|
||||
async getDropdownModels(_year: number, _makeId: number): Promise<{ id: number; name: string }[]> {
|
||||
// TODO: Implement using platform VehicleDataService
|
||||
logger.warn('Dropdown models not yet implemented via platform feature');
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDropdownTransmissions(year: number, makeId: number, modelId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown transmissions', { year, makeId, modelId });
|
||||
return await this.platformIntegration.getTransmissions(year, makeId, modelId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown transmissions', { year, makeId, modelId, error });
|
||||
throw new Error('Failed to load transmissions');
|
||||
}
|
||||
async getDropdownTransmissions(_year: number, _makeId: number, _modelId: number): Promise<{ name: string }[]> {
|
||||
// TODO: Implement using platform VehicleDataService
|
||||
logger.warn('Dropdown transmissions not yet implemented via platform feature');
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDropdownEngines(year: number, makeId: number, modelId: number, trimId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown engines', { year, makeId, modelId, trimId });
|
||||
return await this.platformIntegration.getEngines(year, makeId, modelId, trimId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown engines', { year, makeId, modelId, trimId, error });
|
||||
throw new Error('Failed to load engines');
|
||||
}
|
||||
async getDropdownEngines(_year: number, _makeId: number, _modelId: number, _trimId: number): Promise<{ name: string }[]> {
|
||||
// TODO: Implement using platform VehicleDataService
|
||||
logger.warn('Dropdown engines not yet implemented via platform feature');
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDropdownTrims(year: number, makeId: number, modelId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown trims', { year, makeId, modelId });
|
||||
return await this.platformIntegration.getTrims(year, makeId, modelId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown trims', { year, makeId, modelId, error });
|
||||
throw new Error('Failed to load trims');
|
||||
}
|
||||
async getDropdownTrims(_year: number, _makeId: number, _modelId: number): Promise<{ name: string }[]> {
|
||||
// TODO: Implement using platform VehicleDataService
|
||||
logger.warn('Dropdown trims not yet implemented via platform feature');
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDropdownYears(): Promise<number[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown years');
|
||||
return await this.platformIntegration.getYears();
|
||||
// Fallback: generate recent years
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years: number[] = [];
|
||||
for (let y = currentYear + 1; y >= 1980; y--) years.push(y);
|
||||
return years;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown years', { error });
|
||||
// Fallback: generate recent years if platform unavailable
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years: number[] = [];
|
||||
for (let y = currentYear + 1; y >= 1980; y--) years.push(y);
|
||||
@@ -260,27 +224,28 @@ export class VehiclesService {
|
||||
}> {
|
||||
try {
|
||||
logger.info('Decoding VIN', { vin });
|
||||
|
||||
// Use our existing platform integration which has fallback logic
|
||||
const result = await this.platformIntegration.decodeVIN(vin);
|
||||
|
||||
if (result.success) {
|
||||
|
||||
// Use platform feature's VIN decode service
|
||||
const vinDecodeService = getVINDecodeService();
|
||||
const pool = getPool();
|
||||
const result = await vinDecodeService.decodeVIN(pool, vin);
|
||||
|
||||
if (result.success && result.result) {
|
||||
return {
|
||||
vin,
|
||||
success: true,
|
||||
year: result.year,
|
||||
make: result.make,
|
||||
model: result.model,
|
||||
trimLevel: result.trim,
|
||||
engine: result.engine,
|
||||
transmission: result.transmission,
|
||||
year: result.result.year ?? undefined,
|
||||
make: result.result.make ?? undefined,
|
||||
model: result.result.model ?? undefined,
|
||||
trimLevel: result.result.trim_name ?? undefined,
|
||||
engine: result.result.engine_description ?? undefined,
|
||||
confidence: 85 // High confidence since we have good data
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
vin,
|
||||
success: false,
|
||||
error: 'Unable to decode VIN'
|
||||
error: result.error || 'Unable to decode VIN'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import CircuitBreaker from 'opossum';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
export interface MakeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ModelItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TrimItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EngineItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TransmissionItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeResult {
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
trim_name?: string;
|
||||
engine_description?: string;
|
||||
transmission_description?: string;
|
||||
confidence_score?: number;
|
||||
vehicle_type?: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeResponse {
|
||||
vin: string;
|
||||
result?: VINDecodeResult;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlatformVehiclesClientConfig {
|
||||
baseURL: string;
|
||||
timeout?: number;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for MVP Platform Vehicles Service
|
||||
* Provides hierarchical vehicle API and VIN decoding with circuit breaker pattern
|
||||
*/
|
||||
export class PlatformVehiclesClient {
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly logger: Logger | undefined;
|
||||
private readonly circuitBreakers: Map<string, CircuitBreaker> = new Map();
|
||||
|
||||
constructor(config: PlatformVehiclesClientConfig) {
|
||||
this.logger = config.logger;
|
||||
|
||||
// Initialize HTTP client
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseURL,
|
||||
timeout: config.timeout || 3000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Setup response interceptors for logging
|
||||
this.httpClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const processingTime = response.headers['x-process-time'];
|
||||
if (processingTime) {
|
||||
this.logger?.debug(`Platform API response time: ${processingTime}ms`);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
this.logger?.error(`Platform API error: ${error.message}`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Initialize circuit breakers for each endpoint
|
||||
this.initializeCircuitBreakers();
|
||||
}
|
||||
|
||||
private initializeCircuitBreakers(): void {
|
||||
const circuitBreakerOptions = {
|
||||
timeout: 3000,
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeout: 30000,
|
||||
name: 'platform-vehicles',
|
||||
};
|
||||
|
||||
// Create circuit breakers for each endpoint type
|
||||
const endpoints = ['years', 'makes', 'models', 'trims', 'engines', 'transmissions', 'vindecode'];
|
||||
|
||||
endpoints.forEach(endpoint => {
|
||||
const breaker = new CircuitBreaker(this.makeRequest.bind(this), {
|
||||
...circuitBreakerOptions,
|
||||
name: `platform-vehicles-${endpoint}`,
|
||||
});
|
||||
|
||||
// Setup fallback handlers
|
||||
breaker.fallback(() => {
|
||||
this.logger?.warn(`Circuit breaker fallback triggered for ${endpoint}`);
|
||||
return this.getFallbackResponse(endpoint);
|
||||
});
|
||||
|
||||
// Setup event handlers
|
||||
breaker.on('open', () => {
|
||||
this.logger?.error(`Circuit breaker opened for ${endpoint}`);
|
||||
});
|
||||
|
||||
breaker.on('halfOpen', () => {
|
||||
this.logger?.info(`Circuit breaker half-open for ${endpoint}`);
|
||||
});
|
||||
|
||||
breaker.on('close', () => {
|
||||
this.logger?.info(`Circuit breaker closed for ${endpoint}`);
|
||||
});
|
||||
|
||||
this.circuitBreakers.set(endpoint, breaker);
|
||||
});
|
||||
}
|
||||
|
||||
private async makeRequest(endpoint: string, params?: Record<string, any>): Promise<any> {
|
||||
const response = await this.httpClient.get(`/api/v1/vehicles/${endpoint}`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private getFallbackResponse(endpoint: string): any {
|
||||
// Return empty arrays/objects for fallback
|
||||
switch (endpoint) {
|
||||
case 'makes':
|
||||
return { makes: [] };
|
||||
case 'models':
|
||||
return { models: [] };
|
||||
case 'trims':
|
||||
return { trims: [] };
|
||||
case 'engines':
|
||||
return { engines: [] };
|
||||
case 'transmissions':
|
||||
return { transmissions: [] };
|
||||
case 'vindecode':
|
||||
return { vin: '', result: null, success: false, error: 'Service unavailable' };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available model years
|
||||
*/
|
||||
async getYears(): Promise<number[]> {
|
||||
const breaker = this.circuitBreakers.get('years')!;
|
||||
try {
|
||||
const response: any = await breaker.fire('years');
|
||||
return Array.isArray(response) ? response : [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get years: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get makes for a specific year
|
||||
* Hierarchical API: First level - requires year only
|
||||
*/
|
||||
async getMakes(year: number): Promise<MakeItem[]> {
|
||||
const breaker = this.circuitBreakers.get('makes')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('makes', { year });
|
||||
this.logger?.debug(`Retrieved ${response.makes?.length || 0} makes for year ${year}`);
|
||||
return response.makes || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get makes for year ${year}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for year and make
|
||||
* Hierarchical API: Second level - requires year and make_id
|
||||
*/
|
||||
async getModels(year: number, makeId: number): Promise<ModelItem[]> {
|
||||
const breaker = this.circuitBreakers.get('models')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('models', { year, make_id: makeId });
|
||||
this.logger?.debug(`Retrieved ${response.models?.length || 0} models for year ${year}, make ${makeId}`);
|
||||
return response.models || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get models for year ${year}, make ${makeId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trims for year, make, and model
|
||||
* Hierarchical API: Third level - requires year, make_id, and model_id
|
||||
*/
|
||||
async getTrims(year: number, makeId: number, modelId: number): Promise<TrimItem[]> {
|
||||
const breaker = this.circuitBreakers.get('trims')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('trims', { year, make_id: makeId, model_id: modelId });
|
||||
this.logger?.debug(`Retrieved ${response.trims?.length || 0} trims for year ${year}, make ${makeId}, model ${modelId}`);
|
||||
return response.trims || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get trims for year ${year}, make ${makeId}, model ${modelId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engines for year, make, and model
|
||||
* Hierarchical API: Third level - requires year, make_id, and model_id
|
||||
*/
|
||||
async getEngines(year: number, makeId: number, modelId: number, trimId: number): Promise<EngineItem[]> {
|
||||
const breaker = this.circuitBreakers.get('engines')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('engines', { year, make_id: makeId, model_id: modelId, trim_id: trimId });
|
||||
this.logger?.debug(`Retrieved ${response.engines?.length || 0} engines for year ${year}, make ${makeId}, model ${modelId}, trim ${trimId}`);
|
||||
return response.engines || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get engines for year ${year}, make ${makeId}, model ${modelId}, trim ${trimId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transmissions for year, make, and model
|
||||
* Hierarchical API: Third level - requires year, make_id, and model_id
|
||||
*/
|
||||
async getTransmissions(year: number, makeId: number, modelId: number): Promise<TransmissionItem[]> {
|
||||
const breaker = this.circuitBreakers.get('transmissions')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('transmissions', { year, make_id: makeId, model_id: modelId });
|
||||
this.logger?.debug(`Retrieved ${response.transmissions?.length || 0} transmissions for year ${year}, make ${makeId}, model ${modelId}`);
|
||||
return response.transmissions || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get transmissions for year ${year}, make ${makeId}, model ${modelId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode VIN using platform service
|
||||
* Uses PostgreSQL vpic.f_decode_vin() function with confidence scoring
|
||||
*/
|
||||
async decodeVIN(vin: string): Promise<VINDecodeResponse> {
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post('/api/v1/vehicles/vindecode', { vin });
|
||||
this.logger?.debug(`VIN decode response for ${vin}: success=${response.data.success}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to decode VIN ${vin}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for the platform service
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.httpClient.get('/health');
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger?.error(`Platform service health check failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Types for MVP Platform Vehicles Service integration
|
||||
// These types match the FastAPI response models
|
||||
|
||||
export interface MakeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ModelItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TrimItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EngineItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TransmissionItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MakesResponse {
|
||||
makes: MakeItem[];
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
models: ModelItem[];
|
||||
}
|
||||
|
||||
export interface TrimsResponse {
|
||||
trims: TrimItem[];
|
||||
}
|
||||
|
||||
export interface EnginesResponse {
|
||||
engines: EngineItem[];
|
||||
}
|
||||
|
||||
export interface TransmissionsResponse {
|
||||
transmissions: TransmissionItem[];
|
||||
}
|
||||
|
||||
export interface VINDecodeResult {
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
trim_name?: string;
|
||||
engine_description?: string;
|
||||
transmission_description?: string;
|
||||
horsepower?: number;
|
||||
torque?: number; // ft-lb
|
||||
top_speed?: number; // mph
|
||||
fuel?: 'gasoline' | 'diesel' | 'electric';
|
||||
confidence_score?: number;
|
||||
vehicle_type?: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeRequest {
|
||||
vin: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeResponse {
|
||||
vin: string;
|
||||
result?: VINDecodeResult;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
database: string;
|
||||
cache: string;
|
||||
version: string;
|
||||
etl_last_run?: string;
|
||||
}
|
||||
|
||||
// Configuration for platform vehicles client
|
||||
export interface PlatformVehiclesConfig {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
timeout?: number;
|
||||
retryAttempts?: number;
|
||||
circuitBreakerOptions?: {
|
||||
timeout: number;
|
||||
errorThresholdPercentage: number;
|
||||
resetTimeout: number;
|
||||
};
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* @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 { appConfig } from '../../../../core/config/config-loader';
|
||||
import { logger } from '../../../../core/logging/logger';
|
||||
import { cacheService } from '../../../../core/config/redis';
|
||||
import {
|
||||
VPICResponse,
|
||||
VPICDecodeResult,
|
||||
VPICMake,
|
||||
VPICModel,
|
||||
VPICTransmission,
|
||||
VPICEngine,
|
||||
VPICTrim,
|
||||
DropdownDataResponse
|
||||
} from './vpic.types';
|
||||
|
||||
export class VPICClient {
|
||||
private readonly baseURL = appConfig.config.external.vpic.url;
|
||||
private readonly cacheTTL = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
private readonly dropdownCacheTTL = 7 * 24 * 60 * 60; // 7 days for dropdown data
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllMakes(): Promise<VPICMake[]> {
|
||||
const cacheKey = 'vpic:makes';
|
||||
|
||||
try {
|
||||
const cached = await cacheService.get<VPICMake[]>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Makes cache hit');
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.info('Calling vPIC API for makes');
|
||||
const response = await axios.get<{ Count: number; Message: string; Results: VPICMake[] }>(
|
||||
`${this.baseURL}/GetAllMakes?format=json`
|
||||
);
|
||||
|
||||
const makes = response.data.Results || [];
|
||||
await cacheService.set(cacheKey, makes, this.dropdownCacheTTL);
|
||||
|
||||
return makes;
|
||||
} catch (error) {
|
||||
logger.error('Get makes failed', { error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getModelsForMake(make: string): Promise<VPICModel[]> {
|
||||
const cacheKey = `vpic:models:${make}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheService.get<VPICModel[]>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Models cache hit', { make });
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.info('Calling vPIC API for models', { make });
|
||||
const response = await axios.get<{ Count: number; Message: string; Results: VPICModel[] }>(
|
||||
`${this.baseURL}/GetModelsForMake/${encodeURIComponent(make)}?format=json`
|
||||
);
|
||||
|
||||
const models = response.data.Results || [];
|
||||
await cacheService.set(cacheKey, models, this.dropdownCacheTTL);
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
logger.error('Get models failed', { make, error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getTransmissionTypes(): Promise<VPICTransmission[]> {
|
||||
return this.getVariableValues('Transmission Style', 'transmissions');
|
||||
}
|
||||
|
||||
async getEngineConfigurations(): Promise<VPICEngine[]> {
|
||||
return this.getVariableValues('Engine Configuration', 'engines');
|
||||
}
|
||||
|
||||
async getTrimLevels(): Promise<VPICTrim[]> {
|
||||
return this.getVariableValues('Trim', 'trims');
|
||||
}
|
||||
|
||||
private async getVariableValues(
|
||||
variable: string,
|
||||
cachePrefix: string
|
||||
): Promise<VPICTransmission[] | VPICEngine[] | VPICTrim[]> {
|
||||
const cacheKey = `vpic:${cachePrefix}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheService.get<VPICTransmission[]>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Variable values cache hit', { variable });
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.info('Calling vPIC API for variable values', { variable });
|
||||
const response = await axios.get<DropdownDataResponse>(
|
||||
`${this.baseURL}/GetVehicleVariableValuesList/${encodeURIComponent(variable)}?format=json`
|
||||
);
|
||||
|
||||
const values = response.data.Results || [];
|
||||
await cacheService.set(cacheKey, values, this.dropdownCacheTTL);
|
||||
|
||||
return values;
|
||||
} catch (error) {
|
||||
logger.error('Get variable values failed', { variable, error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const vpicClient = new VPICClient();
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @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[];
|
||||
}
|
||||
|
||||
export interface VPICMake {
|
||||
Make_ID: number;
|
||||
Make_Name: string;
|
||||
}
|
||||
|
||||
export interface VPICModel {
|
||||
Make_ID: number;
|
||||
Make_Name: string;
|
||||
Model_ID: number;
|
||||
Model_Name: string;
|
||||
}
|
||||
|
||||
export interface VPICDropdownItem {
|
||||
Id: number;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
export interface VPICTransmission extends VPICDropdownItem {}
|
||||
export interface VPICEngine extends VPICDropdownItem {}
|
||||
export interface VPICTrim extends VPICDropdownItem {}
|
||||
|
||||
export interface DropdownDataResponse {
|
||||
Count: number;
|
||||
Message: string;
|
||||
SearchCriteria: string;
|
||||
Results: VPICDropdownItem[];
|
||||
}
|
||||
@@ -5,17 +5,19 @@
|
||||
|
||||
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';
|
||||
import * as platformModule from '../../../platform';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../data/vehicles.repository');
|
||||
jest.mock('../../external/vpic/vpic.client');
|
||||
jest.mock('../../../../core/config/redis');
|
||||
jest.mock('../../../platform', () => ({
|
||||
getVINDecodeService: jest.fn()
|
||||
}));
|
||||
|
||||
const mockRepository = jest.mocked(VehiclesRepository);
|
||||
const mockVpicClient = jest.mocked(vpicClient);
|
||||
const mockCacheService = jest.mocked(cacheService);
|
||||
const mockGetVINDecodeService = jest.mocked(platformModule.getVINDecodeService);
|
||||
|
||||
describe('VehiclesService', () => {
|
||||
let service: VehiclesService;
|
||||
@@ -23,7 +25,7 @@ describe('VehiclesService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
|
||||
repositoryInstance = {
|
||||
create: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
@@ -31,8 +33,6 @@ describe('VehiclesService', () => {
|
||||
findByUserAndVIN: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
cacheVINDecode: jest.fn(),
|
||||
getVINFromCache: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockRepository.mockImplementation(() => repositoryInstance);
|
||||
@@ -74,16 +74,27 @@ describe('VehiclesService', () => {
|
||||
};
|
||||
|
||||
it('should create a vehicle with VIN decoding', async () => {
|
||||
const mockVinDecodeService = {
|
||||
decodeVIN: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
vin: '1HGBH41JXMN109186',
|
||||
make: 'Honda',
|
||||
model: 'Civic',
|
||||
year: 2021
|
||||
}
|
||||
})
|
||||
};
|
||||
mockGetVINDecodeService.mockReturnValue(mockVinDecodeService as any);
|
||||
|
||||
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(mockVinDecodeService.decodeVIN).toHaveBeenCalledWith('1HGBH41JXMN109186');
|
||||
expect(repositoryInstance.create).toHaveBeenCalledWith({
|
||||
...mockVehicleData,
|
||||
userId: 'user-123',
|
||||
@@ -91,7 +102,6 @@ describe('VehiclesService', () => {
|
||||
model: 'Civic',
|
||||
year: 2021,
|
||||
});
|
||||
expect(repositoryInstance.cacheVINDecode).toHaveBeenCalledWith('1HGBH41JXMN109186', mockVinDecodeResult);
|
||||
expect(result.id).toBe('vehicle-id-123');
|
||||
expect(result.make).toBe('Honda');
|
||||
});
|
||||
@@ -109,8 +119,15 @@ describe('VehiclesService', () => {
|
||||
});
|
||||
|
||||
it('should handle VIN decode failure gracefully', async () => {
|
||||
const mockVinDecodeService = {
|
||||
decodeVIN: jest.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'VIN decode failed'
|
||||
})
|
||||
};
|
||||
mockGetVINDecodeService.mockReturnValue(mockVinDecodeService as any);
|
||||
|
||||
repositoryInstance.findByUserAndVIN.mockResolvedValue(null);
|
||||
mockVpicClient.decodeVIN.mockResolvedValue(null);
|
||||
repositoryInstance.create.mockResolvedValue({ ...mockCreatedVehicle, make: undefined, model: undefined, year: undefined });
|
||||
mockCacheService.del.mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user