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
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>
297 lines
12 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|