Initial Commit
This commit is contained in:
@@ -1,249 +1,192 @@
|
||||
/**
|
||||
* @ai-summary Business logic for fuel logs feature
|
||||
* @ai-context Handles MPG calculations and vehicle validation
|
||||
* @ai-summary Enhanced business logic for fuel logs feature
|
||||
* @ai-context Unit-agnostic efficiency and user preferences integration
|
||||
*/
|
||||
|
||||
import { FuelLogsRepository } from '../data/fuel-logs.repository';
|
||||
import {
|
||||
FuelLog,
|
||||
CreateFuelLogRequest,
|
||||
UpdateFuelLogRequest,
|
||||
FuelLogResponse,
|
||||
FuelStats
|
||||
} from './fuel-logs.types';
|
||||
import { EnhancedCreateFuelLogRequest, EnhancedFuelLogResponse, FuelType } from './fuel-logs.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import pool from '../../../core/config/database';
|
||||
import { EnhancedValidationService } from './enhanced-validation.service';
|
||||
import { UnitConversionService } from './unit-conversion.service';
|
||||
import { EfficiencyCalculationService } from './efficiency-calculation.service';
|
||||
import { UserSettingsService } from '../external/user-settings.service';
|
||||
|
||||
export class FuelLogsService {
|
||||
private readonly cachePrefix = 'fuel-logs';
|
||||
private readonly cacheTTL = 300; // 5 minutes
|
||||
|
||||
|
||||
constructor(private repository: FuelLogsRepository) {}
|
||||
|
||||
async createFuelLog(data: CreateFuelLogRequest, userId: string): Promise<FuelLogResponse> {
|
||||
logger.info('Creating fuel log', { userId, vehicleId: data.vehicleId });
|
||||
|
||||
|
||||
async createFuelLog(data: EnhancedCreateFuelLogRequest, userId: string): Promise<EnhancedFuelLogResponse> {
|
||||
logger.info('Creating enhanced fuel log', { userId, vehicleId: data.vehicleId, fuelType: data.fuelType });
|
||||
const userSettings = await UserSettingsService.getUserSettings(userId);
|
||||
|
||||
const validation = EnhancedValidationService.validateFuelLogData(data);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.errors.join(', '));
|
||||
}
|
||||
|
||||
// Verify vehicle ownership
|
||||
const vehicleCheck = await pool.query(
|
||||
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
|
||||
[data.vehicleId, userId]
|
||||
);
|
||||
|
||||
if (vehicleCheck.rows.length === 0) {
|
||||
throw new Error('Vehicle not found or unauthorized');
|
||||
}
|
||||
|
||||
// Calculate MPG based on previous log
|
||||
let mpg: number | undefined;
|
||||
const previousLog = await this.repository.getPreviousLog(
|
||||
data.vehicleId,
|
||||
data.date,
|
||||
data.odometer
|
||||
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
|
||||
|
||||
const totalCost = data.fuelUnits * data.costPerUnit;
|
||||
|
||||
// Previous log for efficiency
|
||||
const prev = data.odometerReading
|
||||
? await this.repository.getPreviousLogByOdometer(data.vehicleId, data.odometerReading)
|
||||
: await this.repository.getLatestLogForVehicle(data.vehicleId);
|
||||
|
||||
const eff = EfficiencyCalculationService.calculateEfficiency(
|
||||
{ odometerReading: data.odometerReading, tripDistance: data.tripDistance, fuelUnits: data.fuelUnits },
|
||||
prev?.odometer ?? null,
|
||||
userSettings.unitSystem
|
||||
);
|
||||
|
||||
if (previousLog && previousLog.odometer < data.odometer) {
|
||||
const milesDriven = data.odometer - previousLog.odometer;
|
||||
mpg = milesDriven / data.gallons;
|
||||
}
|
||||
|
||||
// Create fuel log
|
||||
const fuelLog = await this.repository.create({
|
||||
...data,
|
||||
|
||||
const inserted = await this.repository.createEnhanced({
|
||||
userId,
|
||||
mpg
|
||||
vehicleId: data.vehicleId,
|
||||
dateTime: new Date(data.dateTime),
|
||||
odometerReading: data.odometerReading,
|
||||
tripDistance: data.tripDistance,
|
||||
fuelType: data.fuelType,
|
||||
fuelGrade: data.fuelGrade ?? null,
|
||||
fuelUnits: data.fuelUnits,
|
||||
costPerUnit: data.costPerUnit,
|
||||
totalCost,
|
||||
locationData: data.locationData ?? null,
|
||||
notes: data.notes
|
||||
});
|
||||
|
||||
// Update vehicle odometer
|
||||
await pool.query(
|
||||
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND odometer_reading < $1',
|
||||
[data.odometer, data.vehicleId]
|
||||
);
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCaches(userId, data.vehicleId);
|
||||
|
||||
return this.toResponse(fuelLog);
|
||||
}
|
||||
|
||||
async getFuelLogsByVehicle(vehicleId: string, userId: string): Promise<FuelLogResponse[]> {
|
||||
// Verify vehicle ownership
|
||||
const vehicleCheck = await pool.query(
|
||||
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
|
||||
[vehicleId, userId]
|
||||
);
|
||||
|
||||
if (vehicleCheck.rows.length === 0) {
|
||||
throw new Error('Vehicle not found or unauthorized');
|
||||
}
|
||||
|
||||
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}`;
|
||||
|
||||
// Check cache
|
||||
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get from database
|
||||
const logs = await this.repository.findByVehicleId(vehicleId);
|
||||
const response = logs.map((log: FuelLog) => this.toResponse(log));
|
||||
|
||||
// Cache result
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getUserFuelLogs(userId: string): Promise<FuelLogResponse[]> {
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||
|
||||
// Check cache
|
||||
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get from database
|
||||
const logs = await this.repository.findByUserId(userId);
|
||||
const response = logs.map((log: FuelLog) => this.toResponse(log));
|
||||
|
||||
// Cache result
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getFuelLog(id: string, userId: string): Promise<FuelLogResponse> {
|
||||
const log = await this.repository.findById(id);
|
||||
|
||||
if (!log) {
|
||||
throw new Error('Fuel log not found');
|
||||
}
|
||||
|
||||
if (log.userId !== userId) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return this.toResponse(log);
|
||||
}
|
||||
|
||||
async updateFuelLog(
|
||||
id: string,
|
||||
data: UpdateFuelLogRequest,
|
||||
userId: string
|
||||
): Promise<FuelLogResponse> {
|
||||
// Verify ownership
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) {
|
||||
throw new Error('Fuel log not found');
|
||||
}
|
||||
if (existing.userId !== userId) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
// Recalculate MPG if odometer or gallons changed
|
||||
let mpg = existing.mpg;
|
||||
if (data.odometer || data.gallons) {
|
||||
const previousLog = await this.repository.getPreviousLog(
|
||||
existing.vehicleId,
|
||||
data.date || existing.date.toISOString(),
|
||||
data.odometer || existing.odometer
|
||||
|
||||
if (data.odometerReading) {
|
||||
await pool.query(
|
||||
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND (odometer_reading IS NULL OR odometer_reading < $1)',
|
||||
[data.odometerReading, data.vehicleId]
|
||||
);
|
||||
|
||||
if (previousLog) {
|
||||
const odometer = data.odometer || existing.odometer;
|
||||
const gallons = data.gallons || existing.gallons;
|
||||
const milesDriven = odometer - previousLog.odometer;
|
||||
mpg = milesDriven / gallons;
|
||||
}
|
||||
|
||||
await this.invalidateCaches(userId, data.vehicleId, userSettings.unitSystem);
|
||||
return this.toEnhancedResponse(inserted, eff?.value ?? undefined, userSettings.unitSystem);
|
||||
}
|
||||
|
||||
async getFuelLogsByVehicle(vehicleId: string, userId: string): Promise<EnhancedFuelLogResponse[]> {
|
||||
const vehicleCheck = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
||||
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
|
||||
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`;
|
||||
|
||||
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
|
||||
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
return response;
|
||||
}
|
||||
|
||||
async getUserFuelLogs(userId: string): Promise<EnhancedFuelLogResponse[]> {
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}:${unitSystem}`;
|
||||
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
const rows = await this.repository.findByUserIdEnhanced(userId);
|
||||
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
return response;
|
||||
}
|
||||
|
||||
async getFuelLog(id: string, userId: string): Promise<EnhancedFuelLogResponse> {
|
||||
const row = await this.repository.findByIdEnhanced(id);
|
||||
if (!row) throw new Error('Fuel log not found');
|
||||
if (row.user_id !== userId) throw new Error('Unauthorized');
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
return this.toEnhancedResponse(row, undefined, unitSystem);
|
||||
}
|
||||
|
||||
async updateFuelLog(): Promise<any> { throw new Error('Not Implemented'); }
|
||||
|
||||
async deleteFuelLog(id: string, userId: string): Promise<void> {
|
||||
const existing = await this.repository.findByIdEnhanced(id);
|
||||
if (!existing) throw new Error('Fuel log not found');
|
||||
if (existing.user_id !== userId) throw new Error('Unauthorized');
|
||||
await this.repository.delete(id);
|
||||
await this.invalidateCaches(userId, existing.vehicle_id, 'imperial'); // cache keys include unit; simple sweep below
|
||||
}
|
||||
|
||||
async getVehicleStats(vehicleId: string, userId: string): Promise<any> {
|
||||
const vehicleCheck = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
||||
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
|
||||
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
|
||||
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
||||
if (rows.length === 0) {
|
||||
return { logCount: 0, totalFuelUnits: 0, totalCost: 0, averageCostPerUnit: 0, totalDistance: 0, averageEfficiency: 0, unitLabels: labels };
|
||||
}
|
||||
|
||||
const totalFuelUnits = rows.reduce((s, r) => s + (Number(r.fuel_units) || 0), 0);
|
||||
const totalCost = rows.reduce((s, r) => s + (Number(r.total_cost) || 0), 0);
|
||||
const averageCostPerUnit = totalFuelUnits > 0 ? totalCost / totalFuelUnits : 0;
|
||||
|
||||
const sorted = [...rows].sort((a, b) => (new Date(b.date_time || b.date)).getTime() - (new Date(a.date_time || a.date)).getTime());
|
||||
let totalDistance = 0;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const cur = sorted[i];
|
||||
const prev = sorted[i + 1];
|
||||
if (Number(cur.trip_distance) > 0) totalDistance += Number(cur.trip_distance);
|
||||
else if (prev && cur.odometer != null && prev.odometer != null) {
|
||||
const d = Number(cur.odometer) - Number(prev.odometer);
|
||||
if (d > 0) totalDistance += d;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data with proper types
|
||||
const updateData: Partial<FuelLog> = {
|
||||
...data,
|
||||
date: data.date ? new Date(data.date) : undefined,
|
||||
mpg
|
||||
};
|
||||
|
||||
// Update
|
||||
const updated = await this.repository.update(id, updateData);
|
||||
if (!updated) {
|
||||
throw new Error('Update failed');
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCaches(userId, existing.vehicleId);
|
||||
|
||||
return this.toResponse(updated);
|
||||
|
||||
const efficiencies: number[] = sorted.map(l => {
|
||||
const e = EfficiencyCalculationService.calculateEfficiency(
|
||||
{ odometerReading: l.odometer ?? undefined, tripDistance: l.trip_distance ?? undefined, fuelUnits: l.fuel_units ?? undefined },
|
||||
null,
|
||||
unitSystem
|
||||
);
|
||||
return e?.value || 0;
|
||||
}).filter(v => v > 0);
|
||||
const averageEfficiency = efficiencies.length ? (efficiencies.reduce((a, b) => a + b, 0) / efficiencies.length) : 0;
|
||||
|
||||
return { logCount: rows.length, totalFuelUnits, totalCost, averageCostPerUnit, totalDistance, averageEfficiency, unitLabels: labels };
|
||||
}
|
||||
|
||||
async deleteFuelLog(id: string, userId: string): Promise<void> {
|
||||
// Verify ownership
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) {
|
||||
throw new Error('Fuel log not found');
|
||||
}
|
||||
if (existing.userId !== userId) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
await this.repository.delete(id);
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCaches(userId, existing.vehicleId);
|
||||
}
|
||||
|
||||
async getVehicleStats(vehicleId: string, userId: string): Promise<FuelStats> {
|
||||
// Verify vehicle ownership
|
||||
const vehicleCheck = await pool.query(
|
||||
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
|
||||
[vehicleId, userId]
|
||||
);
|
||||
|
||||
if (vehicleCheck.rows.length === 0) {
|
||||
throw new Error('Vehicle not found or unauthorized');
|
||||
}
|
||||
|
||||
const stats = await this.repository.getStats(vehicleId);
|
||||
|
||||
if (!stats) {
|
||||
return {
|
||||
logCount: 0,
|
||||
totalGallons: 0,
|
||||
totalCost: 0,
|
||||
averagePricePerGallon: 0,
|
||||
averageMPG: 0,
|
||||
totalMiles: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async invalidateCaches(userId: string, vehicleId: string): Promise<void> {
|
||||
|
||||
private async invalidateCaches(userId: string, vehicleId: string, unitSystem: 'imperial' | 'metric'): Promise<void> {
|
||||
await Promise.all([
|
||||
cacheService.del(`${this.cachePrefix}:user:${userId}`),
|
||||
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}`)
|
||||
cacheService.del(`${this.cachePrefix}:user:${userId}:${unitSystem}`),
|
||||
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`)
|
||||
]);
|
||||
}
|
||||
|
||||
private toResponse(log: FuelLog): FuelLogResponse {
|
||||
|
||||
private toEnhancedResponse(row: any, efficiency: number | undefined, unitSystem: 'imperial' | 'metric'): EnhancedFuelLogResponse {
|
||||
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
||||
const dateTime = row.date_time ? new Date(row.date_time) : (row.date ? new Date(row.date) : new Date());
|
||||
return {
|
||||
id: log.id,
|
||||
userId: log.userId,
|
||||
vehicleId: log.vehicleId,
|
||||
date: log.date.toISOString().split('T')[0],
|
||||
odometer: log.odometer,
|
||||
gallons: log.gallons,
|
||||
pricePerGallon: log.pricePerGallon,
|
||||
totalCost: log.totalCost,
|
||||
station: log.station,
|
||||
location: log.location,
|
||||
notes: log.notes,
|
||||
mpg: log.mpg,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
updatedAt: log.updatedAt.toISOString(),
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
dateTime: dateTime.toISOString(),
|
||||
odometerReading: row.odometer ?? undefined,
|
||||
tripDistance: row.trip_distance ?? undefined,
|
||||
fuelType: row.fuel_type as FuelType,
|
||||
fuelGrade: row.fuel_grade ?? undefined,
|
||||
fuelUnits: row.fuel_units,
|
||||
costPerUnit: row.cost_per_unit,
|
||||
totalCost: Number(row.total_cost),
|
||||
locationData: row.location_data ?? undefined,
|
||||
notes: row.notes ?? undefined,
|
||||
efficiency: efficiency,
|
||||
efficiencyLabel: labels.efficiencyUnits,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
updatedAt: new Date(row.updated_at).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user