Initial Commit

This commit is contained in:
Eric Gullickson
2025-09-17 16:09:15 -05:00
parent 0cdb9803de
commit a052040e3a
373 changed files with 437090 additions and 6773 deletions

View File

@@ -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(),
};
}
}
}