284 lines
9.0 KiB
TypeScript
284 lines
9.0 KiB
TypeScript
/**
|
|
* @ai-summary Business logic for vehicles feature
|
|
* @ai-context Handles VIN decoding, caching, and business rules
|
|
*/
|
|
|
|
import { VehiclesRepository } from '../data/vehicles.repository';
|
|
import { getVINDecodeService, getPool } from '../../platform';
|
|
import {
|
|
Vehicle,
|
|
CreateVehicleRequest,
|
|
UpdateVehicleRequest,
|
|
VehicleResponse
|
|
} from './vehicles.types';
|
|
import { logger } from '../../../core/logging/logger';
|
|
import { cacheService } from '../../../core/config/redis';
|
|
import { isValidVIN } from '../../../shared-minimal/utils/validators';
|
|
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
|
|
|
export class VehiclesService {
|
|
private readonly cachePrefix = 'vehicles';
|
|
private readonly listCacheTTL = 300; // 5 minutes
|
|
|
|
constructor(private repository: VehiclesRepository) {
|
|
// VIN decode service is now provided by platform feature
|
|
}
|
|
|
|
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
|
|
logger.info('Creating vehicle', { userId, vin: data.vin, licensePlate: (data as any).licensePlate });
|
|
|
|
let make: string | undefined;
|
|
let model: string | undefined;
|
|
let year: number | undefined;
|
|
|
|
if (data.vin) {
|
|
// Validate VIN if provided
|
|
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');
|
|
}
|
|
// Attempt VIN decode to enrich fields using platform service
|
|
const vinDecodeService = getVINDecodeService();
|
|
const pool = getPool();
|
|
const vinDecodeResult = await vinDecodeService.decodeVIN(pool, data.vin);
|
|
if (vinDecodeResult.success && vinDecodeResult.result) {
|
|
make = normalizeMakeName(vinDecodeResult.result.make);
|
|
model = normalizeModelName(vinDecodeResult.result.model);
|
|
year = vinDecodeResult.result.year ?? undefined;
|
|
// VIN caching is now handled by platform feature
|
|
}
|
|
}
|
|
|
|
// Create vehicle (VIN optional). Client-sent make/model/year override decode if provided.
|
|
const inputMake = (data as any).make ?? make;
|
|
const inputModel = (data as any).model ?? model;
|
|
|
|
const vehicle = await this.repository.create({
|
|
...data,
|
|
userId,
|
|
make: normalizeMakeName(inputMake),
|
|
model: normalizeModelName(inputModel),
|
|
year: (data as any).year ?? year,
|
|
});
|
|
|
|
// Invalidate user's vehicle list cache
|
|
await this.invalidateUserCache(userId);
|
|
|
|
return this.toResponse(vehicle);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
// 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);
|
|
|
|
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');
|
|
}
|
|
|
|
// Soft delete
|
|
await this.repository.softDelete(id);
|
|
|
|
// Invalidate cache
|
|
await this.invalidateUserCache(userId);
|
|
}
|
|
|
|
private async invalidateUserCache(userId: string): Promise<void> {
|
|
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
|
await cacheService.del(cacheKey);
|
|
}
|
|
|
|
async getDropdownMakes(_year: number): Promise<{ id: number; name: string }[]> {
|
|
// TODO: Implement using platform VehicleDataService
|
|
// For now, return empty array to allow migration to complete
|
|
logger.warn('Dropdown makes not yet implemented via platform feature');
|
|
return [];
|
|
}
|
|
|
|
async getDropdownModels(_year: number, _makeId: number): Promise<{ id: number; name: string }[]> {
|
|
// TODO: Implement using platform VehicleDataService
|
|
logger.warn('Dropdown models not yet implemented via platform feature');
|
|
return [];
|
|
}
|
|
|
|
async getDropdownTransmissions(_year: number, _makeId: number, _modelId: number): Promise<{ name: string }[]> {
|
|
// TODO: Implement using platform VehicleDataService
|
|
logger.warn('Dropdown transmissions not yet implemented via platform feature');
|
|
return [];
|
|
}
|
|
|
|
async getDropdownEngines(_year: number, _makeId: number, _modelId: number, _trimId: number): Promise<{ name: string }[]> {
|
|
// TODO: Implement using platform VehicleDataService
|
|
logger.warn('Dropdown engines not yet implemented via platform feature');
|
|
return [];
|
|
}
|
|
|
|
async getDropdownTrims(_year: number, _makeId: number, _modelId: number): Promise<{ name: string }[]> {
|
|
// TODO: Implement using platform VehicleDataService
|
|
logger.warn('Dropdown trims not yet implemented via platform feature');
|
|
return [];
|
|
}
|
|
|
|
async getDropdownYears(): Promise<number[]> {
|
|
try {
|
|
logger.info('Getting dropdown years');
|
|
// Fallback: generate recent years
|
|
const currentYear = new Date().getFullYear();
|
|
const years: number[] = [];
|
|
for (let y = currentYear + 1; y >= 1980; y--) years.push(y);
|
|
return years;
|
|
} catch (error) {
|
|
logger.error('Failed to get dropdown years', { error });
|
|
const currentYear = new Date().getFullYear();
|
|
const years: number[] = [];
|
|
for (let y = currentYear + 1; y >= 1980; y--) years.push(y);
|
|
return years;
|
|
}
|
|
}
|
|
|
|
async decodeVIN(vin: string): Promise<{
|
|
vin: string;
|
|
success: boolean;
|
|
year?: number;
|
|
make?: string;
|
|
model?: string;
|
|
trimLevel?: string;
|
|
engine?: string;
|
|
transmission?: string;
|
|
confidence?: number;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
logger.info('Decoding VIN', { vin });
|
|
|
|
// Use platform feature's VIN decode service
|
|
const vinDecodeService = getVINDecodeService();
|
|
const pool = getPool();
|
|
const result = await vinDecodeService.decodeVIN(pool, vin);
|
|
|
|
if (result.success && result.result) {
|
|
return {
|
|
vin,
|
|
success: true,
|
|
year: result.result.year ?? undefined,
|
|
make: result.result.make ?? undefined,
|
|
model: result.result.model ?? undefined,
|
|
trimLevel: result.result.trim_name ?? undefined,
|
|
engine: result.result.engine_description ?? undefined,
|
|
confidence: 85 // High confidence since we have good data
|
|
};
|
|
} else {
|
|
return {
|
|
vin,
|
|
success: false,
|
|
error: result.error || 'Unable to decode VIN'
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to decode VIN', { vin, error });
|
|
return {
|
|
vin,
|
|
success: false,
|
|
error: 'VIN decode service unavailable'
|
|
};
|
|
}
|
|
}
|
|
|
|
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(),
|
|
};
|
|
}
|
|
}
|