Very minimal MVP

This commit is contained in:
Eric Gullickson
2025-08-23 09:54:22 -05:00
parent d60c3ec00e
commit 6683f1eeff
12 changed files with 661 additions and 13 deletions

View File

@@ -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);
}
};
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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();

View File

@@ -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[];
}

View File

@@ -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';