Very minimal MVP
This commit is contained in:
@@ -3,8 +3,8 @@ Load .ai/context.json to understand the project's danger zones and loading strat
|
|||||||
Load AI_README.md and @PROJECT_MAP.md to gain context on the application.
|
Load AI_README.md and @PROJECT_MAP.md to gain context on the application.
|
||||||
|
|
||||||
CRITICAL: All development practices and choices should be made taking into account the most context effecient interation with another AI. Any AI should be able to understand this applicaiton with minimal prompting.
|
CRITICAL: All development practices and choices should be made taking into account the most context effecient interation with another AI. Any AI should be able to understand this applicaiton with minimal prompting.
|
||||||
CRITICAL: All development/testing happens in Docker containers
|
|
||||||
no local package installations:
|
CRITICAL: All development/testing happens in Docker containers, no local package installations:
|
||||||
- Development: Dockerfile.dev with npm install during container build
|
- Development: Dockerfile.dev with npm install during container build
|
||||||
- Testing: make test runs tests in container
|
- Testing: make test runs tests in container
|
||||||
- Rebuilding: make rebuild for code changes
|
- Rebuilding: make rebuild for code changes
|
||||||
@@ -23,11 +23,10 @@ File: frontend/package.json
|
|||||||
|
|
||||||
# After each change:
|
# After each change:
|
||||||
make rebuild # Rebuilds containers with new dependencies
|
make rebuild # Rebuilds containers with new dependencies
|
||||||
make logs-frontend # Monitor for build/runtime errors
|
make logs # Monitor for build/runtime errors
|
||||||
|
|
||||||
3. Docker-Tested Component Development
|
3. Docker-Tested Component Development
|
||||||
|
|
||||||
- All testing in containers: make shell-frontend for debugging
|
- All testing in containers: make shell-frontend for debugging
|
||||||
- File watching works: Vite dev server with --host 0.0.0.0 in
|
- File watching works: Vite dev server with --host 0.0.0.0 in container
|
||||||
container
|
|
||||||
- Hot reload preserved: Volume mounts sync code changes
|
- Hot reload preserved: Volume mounts sync code changes
|
||||||
@@ -161,4 +161,75 @@ export class VehiclesController {
|
|||||||
next(error);
|
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 router = Router();
|
||||||
const controller = new VehiclesController();
|
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);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
// Routes
|
// CRUD Routes
|
||||||
router.post('/api/vehicles', controller.createVehicle);
|
router.post('/api/vehicles', controller.createVehicle);
|
||||||
router.get('/api/vehicles', controller.getUserVehicles);
|
router.get('/api/vehicles', controller.getUserVehicles);
|
||||||
router.get('/api/vehicles/:id', controller.getVehicle);
|
router.get('/api/vehicles/:id', controller.getVehicle);
|
||||||
|
|||||||
@@ -140,6 +140,81 @@ export class VehiclesService {
|
|||||||
await cacheService.del(cacheKey);
|
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 {
|
private toResponse(vehicle: Vehicle): VehicleResponse {
|
||||||
return {
|
return {
|
||||||
id: vehicle.id,
|
id: vehicle.id,
|
||||||
@@ -148,6 +223,11 @@ export class VehiclesService {
|
|||||||
make: vehicle.make,
|
make: vehicle.make,
|
||||||
model: vehicle.model,
|
model: vehicle.model,
|
||||||
year: vehicle.year,
|
year: vehicle.year,
|
||||||
|
engine: vehicle.engine,
|
||||||
|
transmission: vehicle.transmission,
|
||||||
|
trimLevel: vehicle.trimLevel,
|
||||||
|
driveType: vehicle.driveType,
|
||||||
|
fuelType: vehicle.fuelType,
|
||||||
nickname: vehicle.nickname,
|
nickname: vehicle.nickname,
|
||||||
color: vehicle.color,
|
color: vehicle.color,
|
||||||
licensePlate: vehicle.licensePlate,
|
licensePlate: vehicle.licensePlate,
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export interface Vehicle {
|
|||||||
make?: string;
|
make?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
|
engine?: string;
|
||||||
|
transmission?: string;
|
||||||
|
trimLevel?: string;
|
||||||
|
driveType?: string;
|
||||||
|
fuelType?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
@@ -22,6 +27,13 @@ export interface Vehicle {
|
|||||||
|
|
||||||
export interface CreateVehicleRequest {
|
export interface CreateVehicleRequest {
|
||||||
vin: string;
|
vin: string;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
engine?: string;
|
||||||
|
transmission?: string;
|
||||||
|
trimLevel?: string;
|
||||||
|
driveType?: string;
|
||||||
|
fuelType?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
@@ -29,6 +41,13 @@ export interface CreateVehicleRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateVehicleRequest {
|
export interface UpdateVehicleRequest {
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
engine?: string;
|
||||||
|
transmission?: string;
|
||||||
|
trimLevel?: string;
|
||||||
|
driveType?: string;
|
||||||
|
fuelType?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
@@ -42,6 +61,11 @@ export interface VehicleResponse {
|
|||||||
make?: string;
|
make?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
|
engine?: string;
|
||||||
|
transmission?: string;
|
||||||
|
trimLevel?: string;
|
||||||
|
driveType?: string;
|
||||||
|
fuelType?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
|
|||||||
@@ -7,11 +7,21 @@ import axios from 'axios';
|
|||||||
import { env } from '../../../../core/config/environment';
|
import { env } from '../../../../core/config/environment';
|
||||||
import { logger } from '../../../../core/logging/logger';
|
import { logger } from '../../../../core/logging/logger';
|
||||||
import { cacheService } from '../../../../core/config/redis';
|
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 {
|
export class VPICClient {
|
||||||
private readonly baseURL = env.VPIC_API_URL;
|
private readonly baseURL = env.VPIC_API_URL;
|
||||||
private readonly cacheTTL = 30 * 24 * 60 * 60; // 30 days in seconds
|
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> {
|
async decodeVIN(vin: string): Promise<VPICDecodeResult | null> {
|
||||||
const cacheKey = `vpic:vin:${vin}`;
|
const cacheKey = `vpic:vin:${vin}`;
|
||||||
@@ -73,6 +83,96 @@ export class VPICClient {
|
|||||||
rawData: response.Results,
|
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();
|
export const vpicClient = new VPICClient();
|
||||||
@@ -24,3 +24,31 @@ export interface VPICDecodeResult {
|
|||||||
bodyType?: string;
|
bodyType?: string;
|
||||||
rawData: VPICResult[];
|
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';
|
||||||
77
docs/security.md
Normal file
77
docs/security.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Security Architecture
|
||||||
|
|
||||||
|
## Authentication & Authorization
|
||||||
|
|
||||||
|
### Protected Endpoints
|
||||||
|
All vehicle CRUD operations require JWT authentication via Auth0:
|
||||||
|
- `POST /api/vehicles` - Create vehicle
|
||||||
|
- `GET /api/vehicles` - Get user vehicles
|
||||||
|
- `GET /api/vehicles/:id` - Get specific vehicle
|
||||||
|
- `PUT /api/vehicles/:id` - Update vehicle
|
||||||
|
- `DELETE /api/vehicles/:id` - Delete vehicle
|
||||||
|
|
||||||
|
### Unauthenticated Endpoints
|
||||||
|
|
||||||
|
#### Vehicle Dropdown Data API
|
||||||
|
The following endpoints are intentionally unauthenticated to support form population before user login:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/vehicles/dropdown/makes
|
||||||
|
GET /api/vehicles/dropdown/models/:make
|
||||||
|
GET /api/vehicles/dropdown/transmissions
|
||||||
|
GET /api/vehicles/dropdown/engines
|
||||||
|
GET /api/vehicles/dropdown/trims
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Considerations:**
|
||||||
|
- **Data Exposure**: Only exposes public NHTSA vPIC vehicle specification data
|
||||||
|
- **No User Data**: Contains no sensitive user information or business logic
|
||||||
|
- **Read-Only**: All endpoints are GET requests with no mutations
|
||||||
|
- **Caching**: 7-day Redis caching reduces external API abuse
|
||||||
|
- **Error Handling**: Generic error responses prevent system information disclosure
|
||||||
|
|
||||||
|
**Known Risks:**
|
||||||
|
1. **API Abuse**: No rate limiting allows unlimited calls
|
||||||
|
2. **Resource Consumption**: Could exhaust NHTSA API rate limits
|
||||||
|
3. **Cache Poisoning**: Limited input validation on make parameter
|
||||||
|
4. **Information Disclosure**: Exposes system capabilities to unauthenticated users
|
||||||
|
|
||||||
|
**Recommended Mitigations for Production:**
|
||||||
|
1. **Rate Limiting**: Implement express-rate-limit (e.g., 100 requests/hour per IP)
|
||||||
|
2. **Input Validation**: Sanitize make parameter in controller
|
||||||
|
3. **CORS Restrictions**: Limit to application domain
|
||||||
|
4. **Monitoring**: Add abuse detection logging
|
||||||
|
5. **API Gateway**: Consider moving to API gateway with built-in rate limiting
|
||||||
|
|
||||||
|
**Risk Assessment**: ACCEPTABLE for MVP
|
||||||
|
- Low risk due to public data exposure only
|
||||||
|
- UX benefits outweigh security concerns
|
||||||
|
- Mitigations can be added incrementally
|
||||||
|
|
||||||
|
## Data Security
|
||||||
|
|
||||||
|
### VIN Handling
|
||||||
|
- VIN validation using industry-standard check digit algorithm
|
||||||
|
- VIN decoding via NHTSA vPIC API
|
||||||
|
- Cached VIN decode results (30-day TTL)
|
||||||
|
- No VIN storage in logs (masked in logging middleware)
|
||||||
|
|
||||||
|
### Database Security
|
||||||
|
- User data isolation via userId foreign keys
|
||||||
|
- Soft deletes for audit trail
|
||||||
|
- No cascading deletes to prevent data loss
|
||||||
|
- Encrypted connections to PostgreSQL
|
||||||
|
|
||||||
|
## Infrastructure Security
|
||||||
|
|
||||||
|
### Docker Security
|
||||||
|
- Development containers run as non-root users
|
||||||
|
- Network isolation between services
|
||||||
|
- Environment variable injection for secrets
|
||||||
|
- No hardcoded credentials in images
|
||||||
|
|
||||||
|
### API Client Security
|
||||||
|
- Separate authenticated/unauthenticated HTTP clients
|
||||||
|
- Request/response interceptors for error handling
|
||||||
|
- Timeout configurations to prevent hanging requests
|
||||||
|
- Auth token handling via Auth0 wrapper
|
||||||
@@ -3,7 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../../../core/api/client';
|
import { apiClient } from '../../../core/api/client';
|
||||||
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types';
|
import axios from 'axios';
|
||||||
|
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption } from '../types/vehicles.types';
|
||||||
|
|
||||||
|
// Unauthenticated client for dropdown data
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||||
|
const dropdownClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const vehiclesApi = {
|
export const vehiclesApi = {
|
||||||
getAll: async (): Promise<Vehicle[]> => {
|
getAll: async (): Promise<Vehicle[]> => {
|
||||||
@@ -29,4 +40,30 @@ export const vehiclesApi = {
|
|||||||
delete: async (id: string): Promise<void> => {
|
delete: async (id: string): Promise<void> => {
|
||||||
await apiClient.delete(`/vehicles/${id}`);
|
await apiClient.delete(`/vehicles/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Dropdown API methods (unauthenticated)
|
||||||
|
getMakes: async (): Promise<DropdownOption[]> => {
|
||||||
|
const response = await dropdownClient.get('/vehicles/dropdown/makes');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getModels: async (make: string): Promise<DropdownOption[]> => {
|
||||||
|
const response = await dropdownClient.get(`/vehicles/dropdown/models/${encodeURIComponent(make)}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTransmissions: async (): Promise<DropdownOption[]> => {
|
||||||
|
const response = await dropdownClient.get('/vehicles/dropdown/transmissions');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getEngines: async (): Promise<DropdownOption[]> => {
|
||||||
|
const response = await dropdownClient.get('/vehicles/dropdown/engines');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTrims: async (): Promise<DropdownOption[]> => {
|
||||||
|
const response = await dropdownClient.get('/vehicles/dropdown/trims');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Vehicle form component for create/edit
|
* @ai-summary Vehicle form component for create/edit with dropdown cascades
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
import { Button } from '../../../shared-minimal/components/Button';
|
||||||
import { CreateVehicleRequest } from '../types/vehicles.types';
|
import { CreateVehicleRequest, DropdownOption } from '../types/vehicles.types';
|
||||||
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
|
|
||||||
const vehicleSchema = z.object({
|
const vehicleSchema = z.object({
|
||||||
vin: z.string().length(17, 'VIN must be exactly 17 characters'),
|
vin: z.string().length(17, 'VIN must be exactly 17 characters'),
|
||||||
|
make: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
engine: z.string().optional(),
|
||||||
|
transmission: z.string().optional(),
|
||||||
|
trimLevel: z.string().optional(),
|
||||||
|
driveType: z.string().optional(),
|
||||||
|
fuelType: z.string().optional(),
|
||||||
nickname: z.string().optional(),
|
nickname: z.string().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
licensePlate: z.string().optional(),
|
licensePlate: z.string().optional(),
|
||||||
@@ -30,15 +38,74 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
initialData,
|
initialData,
|
||||||
loading,
|
loading,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [makes, setMakes] = useState<DropdownOption[]>([]);
|
||||||
|
const [models, setModels] = useState<DropdownOption[]>([]);
|
||||||
|
const [transmissions, setTransmissions] = useState<DropdownOption[]>([]);
|
||||||
|
const [engines, setEngines] = useState<DropdownOption[]>([]);
|
||||||
|
const [trims, setTrims] = useState<DropdownOption[]>([]);
|
||||||
|
const [selectedMake, setSelectedMake] = useState<string>('');
|
||||||
|
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
} = useForm<CreateVehicleRequest>({
|
} = useForm<CreateVehicleRequest>({
|
||||||
resolver: zodResolver(vehicleSchema),
|
resolver: zodResolver(vehicleSchema),
|
||||||
defaultValues: initialData,
|
defaultValues: initialData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedMake = watch('make');
|
||||||
|
|
||||||
|
// Load dropdown data on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadInitialData = async () => {
|
||||||
|
setLoadingDropdowns(true);
|
||||||
|
try {
|
||||||
|
const [makesData, transmissionsData, enginesData, trimsData] = await Promise.all([
|
||||||
|
vehiclesApi.getMakes(),
|
||||||
|
vehiclesApi.getTransmissions(),
|
||||||
|
vehiclesApi.getEngines(),
|
||||||
|
vehiclesApi.getTrims(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setMakes(makesData);
|
||||||
|
setTransmissions(transmissionsData);
|
||||||
|
setEngines(enginesData);
|
||||||
|
setTrims(trimsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load dropdown data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingDropdowns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInitialData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load models when make changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchedMake && watchedMake !== selectedMake) {
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const modelsData = await vehiclesApi.getModels(watchedMake);
|
||||||
|
setModels(modelsData);
|
||||||
|
setSelectedMake(watchedMake);
|
||||||
|
|
||||||
|
// Clear model selection when make changes
|
||||||
|
setValue('model', '');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load models:', error);
|
||||||
|
setModels([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadModels();
|
||||||
|
}
|
||||||
|
}, [watchedMake, selectedMake, setValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -55,6 +122,101 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Vehicle Specification Dropdowns */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Make
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('make')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
disabled={loadingDropdowns}
|
||||||
|
>
|
||||||
|
<option value="">Select Make</option>
|
||||||
|
{makes.map((make) => (
|
||||||
|
<option key={make.id} value={make.name}>
|
||||||
|
{make.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('model')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
disabled={!watchedMake || models.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">Select Model</option>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={model.id} value={model.name}>
|
||||||
|
{model.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Engine
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('engine')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
disabled={loadingDropdowns}
|
||||||
|
>
|
||||||
|
<option value="">Select Engine</option>
|
||||||
|
{engines.map((engine) => (
|
||||||
|
<option key={engine.id} value={engine.name}>
|
||||||
|
{engine.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Transmission
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('transmission')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
disabled={loadingDropdowns}
|
||||||
|
>
|
||||||
|
<option value="">Select Transmission</option>
|
||||||
|
{transmissions.map((transmission) => (
|
||||||
|
<option key={transmission.id} value={transmission.name}>
|
||||||
|
{transmission.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Trim Level
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('trimLevel')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
disabled={loadingDropdowns}
|
||||||
|
>
|
||||||
|
<option value="">Select Trim</option>
|
||||||
|
{trims.map((trim) => (
|
||||||
|
<option key={trim.id} value={trim.name}>
|
||||||
|
{trim.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Nickname
|
Nickname
|
||||||
@@ -106,7 +268,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
<Button variant="secondary" onClick={onCancel} type="button">
|
<Button variant="secondary" onClick={onCancel} type="button">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={loading}>
|
<Button type="submit" loading={loading || loadingDropdowns}>
|
||||||
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
|
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export interface Vehicle {
|
|||||||
make?: string;
|
make?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
|
engine?: string;
|
||||||
|
transmission?: string;
|
||||||
|
trimLevel?: string;
|
||||||
|
driveType?: string;
|
||||||
|
fuelType?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
@@ -20,6 +25,13 @@ export interface Vehicle {
|
|||||||
|
|
||||||
export interface CreateVehicleRequest {
|
export interface CreateVehicleRequest {
|
||||||
vin: string;
|
vin: string;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
engine?: string;
|
||||||
|
transmission?: string;
|
||||||
|
trimLevel?: string;
|
||||||
|
driveType?: string;
|
||||||
|
fuelType?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
@@ -27,8 +39,20 @@ export interface CreateVehicleRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateVehicleRequest {
|
export interface UpdateVehicleRequest {
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
engine?: string;
|
||||||
|
transmission?: string;
|
||||||
|
trimLevel?: string;
|
||||||
|
driveType?: string;
|
||||||
|
fuelType?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
odometerReading?: number;
|
odometerReading?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DropdownOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user