Possible working ETL
This commit is contained in:
@@ -5,7 +5,6 @@
|
||||
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 {
|
||||
@@ -13,21 +12,18 @@ import {
|
||||
ModelsQuery,
|
||||
TrimsQuery,
|
||||
EnginesQuery,
|
||||
TransmissionsQuery,
|
||||
VINDecodeRequest
|
||||
TransmissionsQuery
|
||||
} 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,12 +96,14 @@ export class PlatformController {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/platform/transmissions?year={year}&make={make}&model={model}
|
||||
* GET /api/platform/transmissions?year={year}&make={make}&model={model}&trim={trim}[&engine={engine}]
|
||||
*/
|
||||
async getTransmissions(request: FastifyRequest<{ Querystring: TransmissionsQuery }>, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const { year, make, model } = request.query as any;
|
||||
const transmissions = await this.vehicleDataService.getTransmissions(this.pool, year, make, model);
|
||||
const { year, make, model, trim, engine } = request.query as any;
|
||||
const transmissions = engine
|
||||
? await this.vehicleDataService.getTransmissionsForTrimAndEngine(this.pool, year, make, model, trim, engine)
|
||||
: await this.vehicleDataService.getTransmissionsForTrim(this.pool, year, make, model, trim);
|
||||
reply.code(200).send({ transmissions });
|
||||
} catch (error) {
|
||||
logger.error('Controller error: getTransmissions', { error, query: request.query });
|
||||
@@ -113,34 +111,4 @@ export class PlatformController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
ModelsQuery,
|
||||
TrimsQuery,
|
||||
EnginesQuery,
|
||||
TransmissionsQuery,
|
||||
VINDecodeRequest
|
||||
TransmissionsQuery
|
||||
} from '../models/requests';
|
||||
import pool from '../../../core/config/database';
|
||||
|
||||
@@ -41,10 +40,6 @@ async function platformRoutes(fastify: FastifyInstance) {
|
||||
fastify.get<{ Querystring: TransmissionsQuery }>('/platform/transmissions', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, controller.getTransmissions.bind(controller));
|
||||
|
||||
fastify.get<{ Querystring: VINDecodeRequest }>('/platform/vehicle', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, controller.decodeVIN.bind(controller));
|
||||
}
|
||||
|
||||
export default fastifyPlugin(platformRoutes);
|
||||
|
||||
@@ -84,18 +84,7 @@ export class VehicleDataRepository {
|
||||
*/
|
||||
async getEngines(pool: Pool, year: number, make: string, model: string, trim: string): Promise<string[]> {
|
||||
const query = `
|
||||
SELECT DISTINCT
|
||||
CASE
|
||||
WHEN vo.engine_id IS NULL THEN 'N/A (Electric)'
|
||||
ELSE e.name
|
||||
END as engine_name
|
||||
FROM vehicle_options vo
|
||||
LEFT JOIN engines e ON e.id = vo.engine_id
|
||||
WHERE vo.year = $1
|
||||
AND vo.make = $2
|
||||
AND vo.model = $3
|
||||
AND vo.trim = $4
|
||||
ORDER BY engine_name
|
||||
SELECT engine_name FROM get_engines_for_vehicle($1, $2, $3, $4)
|
||||
`;
|
||||
|
||||
try {
|
||||
@@ -108,30 +97,46 @@ export class VehicleDataRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transmissions for a specific year, make, and model
|
||||
* Get transmissions for a specific year, make, model, and trim
|
||||
* Returns real transmission types from the database (not hardcoded)
|
||||
*/
|
||||
async getTransmissions(pool: Pool, year: number, make: string, model: string): Promise<string[]> {
|
||||
async getTransmissionsForTrim(pool: Pool, year: number, make: string, model: string, trim: string): Promise<string[]> {
|
||||
const query = `
|
||||
SELECT DISTINCT
|
||||
CASE
|
||||
WHEN vo.transmission_id IS NULL THEN 'N/A'
|
||||
ELSE t.type
|
||||
END as transmission_type
|
||||
FROM vehicle_options vo
|
||||
LEFT JOIN transmissions t ON t.id = vo.transmission_id
|
||||
WHERE vo.year = $1
|
||||
AND vo.make = $2
|
||||
AND vo.model = $3
|
||||
ORDER BY transmission_type
|
||||
SELECT transmission_type FROM get_transmissions_for_vehicle($1, $2, $3, $4)
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(query, [year, make, model]);
|
||||
const result = await pool.query(query, [year, make, model, trim]);
|
||||
return result.rows.map(row => row.transmission_type);
|
||||
} catch (error) {
|
||||
logger.error('Repository error: getTransmissions', { error, year, make, model });
|
||||
throw new Error(`Failed to retrieve transmissions for year ${year}, make ${make}, model ${model}`);
|
||||
logger.error('Repository error: getTransmissionsForTrim', { error, year, make, model, trim });
|
||||
throw new Error(`Failed to retrieve transmissions for year ${year}, make ${make}, model ${model}, trim ${trim}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTransmissionsForTrimAndEngine(pool: Pool, year: number, make: string, model: string, trim: string, engine: string): Promise<string[]> {
|
||||
const query = `
|
||||
SELECT transmission_type FROM get_transmissions_for_vehicle_engine($1, $2, $3, $4, $5)
|
||||
`;
|
||||
try {
|
||||
const result = await pool.query(query, [year, make, model, trim, engine]);
|
||||
return result.rows.map(row => row.transmission_type);
|
||||
} catch (error) {
|
||||
logger.error('Repository error: getTransmissionsForTrimAndEngine', { error, year, make, model, trim, engine });
|
||||
throw new Error(`Failed to retrieve transmissions for year ${year}, make ${make}, model ${model}, trim ${trim}, engine ${engine}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getEnginesForTrimAndTransmission(pool: Pool, year: number, make: string, model: string, trim: string, transmission: string): Promise<string[]> {
|
||||
const query = `
|
||||
SELECT engine_name FROM get_engines_for_vehicle_trans($1, $2, $3, $4, $5)
|
||||
`;
|
||||
try {
|
||||
const result = await pool.query(query, [year, make, model, trim, transmission]);
|
||||
return result.rows.map(row => row.engine_name);
|
||||
} catch (error) {
|
||||
logger.error('Repository error: getEnginesForTrimAndTransmission', { error, year, make, model, trim, transmission });
|
||||
throw new Error(`Failed to retrieve engines for year ${year}, make ${make}, model ${model}, trim ${trim}, transmission ${transmission}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -94,21 +94,41 @@ export class PlatformCacheService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached transmissions for year, make, and model
|
||||
* Get cached transmissions for year, make, model, and trim
|
||||
*/
|
||||
async getTransmissions(year: number, make: string, model: string): Promise<string[] | null> {
|
||||
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model;
|
||||
async getTransmissionsForTrim(year: number, make: string, model: string, trim: string): Promise<string[] | null> {
|
||||
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model + ':' + trim;
|
||||
return await this.cacheService.get<string[]>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached transmissions for year, make, and model
|
||||
* Set cached transmissions for year, make, model, and trim
|
||||
*/
|
||||
async setTransmissions(year: number, make: string, model: string, transmissions: string[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model;
|
||||
async setTransmissionsForTrim(year: number, make: string, model: string, trim: string, transmissions: string[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model + ':' + trim;
|
||||
await this.cacheService.set(key, transmissions, ttl);
|
||||
}
|
||||
|
||||
async getTransmissionsForTrimAndEngine(year: number, make: string, model: string, trim: string, engine: string): Promise<string[] | null> {
|
||||
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model + ':' + trim + ':engine:' + engine;
|
||||
return await this.cacheService.get<string[]>(key);
|
||||
}
|
||||
|
||||
async setTransmissionsForTrimAndEngine(year: number, make: string, model: string, trim: string, engine: string, transmissions: string[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model + ':' + trim + ':engine:' + engine;
|
||||
await this.cacheService.set(key, transmissions, ttl);
|
||||
}
|
||||
|
||||
async getEnginesForTrimAndTransmission(year: number, make: string, model: string, trim: string, transmission: string): Promise<string[] | null> {
|
||||
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + make + ':' + model + ':' + trim + ':transmission:' + transmission;
|
||||
return await this.cacheService.get<string[]>(key);
|
||||
}
|
||||
|
||||
async setEnginesForTrimAndTransmission(year: number, make: string, model: string, trim: string, transmission: string, engines: string[], ttl: number = 6 * 3600): Promise<void> {
|
||||
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + make + ':' + model + ':' + trim + ':transmission:' + transmission;
|
||||
await this.cacheService.set(key, engines, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached VIN decode result
|
||||
*/
|
||||
|
||||
@@ -123,23 +123,94 @@ export class VehicleDataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transmissions for a year, make, and model with caching
|
||||
* Get transmissions for a year, make, model, and trim with caching
|
||||
*/
|
||||
async getTransmissions(pool: Pool, year: number, make: string, model: string): Promise<string[]> {
|
||||
async getTransmissionsForTrim(pool: Pool, year: number, make: string, model: string, trim: string): Promise<string[]> {
|
||||
try {
|
||||
const cached = await this.cache.getTransmissions(year, make, model);
|
||||
const cached = await this.cache.getTransmissionsForTrim(year, make, model, trim);
|
||||
if (cached) {
|
||||
logger.debug('Transmissions retrieved from cache', { year, make, model });
|
||||
logger.debug('Transmissions retrieved from cache', { year, make, model, trim });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const transmissions = await this.repository.getTransmissions(pool, year, make, model);
|
||||
await this.cache.setTransmissions(year, make, model, transmissions);
|
||||
logger.debug('Transmissions retrieved from database and cached', { year, make, model, count: transmissions.length });
|
||||
const transmissions = await this.repository.getTransmissionsForTrim(pool, year, make, model, trim);
|
||||
await this.cache.setTransmissionsForTrim(year, make, model, trim, transmissions);
|
||||
logger.debug('Transmissions retrieved from database and cached', { year, make, model, trim, count: transmissions.length });
|
||||
return transmissions;
|
||||
} catch (error) {
|
||||
logger.error('Service error: getTransmissions', { error, year, make, model });
|
||||
logger.error('Service error: getTransmissionsForTrim', { error, year, make, model, trim });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTransmissionsForTrimAndEngine(
|
||||
pool: Pool,
|
||||
year: number,
|
||||
make: string,
|
||||
model: string,
|
||||
trim: string,
|
||||
engine: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const cached = await this.cache.getTransmissionsForTrimAndEngine(year, make, model, trim, engine);
|
||||
if (cached) {
|
||||
logger.debug('Transmissions (engine-filtered) retrieved from cache', { year, make, model, trim, engine });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const transmissions = await this.repository.getTransmissionsForTrimAndEngine(pool, year, make, model, trim, engine);
|
||||
await this.cache.setTransmissionsForTrimAndEngine(year, make, model, trim, engine, transmissions);
|
||||
logger.debug('Transmissions (engine-filtered) retrieved from database and cached', { year, make, model, trim, engine, count: transmissions.length });
|
||||
return transmissions;
|
||||
} catch (error) {
|
||||
logger.error('Service error: getTransmissionsForTrimAndEngine', { error, year, make, model, trim, engine });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getEnginesForTrimAndTransmission(
|
||||
pool: Pool,
|
||||
year: number,
|
||||
make: string,
|
||||
model: string,
|
||||
trim: string,
|
||||
transmission: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const cached = await this.cache.getEnginesForTrimAndTransmission(year, make, model, trim, transmission);
|
||||
if (cached) {
|
||||
logger.debug('Engines (transmission-filtered) retrieved from cache', { year, make, model, trim, transmission });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const engines = await this.repository.getEnginesForTrimAndTransmission(pool, year, make, model, trim, transmission);
|
||||
await this.cache.setEnginesForTrimAndTransmission(year, make, model, trim, transmission, engines);
|
||||
logger.debug('Engines (transmission-filtered) retrieved from database and cached', { year, make, model, trim, transmission, count: engines.length });
|
||||
return engines;
|
||||
} catch (error) {
|
||||
logger.error('Service error: getEnginesForTrimAndTransmission', { error, year, make, model, trim, transmission });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getOptions(
|
||||
pool: Pool,
|
||||
year: number,
|
||||
make: string,
|
||||
model: string,
|
||||
trim: string,
|
||||
engine?: string,
|
||||
transmission?: string
|
||||
): Promise<{ engines: string[]; transmissions: string[] }> {
|
||||
const [engines, transmissions] = await Promise.all([
|
||||
transmission
|
||||
? this.getEnginesForTrimAndTransmission(pool, year, make, model, trim, transmission)
|
||||
: this.getEngines(pool, year, make, model, trim),
|
||||
engine
|
||||
? this.getTransmissionsForTrimAndEngine(pool, year, make, model, trim, engine)
|
||||
: this.getTransmissionsForTrim(pool, year, make, model, trim)
|
||||
]);
|
||||
|
||||
return { engines, transmissions };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* @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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,19 @@
|
||||
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';
|
||||
import { VehicleDataService } from './domain/vehicle-data.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;
|
||||
// Singleton vehicle data service for use by other features
|
||||
let vehicleDataServiceInstance: VehicleDataService | null = null;
|
||||
|
||||
export function getVINDecodeService(): VINDecodeService {
|
||||
if (!vinDecodeServiceInstance) {
|
||||
const platformCache = new PlatformCacheService(cacheService);
|
||||
vinDecodeServiceInstance = new VINDecodeService(platformCache);
|
||||
}
|
||||
return vinDecodeServiceInstance;
|
||||
}
|
||||
|
||||
export function getVehicleDataService(): VehicleDataService {
|
||||
if (!vehicleDataServiceInstance) {
|
||||
const platformCache = new PlatformCacheService(cacheService);
|
||||
|
||||
@@ -102,7 +102,11 @@ export const transmissionsQuerySchema = z.object({
|
||||
.max(100, 'Make must be less than 100 characters'),
|
||||
model: z.string()
|
||||
.min(1, 'Model is required')
|
||||
.max(100, 'Model must be less than 100 characters')
|
||||
.max(100, 'Model must be less than 100 characters'),
|
||||
trim: z.string()
|
||||
.min(1, 'Trim is required')
|
||||
.max(100, 'Trim must be less than 100 characters'),
|
||||
engine: z.string().optional()
|
||||
});
|
||||
|
||||
export type TransmissionsQuery = z.infer<typeof transmissionsQuerySchema>;
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/v
|
||||
|
||||
export class VehiclesController {
|
||||
private vehiclesService: VehiclesService;
|
||||
private static readonly MIN_YEAR = 2017;
|
||||
private static readonly MAX_YEAR = 2022;
|
||||
|
||||
constructor() {
|
||||
const repository = new VehiclesRepository(pool);
|
||||
@@ -153,10 +155,10 @@ export class VehiclesController {
|
||||
async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { year } = request.query;
|
||||
if (!year || year < 1980 || year > new Date().getFullYear() + 1) {
|
||||
if (!year || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year parameter is required (1980-' + (new Date().getFullYear() + 1) + ')'
|
||||
message: `Valid year parameter is required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,10 +176,10 @@ export class VehiclesController {
|
||||
async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { year, make } = request.query;
|
||||
if (!year || !make || year < 1980 || year > new Date().getFullYear() + 1 || make.trim().length === 0) {
|
||||
if (!year || !make || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year and make parameters are required'
|
||||
message: `Valid year and make parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,20 +194,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) {
|
||||
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { year, make, model } = request.query;
|
||||
if (!year || !make || !model || year < 1980 || year > new Date().getFullYear() + 1 || make.trim().length === 0 || model.trim().length === 0) {
|
||||
const { year, make, model, trim } = request.query;
|
||||
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make, and model parameters are required'
|
||||
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
||||
});
|
||||
}
|
||||
|
||||
const transmissions = await this.vehiclesService.getDropdownTransmissions(year, make, model);
|
||||
const transmissions = await this.vehiclesService.getDropdownTransmissions(year, make, model, trim);
|
||||
return reply.code(200).send(transmissions);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown transmissions', { error, year: request.query?.year, make: request.query?.make, model: request.query?.model });
|
||||
logger.error('Error getting dropdown transmissions', { error, year: request.query?.year, make: request.query?.make, model: request.query?.model, trim: request.query?.trim });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get transmissions'
|
||||
@@ -216,10 +218,10 @@ export class VehiclesController {
|
||||
async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { year, make, model, trim } = request.query;
|
||||
if (!year || !make || !model || !trim || year < 1980 || year > new Date().getFullYear() + 1 || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
||||
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make, model, and trim parameters are required'
|
||||
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,10 +239,10 @@ export class VehiclesController {
|
||||
async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { year, make, model } = request.query;
|
||||
if (!year || !make || !model || year < 1980 || year > new Date().getFullYear() + 1 || make.trim().length === 0 || model.trim().length === 0) {
|
||||
if (!year || !make || !model || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make, and model parameters are required'
|
||||
message: `Valid year, make, and model parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,26 +271,23 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async decodeVIN(request: FastifyRequest<{ Body: { vin: string } }>, reply: FastifyReply) {
|
||||
async getDropdownOptions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { vin } = request.body;
|
||||
|
||||
if (!vin || vin.length !== 17) {
|
||||
const { year, make, model, trim, engine, transmission } = request.query;
|
||||
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
||||
return reply.code(400).send({
|
||||
vin: vin || '',
|
||||
success: false,
|
||||
error: 'VIN must be exactly 17 characters'
|
||||
error: 'Bad Request',
|
||||
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
const options = await this.vehiclesService.getDropdownOptions(year, make, model, trim, engine, transmission);
|
||||
return reply.code(200).send(options);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown options', { error, query: request.query });
|
||||
return reply.code(500).send({
|
||||
vin: request.body?.vin || '',
|
||||
success: false,
|
||||
error: 'VIN decode failed'
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get engine/transmission options'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,16 +63,16 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
|
||||
handler: vehiclesController.getDropdownEngines.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/transmissions?year=2024&make=Ford&model=F-150 - Get transmissions (Level 3)
|
||||
fastify.get<{ Querystring: { year: number; make: string; model: string } }>('/vehicles/dropdown/transmissions', {
|
||||
// GET /api/vehicles/dropdown/transmissions?year=2024&make=Ford&model=F-150&trim=XLT - Get transmissions (Level 4, trim-filtered)
|
||||
fastify.get<{ Querystring: { year: number; make: string; model: string; trim: string } }>('/vehicles/dropdown/transmissions', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: vehiclesController.getDropdownTransmissions.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// POST /api/vehicles/decode-vin - Decode VIN and return vehicle information
|
||||
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
|
||||
// GET /api/vehicles/dropdown/options?year&make&model&trim[&engine=...][&transmission=...] - Pair-safe options for engine/transmission
|
||||
fastify.get<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>('/vehicles/dropdown/options', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: vehiclesController.decodeVIN.bind(vehiclesController)
|
||||
handler: vehiclesController.getDropdownOptions.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// Dynamic :id routes MUST come last to avoid matching specific paths like "dropdown"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import { VehiclesRepository } from '../data/vehicles.repository';
|
||||
import { getVINDecodeService, getVehicleDataService, getPool } from '../../platform';
|
||||
import {
|
||||
Vehicle,
|
||||
CreateVehicleRequest,
|
||||
@@ -15,6 +14,7 @@ import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import { isValidVIN } from '../../../shared-minimal/utils/validators';
|
||||
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
||||
import { getVehicleDataService, getPool } from '../../platform';
|
||||
|
||||
export class VehiclesService {
|
||||
private readonly cachePrefix = 'vehicles';
|
||||
@@ -27,10 +27,6 @@ export class VehiclesService {
|
||||
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)) {
|
||||
@@ -41,33 +37,19 @@ export class VehiclesService {
|
||||
if (existing) {
|
||||
throw new Error('Vehicle with this VIN already exists');
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Create vehicle with user-provided data
|
||||
const vehicle = await this.repository.create({
|
||||
...data,
|
||||
userId,
|
||||
make: normalizeMakeName(inputMake),
|
||||
model: normalizeModelName(inputModel),
|
||||
year: (data as any).year ?? year,
|
||||
make: data.make ? normalizeMakeName(data.make) : undefined,
|
||||
model: data.model ? normalizeModelName(data.model) : undefined,
|
||||
});
|
||||
|
||||
|
||||
// Invalidate user's vehicle list cache
|
||||
await this.invalidateUserCache(userId);
|
||||
|
||||
|
||||
return this.toResponse(vehicle);
|
||||
}
|
||||
|
||||
@@ -178,12 +160,12 @@ export class VehiclesService {
|
||||
return vehicleDataService.getModels(pool, year, make);
|
||||
}
|
||||
|
||||
async getDropdownTransmissions(year: number, make: string, model: string): Promise<string[]> {
|
||||
async getDropdownTransmissions(year: number, make: string, model: string, trim: string): Promise<string[]> {
|
||||
const vehicleDataService = getVehicleDataService();
|
||||
const pool = getPool();
|
||||
|
||||
logger.info('Fetching dropdown transmissions via platform module', { year, make, model });
|
||||
return vehicleDataService.getTransmissions(pool, year, make, model);
|
||||
logger.info('Fetching dropdown transmissions via platform module', { year, make, model, trim });
|
||||
return vehicleDataService.getTransmissionsForTrim(pool, year, make, model, trim);
|
||||
}
|
||||
|
||||
async getDropdownEngines(year: number, make: string, model: string, trim: string): Promise<string[]> {
|
||||
@@ -202,6 +184,21 @@ export class VehiclesService {
|
||||
return vehicleDataService.getTrims(pool, year, make, model);
|
||||
}
|
||||
|
||||
async getDropdownOptions(
|
||||
year: number,
|
||||
make: string,
|
||||
model: string,
|
||||
trim: string,
|
||||
engine?: string,
|
||||
transmission?: string
|
||||
): Promise<{ engines: string[]; transmissions: string[] }> {
|
||||
const vehicleDataService = getVehicleDataService();
|
||||
const pool = getPool();
|
||||
|
||||
logger.info('Fetching dropdown options via platform module', { year, make, model, trim, engine, transmission });
|
||||
return vehicleDataService.getOptions(pool, year, make, model, trim, engine, transmission);
|
||||
}
|
||||
|
||||
async getDropdownYears(): Promise<number[]> {
|
||||
const vehicleDataService = getVehicleDataService();
|
||||
const pool = getPool();
|
||||
@@ -210,54 +207,6 @@ export class VehiclesService {
|
||||
return vehicleDataService.getYears(pool);
|
||||
}
|
||||
|
||||
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 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.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: result.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,
|
||||
|
||||
@@ -22,19 +22,6 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock external VIN decoder
|
||||
jest.mock('../../external/vpic/vpic.client', () => ({
|
||||
vpicClient: {
|
||||
decodeVIN: jest.fn().mockResolvedValue({
|
||||
make: 'Honda',
|
||||
model: 'Civic',
|
||||
year: 2021,
|
||||
engineType: '2.0L',
|
||||
bodyType: 'Sedan',
|
||||
rawData: []
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Vehicles Integration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -67,7 +54,7 @@ describe('Vehicles Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('POST /api/vehicles', () => {
|
||||
it('should create a new vehicle', async () => {
|
||||
it('should create a new vehicle with VIN', async () => {
|
||||
const vehicleData = {
|
||||
vin: '1HGBH41JXMN109186',
|
||||
nickname: 'My Test Car',
|
||||
@@ -84,9 +71,6 @@ describe('Vehicles Integration Tests', () => {
|
||||
id: expect.any(String),
|
||||
userId: 'test-user-123',
|
||||
vin: '1HGBH41JXMN109186',
|
||||
make: 'Honda',
|
||||
model: 'Civic',
|
||||
year: 2021,
|
||||
nickname: 'My Test Car',
|
||||
color: 'Blue',
|
||||
odometerReading: 50000,
|
||||
@@ -113,7 +97,8 @@ describe('Vehicles Integration Tests', () => {
|
||||
it('should reject duplicate VIN for same user', async () => {
|
||||
const vehicleData = {
|
||||
vin: '1HGBH41JXMN109186',
|
||||
nickname: 'First Car'
|
||||
nickname: 'First Car',
|
||||
licensePlate: 'ABC123'
|
||||
};
|
||||
|
||||
// Create first vehicle
|
||||
@@ -128,7 +113,7 @@ describe('Vehicles Integration Tests', () => {
|
||||
.send({ ...vehicleData, nickname: 'Duplicate Car' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Vehicle with this VIN already exists');
|
||||
expect(response.body.message).toContain('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,14 +12,12 @@ import * as platformModule from '../../../platform';
|
||||
jest.mock('../../data/vehicles.repository');
|
||||
jest.mock('../../../../core/config/redis');
|
||||
jest.mock('../../../platform', () => ({
|
||||
getVINDecodeService: jest.fn(),
|
||||
getVehicleDataService: jest.fn(),
|
||||
getPool: jest.fn()
|
||||
}));
|
||||
|
||||
const mockRepository = jest.mocked(VehiclesRepository);
|
||||
const mockCacheService = jest.mocked(cacheService);
|
||||
const mockGetVINDecodeService = jest.mocked(platformModule.getVINDecodeService);
|
||||
const mockGetVehicleDataService = jest.mocked(platformModule.getVehicleDataService);
|
||||
const mockGetPool = jest.mocked(platformModule.getPool);
|
||||
|
||||
@@ -117,7 +115,7 @@ describe('VehiclesService', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createVehicle', () => {
|
||||
const mockVehicleData = {
|
||||
vin: '1HGBH41JXMN109186',
|
||||
@@ -126,22 +124,13 @@ describe('VehiclesService', () => {
|
||||
odometerReading: 50000,
|
||||
};
|
||||
|
||||
const mockVinDecodeResult = {
|
||||
make: 'Honda',
|
||||
model: 'Civic',
|
||||
year: 2021,
|
||||
engineType: '2.0L',
|
||||
bodyType: 'Sedan',
|
||||
rawData: [],
|
||||
};
|
||||
|
||||
const mockCreatedVehicle = {
|
||||
id: 'vehicle-id-123',
|
||||
userId: 'user-123',
|
||||
vin: '1HGBH41JXMN109186',
|
||||
make: 'Honda',
|
||||
model: 'Civic',
|
||||
year: 2021,
|
||||
make: undefined,
|
||||
model: undefined,
|
||||
year: undefined,
|
||||
nickname: 'My Car',
|
||||
color: 'Blue',
|
||||
licensePlate: undefined,
|
||||
@@ -152,20 +141,7 @@ describe('VehiclesService', () => {
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
it('should create a vehicle with user-provided VIN', async () => {
|
||||
repositoryInstance.findByUserAndVIN.mockResolvedValue(null);
|
||||
repositoryInstance.create.mockResolvedValue(mockCreatedVehicle);
|
||||
mockCacheService.del.mockResolvedValue(undefined);
|
||||
@@ -173,16 +149,13 @@ describe('VehiclesService', () => {
|
||||
const result = await service.createVehicle(mockVehicleData, 'user-123');
|
||||
|
||||
expect(repositoryInstance.findByUserAndVIN).toHaveBeenCalledWith('user-123', '1HGBH41JXMN109186');
|
||||
expect(mockVinDecodeService.decodeVIN).toHaveBeenCalledWith('mock-pool', '1HGBH41JXMN109186');
|
||||
expect(repositoryInstance.create).toHaveBeenCalledWith({
|
||||
...mockVehicleData,
|
||||
userId: 'user-123',
|
||||
make: 'Honda',
|
||||
model: 'Civic',
|
||||
year: 2021,
|
||||
make: undefined,
|
||||
model: undefined,
|
||||
});
|
||||
expect(result.id).toBe('vehicle-id-123');
|
||||
expect(result.make).toBe('Honda');
|
||||
});
|
||||
|
||||
it('should reject invalid VIN format', async () => {
|
||||
@@ -196,31 +169,6 @@ describe('VehiclesService', () => {
|
||||
|
||||
await expect(service.createVehicle(mockVehicleData, 'user-123')).rejects.toThrow('Vehicle with this VIN already exists');
|
||||
});
|
||||
|
||||
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);
|
||||
repositoryInstance.create.mockResolvedValue({ ...mockCreatedVehicle, make: undefined, model: undefined, year: undefined });
|
||||
mockCacheService.del.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.createVehicle(mockVehicleData, 'user-123');
|
||||
|
||||
expect(repositoryInstance.create).toHaveBeenCalledWith({
|
||||
...mockVehicleData,
|
||||
userId: 'user-123',
|
||||
make: undefined,
|
||||
model: undefined,
|
||||
year: undefined,
|
||||
});
|
||||
expect(result.make).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserVehicles', () => {
|
||||
|
||||
Reference in New Issue
Block a user