Files
motovaultpro/backend/src/features/fuel-logs/domain/fuel-logs.service.ts
Eric Gullickson 0d90829d31
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 43s
Deploy to Staging / Verify Staging (pull_request) Successful in 3s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 3s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
fix: coerce decimals in fuel-logs enhanced repository methods (refs #244)
The enhanced API path on FuelLogsRepository returned raw pg rows
straight to the service layer, so DECIMAL columns (fuel_units,
cost_per_unit, total_cost, gallons, price_per_gallon) arrived as
strings instead of numbers. The service layer compensated with
scattered Number() coercion in toEnhancedResponse and getVehicleStats,
and EfficiencyCalculationService silently leaned on JS string-to-
number coercion in division. Type signatures across the stack
declared number and the runtime delivered string.

Add a private mapEnhancedRow that coerces all DECIMAL columns with
parseFloat while preserving snake_case keys (the convention the
service layer already uses to access these rows). Apply it in every
enhanced read/write path: createEnhanced, findByVehicleIdEnhanced,
findByUserIdEnhanced, findByIdEnhanced, getPreviousLogByOdometer,
getLatestLogForVehicle, updateEnhanced. Drop the now-redundant
Number() wrappers in toEnhancedResponse and getVehicleStats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:46:48 -05:00

297 lines
12 KiB
TypeScript

/**
* @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 { EnhancedCreateFuelLogRequest, EnhancedUpdateFuelLogRequest, 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: 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');
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
);
const inserted = await this.repository.createEnhanced({
userId,
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
});
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]
);
}
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 => {
const efficiency = EfficiencyCalculationService.calculateEfficiency(
{
tripDistance: r.trip_distance ?? undefined,
fuelUnits: r.fuel_units ?? undefined
},
null, // No previous odometer needed for trip distance calculation
unitSystem
);
return this.toEnhancedResponse(r, efficiency?.value ?? 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 => {
const efficiency = EfficiencyCalculationService.calculateEfficiency(
{
tripDistance: r.trip_distance ?? undefined,
fuelUnits: r.fuel_units ?? undefined
},
null, // No previous odometer needed for trip distance calculation
unitSystem
);
return this.toEnhancedResponse(r, efficiency?.value ?? 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);
const efficiency = EfficiencyCalculationService.calculateEfficiency(
{
tripDistance: row.trip_distance ?? undefined,
fuelUnits: row.fuel_units ?? undefined
},
null, // No previous odometer needed for trip distance calculation
unitSystem
);
return this.toEnhancedResponse(row, efficiency?.value ?? undefined, unitSystem);
}
async updateFuelLog(id: string, data: EnhancedUpdateFuelLogRequest, userId: string): Promise<EnhancedFuelLogResponse> {
logger.info('Updating enhanced fuel log', { id, userId });
// Verify the fuel log exists and belongs to the user
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');
// Get user settings for unit conversion
const userSettings = await UserSettingsService.getUserSettings(userId);
// Validate the update data
if (Object.keys(data).length === 0) {
throw new Error('No fields provided for update');
}
// Prepare update data with proper type conversion
const updateData: any = {};
if (data.dateTime !== undefined) {
updateData.dateTime = new Date(data.dateTime);
}
if (data.odometerReading !== undefined) {
updateData.odometerReading = data.odometerReading;
}
if (data.tripDistance !== undefined) {
updateData.tripDistance = data.tripDistance;
}
if (data.fuelType !== undefined) {
updateData.fuelType = data.fuelType;
}
if (data.fuelGrade !== undefined) {
updateData.fuelGrade = data.fuelGrade;
}
if (data.fuelUnits !== undefined) {
updateData.fuelUnits = data.fuelUnits;
}
if (data.costPerUnit !== undefined) {
updateData.costPerUnit = data.costPerUnit;
}
if (data.locationData !== undefined) {
updateData.locationData = data.locationData;
}
if (data.notes !== undefined) {
updateData.notes = data.notes;
}
// Update the fuel log
const updated = await this.repository.updateEnhanced(id, updateData);
if (!updated) throw new Error('Failed to update fuel log');
// Update vehicle odometer if changed
if (data.odometerReading !== undefined) {
await pool.query(
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND user_id = $3 AND (odometer_reading IS NULL OR odometer_reading < $1)',
[data.odometerReading, existing.vehicle_id, userId]
);
}
// Invalidate caches
await this.invalidateCaches(userId, existing.vehicle_id, userSettings.unitSystem);
// Calculate efficiency for response
const efficiency = EfficiencyCalculationService.calculateEfficiency(
{
odometerReading: updated.odometer ?? undefined,
tripDistance: updated.trip_distance ?? undefined,
fuelUnits: updated.fuel_units ?? undefined
},
null, // Previous log efficiency calculation would require more complex logic for updates
userSettings.unitSystem
);
return this.toEnhancedResponse(updated, efficiency?.value ?? undefined, userSettings.unitSystem);
}
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 + (r.fuel_units || 0), 0);
const totalCost = rows.reduce((s, r) => s + (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;
}
}
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 };
}
private async invalidateCaches(userId: string, vehicleId: string, unitSystem: 'imperial' | 'metric'): Promise<void> {
await Promise.all([
cacheService.del(`${this.cachePrefix}:user:${userId}:${unitSystem}`),
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`)
]);
}
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: 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 ?? 0,
costPerUnit: row.cost_per_unit ?? 0,
totalCost: row.total_cost ?? 0,
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(),
};
}
}