Very minimal MVP
This commit is contained in:
@@ -161,4 +161,75 @@ export class VehiclesController {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getDropdownMakes = async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const makes = await this.service.getDropdownMakes();
|
||||
res.json(makes);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Failed to load makes') {
|
||||
res.status(503).json({ error: 'Unable to load makes data' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getDropdownModels = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { make } = req.params;
|
||||
if (!make) {
|
||||
res.status(400).json({ error: 'Make parameter is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const models = await this.service.getDropdownModels(make);
|
||||
res.json(models);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Failed to load models') {
|
||||
res.status(503).json({ error: 'Unable to load models data' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getDropdownTransmissions = async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const transmissions = await this.service.getDropdownTransmissions();
|
||||
res.json(transmissions);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Failed to load transmissions') {
|
||||
res.status(503).json({ error: 'Unable to load transmissions data' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getDropdownEngines = async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const engines = await this.service.getDropdownEngines();
|
||||
res.json(engines);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Failed to load engines') {
|
||||
res.status(503).json({ error: 'Unable to load engines data' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getDropdownTrims = async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const trims = await this.service.getDropdownTrims();
|
||||
res.json(trims);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Failed to load trims') {
|
||||
res.status(503).json({ error: 'Unable to load trims data' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -11,10 +11,17 @@ export function registerVehiclesRoutes(): Router {
|
||||
const router = Router();
|
||||
const controller = new VehiclesController();
|
||||
|
||||
// All vehicle routes require authentication
|
||||
// Dropdown Data Routes (no auth required for form population)
|
||||
router.get('/api/vehicles/dropdown/makes', controller.getDropdownMakes);
|
||||
router.get('/api/vehicles/dropdown/models/:make', controller.getDropdownModels);
|
||||
router.get('/api/vehicles/dropdown/transmissions', controller.getDropdownTransmissions);
|
||||
router.get('/api/vehicles/dropdown/engines', controller.getDropdownEngines);
|
||||
router.get('/api/vehicles/dropdown/trims', controller.getDropdownTrims);
|
||||
|
||||
// All other vehicle routes require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Routes
|
||||
// CRUD Routes
|
||||
router.post('/api/vehicles', controller.createVehicle);
|
||||
router.get('/api/vehicles', controller.getUserVehicles);
|
||||
router.get('/api/vehicles/:id', controller.getVehicle);
|
||||
|
||||
@@ -140,6 +140,81 @@ export class VehiclesService {
|
||||
await cacheService.del(cacheKey);
|
||||
}
|
||||
|
||||
async getDropdownMakes(): 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
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown makes', { error });
|
||||
throw new Error('Failed to load makes');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownModels(make: string): 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
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown models', { make, error });
|
||||
throw new Error('Failed to load models');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTransmissions(): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown transmissions');
|
||||
const transmissions = await vpicClient.getTransmissionTypes();
|
||||
|
||||
return transmissions.map(transmission => ({
|
||||
id: transmission.Id,
|
||||
name: transmission.Name
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown transmissions', { error });
|
||||
throw new Error('Failed to load transmissions');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownEngines(): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown engines');
|
||||
const engines = await vpicClient.getEngineConfigurations();
|
||||
|
||||
return engines.map(engine => ({
|
||||
id: engine.Id,
|
||||
name: engine.Name
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown engines', { error });
|
||||
throw new Error('Failed to load engines');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTrims(): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown trims');
|
||||
const trims = await vpicClient.getTrimLevels();
|
||||
|
||||
return trims.map(trim => ({
|
||||
id: trim.Id,
|
||||
name: trim.Name
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown trims', { error });
|
||||
throw new Error('Failed to load trims');
|
||||
}
|
||||
}
|
||||
|
||||
private toResponse(vehicle: Vehicle): VehicleResponse {
|
||||
return {
|
||||
id: vehicle.id,
|
||||
@@ -148,6 +223,11 @@ export class VehiclesService {
|
||||
make: vehicle.make,
|
||||
model: vehicle.model,
|
||||
year: vehicle.year,
|
||||
engine: vehicle.engine,
|
||||
transmission: vehicle.transmission,
|
||||
trimLevel: vehicle.trimLevel,
|
||||
driveType: vehicle.driveType,
|
||||
fuelType: vehicle.fuelType,
|
||||
nickname: vehicle.nickname,
|
||||
color: vehicle.color,
|
||||
licensePlate: vehicle.licensePlate,
|
||||
|
||||
@@ -10,6 +10,11 @@ export interface Vehicle {
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
trimLevel?: string;
|
||||
driveType?: string;
|
||||
fuelType?: string;
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
@@ -22,6 +27,13 @@ export interface Vehicle {
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
vin: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
trimLevel?: string;
|
||||
driveType?: string;
|
||||
fuelType?: string;
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
@@ -29,6 +41,13 @@ export interface CreateVehicleRequest {
|
||||
}
|
||||
|
||||
export interface UpdateVehicleRequest {
|
||||
make?: string;
|
||||
model?: string;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
trimLevel?: string;
|
||||
driveType?: string;
|
||||
fuelType?: string;
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
@@ -42,6 +61,11 @@ export interface VehicleResponse {
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
trimLevel?: string;
|
||||
driveType?: string;
|
||||
fuelType?: string;
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
|
||||
@@ -7,11 +7,21 @@ import axios from 'axios';
|
||||
import { env } from '../../../../core/config/environment';
|
||||
import { logger } from '../../../../core/logging/logger';
|
||||
import { cacheService } from '../../../../core/config/redis';
|
||||
import { VPICResponse, VPICDecodeResult } from './vpic.types';
|
||||
import {
|
||||
VPICResponse,
|
||||
VPICDecodeResult,
|
||||
VPICMake,
|
||||
VPICModel,
|
||||
VPICTransmission,
|
||||
VPICEngine,
|
||||
VPICTrim,
|
||||
DropdownDataResponse
|
||||
} from './vpic.types';
|
||||
|
||||
export class VPICClient {
|
||||
private readonly baseURL = env.VPIC_API_URL;
|
||||
private readonly cacheTTL = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
private readonly dropdownCacheTTL = 7 * 24 * 60 * 60; // 7 days for dropdown data
|
||||
|
||||
async decodeVIN(vin: string): Promise<VPICDecodeResult | null> {
|
||||
const cacheKey = `vpic:vin:${vin}`;
|
||||
@@ -73,6 +83,96 @@ export class VPICClient {
|
||||
rawData: response.Results,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllMakes(): Promise<VPICMake[]> {
|
||||
const cacheKey = 'vpic:makes';
|
||||
|
||||
try {
|
||||
const cached = await cacheService.get<VPICMake[]>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Makes cache hit');
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.info('Calling vPIC API for makes');
|
||||
const response = await axios.get<{ Count: number; Message: string; Results: VPICMake[] }>(
|
||||
`${this.baseURL}/GetAllMakes?format=json`
|
||||
);
|
||||
|
||||
const makes = response.data.Results || [];
|
||||
await cacheService.set(cacheKey, makes, this.dropdownCacheTTL);
|
||||
|
||||
return makes;
|
||||
} catch (error) {
|
||||
logger.error('Get makes failed', { error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getModelsForMake(make: string): Promise<VPICModel[]> {
|
||||
const cacheKey = `vpic:models:${make}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheService.get<VPICModel[]>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Models cache hit', { make });
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.info('Calling vPIC API for models', { make });
|
||||
const response = await axios.get<{ Count: number; Message: string; Results: VPICModel[] }>(
|
||||
`${this.baseURL}/GetModelsForMake/${encodeURIComponent(make)}?format=json`
|
||||
);
|
||||
|
||||
const models = response.data.Results || [];
|
||||
await cacheService.set(cacheKey, models, this.dropdownCacheTTL);
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
logger.error('Get models failed', { make, error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getTransmissionTypes(): Promise<VPICTransmission[]> {
|
||||
return this.getVariableValues('Transmission Style', 'transmissions');
|
||||
}
|
||||
|
||||
async getEngineConfigurations(): Promise<VPICEngine[]> {
|
||||
return this.getVariableValues('Engine Configuration', 'engines');
|
||||
}
|
||||
|
||||
async getTrimLevels(): Promise<VPICTrim[]> {
|
||||
return this.getVariableValues('Trim', 'trims');
|
||||
}
|
||||
|
||||
private async getVariableValues(
|
||||
variable: string,
|
||||
cachePrefix: string
|
||||
): Promise<VPICTransmission[] | VPICEngine[] | VPICTrim[]> {
|
||||
const cacheKey = `vpic:${cachePrefix}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheService.get<VPICTransmission[]>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Variable values cache hit', { variable });
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.info('Calling vPIC API for variable values', { variable });
|
||||
const response = await axios.get<DropdownDataResponse>(
|
||||
`${this.baseURL}/GetVehicleVariableValuesList/${encodeURIComponent(variable)}?format=json`
|
||||
);
|
||||
|
||||
const values = response.data.Results || [];
|
||||
await cacheService.set(cacheKey, values, this.dropdownCacheTTL);
|
||||
|
||||
return values;
|
||||
} catch (error) {
|
||||
logger.error('Get variable values failed', { variable, error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const vpicClient = new VPICClient();
|
||||
@@ -23,4 +23,32 @@ export interface VPICDecodeResult {
|
||||
engineType?: string;
|
||||
bodyType?: string;
|
||||
rawData: VPICResult[];
|
||||
}
|
||||
|
||||
export interface VPICMake {
|
||||
Make_ID: number;
|
||||
Make_Name: string;
|
||||
}
|
||||
|
||||
export interface VPICModel {
|
||||
Make_ID: number;
|
||||
Make_Name: string;
|
||||
Model_ID: number;
|
||||
Model_Name: string;
|
||||
}
|
||||
|
||||
export interface VPICDropdownItem {
|
||||
Id: number;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
export interface VPICTransmission extends VPICDropdownItem {}
|
||||
export interface VPICEngine extends VPICDropdownItem {}
|
||||
export interface VPICTrim extends VPICDropdownItem {}
|
||||
|
||||
export interface DropdownDataResponse {
|
||||
Count: number;
|
||||
Message: string;
|
||||
SearchCriteria: string;
|
||||
Results: VPICDropdownItem[];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Add new dropdown fields to vehicles table
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS engine VARCHAR(100);
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS transmission VARCHAR(100);
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS trim_level VARCHAR(100);
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS drive_type VARCHAR(50);
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS fuel_type VARCHAR(50);
|
||||
|
||||
-- Create indexes for new fields for potential future searches
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_make ON vehicles(make);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_model ON vehicles(model);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_engine ON vehicles(engine);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_transmission ON vehicles(transmission);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_trim_level ON vehicles(trim_level);
|
||||
|
||||
-- Create dropdown cache table for performance optimization
|
||||
CREATE TABLE IF NOT EXISTS vehicle_dropdown_cache (
|
||||
cache_key VARCHAR(255) PRIMARY KEY,
|
||||
data JSONB NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index on expiry for cleanup tasks
|
||||
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
|
||||
BEFORE UPDATE ON vehicle_dropdown_cache
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON COLUMN vehicles.engine IS 'Engine configuration from NHTSA vPIC API';
|
||||
COMMENT ON COLUMN vehicles.transmission IS 'Transmission style from NHTSA vPIC API';
|
||||
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';
|
||||
Reference in New Issue
Block a user