All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m36s
Deploy to Staging / Deploy to Staging (push) Successful in 52s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 42s
846 lines
30 KiB
TypeScript
846 lines
30 KiB
TypeScript
/**
|
|
* @ai-summary Business logic for vehicles feature
|
|
* @ai-context Handles VIN decoding and business rules
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
import { VehiclesRepository } from '../data/vehicles.repository';
|
|
import {
|
|
Vehicle,
|
|
CreateVehicleRequest,
|
|
UpdateVehicleRequest,
|
|
VehicleResponse,
|
|
VehicleImageMeta,
|
|
TCOResponse,
|
|
CostInterval,
|
|
PAYMENTS_PER_YEAR
|
|
} from './vehicles.types';
|
|
import { logger } from '../../../core/logging/logger';
|
|
import { cacheService } from '../../../core/config/redis';
|
|
import { getStorageService } from '../../../core/storage/storage.service';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/validators';
|
|
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
|
import { getVehicleDataService, getPool } from '../../platform';
|
|
import { auditLogService } from '../../audit-log';
|
|
import type { VinDecodeResponse } from '../../ocr/domain/ocr.types';
|
|
import type { DecodedVehicleData, MatchedField } from './vehicles.types';
|
|
import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers';
|
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
|
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
|
|
import { OwnershipCostsService } from '../../ownership-costs';
|
|
import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service';
|
|
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
|
|
import { MaintenanceService } from '../../maintenance/domain/maintenance.service';
|
|
import { UserSettingsService } from '../../fuel-logs/external/user-settings.service';
|
|
|
|
export class VehicleLimitExceededError extends Error {
|
|
constructor(
|
|
public tier: SubscriptionTier,
|
|
public currentCount: number,
|
|
public limit: number,
|
|
public upgradePrompt: string
|
|
) {
|
|
super('Vehicle limit exceeded');
|
|
this.name = 'VehicleLimitExceededError';
|
|
}
|
|
}
|
|
|
|
export class VehiclesService {
|
|
private readonly cachePrefix = 'vehicles';
|
|
private readonly listCacheTTL = 300; // 5 minutes
|
|
private userProfileRepository: UserProfileRepository;
|
|
|
|
constructor(
|
|
private repository: VehiclesRepository,
|
|
private pool: Pool
|
|
) {
|
|
// VIN decode service is now provided by platform feature
|
|
this.userProfileRepository = new UserProfileRepository(pool);
|
|
}
|
|
|
|
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
|
|
logger.info('Creating vehicle', { userId, vin: data.vin, licensePlate: data.licensePlate });
|
|
|
|
// Pre-1981 vehicles have relaxed VIN format requirements
|
|
const isPreModern = data.year && data.year < 1981;
|
|
|
|
if (data.vin) {
|
|
// Validate VIN format based on vehicle year
|
|
if (isPreModern) {
|
|
if (!isValidPreModernVIN(data.vin)) {
|
|
throw new Error('Invalid VIN format for pre-1981 vehicle');
|
|
}
|
|
} else if (!isValidVIN(data.vin)) {
|
|
throw new Error('Invalid VIN format');
|
|
}
|
|
// Duplicate check only when VIN is present
|
|
const existing = await this.repository.findByUserAndVIN(userId, data.vin);
|
|
if (existing) {
|
|
throw new Error('Vehicle with this VIN already exists');
|
|
}
|
|
}
|
|
|
|
// Get user's tier for limit enforcement
|
|
const userProfile = await this.userProfileRepository.getById(userId);
|
|
if (!userProfile) {
|
|
throw new Error('User profile not found');
|
|
}
|
|
const userTier = userProfile.subscriptionTier;
|
|
|
|
// Tier limit enforcement with transaction + FOR UPDATE locking to prevent race condition
|
|
const client = await this.pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Lock user's vehicle rows and get count
|
|
// Note: Cannot use COUNT(*) with FOR UPDATE, so we select IDs and count in app
|
|
const lockResult = await client.query(
|
|
'SELECT id FROM vehicles WHERE user_id = $1 AND is_active = true FOR UPDATE',
|
|
[userId]
|
|
);
|
|
const currentCount = lockResult.rows.length;
|
|
|
|
// Check if user can add another vehicle
|
|
if (!canAddVehicle(userTier, currentCount)) {
|
|
await client.query('ROLLBACK');
|
|
const limitConfig = getVehicleLimitConfig(userTier);
|
|
throw new VehicleLimitExceededError(
|
|
userTier,
|
|
currentCount,
|
|
limitConfig.limit!,
|
|
limitConfig.upgradePrompt
|
|
);
|
|
}
|
|
|
|
// Create vehicle with user-provided data (within transaction)
|
|
const query = `
|
|
INSERT INTO vehicles (
|
|
user_id, vin, make, model, year,
|
|
engine, transmission, trim_level, drive_type, fuel_type,
|
|
nickname, color, license_plate, odometer_reading
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING *
|
|
`;
|
|
|
|
const values = [
|
|
userId,
|
|
(data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null,
|
|
data.make ? normalizeMakeName(data.make) : null,
|
|
data.model ? normalizeModelName(data.model) : null,
|
|
data.year,
|
|
data.engine,
|
|
data.transmission,
|
|
data.trimLevel,
|
|
data.driveType,
|
|
data.fuelType,
|
|
data.nickname,
|
|
data.color,
|
|
data.licensePlate,
|
|
data.odometerReading || 0
|
|
];
|
|
|
|
const result = await client.query(query, values);
|
|
await client.query('COMMIT');
|
|
|
|
const vehicle = this.mapVehicleRow(result.rows[0]);
|
|
|
|
// Invalidate user's vehicle list cache
|
|
await this.invalidateUserCache(userId);
|
|
|
|
// Log vehicle creation to unified audit log
|
|
const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ');
|
|
await auditLogService.info(
|
|
'vehicle',
|
|
userId,
|
|
`Vehicle created: ${vehicleDesc || vehicle.id}`,
|
|
'vehicle',
|
|
vehicle.id,
|
|
{ vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year }
|
|
).catch(err => logger.error('Failed to log vehicle create audit event', { error: err }));
|
|
|
|
return this.toResponse(vehicle);
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map database row to Vehicle domain object
|
|
*/
|
|
private mapVehicleRow(row: any): Vehicle {
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
vin: row.vin,
|
|
make: row.make,
|
|
model: row.model,
|
|
year: row.year,
|
|
engine: row.engine,
|
|
transmission: row.transmission,
|
|
trimLevel: row.trim_level,
|
|
driveType: row.drive_type,
|
|
fuelType: row.fuel_type,
|
|
nickname: row.nickname,
|
|
color: row.color,
|
|
licensePlate: row.license_plate,
|
|
odometerReading: row.odometer_reading,
|
|
isActive: row.is_active,
|
|
deletedAt: row.deleted_at,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
imageStorageBucket: row.image_storage_bucket,
|
|
imageStorageKey: row.image_storage_key,
|
|
imageFileName: row.image_file_name,
|
|
imageContentType: row.image_content_type,
|
|
imageFileSize: row.image_file_size,
|
|
};
|
|
}
|
|
|
|
async getUserVehicles(userId: string): Promise<VehicleResponse[]> {
|
|
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
|
|
|
// Check cache
|
|
const cached = await cacheService.get<VehicleResponse[]>(cacheKey);
|
|
if (cached) {
|
|
logger.debug('Vehicle list cache hit', { userId });
|
|
return cached;
|
|
}
|
|
|
|
// Get from database
|
|
const vehicles = await this.repository.findByUserId(userId);
|
|
const response = vehicles.map((v: Vehicle) => this.toResponse(v));
|
|
|
|
// Cache result
|
|
await cacheService.set(cacheKey, response, this.listCacheTTL);
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Get user vehicles with tier-gated status
|
|
* Returns vehicles with tierStatus: 'active' | 'locked'
|
|
*/
|
|
async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
|
|
// Get user's subscription tier
|
|
const userProfile = await this.userProfileRepository.getById(userId);
|
|
if (!userProfile) {
|
|
throw new Error('User profile not found');
|
|
}
|
|
const userTier = userProfile.subscriptionTier;
|
|
|
|
// Get all vehicles
|
|
const vehicles = await this.repository.findByUserId(userId);
|
|
|
|
// Define tier limits
|
|
const tierLimits: Record<SubscriptionTier, number | null> = {
|
|
free: 2,
|
|
pro: 5,
|
|
enterprise: null, // unlimited
|
|
};
|
|
|
|
const tierLimit = tierLimits[userTier];
|
|
|
|
// If tier has unlimited vehicles, all are active
|
|
if (tierLimit === null) {
|
|
return vehicles.map((v: Vehicle) => ({
|
|
...this.toResponse(v),
|
|
tierStatus: 'active' as const,
|
|
}));
|
|
}
|
|
|
|
// If vehicle count is within tier limit, all are active
|
|
if (vehicles.length <= tierLimit) {
|
|
return vehicles.map((v: Vehicle) => ({
|
|
...this.toResponse(v),
|
|
tierStatus: 'active' as const,
|
|
}));
|
|
}
|
|
|
|
// Vehicle count exceeds tier limit - check for tier_vehicle_selections
|
|
// Get vehicle selections from subscriptions repository
|
|
const { SubscriptionsRepository } = await import('../../subscriptions/data/subscriptions.repository');
|
|
const subscriptionsRepo = new SubscriptionsRepository(this.pool);
|
|
const selections = await subscriptionsRepo.findVehicleSelectionsByUserId(userId);
|
|
const selectedVehicleIds = new Set(selections.map(s => s.vehicleId));
|
|
|
|
// If no selections exist, return all as active (selections only exist after downgrade)
|
|
if (selections.length === 0) {
|
|
return vehicles.map((v: Vehicle) => ({
|
|
...this.toResponse(v),
|
|
tierStatus: 'active' as const,
|
|
}));
|
|
}
|
|
|
|
// Mark vehicles as active or locked based on selections
|
|
return vehicles.map((v: Vehicle) => ({
|
|
...this.toResponse(v),
|
|
tierStatus: selectedVehicleIds.has(v.id) ? ('active' as const) : ('locked' as const),
|
|
}));
|
|
}
|
|
|
|
async getVehicle(id: string, userId: string): Promise<VehicleResponse> {
|
|
const vehicle = await this.repository.findById(id);
|
|
|
|
if (!vehicle) {
|
|
throw new Error('Vehicle not found');
|
|
}
|
|
|
|
if (vehicle.userId !== userId) {
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
return this.toResponse(vehicle);
|
|
}
|
|
|
|
async updateVehicle(
|
|
id: string,
|
|
data: UpdateVehicleRequest,
|
|
userId: string
|
|
): Promise<VehicleResponse> {
|
|
// Verify ownership
|
|
const existing = await this.repository.findById(id);
|
|
if (!existing) {
|
|
throw new Error('Vehicle not found');
|
|
}
|
|
if (existing.userId !== userId) {
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
// Determine the effective year for validation (use new year if provided, else existing)
|
|
const effectiveYear = data.year !== undefined ? data.year : existing.year;
|
|
const isPreModern = effectiveYear && effectiveYear < 1981;
|
|
|
|
// Validate VIN if provided
|
|
if (data.vin !== undefined && data.vin.trim().length > 0) {
|
|
const trimmedVin = data.vin.trim();
|
|
// Validate VIN format based on vehicle year
|
|
if (isPreModern) {
|
|
if (!isValidPreModernVIN(trimmedVin)) {
|
|
throw new Error('Invalid VIN format for pre-1981 vehicle');
|
|
}
|
|
} else if (!isValidVIN(trimmedVin)) {
|
|
throw new Error('Invalid VIN format');
|
|
}
|
|
// Check for duplicate VIN (only if VIN is changing)
|
|
if (trimmedVin !== existing.vin) {
|
|
const duplicate = await this.repository.findByUserAndVIN(userId, trimmedVin);
|
|
if (duplicate && duplicate.id !== id) {
|
|
throw new Error('Vehicle with this VIN already exists');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normalize any provided name fields
|
|
const normalized: UpdateVehicleRequest = { ...data } as any;
|
|
if (data.make !== undefined) {
|
|
(normalized as any).make = normalizeMakeName(data.make);
|
|
}
|
|
if (data.model !== undefined) {
|
|
(normalized as any).model = normalizeModelName(data.model);
|
|
}
|
|
|
|
// Update vehicle
|
|
const updated = await this.repository.update(id, normalized);
|
|
if (!updated) {
|
|
throw new Error('Update failed');
|
|
}
|
|
|
|
// Invalidate cache
|
|
await this.invalidateUserCache(userId);
|
|
|
|
// Log vehicle update to unified audit log
|
|
const vehicleDesc = [updated.year, updated.make, updated.model].filter(Boolean).join(' ');
|
|
await auditLogService.info(
|
|
'vehicle',
|
|
userId,
|
|
`Vehicle updated: ${vehicleDesc || id}`,
|
|
'vehicle',
|
|
id,
|
|
{ updatedFields: Object.keys(data) }
|
|
).catch(err => logger.error('Failed to log vehicle update audit event', { error: err }));
|
|
|
|
return this.toResponse(updated);
|
|
}
|
|
|
|
async deleteVehicle(id: string, userId: string): Promise<void> {
|
|
// Verify ownership
|
|
const existing = await this.repository.findById(id);
|
|
if (!existing) {
|
|
throw new Error('Vehicle not found');
|
|
}
|
|
if (existing.userId !== userId) {
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
logger.info('Deleting vehicle', {
|
|
vehicleId: id,
|
|
userId,
|
|
hasImageKey: !!existing.imageStorageKey,
|
|
hasImageBucket: !!existing.imageStorageBucket,
|
|
imageStorageKey: existing.imageStorageKey,
|
|
imageStorageBucket: existing.imageStorageBucket
|
|
});
|
|
|
|
// Delete associated image from storage if present
|
|
if (existing.imageStorageKey && existing.imageStorageBucket) {
|
|
try {
|
|
const storage = getStorageService();
|
|
logger.info('Attempting to delete vehicle image', {
|
|
vehicleId: id,
|
|
bucket: existing.imageStorageBucket,
|
|
key: existing.imageStorageKey
|
|
});
|
|
await storage.deleteObject(existing.imageStorageBucket, existing.imageStorageKey);
|
|
logger.info('Successfully deleted vehicle image on vehicle deletion', {
|
|
vehicleId: id,
|
|
bucket: existing.imageStorageBucket,
|
|
key: existing.imageStorageKey
|
|
});
|
|
|
|
// Clean up empty vehicle directory
|
|
// Key format: vehicle-images/{userId}/{vehicleId}/{filename}
|
|
const basePath = '/app/data/documents';
|
|
const vehicleDir = path.join(basePath, path.dirname(existing.imageStorageKey));
|
|
try {
|
|
await fs.rmdir(vehicleDir);
|
|
logger.info('Removed empty vehicle image directory', { vehicleId: id, directory: vehicleDir });
|
|
} catch (dirError) {
|
|
// Directory might not be empty or might not exist, ignore
|
|
logger.debug('Could not remove vehicle image directory', {
|
|
vehicleId: id,
|
|
directory: vehicleDir,
|
|
error: dirError instanceof Error ? dirError.message : String(dirError)
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Log warning but don't block vehicle deletion on storage failure
|
|
logger.warn('Failed to delete vehicle image on vehicle deletion', {
|
|
vehicleId: id,
|
|
bucket: existing.imageStorageBucket,
|
|
key: existing.imageStorageKey,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
} else {
|
|
logger.info('No image to delete for vehicle', { vehicleId: id });
|
|
}
|
|
|
|
// Soft delete
|
|
await this.repository.softDelete(id);
|
|
|
|
// Invalidate cache
|
|
await this.invalidateUserCache(userId);
|
|
|
|
// Log vehicle deletion to unified audit log
|
|
const vehicleDesc = [existing.year, existing.make, existing.model].filter(Boolean).join(' ');
|
|
await auditLogService.info(
|
|
'vehicle',
|
|
userId,
|
|
`Vehicle deleted: ${vehicleDesc || id}`,
|
|
'vehicle',
|
|
id,
|
|
{ vin: existing.vin, make: existing.make, model: existing.model, year: existing.year }
|
|
).catch(err => logger.error('Failed to log vehicle delete audit event', { error: err }));
|
|
}
|
|
|
|
async getTCO(id: string, userId: string): Promise<TCOResponse> {
|
|
// Get vehicle and verify ownership
|
|
const vehicle = await this.repository.findById(id);
|
|
if (!vehicle) {
|
|
const err: any = new Error('Vehicle not found');
|
|
err.statusCode = 404;
|
|
throw err;
|
|
}
|
|
if (vehicle.userId !== userId) {
|
|
const err: any = new Error('Unauthorized');
|
|
err.statusCode = 403;
|
|
throw err;
|
|
}
|
|
|
|
// Get user preferences for units
|
|
const userSettings = await UserSettingsService.getUserSettings(userId);
|
|
const distanceUnit = userSettings.unitSystem === 'metric' ? 'km' : 'mi';
|
|
const currencyCode = userSettings.currencyCode || 'USD';
|
|
|
|
// Get fuel costs from fuel-logs service
|
|
const fuelLogsRepository = new FuelLogsRepository(this.pool);
|
|
const fuelLogsService = new FuelLogsService(fuelLogsRepository);
|
|
let fuelCosts = 0;
|
|
try {
|
|
const fuelStats = await fuelLogsService.getVehicleStats(id, userId);
|
|
fuelCosts = fuelStats.totalCost || 0;
|
|
} catch {
|
|
// Vehicle may have no fuel logs
|
|
fuelCosts = 0;
|
|
}
|
|
|
|
// Get maintenance costs from maintenance service
|
|
const maintenanceService = new MaintenanceService();
|
|
let maintenanceCosts = 0;
|
|
try {
|
|
const maintenanceStats = await maintenanceService.getVehicleMaintenanceCosts(id, userId);
|
|
maintenanceCosts = maintenanceStats.totalCost || 0;
|
|
} catch {
|
|
// Vehicle may have no maintenance records
|
|
maintenanceCosts = 0;
|
|
}
|
|
|
|
// Get fixed costs from vehicle record
|
|
const purchasePrice = vehicle.purchasePrice || 0;
|
|
|
|
// Get recurring ownership costs from ownership-costs service
|
|
const ownershipCostsService = new OwnershipCostsService(this.pool);
|
|
let insuranceCosts = 0;
|
|
let registrationCosts = 0;
|
|
let taxCosts = 0;
|
|
let otherCosts = 0;
|
|
try {
|
|
const ownershipStats = await ownershipCostsService.getVehicleCostStats(id, userId);
|
|
insuranceCosts = ownershipStats.insuranceCosts || 0;
|
|
registrationCosts = ownershipStats.registrationCosts || 0;
|
|
taxCosts = ownershipStats.taxCosts || 0;
|
|
otherCosts = ownershipStats.otherCosts || 0;
|
|
} catch {
|
|
// Vehicle may have no ownership cost records
|
|
// Fall back to legacy vehicle fields if they exist
|
|
insuranceCosts = this.normalizeRecurringCost(
|
|
vehicle.insuranceCost,
|
|
vehicle.insuranceInterval,
|
|
vehicle.purchaseDate
|
|
);
|
|
registrationCosts = this.normalizeRecurringCost(
|
|
vehicle.registrationCost,
|
|
vehicle.registrationInterval,
|
|
vehicle.purchaseDate
|
|
);
|
|
}
|
|
|
|
// Calculate lifetime total (includes all ownership costs: insurance, registration, tax, other)
|
|
const lifetimeTotal = purchasePrice + insuranceCosts + registrationCosts + taxCosts + otherCosts + fuelCosts + maintenanceCosts;
|
|
|
|
// Calculate cost per distance
|
|
const odometerReading = vehicle.odometerReading || 0;
|
|
const costPerDistance = odometerReading > 0 ? lifetimeTotal / odometerReading : 0;
|
|
|
|
return {
|
|
vehicleId: id,
|
|
purchasePrice,
|
|
insuranceCosts,
|
|
registrationCosts,
|
|
taxCosts,
|
|
otherCosts,
|
|
fuelCosts,
|
|
maintenanceCosts,
|
|
lifetimeTotal,
|
|
costPerDistance,
|
|
distanceUnit,
|
|
currencyCode
|
|
};
|
|
}
|
|
|
|
private normalizeRecurringCost(
|
|
cost: number | null | undefined,
|
|
interval: CostInterval | null | undefined,
|
|
purchaseDate: string | null | undefined
|
|
): number {
|
|
if (!cost || !interval || !purchaseDate) return 0;
|
|
|
|
const monthsOwned = Math.max(1, this.calculateMonthsOwned(purchaseDate));
|
|
const paymentsPerYear = PAYMENTS_PER_YEAR[interval];
|
|
if (!paymentsPerYear) {
|
|
throw new Error(`Invalid cost interval: ${interval}`);
|
|
}
|
|
const totalPayments = (monthsOwned / 12) * paymentsPerYear;
|
|
return cost * totalPayments;
|
|
}
|
|
|
|
private calculateMonthsOwned(purchaseDate: string): number {
|
|
const purchase = new Date(purchaseDate);
|
|
const now = new Date();
|
|
// Guard against future dates - treat as 0 months owned
|
|
if (purchase > now) {
|
|
return 0;
|
|
}
|
|
const yearDiff = now.getFullYear() - purchase.getFullYear();
|
|
const monthDiff = now.getMonth() - purchase.getMonth();
|
|
return yearDiff * 12 + monthDiff;
|
|
}
|
|
|
|
async getVehicleRaw(id: string, userId: string): Promise<Vehicle | null> {
|
|
const vehicle = await this.repository.findById(id);
|
|
if (!vehicle || vehicle.userId !== userId) {
|
|
return null;
|
|
}
|
|
return vehicle;
|
|
}
|
|
|
|
async updateVehicleImage(id: string, userId: string, meta: VehicleImageMeta | null): Promise<VehicleResponse | null> {
|
|
const updated = await this.repository.updateImageMeta(id, userId, meta);
|
|
if (!updated) {
|
|
return null;
|
|
}
|
|
await this.invalidateUserCache(userId);
|
|
return this.toResponse(updated);
|
|
}
|
|
|
|
private async invalidateUserCache(userId: string): Promise<void> {
|
|
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
|
await cacheService.del(cacheKey);
|
|
}
|
|
|
|
async getDropdownMakes(year: number): Promise<string[]> {
|
|
const vehicleDataService = getVehicleDataService();
|
|
const pool = getPool();
|
|
|
|
logger.info('Fetching dropdown makes via platform module', { year });
|
|
return vehicleDataService.getMakes(pool, year);
|
|
}
|
|
|
|
async getDropdownModels(year: number, make: string): Promise<string[]> {
|
|
const vehicleDataService = getVehicleDataService();
|
|
const pool = getPool();
|
|
|
|
logger.info('Fetching dropdown models via platform module', { year, make });
|
|
return vehicleDataService.getModels(pool, year, make);
|
|
}
|
|
|
|
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, trim });
|
|
return vehicleDataService.getTransmissionsForTrim(pool, year, make, model, trim);
|
|
}
|
|
|
|
async getDropdownEngines(year: number, make: string, model: string, trim: string): Promise<string[]> {
|
|
const vehicleDataService = getVehicleDataService();
|
|
const pool = getPool();
|
|
|
|
logger.info('Fetching dropdown engines via platform module', { year, make, model, trim });
|
|
return vehicleDataService.getEngines(pool, year, make, model, trim);
|
|
}
|
|
|
|
async getDropdownTrims(year: number, make: string, model: string): Promise<string[]> {
|
|
const vehicleDataService = getVehicleDataService();
|
|
const pool = getPool();
|
|
|
|
logger.info('Fetching dropdown trims via platform module', { year, make, model });
|
|
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();
|
|
|
|
logger.info('Fetching dropdown years via platform module');
|
|
return vehicleDataService.getYears(pool);
|
|
}
|
|
|
|
/**
|
|
* Map VIN decode response to internal decoded vehicle data format
|
|
* with dropdown matching and confidence levels
|
|
*/
|
|
async mapVinDecodeResponse(response: VinDecodeResponse): Promise<DecodedVehicleData> {
|
|
const vehicleDataService = getVehicleDataService();
|
|
const pool = getPool();
|
|
|
|
// Read flat fields directly from Gemini response
|
|
const sourceYear = response.year;
|
|
const sourceMake = response.make;
|
|
const sourceModel = response.model;
|
|
const sourceTrim = response.trimLevel;
|
|
const sourceBodyType = response.bodyType;
|
|
const sourceDriveType = response.driveType;
|
|
const sourceFuelType = response.fuelType;
|
|
const sourceEngine = response.engine;
|
|
const sourceTransmission = response.transmission;
|
|
|
|
logger.debug('VIN decode raw values', {
|
|
vin: response.vin,
|
|
year: sourceYear, make: sourceMake, model: sourceModel,
|
|
trim: sourceTrim, confidence: response.confidence
|
|
});
|
|
|
|
// Year is always high confidence if present (exact numeric match)
|
|
const year: MatchedField<number> = {
|
|
value: sourceYear,
|
|
sourceValue: sourceYear?.toString() || null,
|
|
confidence: sourceYear ? 'high' : 'none'
|
|
};
|
|
|
|
// Match make against dropdown options
|
|
let make: MatchedField<string> = { value: null, sourceValue: sourceMake, confidence: 'none' };
|
|
if (sourceYear && sourceMake) {
|
|
const makes = await vehicleDataService.getMakes(pool, sourceYear);
|
|
make = this.matchField(sourceMake, makes);
|
|
}
|
|
|
|
// Match model against dropdown options
|
|
let model: MatchedField<string> = { value: null, sourceValue: sourceModel, confidence: 'none' };
|
|
if (sourceYear && make.value && sourceModel) {
|
|
const models = await vehicleDataService.getModels(pool, sourceYear, make.value);
|
|
model = this.matchField(sourceModel, models);
|
|
}
|
|
|
|
// Match trim against dropdown options
|
|
let trimLevel: MatchedField<string> = { value: null, sourceValue: sourceTrim, confidence: 'none' };
|
|
if (sourceYear && make.value && model.value && sourceTrim) {
|
|
const trims = await vehicleDataService.getTrims(pool, sourceYear, make.value, model.value);
|
|
trimLevel = this.matchField(sourceTrim, trims);
|
|
}
|
|
|
|
// Match engine against dropdown options
|
|
let engine: MatchedField<string> = { value: null, sourceValue: sourceEngine, confidence: 'none' };
|
|
if (sourceYear && make.value && model.value && trimLevel.value && sourceEngine) {
|
|
const engines = await vehicleDataService.getEngines(pool, sourceYear, make.value, model.value, trimLevel.value);
|
|
engine = this.matchField(sourceEngine, engines);
|
|
}
|
|
|
|
// Match transmission against dropdown options
|
|
let transmission: MatchedField<string> = { value: null, sourceValue: sourceTransmission, confidence: 'none' };
|
|
if (sourceYear && make.value && model.value && trimLevel.value && sourceTransmission) {
|
|
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, sourceYear, make.value, model.value, trimLevel.value);
|
|
transmission = this.matchField(sourceTransmission, transmissions);
|
|
}
|
|
|
|
// Body type, drive type, and fuel type are display-only (no dropdown matching)
|
|
const bodyType: MatchedField<string> = {
|
|
value: null,
|
|
sourceValue: sourceBodyType,
|
|
confidence: 'none'
|
|
};
|
|
|
|
const driveType: MatchedField<string> = {
|
|
value: null,
|
|
sourceValue: sourceDriveType,
|
|
confidence: 'none'
|
|
};
|
|
|
|
const fuelType: MatchedField<string> = {
|
|
value: null,
|
|
sourceValue: sourceFuelType,
|
|
confidence: 'none'
|
|
};
|
|
|
|
return {
|
|
year,
|
|
make,
|
|
model,
|
|
trimLevel,
|
|
bodyType,
|
|
driveType,
|
|
fuelType,
|
|
engine,
|
|
transmission
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Match a value against dropdown options using fuzzy matching
|
|
* Returns the matched dropdown value with confidence level
|
|
* Matching order: exact -> normalized -> prefix -> contains
|
|
*/
|
|
private matchField(sourceValue: string, options: string[]): MatchedField<string> {
|
|
if (!sourceValue || options.length === 0) {
|
|
return { value: null, sourceValue, confidence: 'none' };
|
|
}
|
|
|
|
const normalizedSource = sourceValue.toLowerCase().trim();
|
|
|
|
// Try exact case-insensitive match
|
|
const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedSource);
|
|
if (exactMatch) {
|
|
return { value: exactMatch, sourceValue, confidence: 'high' };
|
|
}
|
|
|
|
// Try normalized comparison (remove special chars)
|
|
const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
const normalizedSourceClean = normalizeForCompare(sourceValue);
|
|
|
|
const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedSourceClean);
|
|
if (normalizedMatch) {
|
|
return { value: normalizedMatch, sourceValue, confidence: 'medium' };
|
|
}
|
|
|
|
// Try prefix match - option starts with source value
|
|
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedSource));
|
|
if (prefixMatch) {
|
|
return { value: prefixMatch, sourceValue, confidence: 'medium' };
|
|
}
|
|
|
|
// Try contains match - option contains source value
|
|
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedSource));
|
|
if (containsMatch) {
|
|
return { value: containsMatch, sourceValue, confidence: 'medium' };
|
|
}
|
|
|
|
// Try reverse contains - source value contains option (e.g., source "X5 xDrive35i" contains option "X5")
|
|
// Prefer the longest matching option to avoid false positives (e.g., "X5 M" over "X5")
|
|
const reverseMatches = options.filter(opt => {
|
|
const normalizedOpt = opt.toLowerCase().trim();
|
|
return normalizedSource.includes(normalizedOpt) && normalizedOpt.length > 0;
|
|
});
|
|
if (reverseMatches.length > 0) {
|
|
const bestMatch = reverseMatches.reduce((a, b) => a.length >= b.length ? a : b);
|
|
return { value: bestMatch, sourceValue, confidence: 'medium' };
|
|
}
|
|
|
|
// Try word-start match - source starts with option + separator (e.g., "X5 xDrive" starts with "X5 ")
|
|
const wordStartMatch = options.find(opt => {
|
|
const normalizedOpt = opt.toLowerCase().trim();
|
|
return normalizedSource.startsWith(normalizedOpt + ' ') || normalizedSource.startsWith(normalizedOpt + '-');
|
|
});
|
|
if (wordStartMatch) {
|
|
return { value: wordStartMatch, sourceValue, confidence: 'medium' };
|
|
}
|
|
|
|
// No match found - return source value as hint with no match
|
|
return { value: null, sourceValue, confidence: 'none' };
|
|
}
|
|
|
|
private toResponse(vehicle: Vehicle): VehicleResponse {
|
|
return {
|
|
id: vehicle.id,
|
|
userId: vehicle.userId,
|
|
vin: vehicle.vin,
|
|
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,
|
|
odometerReading: vehicle.odometerReading,
|
|
isActive: vehicle.isActive,
|
|
createdAt: vehicle.createdAt.toISOString(),
|
|
updatedAt: vehicle.updatedAt.toISOString(),
|
|
imageUrl: vehicle.imageStorageKey ? `/api/vehicles/${vehicle.id}/image` : undefined,
|
|
};
|
|
}
|
|
}
|