Updates to database and API for dropdowns.

This commit is contained in:
Eric Gullickson
2025-11-11 10:29:02 -06:00
parent 3dc0f2a733
commit 8376aee7ed
157 changed files with 2573659 additions and 1548221 deletions

View File

@@ -13,6 +13,7 @@ import {
ModelsQuery,
TrimsQuery,
EnginesQuery,
TransmissionsQuery,
VINDecodeRequest
} from '../models/requests';
import { logger } from '../../../core/logging/logger';
@@ -57,12 +58,12 @@ export class PlatformController {
}
/**
* GET /api/platform/models?year={year}&make_id={id}
* GET /api/platform/models?year={year}&make={make}
*/
async getModels(request: FastifyRequest<{ Querystring: ModelsQuery }>, reply: FastifyReply): Promise<void> {
try {
const { year, make_id } = request.query;
const models = await this.vehicleDataService.getModels(this.pool, year, make_id);
const { year, make } = request.query as any;
const models = await this.vehicleDataService.getModels(this.pool, year, make);
reply.code(200).send({ models });
} catch (error) {
logger.error('Controller error: getModels', { error, query: request.query });
@@ -71,12 +72,12 @@ export class PlatformController {
}
/**
* GET /api/platform/trims?year={year}&model_id={id}
* GET /api/platform/trims?year={year}&make={make}&model={model}
*/
async getTrims(request: FastifyRequest<{ Querystring: TrimsQuery }>, reply: FastifyReply): Promise<void> {
try {
const { year, model_id } = request.query;
const trims = await this.vehicleDataService.getTrims(this.pool, year, model_id);
const { year, make, model } = request.query as any;
const trims = await this.vehicleDataService.getTrims(this.pool, year, make, model);
reply.code(200).send({ trims });
} catch (error) {
logger.error('Controller error: getTrims', { error, query: request.query });
@@ -85,12 +86,12 @@ export class PlatformController {
}
/**
* GET /api/platform/engines?year={year}&model_id={id}&trim_id={id}
* GET /api/platform/engines?year={year}&make={make}&model={model}&trim={trim}
*/
async getEngines(request: FastifyRequest<{ Querystring: EnginesQuery }>, reply: FastifyReply): Promise<void> {
try {
const { year, model_id, trim_id } = request.query;
const engines = await this.vehicleDataService.getEngines(this.pool, year, model_id, trim_id);
const { year, make, model, trim } = request.query as any;
const engines = await this.vehicleDataService.getEngines(this.pool, year, make, model, trim);
reply.code(200).send({ engines });
} catch (error) {
logger.error('Controller error: getEngines', { error, query: request.query });
@@ -98,6 +99,20 @@ export class PlatformController {
}
}
/**
* GET /api/platform/transmissions?year={year}&make={make}&model={model}
*/
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);
reply.code(200).send({ transmissions });
} catch (error) {
logger.error('Controller error: getTransmissions', { error, query: request.query });
reply.code(500).send({ error: 'Failed to retrieve transmissions' });
}
}
/**
* GET /api/platform/vehicle?vin={vin}
*/

View File

@@ -10,6 +10,7 @@ import {
ModelsQuery,
TrimsQuery,
EnginesQuery,
TransmissionsQuery,
VINDecodeRequest
} from '../models/requests';
import pool from '../../../core/config/database';
@@ -37,6 +38,10 @@ async function platformRoutes(fastify: FastifyInstance) {
preHandler: [fastify.authenticate]
}, controller.getEngines.bind(controller));
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));

View File

@@ -1,23 +1,23 @@
/**
* @ai-summary Vehicle data repository for hierarchical queries
* @ai-context PostgreSQL queries against vehicles schema
* @ai-summary Vehicle data repository for hierarchical dropdown queries
* @ai-context Queries denormalized vehicle_options table with string-based cascade
* @ai-migration Updated to use new ETL-generated database (1.1M+ vehicle configurations)
*/
import { Pool } from 'pg';
import { MakeItem, ModelItem, TrimItem, EngineItem } from '../models/responses';
import { VINDecodeResult } from '../models/responses';
import { logger } from '../../../core/logging/logger';
export class VehicleDataRepository {
/**
* Get distinct years from model_year table
* Get distinct years from vehicle_options table
*/
async getYears(pool: Pool): Promise<number[]> {
const query = `
SELECT DISTINCT year
FROM vehicles.model_year
FROM vehicle_options
ORDER BY year DESC
`;
try {
const result = await pool.query(query);
return result.rows.map(row => row.year);
@@ -28,23 +28,16 @@ export class VehicleDataRepository {
}
/**
* Get makes for a specific year
* Get makes for a specific year using database function
*/
async getMakes(pool: Pool, year: number): Promise<MakeItem[]> {
async getMakes(pool: Pool, year: number): Promise<string[]> {
const query = `
SELECT DISTINCT ma.id, ma.name
FROM vehicles.make ma
JOIN vehicles.model mo ON mo.make_id = ma.id
JOIN vehicles.model_year my ON my.model_id = mo.id AND my.year = $1
ORDER BY ma.name
SELECT make FROM get_makes_for_year($1)
`;
try {
const result = await pool.query(query, [year]);
return result.rows.map(row => ({
id: row.id,
name: row.name
}));
return result.rows.map(row => row.make);
} catch (error) {
logger.error('Repository error: getMakes', { error, year });
throw new Error(`Failed to retrieve makes for year ${year}`);
@@ -52,96 +45,114 @@ export class VehicleDataRepository {
}
/**
* Get models for a specific year and make
* Get models for a specific year and make using database function
*/
async getModels(pool: Pool, year: number, makeId: number): Promise<ModelItem[]> {
async getModels(pool: Pool, year: number, make: string): Promise<string[]> {
const query = `
SELECT DISTINCT mo.id, mo.name
FROM vehicles.model mo
JOIN vehicles.model_year my ON my.model_id = mo.id AND my.year = $1
WHERE mo.make_id = $2
ORDER BY mo.name
SELECT model FROM get_models_for_year_make($1, $2)
`;
try {
const result = await pool.query(query, [year, makeId]);
return result.rows.map(row => ({
id: row.id,
name: row.name
}));
const result = await pool.query(query, [year, make]);
return result.rows.map(row => row.model);
} catch (error) {
logger.error('Repository error: getModels', { error, year, makeId });
throw new Error(`Failed to retrieve models for year ${year}, make ${makeId}`);
logger.error('Repository error: getModels', { error, year, make });
throw new Error(`Failed to retrieve models for year ${year}, make ${make}`);
}
}
/**
* Get trims for a specific year and model
* Get trims for a specific year, make, and model using database function
*/
async getTrims(pool: Pool, year: number, modelId: number): Promise<TrimItem[]> {
async getTrims(pool: Pool, year: number, make: string, model: string): Promise<string[]> {
const query = `
SELECT t.id, t.name
FROM vehicles.trim t
JOIN vehicles.model_year my ON my.id = t.model_year_id
WHERE my.year = $1 AND my.model_id = $2
ORDER BY t.name
SELECT trim_name FROM get_trims_for_year_make_model($1, $2, $3)
`;
try {
const result = await pool.query(query, [year, modelId]);
return result.rows.map(row => ({
id: row.id,
name: row.name
}));
const result = await pool.query(query, [year, make, model]);
return result.rows.map(row => row.trim_name);
} catch (error) {
logger.error('Repository error: getTrims', { error, year, modelId });
throw new Error(`Failed to retrieve trims for year ${year}, model ${modelId}`);
logger.error('Repository error: getTrims', { error, year, make, model });
throw new Error(`Failed to retrieve trims for year ${year}, make ${make}, model ${model}`);
}
}
/**
* Get engines for a specific year, model, and trim
* Get engines for a specific year, make, model, and trim
* Returns 'N/A (Electric)' for electric vehicles with NULL engine_id
*/
async getEngines(pool: Pool, year: number, modelId: number, trimId: number): Promise<EngineItem[]> {
async getEngines(pool: Pool, year: number, make: string, model: string, trim: string): Promise<string[]> {
const query = `
SELECT DISTINCT e.id, e.name
FROM vehicles.engine e
JOIN vehicles.trim_engine te ON te.engine_id = e.id
JOIN vehicles.trim t ON t.id = te.trim_id
JOIN vehicles.model_year my ON my.id = t.model_year_id
WHERE my.year = $1
AND my.model_id = $2
AND t.id = $3
ORDER BY e.name
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
`;
try {
const result = await pool.query(query, [year, modelId, trimId]);
return result.rows.map(row => ({
id: row.id,
name: row.name
}));
const result = await pool.query(query, [year, make, model, trim]);
return result.rows.map(row => row.engine_name);
} catch (error) {
logger.error('Repository error: getEngines', { error, year, modelId, trimId });
throw new Error(`Failed to retrieve engines for year ${year}, model ${modelId}, trim ${trimId}`);
logger.error('Repository error: getEngines', { error, year, make, model, trim });
throw new Error(`Failed to retrieve engines for year ${year}, make ${make}, model ${model}, trim ${trim}`);
}
}
/**
* Get transmissions for a specific year, make, and model
* Returns real transmission types from the database (not hardcoded)
*/
async getTransmissions(pool: Pool, year: number, make: string, model: 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
`;
try {
const result = await pool.query(query, [year, make, model]);
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}`);
}
}
/**
* Decode VIN using PostgreSQL function
* NOTE: This function may need updates after vehicles.* schema migration
* If the old f_decode_vin function no longer exists, it will need to be
* reimplemented against the new vehicle_options schema
*/
async decodeVIN(pool: Pool, vin: string): Promise<VINDecodeResult | null> {
const query = `
SELECT * FROM vehicles.f_decode_vin($1)
`;
try {
const result = await pool.query(query, [vin]);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
make: row.make || null,

View File

@@ -32,15 +32,15 @@ export class PlatformCacheService {
/**
* Get cached makes for year
*/
async getMakes(year: number): Promise<any[] | null> {
async getMakes(year: number): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:makes:' + year;
return await this.cacheService.get<any[]>(key);
return await this.cacheService.get<string[]>(key);
}
/**
* Set cached makes for year
*/
async setMakes(year: number, makes: any[], ttl: number = 6 * 3600): Promise<void> {
async setMakes(year: number, makes: string[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:makes:' + year;
await this.cacheService.set(key, makes, ttl);
}
@@ -48,51 +48,67 @@ export class PlatformCacheService {
/**
* Get cached models for year and make
*/
async getModels(year: number, makeId: number): Promise<any[] | null> {
const key = this.prefix + 'vehicle-data:models:' + year + ':' + makeId;
return await this.cacheService.get<any[]>(key);
async getModels(year: number, make: string): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:models:' + year + ':' + make;
return await this.cacheService.get<string[]>(key);
}
/**
* Set cached models for year and make
*/
async setModels(year: number, makeId: number, models: any[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:models:' + year + ':' + makeId;
async setModels(year: number, make: string, models: string[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:models:' + year + ':' + make;
await this.cacheService.set(key, models, ttl);
}
/**
* Get cached trims for year and model
* Get cached trims for year, make, and model
*/
async getTrims(year: number, modelId: number): Promise<any[] | null> {
const key = this.prefix + 'vehicle-data:trims:' + year + ':' + modelId;
return await this.cacheService.get<any[]>(key);
async getTrims(year: number, make: string, model: string): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:trims:' + year + ':' + make + ':' + model;
return await this.cacheService.get<string[]>(key);
}
/**
* Set cached trims for year and model
* Set cached trims for year, make, and model
*/
async setTrims(year: number, modelId: number, trims: any[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:trims:' + year + ':' + modelId;
async setTrims(year: number, make: string, model: string, trims: string[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:trims:' + year + ':' + make + ':' + model;
await this.cacheService.set(key, trims, ttl);
}
/**
* Get cached engines for year, model, and trim
* Get cached engines for year, make, model, and trim
*/
async getEngines(year: number, modelId: number, trimId: number): Promise<any[] | null> {
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + modelId + ':' + trimId;
return await this.cacheService.get<any[]>(key);
async getEngines(year: number, make: string, model: string, trim: string): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + make + ':' + model + ':' + trim;
return await this.cacheService.get<string[]>(key);
}
/**
* Set cached engines for year, model, and trim
* Set cached engines for year, make, model, and trim
*/
async setEngines(year: number, modelId: number, trimId: number, engines: any[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + modelId + ':' + trimId;
async setEngines(year: number, make: string, model: string, trim: string, engines: string[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + make + ':' + model + ':' + trim;
await this.cacheService.set(key, engines, ttl);
}
/**
* Get cached transmissions for year, make, and model
*/
async getTransmissions(year: number, make: string, model: string): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model;
return await this.cacheService.get<string[]>(key);
}
/**
* Set cached transmissions for year, make, and model
*/
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;
await this.cacheService.set(key, transmissions, ttl);
}
/**
* Get cached VIN decode result
*/

View File

@@ -1,11 +1,11 @@
/**
* @ai-summary Vehicle data service with caching
* @ai-context Business logic for hierarchical vehicle data queries
* @ai-summary Vehicle data service with caching for dropdown queries
* @ai-context String-based cascade queries with Redis caching
* @ai-migration Updated to use string parameters (not IDs)
*/
import { Pool } from 'pg';
import { VehicleDataRepository } from '../data/vehicle-data.repository';
import { PlatformCacheService } from './platform-cache.service';
import { MakeItem, ModelItem, TrimItem, EngineItem } from '../models/responses';
import { logger } from '../../../core/logging/logger';
export class VehicleDataService {
@@ -41,7 +41,7 @@ export class VehicleDataService {
/**
* Get makes for a year with caching
*/
async getMakes(pool: Pool, year: number): Promise<MakeItem[]> {
async getMakes(pool: Pool, year: number): Promise<string[]> {
try {
const cached = await this.cache.getMakes(year);
if (cached) {
@@ -62,62 +62,83 @@ export class VehicleDataService {
/**
* Get models for a year and make with caching
*/
async getModels(pool: Pool, year: number, makeId: number): Promise<ModelItem[]> {
async getModels(pool: Pool, year: number, make: string): Promise<string[]> {
try {
const cached = await this.cache.getModels(year, makeId);
const cached = await this.cache.getModels(year, make);
if (cached) {
logger.debug('Models retrieved from cache', { year, makeId });
logger.debug('Models retrieved from cache', { year, make });
return cached;
}
const models = await this.repository.getModels(pool, year, makeId);
await this.cache.setModels(year, makeId, models);
logger.debug('Models retrieved from database and cached', { year, makeId, count: models.length });
const models = await this.repository.getModels(pool, year, make);
await this.cache.setModels(year, make, models);
logger.debug('Models retrieved from database and cached', { year, make, count: models.length });
return models;
} catch (error) {
logger.error('Service error: getModels', { error, year, makeId });
logger.error('Service error: getModels', { error, year, make });
throw error;
}
}
/**
* Get trims for a year and model with caching
* Get trims for a year, make, and model with caching
*/
async getTrims(pool: Pool, year: number, modelId: number): Promise<TrimItem[]> {
async getTrims(pool: Pool, year: number, make: string, model: string): Promise<string[]> {
try {
const cached = await this.cache.getTrims(year, modelId);
const cached = await this.cache.getTrims(year, make, model);
if (cached) {
logger.debug('Trims retrieved from cache', { year, modelId });
logger.debug('Trims retrieved from cache', { year, make, model });
return cached;
}
const trims = await this.repository.getTrims(pool, year, modelId);
await this.cache.setTrims(year, modelId, trims);
logger.debug('Trims retrieved from database and cached', { year, modelId, count: trims.length });
const trims = await this.repository.getTrims(pool, year, make, model);
await this.cache.setTrims(year, make, model, trims);
logger.debug('Trims retrieved from database and cached', { year, make, model, count: trims.length });
return trims;
} catch (error) {
logger.error('Service error: getTrims', { error, year, modelId });
logger.error('Service error: getTrims', { error, year, make, model });
throw error;
}
}
/**
* Get engines for a year, model, and trim with caching
* Get engines for a year, make, model, and trim with caching
*/
async getEngines(pool: Pool, year: number, modelId: number, trimId: number): Promise<EngineItem[]> {
async getEngines(pool: Pool, year: number, make: string, model: string, trim: string): Promise<string[]> {
try {
const cached = await this.cache.getEngines(year, modelId, trimId);
const cached = await this.cache.getEngines(year, make, model, trim);
if (cached) {
logger.debug('Engines retrieved from cache', { year, modelId, trimId });
logger.debug('Engines retrieved from cache', { year, make, model, trim });
return cached;
}
const engines = await this.repository.getEngines(pool, year, modelId, trimId);
await this.cache.setEngines(year, modelId, trimId, engines);
logger.debug('Engines retrieved from database and cached', { year, modelId, trimId, count: engines.length });
const engines = await this.repository.getEngines(pool, year, make, model, trim);
await this.cache.setEngines(year, make, model, trim, engines);
logger.debug('Engines retrieved from database and cached', { year, make, model, trim, count: engines.length });
return engines;
} catch (error) {
logger.error('Service error: getEngines', { error, year, modelId, trimId });
logger.error('Service error: getEngines', { error, year, make, model, trim });
throw error;
}
}
/**
* Get transmissions for a year, make, and model with caching
*/
async getTransmissions(pool: Pool, year: number, make: string, model: string): Promise<string[]> {
try {
const cached = await this.cache.getTransmissions(year, make, model);
if (cached) {
logger.debug('Transmissions retrieved from cache', { year, make, model });
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 });
return transmissions;
} catch (error) {
logger.error('Service error: getTransmissions', { error, year, make, model });
throw error;
}
}

View File

@@ -43,9 +43,9 @@ export const modelsQuerySchema = z.object({
.int('Year must be an integer')
.min(1950, 'Year must be at least 1950')
.max(2100, 'Year must be at most 2100'),
make_id: z.coerce.number()
.int('Make ID must be an integer')
.positive('Make ID must be positive')
make: z.string()
.min(1, 'Make is required')
.max(100, 'Make must be less than 100 characters')
});
export type ModelsQuery = z.infer<typeof modelsQuerySchema>;
@@ -58,9 +58,12 @@ export const trimsQuerySchema = z.object({
.int('Year must be an integer')
.min(1950, 'Year must be at least 1950')
.max(2100, 'Year must be at most 2100'),
model_id: z.coerce.number()
.int('Model ID must be an integer')
.positive('Model ID must be positive')
make: z.string()
.min(1, 'Make is required')
.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')
});
export type TrimsQuery = z.infer<typeof trimsQuerySchema>;
@@ -73,12 +76,33 @@ export const enginesQuerySchema = z.object({
.int('Year must be an integer')
.min(1950, 'Year must be at least 1950')
.max(2100, 'Year must be at most 2100'),
model_id: z.coerce.number()
.int('Model ID must be an integer')
.positive('Model ID must be positive'),
trim_id: z.coerce.number()
.int('Trim ID must be an integer')
.positive('Trim ID must be positive')
make: z.string()
.min(1, 'Make is required')
.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'),
trim: z.string()
.min(1, 'Trim is required')
.max(100, 'Trim must be less than 100 characters')
});
export type EnginesQuery = z.infer<typeof enginesQuerySchema>;
/**
* Transmissions query parameters validation
*/
export const transmissionsQuerySchema = z.object({
year: z.coerce.number()
.int('Year must be an integer')
.min(1950, 'Year must be at least 1950')
.max(2100, 'Year must be at most 2100'),
make: z.string()
.min(1, 'Make is required')
.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')
});
export type TransmissionsQuery = z.infer<typeof transmissionsQuerySchema>;

View File

@@ -1,72 +1,37 @@
/**
* @ai-summary Response DTOs for platform feature
* @ai-context Type-safe response structures matching Python API
* @ai-context Type-safe response structures for vehicle data queries
*/
/**
* Make item response
*/
export interface MakeItem {
id: number;
name: string;
}
/**
* Model item response
*/
export interface ModelItem {
id: number;
name: string;
}
/**
* Trim item response
*/
export interface TrimItem {
id: number;
name: string;
}
/**
* Engine item response
*/
export interface EngineItem {
id: number;
name: string;
}
/**
* Years response
*/
export type YearsResponse = number[];
/**
* Makes response
* Makes response - array of make strings
*/
export interface MakesResponse {
makes: MakeItem[];
}
export type MakesResponse = string[];
/**
* Models response
* Models response - array of model strings
*/
export interface ModelsResponse {
models: ModelItem[];
}
export type ModelsResponse = string[];
/**
* Trims response
* Trims response - array of trim strings
*/
export interface TrimsResponse {
trims: TrimItem[];
}
export type TrimsResponse = string[];
/**
* Engines response
* Engines response - array of engine strings (includes 'N/A (Electric)' for EVs)
*/
export interface EnginesResponse {
engines: EngineItem[];
}
export type EnginesResponse = string[];
/**
* Transmissions response - array of transmission type strings
*/
export type TransmissionsResponse = string[];
/**
* VIN decode result (detailed vehicle information)