Files
motovaultpro/backend/src/features/vehicles/domain/vehicles.service.ts
Eric Gullickson 6683f1eeff Very minimal MVP
2025-08-23 09:54:22 -05:00

240 lines
6.8 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 { vpicClient } from '../external/vpic/vpic.client';
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';
export class VehiclesService {
private readonly cachePrefix = 'vehicles';
private readonly listCacheTTL = 300; // 5 minutes
constructor(private repository: VehiclesRepository) {}
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
logger.info('Creating vehicle', { userId, vin: data.vin });
// Validate VIN
if (!isValidVIN(data.vin)) {
throw new Error('Invalid VIN format');
}
// Check for duplicate
const existing = await this.repository.findByUserAndVIN(userId, data.vin);
if (existing) {
throw new Error('Vehicle with this VIN already exists');
}
// Decode VIN
const vinData = await vpicClient.decodeVIN(data.vin);
// Create vehicle with decoded data
const vehicle = await this.repository.create({
...data,
userId,
make: vinData?.make,
model: vinData?.model,
year: vinData?.year,
});
// Cache VIN decode result
if (vinData) {
await this.repository.cacheVINDecode(data.vin, vinData);
}
// 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 => 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');
}
// Update vehicle
const updated = await this.repository.update(id, data);
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(): Promise<{ id: number; name: string }[]> {
try {
logger.info('Getting dropdown makes');
const makes = await vpicClient.getAllMakes();
return makes.map(make => ({
id: make.Make_ID,
name: make.Make_Name
}));
} catch (error) {
logger.error('Failed to get dropdown makes', { error });
throw new Error('Failed to load makes');
}
}
async getDropdownModels(make: string): Promise<{ id: number; name: string }[]> {
try {
logger.info('Getting dropdown models', { make });
const models = await vpicClient.getModelsForMake(make);
return models.map(model => ({
id: model.Model_ID,
name: model.Model_Name
}));
} catch (error) {
logger.error('Failed to get dropdown models', { make, error });
throw new Error('Failed to load models');
}
}
async getDropdownTransmissions(): Promise<{ id: number; name: string }[]> {
try {
logger.info('Getting dropdown transmissions');
const transmissions = await vpicClient.getTransmissionTypes();
return transmissions.map(transmission => ({
id: transmission.Id,
name: transmission.Name
}));
} catch (error) {
logger.error('Failed to get dropdown transmissions', { error });
throw new Error('Failed to load transmissions');
}
}
async getDropdownEngines(): Promise<{ id: number; name: string }[]> {
try {
logger.info('Getting dropdown engines');
const engines = await vpicClient.getEngineConfigurations();
return engines.map(engine => ({
id: engine.Id,
name: engine.Name
}));
} catch (error) {
logger.error('Failed to get dropdown engines', { error });
throw new Error('Failed to load engines');
}
}
async getDropdownTrims(): Promise<{ id: number; name: string }[]> {
try {
logger.info('Getting dropdown trims');
const trims = await vpicClient.getTrimLevels();
return trims.map(trim => ({
id: trim.Id,
name: trim.Name
}));
} catch (error) {
logger.error('Failed to get dropdown trims', { error });
throw new Error('Failed to load trims');
}
}
private toResponse(vehicle: Vehicle): VehicleResponse {
return {
id: vehicle.id,
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(),
};
}
}