Initial Commit
This commit is contained in:
@@ -1,17 +1,26 @@
|
||||
# Vehicles Feature Capsule
|
||||
|
||||
## Quick Summary (50 tokens)
|
||||
Primary entity for vehicle management with VIN decoding via NHTSA vPIC API. Handles CRUD operations, automatic vehicle data population, user ownership validation, caching strategy (VIN lookups: 30 days, user lists: 5 minutes). Foundation for fuel-logs and maintenance features.
|
||||
Primary entity for vehicle management consuming MVP Platform Vehicles Service. Handles CRUD operations, hierarchical vehicle dropdowns, VIN decoding via platform service, user ownership validation, caching strategy (user lists: 5 minutes). Foundation for fuel-logs and maintenance features.
|
||||
|
||||
## API Endpoints
|
||||
- `POST /api/vehicles` - Create new vehicle with VIN decoding
|
||||
|
||||
### Vehicle Management
|
||||
- `POST /api/vehicles` - Create new vehicle with platform VIN decoding
|
||||
- `GET /api/vehicles` - List all user's vehicles (cached 5 min)
|
||||
- `GET /api/vehicles/:id` - Get specific vehicle
|
||||
- `PUT /api/vehicles/:id` - Update vehicle details
|
||||
- `DELETE /api/vehicles/:id` - Soft delete vehicle
|
||||
|
||||
## Authentication Required
|
||||
All endpoints require valid JWT token with user context.
|
||||
### Hierarchical Vehicle Dropdowns (Platform Service Proxy)
|
||||
- `GET /api/vehicles/dropdown/makes?year={year}` - Get makes for year
|
||||
- `GET /api/vehicles/dropdown/models?year={year}&make_id={make_id}` - Get models for make/year
|
||||
- `GET /api/vehicles/dropdown/trims?year={year}&make_id={make_id}&model_id={model_id}` - Get trims
|
||||
- `GET /api/vehicles/dropdown/engines?year={year}&make_id={make_id}&model_id={model_id}` - Get engines
|
||||
- `GET /api/vehicles/dropdown/transmissions?year={year}&make_id={make_id}&model_id={model_id}` - Get transmissions
|
||||
|
||||
## Authentication
|
||||
- All vehicles endpoints (including dropdowns) require a valid JWT (Auth0).
|
||||
|
||||
## Request/Response Examples
|
||||
|
||||
@@ -31,9 +40,9 @@ Response (201):
|
||||
"id": "uuid-here",
|
||||
"userId": "user-id",
|
||||
"vin": "1HGBH41JXMN109186",
|
||||
"make": "Honda", // Auto-decoded
|
||||
"model": "Civic", // Auto-decoded
|
||||
"year": 2021, // Auto-decoded
|
||||
"make": "Honda", // Auto-decoded via platform service
|
||||
"model": "Civic", // Auto-decoded via platform service
|
||||
"year": 2021, // Auto-decoded via platform service
|
||||
"nickname": "My Honda",
|
||||
"color": "Blue",
|
||||
"licensePlate": "ABC123",
|
||||
@@ -44,6 +53,30 @@ Response (201):
|
||||
}
|
||||
```
|
||||
|
||||
### Get Makes for Year
|
||||
```json
|
||||
GET /api/vehicles/dropdown/makes?year=2024
|
||||
|
||||
Response (200):
|
||||
[
|
||||
{"id": 1, "name": "Honda"},
|
||||
{"id": 2, "name": "Toyota"},
|
||||
{"id": 3, "name": "Ford"}
|
||||
]
|
||||
```
|
||||
|
||||
### Get Models for Make/Year
|
||||
```json
|
||||
GET /api/vehicles/dropdown/models?year=2024&make_id=1
|
||||
|
||||
Response (200):
|
||||
[
|
||||
{"id": 101, "name": "Civic"},
|
||||
{"id": 102, "name": "Accord"},
|
||||
{"id": 103, "name": "CR-V"}
|
||||
]
|
||||
```
|
||||
|
||||
## Feature Architecture
|
||||
|
||||
### Complete Self-Contained Structure
|
||||
@@ -62,14 +95,14 @@ vehicles/
|
||||
│ └── vehicles.repository.ts
|
||||
├── migrations/ # Feature schema
|
||||
│ └── 001_create_vehicles_tables.sql
|
||||
├── external/ # External APIs
|
||||
│ └── vpic/
|
||||
│ ├── vpic.client.ts
|
||||
│ └── vpic.types.ts
|
||||
├── external/ # Platform Service Integration
|
||||
│ └── platform-vehicles/
|
||||
│ ├── platform-vehicles.client.ts
|
||||
│ └── platform-vehicles.types.ts
|
||||
├── tests/ # All tests
|
||||
│ ├── unit/
|
||||
│ │ ├── vehicles.service.test.ts
|
||||
│ │ └── vpic.client.test.ts
|
||||
│ │ └── platform-vehicles.client.test.ts
|
||||
│ └── integration/
|
||||
│ └── vehicles.integration.test.ts
|
||||
└── docs/ # Additional docs
|
||||
@@ -78,21 +111,28 @@ vehicles/
|
||||
## Key Features
|
||||
|
||||
### 🔍 Automatic VIN Decoding
|
||||
- **External API**: NHTSA vPIC (Vehicle Product Information Catalog)
|
||||
- **Caching**: 30-day Redis cache for VIN lookups
|
||||
- **Fallback**: Graceful handling of decode failures
|
||||
- **Platform Service**: MVP Platform Vehicles Service VIN decode endpoint
|
||||
- **Caching**: Platform service handles caching strategy
|
||||
- **Fallback**: Circuit breaker pattern with graceful degradation
|
||||
- **Validation**: 17-character VIN format validation
|
||||
|
||||
### 📋 Hierarchical Vehicle Dropdowns
|
||||
- **Platform Service**: Consumes year-based hierarchical vehicle API
|
||||
- **Performance**: < 100ms response times via platform service caching
|
||||
- **Parameters**: Hierarchical filtering (year → make → model → trims/engines/transmissions)
|
||||
- **Circuit Breaker**: Graceful degradation with cached fallbacks
|
||||
|
||||
### 🏗️ Database Schema
|
||||
- **Primary Table**: `vehicles` with soft delete
|
||||
- **Cache Table**: `vin_cache` for external API results
|
||||
- **Indexes**: Optimized for user queries and VIN lookups
|
||||
- **Constraints**: Unique VIN per user, proper foreign keys
|
||||
- **Platform Integration**: No duplicate caching - relies on platform service
|
||||
|
||||
### 🚀 Performance Optimizations
|
||||
- **Redis Caching**: User vehicle lists cached for 5 minutes
|
||||
- **VIN Cache**: 30-day persistent cache in PostgreSQL
|
||||
- **Indexes**: Strategic database indexes for fast queries
|
||||
- **Platform Service**: Offloads heavy VIN decoding and vehicle data caching
|
||||
- **Circuit Breaker**: Prevents cascading failures with fallback responses
|
||||
- **Indexes**: Strategic database indexes for fast user queries
|
||||
- **Soft Deletes**: Maintains referential integrity
|
||||
|
||||
## Business Rules
|
||||
@@ -101,7 +141,7 @@ vehicles/
|
||||
- Must be exactly 17 characters
|
||||
- Cannot contain letters I, O, or Q
|
||||
- Must pass basic checksum validation
|
||||
- Auto-populates make, model, year from vPIC API
|
||||
- Auto-populates make, model, year from MVP Platform Vehicles Service
|
||||
|
||||
### User Ownership
|
||||
- Each user can have multiple vehicles
|
||||
@@ -117,32 +157,36 @@ vehicles/
|
||||
- `core/logging` - Structured logging with Winston
|
||||
- `shared-minimal/utils` - Pure validation utilities
|
||||
|
||||
### External Services
|
||||
- **NHTSA vPIC API** - VIN decoding service
|
||||
### Platform Services
|
||||
- **MVP Platform Vehicles Service** - VIN decoding and hierarchical vehicle data
|
||||
- **PostgreSQL** - Primary data storage
|
||||
- **Redis** - Caching layer
|
||||
|
||||
### Database Tables
|
||||
- `vehicles` - Primary vehicle data
|
||||
- `vin_cache` - External API response cache
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
### VIN Decode Cache (30 days)
|
||||
- **Key**: `vpic:vin:{vin}`
|
||||
- **TTL**: 2,592,000 seconds (30 days)
|
||||
- **Rationale**: Vehicle specifications never change
|
||||
### Platform Service Caching
|
||||
- **VIN Decoding**: Handled entirely by MVP Platform Vehicles Service
|
||||
- **Hierarchical Data**: Year-based caching strategy managed by platform service
|
||||
- **Performance**: < 100ms responses via platform service optimization
|
||||
|
||||
### User Vehicle List (5 minutes)
|
||||
- **Key**: `vehicles:user:{userId}`
|
||||
- **TTL**: 300 seconds (5 minutes)
|
||||
- **Invalidation**: On create, update, delete
|
||||
|
||||
### Platform Service Integration
|
||||
- **Circuit Breaker**: Prevent cascading failures
|
||||
- **Fallback Strategy**: Cached responses when platform service unavailable
|
||||
- **Timeout**: 3 second timeout with automatic retry
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- `vehicles.service.test.ts` - Business logic with mocked dependencies
|
||||
- `vpic.client.test.ts` - External API client with mocked HTTP
|
||||
- `platform-vehicles.client.test.ts` - Platform service client with mocked HTTP
|
||||
|
||||
### Integration Tests
|
||||
- `vehicles.integration.test.ts` - Complete API workflow with test database
|
||||
@@ -172,8 +216,9 @@ npm test -- features/vehicles --coverage
|
||||
- `409` - Duplicate VIN for user
|
||||
|
||||
### Server Errors (5xx)
|
||||
- `500` - Database connection, VIN API failures
|
||||
- Graceful degradation when vPIC API unavailable
|
||||
- `500` - Database connection, platform service failures
|
||||
- `503` - Platform service unavailable (circuit breaker open)
|
||||
- Graceful degradation when platform service unavailable
|
||||
|
||||
## Future Considerations
|
||||
|
||||
@@ -184,9 +229,10 @@ npm test -- features/vehicles --coverage
|
||||
|
||||
### Potential Enhancements
|
||||
- Vehicle image uploads (MinIO integration)
|
||||
- VIN decode webhook for real-time updates
|
||||
- Vehicle value estimation integration
|
||||
- Enhanced platform service integration for real-time updates
|
||||
- Vehicle value estimation via additional platform services
|
||||
- Maintenance scheduling based on vehicle age/mileage
|
||||
- Advanced dropdown features (trim-specific engines/transmissions)
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -194,8 +240,8 @@ npm test -- features/vehicles --coverage
|
||||
# Run migrations
|
||||
make migrate
|
||||
|
||||
# Start development environment
|
||||
make dev
|
||||
# Start environment
|
||||
make start
|
||||
|
||||
# View feature logs
|
||||
make logs-backend | grep vehicles
|
||||
|
||||
@@ -35,6 +35,18 @@ export class VehiclesController {
|
||||
|
||||
async createVehicle(request: FastifyRequest<{ Body: CreateVehicleBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
// Require either a valid 17-char VIN or a non-empty license plate
|
||||
const vin = request.body?.vin?.trim();
|
||||
const plate = request.body?.licensePlate?.trim();
|
||||
const hasValidVin = !!vin && vin.length === 17;
|
||||
const hasPlate = !!plate && plate.length > 0;
|
||||
if (!hasValidVin && !hasPlate) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Either a valid 17-character VIN or a license plate is required'
|
||||
});
|
||||
}
|
||||
|
||||
const userId = (request as any).user.sub;
|
||||
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
|
||||
|
||||
@@ -138,12 +150,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownMakes(_request: FastifyRequest, reply: FastifyReply) {
|
||||
async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const makes = await this.vehiclesService.getDropdownMakes();
|
||||
const { year } = request.query;
|
||||
if (!year || year < 1980 || year > new Date().getFullYear() + 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year parameter is required (1980-' + (new Date().getFullYear() + 1) + ')'
|
||||
});
|
||||
}
|
||||
|
||||
const makes = await this.vehiclesService.getDropdownMakes(year);
|
||||
return reply.code(200).send(makes);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown makes', { error });
|
||||
logger.error('Error getting dropdown makes', { error, year: request.query?.year });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get makes'
|
||||
@@ -151,13 +171,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownModels(request: FastifyRequest<{ Params: { make: string } }>, reply: FastifyReply) {
|
||||
async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make_id: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { make } = request.params;
|
||||
const models = await this.vehiclesService.getDropdownModels(make);
|
||||
const { year, make_id } = request.query;
|
||||
if (!year || !make_id || year < 1980 || year > new Date().getFullYear() + 1 || make_id < 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year and make_id parameters are required'
|
||||
});
|
||||
}
|
||||
|
||||
const models = await this.vehiclesService.getDropdownModels(year, make_id);
|
||||
return reply.code(200).send(models);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown models', { error, make: request.params.make });
|
||||
logger.error('Error getting dropdown models', { error, year: request.query?.year, make_id: request.query?.make_id });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get models'
|
||||
@@ -165,12 +192,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTransmissions(_request: FastifyRequest, reply: FastifyReply) {
|
||||
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make_id: number; model_id: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const transmissions = await this.vehiclesService.getDropdownTransmissions();
|
||||
const { year, make_id, model_id } = request.query;
|
||||
if (!year || !make_id || !model_id || year < 1980 || year > new Date().getFullYear() + 1 || make_id < 1 || model_id < 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make_id, and model_id parameters are required'
|
||||
});
|
||||
}
|
||||
|
||||
const transmissions = await this.vehiclesService.getDropdownTransmissions(year, make_id, model_id);
|
||||
return reply.code(200).send(transmissions);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown transmissions', { error });
|
||||
logger.error('Error getting dropdown transmissions', { error, year: request.query?.year, make_id: request.query?.make_id, model_id: request.query?.model_id });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get transmissions'
|
||||
@@ -178,12 +213,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownEngines(_request: FastifyRequest, reply: FastifyReply) {
|
||||
async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make_id: number; model_id: number; trim_id: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const engines = await this.vehiclesService.getDropdownEngines();
|
||||
const { year, make_id, model_id, trim_id } = request.query;
|
||||
if (!year || !make_id || !model_id || !trim_id || year < 1980 || year > new Date().getFullYear() + 1 || make_id < 1 || model_id < 1 || trim_id < 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make_id, model_id, and trim_id parameters are required'
|
||||
});
|
||||
}
|
||||
|
||||
const engines = await this.vehiclesService.getDropdownEngines(year, make_id, model_id, trim_id);
|
||||
return reply.code(200).send(engines);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown engines', { error });
|
||||
logger.error('Error getting dropdown engines', { error, year: request.query?.year, make_id: request.query?.make_id, model_id: request.query?.model_id, trim_id: request.query?.trim_id });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get engines'
|
||||
@@ -191,16 +234,62 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTrims(_request: FastifyRequest, reply: FastifyReply) {
|
||||
async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make_id: number; model_id: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const trims = await this.vehiclesService.getDropdownTrims();
|
||||
const { year, make_id, model_id } = request.query;
|
||||
if (!year || !make_id || !model_id || year < 1980 || year > new Date().getFullYear() + 1 || make_id < 1 || model_id < 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make_id, and model_id parameters are required'
|
||||
});
|
||||
}
|
||||
|
||||
const trims = await this.vehiclesService.getDropdownTrims(year, make_id, model_id);
|
||||
return reply.code(200).send(trims);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown trims', { error });
|
||||
logger.error('Error getting dropdown trims', { error, year: request.query?.year, make_id: request.query?.make_id, model_id: request.query?.model_id });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get trims'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownYears(_request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
// Use platform client through VehiclesService's integration
|
||||
const years = await this.vehiclesService.getDropdownYears();
|
||||
return reply.code(200).send(years);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown years', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get years'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async decodeVIN(request: FastifyRequest<{ Body: { vin: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { vin } = request.body;
|
||||
|
||||
if (!vin || vin.length !== 17) {
|
||||
return reply.code(400).send({
|
||||
vin: vin || '',
|
||||
success: false,
|
||||
error: 'VIN must be exactly 17 characters'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.vehiclesService.decodeVIN(vin);
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error decoding VIN', { error, vin: request.body?.vin });
|
||||
return reply.code(500).send({
|
||||
vin: request.body?.vin || '',
|
||||
success: false,
|
||||
error: 'VIN decode failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
VehicleParams
|
||||
} from '../domain/vehicles.types';
|
||||
import { VehiclesController } from './vehicles.controller';
|
||||
import { tenantMiddleware } from '../../../core/middleware/tenant';
|
||||
|
||||
export const vehiclesRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
@@ -20,61 +21,80 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
|
||||
|
||||
// GET /api/vehicles - Get user's vehicles
|
||||
fastify.get('/vehicles', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getUserVehicles.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// POST /api/vehicles - Create new vehicle
|
||||
fastify.post<{ Body: CreateVehicleBody }>('/vehicles', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.createVehicle.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/:id - Get specific vehicle
|
||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getVehicle.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// PUT /api/vehicles/:id - Update vehicle
|
||||
fastify.put<{ Params: VehicleParams; Body: UpdateVehicleBody }>('/vehicles/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.updateVehicle.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// DELETE /api/vehicles/:id - Delete vehicle
|
||||
fastify.delete<{ Params: VehicleParams }>('/vehicles/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.deleteVehicle.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/makes - Get vehicle makes
|
||||
fastify.get('/vehicles/dropdown/makes', {
|
||||
// Hierarchical Vehicle API - mirrors MVP Platform Vehicles Service structure
|
||||
|
||||
// GET /api/vehicles/dropdown/years - Available model years
|
||||
fastify.get('/vehicles/dropdown/years', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownYears.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/makes?year=2024 - Get makes for year (Level 1)
|
||||
fastify.get<{ Querystring: { year: number } }>('/vehicles/dropdown/makes', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownMakes.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/models/:make - Get models for make
|
||||
fastify.get<{ Params: { make: string } }>('/vehicles/dropdown/models/:make', {
|
||||
// GET /api/vehicles/dropdown/models?year=2024&make_id=1 - Get models for year/make (Level 2)
|
||||
fastify.get<{ Querystring: { year: number; make_id: number } }>('/vehicles/dropdown/models', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownModels.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/transmissions - Get transmission types
|
||||
fastify.get('/vehicles/dropdown/transmissions', {
|
||||
handler: vehiclesController.getDropdownTransmissions.bind(vehiclesController)
|
||||
// GET /api/vehicles/dropdown/trims?year=2024&make_id=1&model_id=1 - Get trims (Level 3)
|
||||
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number } }>('/vehicles/dropdown/trims', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownTrims.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/engines - Get engine configurations
|
||||
fastify.get('/vehicles/dropdown/engines', {
|
||||
// GET /api/vehicles/dropdown/engines?year=2024&make_id=1&model_id=1&trim_id=1 - Get engines (Level 4)
|
||||
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number; trim_id: number } }>('/vehicles/dropdown/engines', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownEngines.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/trims - Get trim levels
|
||||
fastify.get('/vehicles/dropdown/trims', {
|
||||
handler: vehiclesController.getDropdownTrims.bind(vehiclesController)
|
||||
// GET /api/vehicles/dropdown/transmissions?year=2024&make_id=1&model_id=1 - Get transmissions (Level 3)
|
||||
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number } }>('/vehicles/dropdown/transmissions', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownTransmissions.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// POST /api/vehicles/decode-vin - Decode VIN and return vehicle information
|
||||
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.decodeVIN.bind(vehiclesController)
|
||||
});
|
||||
};
|
||||
|
||||
// For backward compatibility during migration
|
||||
export function registerVehiclesRoutes() {
|
||||
throw new Error('registerVehiclesRoutes is deprecated - use vehiclesRoutes Fastify plugin instead');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,24 @@ export class VehiclesRepository {
|
||||
const query = `
|
||||
INSERT INTO vehicles (
|
||||
user_id, vin, make, model, year,
|
||||
engine, transmission, trim_level, drive_type, fuel_type,
|
||||
nickname, color, license_plate, odometer_reading
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.vin,
|
||||
(data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null,
|
||||
data.make,
|
||||
data.model,
|
||||
data.year,
|
||||
data.engine,
|
||||
data.transmission,
|
||||
data.trimLevel,
|
||||
data.driveType,
|
||||
data.fuelType,
|
||||
data.nickname,
|
||||
data.color,
|
||||
data.licensePlate,
|
||||
@@ -74,6 +80,38 @@ export class VehiclesRepository {
|
||||
let paramCount = 1;
|
||||
|
||||
// Build dynamic update query
|
||||
if (data.make !== undefined) {
|
||||
fields.push(`make = $${paramCount++}`);
|
||||
values.push(data.make);
|
||||
}
|
||||
if (data.model !== undefined) {
|
||||
fields.push(`model = $${paramCount++}`);
|
||||
values.push(data.model);
|
||||
}
|
||||
if (data.year !== undefined) {
|
||||
fields.push(`year = $${paramCount++}`);
|
||||
values.push(data.year);
|
||||
}
|
||||
if (data.engine !== undefined) {
|
||||
fields.push(`engine = $${paramCount++}`);
|
||||
values.push(data.engine);
|
||||
}
|
||||
if (data.transmission !== undefined) {
|
||||
fields.push(`transmission = $${paramCount++}`);
|
||||
values.push(data.transmission);
|
||||
}
|
||||
if (data.trimLevel !== undefined) {
|
||||
fields.push(`trim_level = $${paramCount++}`);
|
||||
values.push(data.trimLevel);
|
||||
}
|
||||
if (data.driveType !== undefined) {
|
||||
fields.push(`drive_type = $${paramCount++}`);
|
||||
values.push(data.driveType);
|
||||
}
|
||||
if (data.fuelType !== undefined) {
|
||||
fields.push(`fuel_type = $${paramCount++}`);
|
||||
values.push(data.fuelType);
|
||||
}
|
||||
if (data.nickname !== undefined) {
|
||||
fields.push(`nickname = $${paramCount++}`);
|
||||
values.push(data.nickname);
|
||||
@@ -164,6 +202,11 @@ export class VehiclesRepository {
|
||||
make: row.make,
|
||||
model: row.model,
|
||||
year: row.year,
|
||||
engine: row.engine,
|
||||
transmission: row.transmission,
|
||||
trimLevel: row.trim_level,
|
||||
driveType: row.drive_type,
|
||||
fuelType: row.fuel_type,
|
||||
nickname: row.nickname,
|
||||
color: row.color,
|
||||
licensePlate: row.license_plate,
|
||||
@@ -174,4 +217,4 @@ export class VehiclesRepository {
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
backend/src/features/vehicles/domain/name-normalizer.ts
Normal file
52
backend/src/features/vehicles/domain/name-normalizer.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Normalizes vehicle make and model names for human-friendly display.
|
||||
* - Replaces underscores with spaces
|
||||
* - Collapses whitespace
|
||||
* - Title-cases standard words
|
||||
* - Uppercases common acronyms (e.g., HD, GT, Z06)
|
||||
*/
|
||||
|
||||
const MODEL_ACRONYMS = new Set([
|
||||
'HD','GT','GL','SE','LE','XLE','RS','SVT','XR','ST','FX4','TRD','ZR1','Z06','GTI','GLI','SI','SS','LT','LTZ','RT','SRT','SR','SR5','XSE','SEL'
|
||||
]);
|
||||
|
||||
export function normalizeModelName(input?: string | null): string | undefined {
|
||||
if (input == null) return input ?? undefined;
|
||||
let s = String(input).replace(/_/g, ' ');
|
||||
s = s.replace(/\s+/g, ' ').trim();
|
||||
if (s.length === 0) return s;
|
||||
|
||||
const tokens = s.split(' ');
|
||||
const normalized = tokens.map(t => {
|
||||
const raw = t;
|
||||
const upper = raw.toUpperCase();
|
||||
const lower = raw.toLowerCase();
|
||||
// Uppercase known acronyms (match case-insensitively)
|
||||
if (MODEL_ACRONYMS.has(upper)) return upper;
|
||||
// Tokens with letters+digits (e.g., Z06) – prefer uppercase
|
||||
if (/^[a-z0-9]+$/i.test(raw) && /[a-z]/i.test(raw) && /\d/.test(raw) && raw.length <= 4) {
|
||||
return upper;
|
||||
}
|
||||
// Pure letters: title case
|
||||
if (/^[a-z]+$/i.test(raw)) {
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}
|
||||
// Numbers or mixed/punctuated tokens: keep as-is except collapse case
|
||||
return raw;
|
||||
});
|
||||
return normalized.join(' ');
|
||||
}
|
||||
|
||||
export function normalizeMakeName(input?: string | null): string | undefined {
|
||||
if (input == null) return input ?? undefined;
|
||||
let s = String(input).replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (s.length === 0) return s;
|
||||
const title = s.toLowerCase().split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
// Special cases
|
||||
if (/^bmw$/i.test(s)) return 'BMW';
|
||||
if (/^gmc$/i.test(s)) return 'GMC';
|
||||
if (/^mini$/i.test(s)) return 'MINI';
|
||||
if (/^mclaren$/i.test(s)) return 'McLaren';
|
||||
return title;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Logger } from 'winston';
|
||||
import { PlatformVehiclesClient } from '../external/platform-vehicles/platform-vehicles.client';
|
||||
import { VPICClient } from '../external/vpic/vpic.client';
|
||||
import { env } from '../../../core/config/environment';
|
||||
|
||||
|
||||
/**
|
||||
* 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 = env.NODE_ENV !== '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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
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 {
|
||||
Vehicle,
|
||||
CreateVehicleRequest,
|
||||
@@ -14,44 +16,76 @@ import {
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import { isValidVIN } from '../../../shared-minimal/utils/validators';
|
||||
import { env } from '../../../core/config/environment';
|
||||
import { 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) {}
|
||||
constructor(private repository: VehiclesRepository) {
|
||||
// Initialize platform vehicles client
|
||||
const platformClient = new PlatformVehiclesClient({
|
||||
baseURL: env.PLATFORM_VEHICLES_API_URL,
|
||||
apiKey: env.PLATFORM_VEHICLES_API_KEY,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
timeout: 3000,
|
||||
logger
|
||||
});
|
||||
|
||||
// Initialize platform integration service with feature flag
|
||||
this.platformIntegration = new PlatformIntegrationService(
|
||||
platformClient,
|
||||
vpicClient,
|
||||
logger
|
||||
);
|
||||
}
|
||||
|
||||
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
|
||||
logger.info('Creating vehicle', { userId, vin: data.vin });
|
||||
logger.info('Creating vehicle', { userId, vin: data.vin, licensePlate: (data as any).licensePlate });
|
||||
|
||||
// Validate VIN
|
||||
if (!isValidVIN(data.vin)) {
|
||||
throw new Error('Invalid VIN format');
|
||||
let make: string | undefined;
|
||||
let model: string | undefined;
|
||||
let year: number | undefined;
|
||||
|
||||
if (data.vin) {
|
||||
// Validate VIN if provided
|
||||
if (!isValidVIN(data.vin)) {
|
||||
throw new Error('Invalid VIN format');
|
||||
}
|
||||
// Duplicate check only when VIN is present
|
||||
const existing = await this.repository.findByUserAndVIN(userId, data.vin);
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await this.repository.findByUserAndVIN(userId, data.vin);
|
||||
if (existing) {
|
||||
throw new Error('Vehicle with this VIN already exists');
|
||||
}
|
||||
|
||||
// Decode VIN
|
||||
const vinData = await vpicClient.decodeVIN(data.vin);
|
||||
|
||||
// Create vehicle with decoded data
|
||||
// Create vehicle (VIN optional). Client-sent make/model/year override decode if provided.
|
||||
const inputMake = (data as any).make ?? make;
|
||||
const inputModel = (data as any).model ?? model;
|
||||
|
||||
const vehicle = await this.repository.create({
|
||||
...data,
|
||||
userId,
|
||||
make: vinData?.make,
|
||||
model: vinData?.model,
|
||||
year: vinData?.year,
|
||||
make: normalizeMakeName(inputMake),
|
||||
model: normalizeModelName(inputModel),
|
||||
year: (data as any).year ?? year,
|
||||
});
|
||||
|
||||
// Cache VIN decode result
|
||||
if (vinData) {
|
||||
await this.repository.cacheVINDecode(data.vin, vinData);
|
||||
}
|
||||
|
||||
// Invalidate user's vehicle list cache
|
||||
await this.invalidateUserCache(userId);
|
||||
|
||||
@@ -106,8 +140,17 @@ export class VehiclesService {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
// Normalize any provided name fields
|
||||
const normalized: UpdateVehicleRequest = { ...data } as any;
|
||||
if (data.make !== undefined) {
|
||||
(normalized as any).make = normalizeMakeName(data.make);
|
||||
}
|
||||
if (data.model !== undefined) {
|
||||
(normalized as any).model = normalizeModelName(data.model);
|
||||
}
|
||||
|
||||
// Update vehicle
|
||||
const updated = await this.repository.update(id, data);
|
||||
const updated = await this.repository.update(id, normalized);
|
||||
if (!updated) {
|
||||
throw new Error('Update failed');
|
||||
}
|
||||
@@ -140,81 +183,117 @@ export class VehiclesService {
|
||||
await cacheService.del(cacheKey);
|
||||
}
|
||||
|
||||
async getDropdownMakes(): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownMakes(year: number): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown makes');
|
||||
const makes = await vpicClient.getAllMakes();
|
||||
|
||||
return makes.map(make => ({
|
||||
id: make.Make_ID,
|
||||
name: make.Make_Name
|
||||
}));
|
||||
logger.info('Getting dropdown makes', { year });
|
||||
return await this.platformIntegration.getMakes(year);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown makes', { error });
|
||||
logger.error('Failed to get dropdown makes', { year, error });
|
||||
throw new Error('Failed to load makes');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownModels(make: string): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownModels(year: number, makeId: number): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown models', { make });
|
||||
const models = await vpicClient.getModelsForMake(make);
|
||||
|
||||
return models.map(model => ({
|
||||
id: model.Model_ID,
|
||||
name: model.Model_Name
|
||||
}));
|
||||
logger.info('Getting dropdown models', { year, makeId });
|
||||
return await this.platformIntegration.getModels(year, makeId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown models', { make, error });
|
||||
logger.error('Failed to get dropdown models', { year, makeId, error });
|
||||
throw new Error('Failed to load models');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTransmissions(): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownTransmissions(year: number, makeId: number, modelId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown transmissions');
|
||||
const transmissions = await vpicClient.getTransmissionTypes();
|
||||
|
||||
return transmissions.map(transmission => ({
|
||||
id: transmission.Id,
|
||||
name: transmission.Name
|
||||
}));
|
||||
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', { error });
|
||||
logger.error('Failed to get dropdown transmissions', { year, makeId, modelId, error });
|
||||
throw new Error('Failed to load transmissions');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownEngines(): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownEngines(year: number, makeId: number, modelId: number, trimId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown engines');
|
||||
const engines = await vpicClient.getEngineConfigurations();
|
||||
|
||||
return engines.map(engine => ({
|
||||
id: engine.Id,
|
||||
name: engine.Name
|
||||
}));
|
||||
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', { error });
|
||||
logger.error('Failed to get dropdown engines', { year, makeId, modelId, trimId, error });
|
||||
throw new Error('Failed to load engines');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTrims(): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownTrims(year: number, makeId: number, modelId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown trims');
|
||||
const trims = await vpicClient.getTrimLevels();
|
||||
|
||||
return trims.map(trim => ({
|
||||
id: trim.Id,
|
||||
name: trim.Name
|
||||
}));
|
||||
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', { error });
|
||||
logger.error('Failed to get dropdown trims', { year, makeId, modelId, error });
|
||||
throw new Error('Failed to load trims');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownYears(): Promise<number[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown years');
|
||||
return await this.platformIntegration.getYears();
|
||||
} 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);
|
||||
return years;
|
||||
}
|
||||
}
|
||||
|
||||
async decodeVIN(vin: string): Promise<{
|
||||
vin: string;
|
||||
success: boolean;
|
||||
year?: number;
|
||||
make?: string;
|
||||
model?: string;
|
||||
trimLevel?: string;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
confidence?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
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) {
|
||||
return {
|
||||
vin,
|
||||
success: true,
|
||||
year: result.year,
|
||||
make: result.make,
|
||||
model: result.model,
|
||||
trimLevel: result.trim,
|
||||
engine: result.engine,
|
||||
transmission: result.transmission,
|
||||
confidence: 85 // High confidence since we have good data
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
vin,
|
||||
success: false,
|
||||
error: 'Unable to decode VIN'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to decode VIN', { vin, error });
|
||||
return {
|
||||
vin,
|
||||
success: false,
|
||||
error: 'VIN decode service unavailable'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private toResponse(vehicle: Vehicle): VehicleResponse {
|
||||
return {
|
||||
id: vehicle.id,
|
||||
@@ -237,4 +316,4 @@ export class VehiclesService {
|
||||
updatedAt: vehicle.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
userId: string;
|
||||
vin: string;
|
||||
vin?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
@@ -26,7 +26,7 @@ export interface Vehicle {
|
||||
}
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
vin: string;
|
||||
vin?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
engine?: string;
|
||||
@@ -57,7 +57,7 @@ export interface UpdateVehicleRequest {
|
||||
export interface VehicleResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
vin: string;
|
||||
vin?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
@@ -86,7 +86,7 @@ export interface VINDecodeResult {
|
||||
|
||||
// Fastify-specific types for HTTP handling
|
||||
export interface CreateVehicleBody {
|
||||
vin: string;
|
||||
vin?: string;
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
@@ -102,4 +102,4 @@ export interface UpdateVehicleBody {
|
||||
|
||||
export interface VehicleParams {
|
||||
id: string;
|
||||
}
|
||||
}
|
||||
|
||||
293
backend/src/features/vehicles/external/platform-vehicles/platform-vehicles.client.ts
vendored
Normal file
293
backend/src/features/vehicles/external/platform-vehicles/platform-vehicles.client.ts
vendored
Normal file
@@ -0,0 +1,293 @@
|
||||
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;
|
||||
apiKey: string;
|
||||
tenantId?: 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();
|
||||
private readonly tenantId: string | undefined;
|
||||
|
||||
constructor(config: PlatformVehiclesClientConfig) {
|
||||
this.logger = config.logger;
|
||||
this.tenantId = config.tenantId || process.env.TENANT_ID;
|
||||
|
||||
// Initialize HTTP client
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseURL,
|
||||
timeout: config.timeout || 3000,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Inject tenant header for all requests when available
|
||||
if (this.tenantId) {
|
||||
this.httpClient.defaults.headers.common['X-Tenant-ID'] = this.tenantId;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
backend/src/features/vehicles/external/platform-vehicles/platform-vehicles.types.ts
vendored
Normal file
91
backend/src/features/vehicles/external/platform-vehicles/platform-vehicles.types.ts
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE TABLE IF NOT EXISTS vehicles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
vin VARCHAR(17) NOT NULL,
|
||||
vin VARCHAR(17),
|
||||
make VARCHAR(100),
|
||||
model VARCHAR(100),
|
||||
year INTEGER,
|
||||
@@ -22,10 +22,10 @@ CREATE TABLE IF NOT EXISTS vehicles (
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_vehicles_user_id ON vehicles(user_id);
|
||||
CREATE INDEX idx_vehicles_vin ON vehicles(vin);
|
||||
CREATE INDEX idx_vehicles_is_active ON vehicles(is_active);
|
||||
CREATE INDEX idx_vehicles_created_at ON vehicles(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_user_id ON vehicles(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_vin ON vehicles(vin);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_is_active ON vehicles(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_created_at ON vehicles(created_at);
|
||||
|
||||
-- Create VIN cache table for external API results
|
||||
CREATE TABLE IF NOT EXISTS vin_cache (
|
||||
@@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS vin_cache (
|
||||
);
|
||||
|
||||
-- Create index on cache timestamp for cleanup
|
||||
CREATE INDEX idx_vin_cache_cached_at ON vin_cache(cached_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vin_cache_cached_at ON vin_cache(cached_at);
|
||||
|
||||
-- Create update trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
@@ -52,7 +52,15 @@ END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Add trigger to vehicles table
|
||||
CREATE TRIGGER update_vehicles_updated_at
|
||||
BEFORE UPDATE ON vehicles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'update_vehicles_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER update_vehicles_updated_at
|
||||
BEFORE UPDATE ON vehicles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
@@ -25,7 +25,10 @@ CREATE TABLE IF NOT EXISTS vehicle_dropdown_cache (
|
||||
CREATE INDEX IF NOT EXISTS idx_dropdown_cache_expires_at ON vehicle_dropdown_cache(expires_at);
|
||||
|
||||
-- Create trigger for updating updated_at on dropdown cache
|
||||
CREATE TRIGGER IF NOT EXISTS update_dropdown_cache_updated_at
|
||||
-- Create trigger to maintain updated_at on vehicle_dropdown_cache
|
||||
-- Use DROP IF EXISTS and CREATE to handle re-runs safely
|
||||
DROP TRIGGER IF EXISTS update_dropdown_cache_updated_at ON vehicle_dropdown_cache;
|
||||
CREATE TRIGGER update_dropdown_cache_updated_at
|
||||
BEFORE UPDATE ON vehicle_dropdown_cache
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -36,4 +39,4 @@ COMMENT ON COLUMN vehicles.transmission IS 'Transmission style from NHTSA vPIC A
|
||||
COMMENT ON COLUMN vehicles.trim_level IS 'Trim level from NHTSA vPIC API';
|
||||
COMMENT ON COLUMN vehicles.drive_type IS 'Drive type (FWD, RWD, AWD, 4WD)';
|
||||
COMMENT ON COLUMN vehicles.fuel_type IS 'Primary fuel type';
|
||||
COMMENT ON TABLE vehicle_dropdown_cache IS 'Cache for dropdown data from NHTSA vPIC API';
|
||||
COMMENT ON TABLE vehicle_dropdown_cache IS 'Cache for dropdown data from NHTSA vPIC API';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Allow vehicles to be created without a VIN (license plate alternative)
|
||||
ALTER TABLE vehicles ALTER COLUMN vin DROP NOT NULL;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Normalize existing model names in application database
|
||||
-- - Replace underscores with spaces
|
||||
-- - Title-case words
|
||||
-- - Uppercase common acronyms (HD, GT, Z06, etc.)
|
||||
|
||||
-- Create helper function to normalize model names
|
||||
CREATE OR REPLACE FUNCTION normalize_model_name_app(input TEXT)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
s TEXT;
|
||||
BEGIN
|
||||
IF input IS NULL THEN RETURN NULL; END IF;
|
||||
s := input;
|
||||
-- underscores to spaces, collapse whitespace, trim
|
||||
s := regexp_replace(s, '_+', ' ', 'g');
|
||||
s := btrim(regexp_replace(s, '\\s+', ' ', 'g'));
|
||||
-- title case baseline
|
||||
s := initcap(lower(s));
|
||||
-- uppercase common acronyms using word boundaries
|
||||
s := regexp_replace(s, '(^|\\s)(Hd)(\\s|$)', '\\1HD\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Gt)(\\s|$)', '\\1GT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Gl)(\\s|$)', '\\1GL\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Se)(\\s|$)', '\\1SE\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Le)(\\s|$)', '\\1LE\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Xle)(\\s|$)', '\\1XLE\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Rs)(\\s|$)', '\\1RS\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Svt)(\\s|$)', '\\1SVT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Xr)(\\s|$)', '\\1XR\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(St)(\\s|$)', '\\1ST\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Fx4)(\\s|$)', '\\1FX4\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Trd)(\\s|$)', '\\1TRD\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Zr1)(\\s|$)', '\\1ZR1\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Z06)(\\s|$)', '\\1Z06\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Gti)(\\s|$)', '\\1GTI\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Gli)(\\s|$)', '\\1GLI\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Si)(\\s|$)', '\\1SI\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Ss)(\\s|$)', '\\1SS\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Lt)(\\s|$)', '\\1LT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Ltz)(\\s|$)', '\\1LTZ\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Rt)(\\s|$)', '\\1RT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Srt)(\\s|$)', '\\1SRT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Sr)(\\s|$)', '\\1SR\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Sr5)(\\s|$)', '\\1SR5\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Xse)(\\s|$)', '\\1XSE\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Sel)(\\s|$)', '\\1SEL\\3', 'gi');
|
||||
RETURN s;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update existing rows in application tables
|
||||
UPDATE vehicles
|
||||
SET model = normalize_model_name_app(model)
|
||||
WHERE model IS NOT NULL AND model <> normalize_model_name_app(model);
|
||||
|
||||
UPDATE vin_cache
|
||||
SET model = normalize_model_name_app(model)
|
||||
WHERE model IS NOT NULL AND model <> normalize_model_name_app(model);
|
||||
|
||||
Reference in New Issue
Block a user