Initial Commit
This commit is contained in:
932
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-3.md
Normal file
932
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-3.md
Normal file
@@ -0,0 +1,932 @@
|
||||
# Phase 3: API & Backend Implementation
|
||||
|
||||
## Overview
|
||||
Update API contracts, implement enhanced backend services, create new endpoints, and build comprehensive test suite for the enhanced fuel logs system.
|
||||
|
||||
## Prerequisites
|
||||
- ✅ Phase 1 completed (database schema and core types)
|
||||
- ✅ Phase 2 completed (enhanced business logic services)
|
||||
- All business logic services tested and functional
|
||||
|
||||
## Updated Service Layer
|
||||
|
||||
### Enhanced Fuel Logs Service
|
||||
|
||||
**File**: `backend/src/features/fuel-logs/domain/fuel-logs.service.ts` (Updated)
|
||||
|
||||
```typescript
|
||||
import { FuelLogsRepository } from '../data/fuel-logs.repository';
|
||||
import {
|
||||
FuelLog, CreateFuelLogRequest, UpdateFuelLogRequest,
|
||||
FuelLogResponse, FuelStats, UnitSystem
|
||||
} from './fuel-logs.types';
|
||||
import { EnhancedValidationService } from './enhanced-validation.service';
|
||||
import { EfficiencyCalculationService } from './efficiency-calculation.service';
|
||||
import { UnitConversionService } from './unit-conversion.service';
|
||||
import { UserSettingsService } from '../external/user-settings.service';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import pool from '../../../core/config/database';
|
||||
|
||||
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 enhanced fuel log', {
|
||||
userId,
|
||||
vehicleId: data.vehicleId,
|
||||
fuelType: data.fuelType,
|
||||
hasTrip: !!data.tripDistance,
|
||||
hasOdometer: !!data.odometerReading
|
||||
});
|
||||
|
||||
// Get user settings for unit system
|
||||
const userSettings = await UserSettingsService.getUserSettings(userId);
|
||||
|
||||
// Enhanced validation
|
||||
const validation = EnhancedValidationService.validateFuelLogData(data, userSettings.unitSystem);
|
||||
if (!validation.isValid) {
|
||||
throw new ValidationError(`Invalid fuel log data: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Log warnings
|
||||
if (validation.warnings.length > 0) {
|
||||
logger.warn('Fuel log validation warnings', { warnings: validation.warnings });
|
||||
}
|
||||
|
||||
// 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 total cost
|
||||
const totalCost = data.fuelUnits * data.costPerUnit;
|
||||
|
||||
// Get previous log for efficiency calculation
|
||||
const previousLog = data.odometerReading ?
|
||||
await this.repository.getPreviousLogByOdometer(data.vehicleId, data.odometerReading) :
|
||||
await this.repository.getLatestLogForVehicle(data.vehicleId);
|
||||
|
||||
// Calculate efficiency
|
||||
const efficiencyResult = EfficiencyCalculationService.calculateEfficiency(
|
||||
{ ...data, totalCost },
|
||||
previousLog,
|
||||
userSettings.unitSystem
|
||||
);
|
||||
|
||||
// Prepare fuel log data
|
||||
const fuelLogData = {
|
||||
...data,
|
||||
userId,
|
||||
dateTime: new Date(data.dateTime),
|
||||
totalCost,
|
||||
mpg: efficiencyResult?.value || null,
|
||||
efficiencyCalculationMethod: efficiencyResult?.calculationMethod || null
|
||||
};
|
||||
|
||||
// Create fuel log
|
||||
const fuelLog = await this.repository.create(fuelLogData);
|
||||
|
||||
// Update vehicle odometer if provided
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCaches(userId, data.vehicleId);
|
||||
|
||||
return this.toResponse(fuelLog, userSettings.unitSystem);
|
||||
}
|
||||
|
||||
async getFuelLogsByVehicle(
|
||||
vehicleId: string,
|
||||
userId: string,
|
||||
options?: { unitSystem?: UnitSystem }
|
||||
): 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');
|
||||
}
|
||||
|
||||
// Get user settings
|
||||
const userSettings = await UserSettingsService.getUserSettings(userId);
|
||||
const unitSystem = options?.unitSystem || userSettings.unitSystem;
|
||||
|
||||
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`;
|
||||
|
||||
// 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, unitSystem));
|
||||
|
||||
// Cache result
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getEnhancedVehicleStats(vehicleId: string, userId: string): Promise<EnhancedFuelStats> {
|
||||
// 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 userSettings = await UserSettingsService.getUserSettings(userId);
|
||||
const logs = await this.repository.findByVehicleId(vehicleId);
|
||||
|
||||
if (logs.length === 0) {
|
||||
return this.getEmptyStats(userSettings.unitSystem);
|
||||
}
|
||||
|
||||
// Calculate comprehensive stats
|
||||
const totalFuelUnits = logs.reduce((sum, log) => sum + log.fuelUnits, 0);
|
||||
const totalCost = logs.reduce((sum, log) => sum + log.totalCost, 0);
|
||||
const averageCostPerUnit = totalCost / totalFuelUnits;
|
||||
|
||||
const totalDistance = EfficiencyCalculationService.calculateTotalDistance(logs, userSettings.unitSystem);
|
||||
const averageEfficiency = EfficiencyCalculationService.calculateAverageEfficiency(logs, userSettings.unitSystem);
|
||||
|
||||
// Group by fuel type
|
||||
const fuelTypeBreakdown = this.calculateFuelTypeBreakdown(logs, userSettings.unitSystem);
|
||||
|
||||
// Calculate trends (last 30 days vs previous 30 days)
|
||||
const trends = this.calculateEfficiencyTrends(logs, userSettings.unitSystem);
|
||||
|
||||
const unitLabels = UnitConversionService.getUnitLabels(userSettings.unitSystem);
|
||||
|
||||
return {
|
||||
logCount: logs.length,
|
||||
totalFuelUnits,
|
||||
totalCost,
|
||||
averageCostPerUnit,
|
||||
totalDistance,
|
||||
averageEfficiency: averageEfficiency?.value || 0,
|
||||
fuelTypeBreakdown,
|
||||
trends,
|
||||
unitLabels,
|
||||
dateRange: {
|
||||
earliest: logs[logs.length - 1]?.dateTime,
|
||||
latest: logs[0]?.dateTime
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private toResponse(log: FuelLog, unitSystem: UnitSystem): FuelLogResponse {
|
||||
const unitLabels = UnitConversionService.getUnitLabels(unitSystem);
|
||||
|
||||
// Convert efficiency to user's unit system if needed
|
||||
let displayEfficiency = log.mpg;
|
||||
if (log.mpg && unitSystem === UnitSystem.METRIC) {
|
||||
displayEfficiency = UnitConversionService.convertEfficiency(
|
||||
log.mpg,
|
||||
UnitSystem.IMPERIAL, // Assuming stored as MPG
|
||||
UnitSystem.METRIC
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
userId: log.userId,
|
||||
vehicleId: log.vehicleId,
|
||||
dateTime: log.dateTime.toISOString(),
|
||||
|
||||
// Distance information
|
||||
odometerReading: log.odometerReading,
|
||||
tripDistance: log.tripDistance,
|
||||
|
||||
// Fuel information
|
||||
fuelType: log.fuelType,
|
||||
fuelGrade: log.fuelGrade,
|
||||
fuelUnits: log.fuelUnits,
|
||||
costPerUnit: log.costPerUnit,
|
||||
totalCost: log.totalCost,
|
||||
|
||||
// Location
|
||||
locationData: log.locationData,
|
||||
|
||||
// Calculated fields
|
||||
efficiency: displayEfficiency,
|
||||
efficiencyLabel: unitLabels.efficiencyUnits,
|
||||
|
||||
// Metadata
|
||||
notes: log.notes,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
updatedAt: log.updatedAt.toISOString(),
|
||||
|
||||
// Legacy fields (for backward compatibility)
|
||||
date: log.dateTime.toISOString().split('T')[0],
|
||||
odometer: log.odometerReading,
|
||||
gallons: log.fuelUnits, // May need conversion
|
||||
pricePerGallon: log.costPerUnit, // May need conversion
|
||||
mpg: log.mpg
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### New API Endpoints
|
||||
|
||||
#### Fuel Grade Endpoint
|
||||
|
||||
**File**: `backend/src/features/fuel-logs/api/fuel-grade.controller.ts`
|
||||
|
||||
```typescript
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FuelGradeService } from '../domain/fuel-grade.service';
|
||||
import { FuelType } from '../domain/fuel-logs.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class FuelGradeController {
|
||||
|
||||
async getFuelGrades(
|
||||
request: FastifyRequest<{ Params: { fuelType: FuelType } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { fuelType } = request.params;
|
||||
|
||||
// Validate fuel type
|
||||
if (!Object.values(FuelType).includes(fuelType)) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: `Invalid fuel type: ${fuelType}`
|
||||
});
|
||||
}
|
||||
|
||||
const grades = FuelGradeService.getFuelGradeOptions(fuelType);
|
||||
|
||||
return reply.code(200).send({
|
||||
fuelType,
|
||||
grades
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting fuel grades', { error, fuelType: request.params.fuelType });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get fuel grades'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAllFuelTypes(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const fuelTypes = Object.values(FuelType).map(type => ({
|
||||
value: type,
|
||||
label: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
grades: FuelGradeService.getFuelGradeOptions(type)
|
||||
}));
|
||||
|
||||
return reply.code(200).send({ fuelTypes });
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting fuel types', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get fuel types'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Enhanced Routes
|
||||
|
||||
**File**: `backend/src/features/fuel-logs/api/fuel-logs.routes.ts` (Updated)
|
||||
|
||||
```typescript
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FuelLogsController } from './fuel-logs.controller';
|
||||
import { FuelGradeController } from './fuel-grade.controller';
|
||||
import {
|
||||
createFuelLogSchema,
|
||||
updateFuelLogSchema,
|
||||
fuelLogParamsSchema,
|
||||
vehicleParamsSchema,
|
||||
fuelTypeParamsSchema
|
||||
} from './fuel-logs.validators';
|
||||
|
||||
export async function fuelLogsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
options: FastifyPluginOptions
|
||||
) {
|
||||
const fuelLogsController = new FuelLogsController();
|
||||
const fuelGradeController = new FuelGradeController();
|
||||
|
||||
// Existing fuel log CRUD endpoints (enhanced)
|
||||
fastify.post('/fuel-logs', {
|
||||
preHandler: [fastify.authenticate],
|
||||
schema: createFuelLogSchema
|
||||
}, fuelLogsController.createFuelLog.bind(fuelLogsController));
|
||||
|
||||
fastify.get('/fuel-logs', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, fuelLogsController.getUserFuelLogs.bind(fuelLogsController));
|
||||
|
||||
fastify.get('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
schema: { params: fuelLogParamsSchema }
|
||||
}, fuelLogsController.getFuelLog.bind(fuelLogsController));
|
||||
|
||||
fastify.put('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
schema: {
|
||||
params: fuelLogParamsSchema,
|
||||
body: updateFuelLogSchema
|
||||
}
|
||||
}, fuelLogsController.updateFuelLog.bind(fuelLogsController));
|
||||
|
||||
fastify.delete('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
schema: { params: fuelLogParamsSchema }
|
||||
}, fuelLogsController.deleteFuelLog.bind(fuelLogsController));
|
||||
|
||||
// Vehicle-specific endpoints (enhanced)
|
||||
fastify.get('/fuel-logs/vehicle/:vehicleId', {
|
||||
preHandler: [fastify.authenticate],
|
||||
schema: { params: vehicleParamsSchema }
|
||||
}, fuelLogsController.getFuelLogsByVehicle.bind(fuelLogsController));
|
||||
|
||||
fastify.get('/fuel-logs/vehicle/:vehicleId/stats', {
|
||||
preHandler: [fastify.authenticate],
|
||||
schema: { params: vehicleParamsSchema }
|
||||
}, fuelLogsController.getEnhancedVehicleStats.bind(fuelLogsController));
|
||||
|
||||
// NEW: Fuel type/grade endpoints
|
||||
fastify.get('/fuel-logs/fuel-types', {
|
||||
preHandler: [fastify.authenticate]
|
||||
}, fuelGradeController.getAllFuelTypes.bind(fuelGradeController));
|
||||
|
||||
fastify.get('/fuel-logs/fuel-grades/:fuelType', {
|
||||
preHandler: [fastify.authenticate],
|
||||
schema: { params: fuelTypeParamsSchema }
|
||||
}, fuelGradeController.getFuelGrades.bind(fuelGradeController));
|
||||
}
|
||||
|
||||
export function registerFuelLogsRoutes(fastify: FastifyInstance) {
|
||||
return fastify.register(fuelLogsRoutes, { prefix: '/api' });
|
||||
}
|
||||
```
|
||||
|
||||
### Enhanced Validation Schemas
|
||||
|
||||
**File**: `backend/src/features/fuel-logs/api/fuel-logs.validators.ts` (Updated)
|
||||
|
||||
```typescript
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { FuelType } from '../domain/fuel-logs.types';
|
||||
|
||||
export const createFuelLogSchema = {
|
||||
body: Type.Object({
|
||||
vehicleId: Type.String({ format: 'uuid' }),
|
||||
dateTime: Type.String({ format: 'date-time' }),
|
||||
|
||||
// Distance (one required)
|
||||
odometerReading: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
tripDistance: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
|
||||
// Fuel system
|
||||
fuelType: Type.Enum(FuelType),
|
||||
fuelGrade: Type.Optional(Type.String()),
|
||||
fuelUnits: Type.Number({ minimum: 0.01 }),
|
||||
costPerUnit: Type.Number({ minimum: 0.01 }),
|
||||
|
||||
// Location (optional)
|
||||
locationData: Type.Optional(Type.Object({
|
||||
address: Type.Optional(Type.String()),
|
||||
coordinates: Type.Optional(Type.Object({
|
||||
latitude: Type.Number({ minimum: -90, maximum: 90 }),
|
||||
longitude: Type.Number({ minimum: -180, maximum: 180 })
|
||||
})),
|
||||
googlePlaceId: Type.Optional(Type.String()),
|
||||
stationName: Type.Optional(Type.String())
|
||||
})),
|
||||
|
||||
notes: Type.Optional(Type.String({ maxLength: 500 }))
|
||||
}),
|
||||
response: {
|
||||
201: Type.Object({
|
||||
id: Type.String({ format: 'uuid' }),
|
||||
userId: Type.String(),
|
||||
vehicleId: Type.String({ format: 'uuid' }),
|
||||
dateTime: Type.String({ format: 'date-time' }),
|
||||
odometerReading: Type.Optional(Type.Number()),
|
||||
tripDistance: Type.Optional(Type.Number()),
|
||||
fuelType: Type.Enum(FuelType),
|
||||
fuelGrade: Type.Optional(Type.String()),
|
||||
fuelUnits: Type.Number(),
|
||||
costPerUnit: Type.Number(),
|
||||
totalCost: Type.Number(),
|
||||
efficiency: Type.Optional(Type.Number()),
|
||||
efficiencyLabel: Type.String(),
|
||||
createdAt: Type.String({ format: 'date-time' }),
|
||||
updatedAt: Type.String({ format: 'date-time' })
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFuelLogSchema = {
|
||||
body: Type.Partial(Type.Object({
|
||||
dateTime: Type.String({ format: 'date-time' }),
|
||||
odometerReading: Type.Number({ minimum: 0 }),
|
||||
tripDistance: Type.Number({ minimum: 0 }),
|
||||
fuelType: Type.Enum(FuelType),
|
||||
fuelGrade: Type.String(),
|
||||
fuelUnits: Type.Number({ minimum: 0.01 }),
|
||||
costPerUnit: Type.Number({ minimum: 0.01 }),
|
||||
locationData: Type.Object({
|
||||
address: Type.Optional(Type.String()),
|
||||
coordinates: Type.Optional(Type.Object({
|
||||
latitude: Type.Number({ minimum: -90, maximum: 90 }),
|
||||
longitude: Type.Number({ minimum: -180, maximum: 180 })
|
||||
})),
|
||||
googlePlaceId: Type.Optional(Type.String()),
|
||||
stationName: Type.Optional(Type.String())
|
||||
}),
|
||||
notes: Type.String({ maxLength: 500 })
|
||||
}))
|
||||
};
|
||||
|
||||
export const fuelLogParamsSchema = Type.Object({
|
||||
id: Type.String({ format: 'uuid' })
|
||||
});
|
||||
|
||||
export const vehicleParamsSchema = Type.Object({
|
||||
vehicleId: Type.String({ format: 'uuid' })
|
||||
});
|
||||
|
||||
export const fuelTypeParamsSchema = Type.Object({
|
||||
fuelType: Type.Enum(FuelType)
|
||||
});
|
||||
```
|
||||
|
||||
## Repository Layer Updates
|
||||
|
||||
### Enhanced Repository
|
||||
|
||||
**File**: `backend/src/features/fuel-logs/data/fuel-logs.repository.ts` (Updated)
|
||||
|
||||
```typescript
|
||||
import { Pool } from 'pg';
|
||||
import { FuelLog, CreateFuelLogData } from '../domain/fuel-logs.types';
|
||||
|
||||
export interface CreateFuelLogData {
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
dateTime: Date;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: string;
|
||||
fuelGrade?: string;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
totalCost: number;
|
||||
locationData?: any;
|
||||
notes?: string;
|
||||
mpg?: number;
|
||||
efficiencyCalculationMethod?: string;
|
||||
}
|
||||
|
||||
export class FuelLogsRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async create(data: CreateFuelLogData): Promise<FuelLog> {
|
||||
const query = `
|
||||
INSERT INTO fuel_logs (
|
||||
user_id, vehicle_id, date_time, odometer_reading, trip_distance,
|
||||
fuel_type, fuel_grade, fuel_units, cost_per_unit, total_cost,
|
||||
location_data, notes, mpg, efficiency_calculation_method,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW()
|
||||
) RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.vehicleId,
|
||||
data.dateTime,
|
||||
data.odometerReading || null,
|
||||
data.tripDistance || null,
|
||||
data.fuelType,
|
||||
data.fuelGrade || null,
|
||||
data.fuelUnits,
|
||||
data.costPerUnit,
|
||||
data.totalCost,
|
||||
data.locationData ? JSON.stringify(data.locationData) : null,
|
||||
data.notes || null,
|
||||
data.mpg || null,
|
||||
data.efficiencyCalculationMethod || null
|
||||
];
|
||||
|
||||
const result = await this.pool.query(query, values);
|
||||
return this.mapRowToFuelLog(result.rows[0]);
|
||||
}
|
||||
|
||||
async getPreviousLogByOdometer(vehicleId: string, currentOdometer: number): Promise<FuelLog | null> {
|
||||
const query = `
|
||||
SELECT * FROM fuel_logs
|
||||
WHERE vehicle_id = $1
|
||||
AND odometer_reading IS NOT NULL
|
||||
AND odometer_reading < $2
|
||||
ORDER BY odometer_reading DESC, date_time DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, [vehicleId, currentOdometer]);
|
||||
return result.rows.length > 0 ? this.mapRowToFuelLog(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async getLatestLogForVehicle(vehicleId: string): Promise<FuelLog | null> {
|
||||
const query = `
|
||||
SELECT * FROM fuel_logs
|
||||
WHERE vehicle_id = $1
|
||||
ORDER BY date_time DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, [vehicleId]);
|
||||
return result.rows.length > 0 ? this.mapRowToFuelLog(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByVehicleId(vehicleId: string): Promise<FuelLog[]> {
|
||||
const query = `
|
||||
SELECT * FROM fuel_logs
|
||||
WHERE vehicle_id = $1
|
||||
ORDER BY date_time DESC, created_at DESC
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, [vehicleId]);
|
||||
return result.rows.map(row => this.mapRowToFuelLog(row));
|
||||
}
|
||||
|
||||
private mapRowToFuelLog(row: any): FuelLog {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
dateTime: row.date_time,
|
||||
odometerReading: row.odometer_reading,
|
||||
tripDistance: row.trip_distance,
|
||||
fuelType: row.fuel_type,
|
||||
fuelGrade: row.fuel_grade,
|
||||
fuelUnits: parseFloat(row.fuel_units),
|
||||
costPerUnit: parseFloat(row.cost_per_unit),
|
||||
totalCost: parseFloat(row.total_cost),
|
||||
locationData: row.location_data ? JSON.parse(row.location_data) : null,
|
||||
notes: row.notes,
|
||||
mpg: row.mpg ? parseFloat(row.mpg) : null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
|
||||
// Legacy field mapping
|
||||
date: row.date_time,
|
||||
odometer: row.odometer_reading,
|
||||
gallons: parseFloat(row.fuel_units), // Assuming stored in user's preferred units
|
||||
pricePerGallon: parseFloat(row.cost_per_unit)
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comprehensive Test Suite
|
||||
|
||||
### Service Layer Tests
|
||||
|
||||
**File**: `backend/src/features/fuel-logs/tests/unit/enhanced-fuel-logs.service.test.ts`
|
||||
|
||||
```typescript
|
||||
import { FuelLogsService } from '../../domain/fuel-logs.service';
|
||||
import { FuelLogsRepository } from '../../data/fuel-logs.repository';
|
||||
import { FuelType, UnitSystem } from '../../domain/fuel-logs.types';
|
||||
import { UserSettingsService } from '../../external/user-settings.service';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../data/fuel-logs.repository');
|
||||
jest.mock('../../external/user-settings.service');
|
||||
jest.mock('../../../core/config/database');
|
||||
jest.mock('../../../core/config/redis');
|
||||
|
||||
describe('Enhanced FuelLogsService', () => {
|
||||
let service: FuelLogsService;
|
||||
let mockRepository: jest.Mocked<FuelLogsRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = new FuelLogsRepository({} as any) as jest.Mocked<FuelLogsRepository>;
|
||||
service = new FuelLogsService(mockRepository);
|
||||
|
||||
// Mock user settings
|
||||
(UserSettingsService.getUserSettings as jest.Mock).mockResolvedValue({
|
||||
unitSystem: UnitSystem.IMPERIAL,
|
||||
currencyCode: 'USD',
|
||||
timeZone: 'America/New_York'
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFuelLog', () => {
|
||||
it('should create fuel log with trip distance', async () => {
|
||||
const createData = {
|
||||
vehicleId: 'vehicle-id',
|
||||
dateTime: '2024-01-15T10:30:00Z',
|
||||
tripDistance: 300,
|
||||
fuelType: FuelType.GASOLINE,
|
||||
fuelGrade: '87',
|
||||
fuelUnits: 10,
|
||||
costPerUnit: 3.50,
|
||||
notes: 'Test fuel log'
|
||||
};
|
||||
|
||||
// Mock vehicle check
|
||||
(pool.query as jest.Mock)
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'vehicle-id' }] }) // Vehicle exists
|
||||
.mockResolvedValueOnce({}); // Odometer update (not applicable for trip distance)
|
||||
|
||||
mockRepository.create.mockResolvedValue({
|
||||
id: 'fuel-log-id',
|
||||
userId: 'user-id',
|
||||
...createData,
|
||||
totalCost: 35.0,
|
||||
mpg: 30,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
} as any);
|
||||
|
||||
const result = await service.createFuelLog(createData, 'user-id');
|
||||
|
||||
expect(result.id).toBe('fuel-log-id');
|
||||
expect(result.totalCost).toBe(35.0);
|
||||
expect(result.efficiency).toBe(30);
|
||||
expect(mockRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tripDistance: 300,
|
||||
totalCost: 35.0
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate distance requirement', async () => {
|
||||
const createData = {
|
||||
vehicleId: 'vehicle-id',
|
||||
dateTime: '2024-01-15T10:30:00Z',
|
||||
fuelType: FuelType.GASOLINE,
|
||||
fuelGrade: '87',
|
||||
fuelUnits: 10,
|
||||
costPerUnit: 3.50
|
||||
// Missing both tripDistance and odometerReading
|
||||
};
|
||||
|
||||
await expect(service.createFuelLog(createData, 'user-id'))
|
||||
.rejects.toThrow('Either odometer reading or trip distance is required');
|
||||
});
|
||||
|
||||
it('should validate fuel grade for fuel type', async () => {
|
||||
const createData = {
|
||||
vehicleId: 'vehicle-id',
|
||||
dateTime: '2024-01-15T10:30:00Z',
|
||||
tripDistance: 300,
|
||||
fuelType: FuelType.GASOLINE,
|
||||
fuelGrade: '#1', // Invalid for gasoline
|
||||
fuelUnits: 10,
|
||||
costPerUnit: 3.50
|
||||
};
|
||||
|
||||
await expect(service.createFuelLog(createData, 'user-id'))
|
||||
.rejects.toThrow('Invalid fuel grade');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnhancedVehicleStats', () => {
|
||||
it('should calculate comprehensive vehicle statistics', async () => {
|
||||
const mockLogs = [
|
||||
{
|
||||
fuelUnits: 10,
|
||||
totalCost: 35,
|
||||
tripDistance: 300,
|
||||
mpg: 30,
|
||||
fuelType: FuelType.GASOLINE,
|
||||
dateTime: new Date('2024-01-15')
|
||||
},
|
||||
{
|
||||
fuelUnits: 12,
|
||||
totalCost: 42,
|
||||
tripDistance: 350,
|
||||
mpg: 29,
|
||||
fuelType: FuelType.GASOLINE,
|
||||
dateTime: new Date('2024-01-10')
|
||||
}
|
||||
];
|
||||
|
||||
// Mock vehicle check
|
||||
(pool.query as jest.Mock).mockResolvedValue({ rows: [{ id: 'vehicle-id' }] });
|
||||
|
||||
mockRepository.findByVehicleId.mockResolvedValue(mockLogs as any);
|
||||
|
||||
const stats = await service.getEnhancedVehicleStats('vehicle-id', 'user-id');
|
||||
|
||||
expect(stats.logCount).toBe(2);
|
||||
expect(stats.totalFuelUnits).toBe(22);
|
||||
expect(stats.totalCost).toBe(77);
|
||||
expect(stats.averageCostPerUnit).toBeCloseTo(3.5, 2);
|
||||
expect(stats.totalDistance).toBe(650);
|
||||
expect(stats.averageEfficiency).toBeCloseTo(29.5, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**File**: `backend/src/features/fuel-logs/tests/integration/enhanced-fuel-logs.integration.test.ts`
|
||||
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import { app } from '../../../app';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { FuelType } from '../../domain/fuel-logs.types';
|
||||
|
||||
describe('Enhanced Fuel Logs API Integration', () => {
|
||||
let authToken: string;
|
||||
let vehicleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Setup test data
|
||||
authToken = await getTestAuthToken();
|
||||
vehicleId = await createTestVehicle();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
await cleanupTestData();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe('POST /api/fuel-logs', () => {
|
||||
it('should create fuel log with enhanced fields', async () => {
|
||||
const fuelLogData = {
|
||||
vehicleId,
|
||||
dateTime: '2024-01-15T10:30:00Z',
|
||||
tripDistance: 300,
|
||||
fuelType: FuelType.GASOLINE,
|
||||
fuelGrade: '87',
|
||||
fuelUnits: 10,
|
||||
costPerUnit: 3.50,
|
||||
locationData: {
|
||||
address: '123 Main St, Anytown, USA',
|
||||
stationName: 'Shell Station'
|
||||
},
|
||||
notes: 'Full tank'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/fuel-logs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(fuelLogData)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.tripDistance).toBe(300);
|
||||
expect(response.body.fuelType).toBe(FuelType.GASOLINE);
|
||||
expect(response.body.fuelGrade).toBe('87');
|
||||
expect(response.body.totalCost).toBe(35.0);
|
||||
expect(response.body.efficiency).toBe(30); // 300 miles / 10 gallons
|
||||
expect(response.body.efficiencyLabel).toBe('mpg');
|
||||
});
|
||||
|
||||
it('should validate distance requirement', async () => {
|
||||
const fuelLogData = {
|
||||
vehicleId,
|
||||
dateTime: '2024-01-15T10:30:00Z',
|
||||
fuelType: FuelType.GASOLINE,
|
||||
fuelGrade: '87',
|
||||
fuelUnits: 10,
|
||||
costPerUnit: 3.50
|
||||
// Missing both tripDistance and odometerReading
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/fuel-logs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(fuelLogData)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toContain('Either odometer reading or trip distance is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fuel-logs/fuel-grades/:fuelType', () => {
|
||||
it('should return gasoline fuel grades', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/fuel-logs/fuel-grades/gasoline')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.fuelType).toBe('gasoline');
|
||||
expect(response.body.grades).toHaveLength(5);
|
||||
expect(response.body.grades[0]).toEqual({
|
||||
value: '87',
|
||||
label: '87 (Regular)',
|
||||
description: 'Regular unleaded gasoline'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty grades for electric', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/fuel-logs/fuel-grades/electric')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.fuelType).toBe('electric');
|
||||
expect(response.body.grades).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fuel-logs/fuel-types', () => {
|
||||
it('should return all fuel types with grades', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/fuel-logs/fuel-types')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.fuelTypes).toHaveLength(3);
|
||||
|
||||
const gasoline = response.body.fuelTypes.find(ft => ft.value === 'gasoline');
|
||||
expect(gasoline.grades).toHaveLength(5);
|
||||
|
||||
const electric = response.body.fuelTypes.find(ft => ft.value === 'electric');
|
||||
expect(electric.grades).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Service Layer Updates
|
||||
1. ✅ Update FuelLogsService with enhanced business logic
|
||||
2. ✅ Integrate validation and efficiency calculation services
|
||||
3. ✅ Add user settings integration
|
||||
4. ✅ Implement comprehensive stats calculations
|
||||
|
||||
### API Layer Updates
|
||||
1. ✅ Create FuelGradeController for dynamic grades
|
||||
2. ✅ Update existing controllers with enhanced validation
|
||||
3. ✅ Add new API endpoints for fuel types/grades
|
||||
4. ✅ Update validation schemas
|
||||
|
||||
### Repository Updates
|
||||
1. ✅ Update repository for new database fields
|
||||
2. ✅ Add methods for enhanced queries
|
||||
3. ✅ Implement proper data mapping
|
||||
|
||||
### Testing Implementation
|
||||
1. ✅ Create comprehensive unit test suite
|
||||
2. ✅ Implement integration tests for all endpoints
|
||||
3. ✅ Add validation testing
|
||||
4. ✅ Test business logic edge cases
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Phase 3 Complete When:
|
||||
- ✅ All API endpoints functional with enhanced data
|
||||
- ✅ Comprehensive validation working correctly
|
||||
- ✅ Fuel type/grade system fully operational
|
||||
- ✅ Unit conversion integration functional
|
||||
- ✅ Enhanced statistics calculations working
|
||||
- ✅ Complete test suite passes (>90% coverage)
|
||||
- ✅ All new endpoints documented and tested
|
||||
- ✅ Backward compatibility maintained
|
||||
|
||||
### Ready for Phase 4 When:
|
||||
- All backend services tested and stable
|
||||
- API contracts finalized and documented
|
||||
- Frontend integration points clearly defined
|
||||
- Enhanced business logic fully functional
|
||||
|
||||
---
|
||||
|
||||
**Next Phase**: [Phase 4 - Frontend Implementation](FUEL-LOGS-PHASE-4.md)
|
||||
Reference in New Issue
Block a user