Initial Commit
This commit is contained in:
@@ -197,8 +197,8 @@ npm test -- features/fuel-logs --coverage
|
||||
# Run migrations
|
||||
make migrate
|
||||
|
||||
# Start development environment
|
||||
make dev
|
||||
# Start environment
|
||||
make start
|
||||
|
||||
# View feature logs
|
||||
make logs-backend | grep fuel-logs
|
||||
|
||||
38
backend/src/features/fuel-logs/api/fuel-grade.controller.ts
Normal file
38
backend/src/features/fuel-logs/api/fuel-grade.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { FastifyReply, FastifyRequest } 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;
|
||||
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 });
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FuelLogsService } from '../domain/fuel-logs.service';
|
||||
import { FuelLogsRepository } from '../data/fuel-logs.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { CreateFuelLogBody, UpdateFuelLogBody, FuelLogParams, VehicleParams } from '../domain/fuel-logs.types';
|
||||
import { FuelLogParams, VehicleParams, EnhancedCreateFuelLogRequest } from '../domain/fuel-logs.types';
|
||||
|
||||
export class FuelLogsController {
|
||||
private fuelLogsService: FuelLogsService;
|
||||
@@ -18,7 +18,7 @@ export class FuelLogsController {
|
||||
this.fuelLogsService = new FuelLogsService(repository);
|
||||
}
|
||||
|
||||
async createFuelLog(request: FastifyRequest<{ Body: CreateFuelLogBody }>, reply: FastifyReply) {
|
||||
async createFuelLog(request: FastifyRequest<{ Body: EnhancedCreateFuelLogRequest }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const fuelLog = await this.fuelLogsService.createFuelLog(request.body, userId);
|
||||
@@ -124,16 +124,12 @@ export class FuelLogsController {
|
||||
}
|
||||
}
|
||||
|
||||
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: UpdateFuelLogBody }>, reply: FastifyReply) {
|
||||
async updateFuelLog(_request: FastifyRequest<{ Params: FuelLogParams; Body: any }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const { id } = request.params;
|
||||
|
||||
const fuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
|
||||
|
||||
return reply.code(200).send(fuelLog);
|
||||
// Update not implemented in enhanced flow
|
||||
return reply.code(501).send({ error: 'Not Implemented', message: 'Update fuel log not implemented' });
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
||||
logger.error('Error updating fuel log', { error });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
@@ -216,4 +212,4 @@ export class FuelLogsController {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,64 +5,72 @@
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import {
|
||||
CreateFuelLogBody,
|
||||
UpdateFuelLogBody,
|
||||
FuelLogParams,
|
||||
VehicleParams
|
||||
} from '../domain/fuel-logs.types';
|
||||
// Types handled in controllers; no explicit generics required here
|
||||
import { FuelLogsController } from './fuel-logs.controller';
|
||||
import { FuelGradeController } from './fuel-grade.controller';
|
||||
import { tenantMiddleware } from '../../../core/middleware/tenant';
|
||||
|
||||
export const fuelLogsRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const fuelLogsController = new FuelLogsController();
|
||||
const fuelGradeController = new FuelGradeController();
|
||||
|
||||
// GET /api/fuel-logs - Get user's fuel logs
|
||||
fastify.get('/fuel-logs', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.getUserFuelLogs.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// POST /api/fuel-logs - Create new fuel log
|
||||
fastify.post<{ Body: CreateFuelLogBody }>('/fuel-logs', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.post('/fuel-logs', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.createFuelLog.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// GET /api/fuel-logs/:id - Get specific fuel log
|
||||
fastify.get<{ Params: FuelLogParams }>('/fuel-logs/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.get('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.getFuelLog.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// PUT /api/fuel-logs/:id - Update fuel log
|
||||
fastify.put<{ Params: FuelLogParams; Body: UpdateFuelLogBody }>('/fuel-logs/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.put('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.updateFuelLog.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// DELETE /api/fuel-logs/:id - Delete fuel log
|
||||
fastify.delete<{ Params: FuelLogParams }>('/fuel-logs/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.delete('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.deleteFuelLog.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/:vehicleId/fuel-logs - Get fuel logs for specific vehicle
|
||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:vehicleId/fuel-logs', {
|
||||
preHandler: fastify.authenticate,
|
||||
// NEW ENDPOINTS under /api/fuel-logs
|
||||
fastify.get('/fuel-logs/vehicle/:vehicleId', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.getFuelLogsByVehicle.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/:vehicleId/fuel-stats - Get fuel stats for specific vehicle
|
||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:vehicleId/fuel-stats', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.get('/fuel-logs/vehicle/:vehicleId/stats', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.getFuelStats.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// Fuel type/grade discovery
|
||||
fastify.get('/fuel-logs/fuel-types', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelGradeController.getAllFuelTypes.bind(fuelGradeController)
|
||||
});
|
||||
|
||||
fastify.get('/fuel-logs/fuel-grades/:fuelType', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelGradeController.getFuelGrades.bind(fuelGradeController)
|
||||
});
|
||||
};
|
||||
|
||||
// For backward compatibility during migration
|
||||
export function registerFuelLogsRoutes() {
|
||||
throw new Error('registerFuelLogsRoutes is deprecated - use fuelLogsRoutes Fastify plugin instead');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,52 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { FuelType } from '../domain/fuel-logs.types';
|
||||
|
||||
// Enhanced create schema (Phase 3)
|
||||
export const createFuelLogSchema = z.object({
|
||||
vehicleId: z.string().uuid(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
odometer: z.number().int().positive(),
|
||||
gallons: z.number().positive(),
|
||||
pricePerGallon: z.number().positive(),
|
||||
totalCost: z.number().positive(),
|
||||
station: z.string().max(200).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
dateTime: z.string().datetime(),
|
||||
// Distance (one required)
|
||||
odometerReading: z.number().int().positive().optional(),
|
||||
tripDistance: z.number().positive().optional(),
|
||||
// Fuel system
|
||||
fuelType: z.nativeEnum(FuelType),
|
||||
fuelGrade: z.string().nullable().optional(),
|
||||
fuelUnits: z.number().positive(),
|
||||
costPerUnit: z.number().positive(),
|
||||
// Location (optional)
|
||||
locationData: z.object({
|
||||
address: z.string().optional(),
|
||||
coordinates: z.object({ latitude: z.number(), longitude: z.number() }).optional(),
|
||||
googlePlaceId: z.string().optional(),
|
||||
stationName: z.string().optional()
|
||||
}).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
}).refine((data) => (data.odometerReading && data.odometerReading > 0) || (data.tripDistance && data.tripDistance > 0), {
|
||||
message: 'Either odometer reading or trip distance is required',
|
||||
path: ['odometerReading']
|
||||
}).refine((data) => !(data.odometerReading && data.tripDistance), {
|
||||
message: 'Cannot specify both odometer reading and trip distance',
|
||||
path: ['odometerReading']
|
||||
});
|
||||
|
||||
export const updateFuelLogSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
odometer: z.number().int().positive().optional(),
|
||||
gallons: z.number().positive().optional(),
|
||||
pricePerGallon: z.number().positive().optional(),
|
||||
totalCost: z.number().positive().optional(),
|
||||
station: z.string().max(200).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
dateTime: z.string().datetime().optional(),
|
||||
odometerReading: z.number().int().positive().optional(),
|
||||
tripDistance: z.number().positive().optional(),
|
||||
fuelType: z.nativeEnum(FuelType).optional(),
|
||||
fuelGrade: z.string().nullable().optional(),
|
||||
fuelUnits: z.number().positive().optional(),
|
||||
costPerUnit: z.number().positive().optional(),
|
||||
locationData: z.object({
|
||||
address: z.string().optional(),
|
||||
coordinates: z.object({ latitude: z.number(), longitude: z.number() }).optional(),
|
||||
googlePlaceId: z.string().optional(),
|
||||
stationName: z.string().optional()
|
||||
}).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
}).refine(data => Object.keys(data).length > 0, {
|
||||
message: 'At least one field must be provided for update'
|
||||
});
|
||||
}).refine(data => Object.keys(data).length > 0, { message: 'At least one field must be provided for update' });
|
||||
|
||||
export function validateCreateFuelLog(data: unknown) {
|
||||
return createFuelLogSchema.safeParse(data);
|
||||
@@ -35,4 +56,4 @@ export function validateCreateFuelLog(data: unknown) {
|
||||
|
||||
export function validateUpdateFuelLog(data: unknown) {
|
||||
return updateFuelLogSchema.safeParse(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ export class FuelLogsRepository {
|
||||
const query = `
|
||||
INSERT INTO fuel_logs (
|
||||
user_id, vehicle_id, date, odometer, gallons,
|
||||
price_per_gallon, total_cost, station, location, notes, mpg
|
||||
price_per_gallon, total_cost, station, location, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
@@ -29,8 +29,7 @@ export class FuelLogsRepository {
|
||||
data.totalCost,
|
||||
data.station,
|
||||
data.location,
|
||||
data.notes,
|
||||
data.mpg
|
||||
data.notes
|
||||
];
|
||||
|
||||
const result = await this.pool.query(query, values);
|
||||
@@ -126,10 +125,7 @@ export class FuelLogsRepository {
|
||||
fields.push(`notes = $${paramCount++}`);
|
||||
values.push(data.notes);
|
||||
}
|
||||
if (data.mpg !== undefined) {
|
||||
fields.push(`mpg = $${paramCount++}`);
|
||||
values.push(data.mpg);
|
||||
}
|
||||
// mpg column removed; efficiency is computed dynamically
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.findById(id);
|
||||
@@ -165,7 +161,6 @@ export class FuelLogsRepository {
|
||||
SUM(gallons) as total_gallons,
|
||||
SUM(total_cost) as total_cost,
|
||||
AVG(price_per_gallon) as avg_price_per_gallon,
|
||||
AVG(mpg) as avg_mpg,
|
||||
MAX(odometer) - MIN(odometer) as total_miles
|
||||
FROM fuel_logs
|
||||
WHERE vehicle_id = $1
|
||||
@@ -183,7 +178,7 @@ export class FuelLogsRepository {
|
||||
totalGallons: parseFloat(row.total_gallons) || 0,
|
||||
totalCost: parseFloat(row.total_cost) || 0,
|
||||
averagePricePerGallon: parseFloat(row.avg_price_per_gallon) || 0,
|
||||
averageMPG: parseFloat(row.avg_mpg) || 0,
|
||||
averageMPG: 0,
|
||||
totalMiles: parseInt(row.total_miles) || 0,
|
||||
};
|
||||
}
|
||||
@@ -201,9 +196,94 @@ export class FuelLogsRepository {
|
||||
station: row.station,
|
||||
location: row.location,
|
||||
notes: row.notes,
|
||||
mpg: row.mpg ? parseFloat(row.mpg) : undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced API support (new schema)
|
||||
async createEnhanced(data: {
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
dateTime: Date;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: string;
|
||||
fuelGrade?: string | null;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
totalCost: number;
|
||||
locationData?: any;
|
||||
notes?: string;
|
||||
}): Promise<any> {
|
||||
const query = `
|
||||
INSERT INTO fuel_logs (
|
||||
user_id, vehicle_id, date, date_time, odometer, trip_distance,
|
||||
fuel_type, fuel_grade, fuel_units, cost_per_unit,
|
||||
gallons, price_per_gallon, total_cost, location_data, notes
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
const values = [
|
||||
data.userId,
|
||||
data.vehicleId,
|
||||
data.dateTime.toISOString().slice(0, 10),
|
||||
data.dateTime,
|
||||
data.odometerReading ?? null,
|
||||
data.tripDistance ?? null,
|
||||
data.fuelType,
|
||||
data.fuelGrade ?? null,
|
||||
data.fuelUnits,
|
||||
data.costPerUnit,
|
||||
data.fuelUnits, // legacy support
|
||||
data.costPerUnit, // legacy support
|
||||
data.totalCost,
|
||||
data.locationData ?? null,
|
||||
data.notes ?? null
|
||||
];
|
||||
const res = await this.pool.query(query, values);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
async findByVehicleIdEnhanced(vehicleId: string): Promise<any[]> {
|
||||
const res = await this.pool.query(
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||
[vehicleId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async findByUserIdEnhanced(userId: string): Promise<any[]> {
|
||||
const res = await this.pool.query(
|
||||
`SELECT * FROM fuel_logs WHERE user_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async findByIdEnhanced(id: string): Promise<any | null> {
|
||||
const res = await this.pool.query(`SELECT * FROM fuel_logs WHERE id = $1`, [id]);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async getPreviousLogByOdometer(vehicleId: string, odometerReading: number): Promise<any | null> {
|
||||
const res = await this.pool.query(
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 AND odometer IS NOT NULL AND odometer < $2 ORDER BY odometer DESC LIMIT 1`,
|
||||
[vehicleId, odometerReading]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async getLatestLogForVehicle(vehicleId: string): Promise<any | null> {
|
||||
const res = await this.pool.query(
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC LIMIT 1`,
|
||||
[vehicleId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { UnitConversionService, UnitSystem } from './unit-conversion.service';
|
||||
|
||||
export interface EfficiencyResult {
|
||||
value: number;
|
||||
unitSystem: UnitSystem;
|
||||
label: string;
|
||||
calculationMethod: 'odometer' | 'trip_distance';
|
||||
}
|
||||
|
||||
export interface PartialEnhancedLog {
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelUnits?: number;
|
||||
}
|
||||
|
||||
export class EfficiencyCalculationService {
|
||||
static calculateEfficiency(
|
||||
currentLog: PartialEnhancedLog,
|
||||
previousOdometerReading: number | null,
|
||||
unitSystem: UnitSystem
|
||||
): EfficiencyResult | null {
|
||||
let distance: number | undefined;
|
||||
let method: 'odometer' | 'trip_distance' | undefined;
|
||||
|
||||
if (currentLog.tripDistance && currentLog.tripDistance > 0) {
|
||||
distance = currentLog.tripDistance;
|
||||
method = 'trip_distance';
|
||||
} else if (currentLog.odometerReading && previousOdometerReading !== null) {
|
||||
const d = currentLog.odometerReading - previousOdometerReading;
|
||||
if (d > 0) {
|
||||
distance = d;
|
||||
method = 'odometer';
|
||||
}
|
||||
}
|
||||
|
||||
if (!distance || !currentLog.fuelUnits || currentLog.fuelUnits <= 0 || !method) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = UnitConversionService.calculateEfficiency(distance, currentLog.fuelUnits, unitSystem);
|
||||
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
||||
return { value, unitSystem, label: labels.efficiencyUnits, calculationMethod: method };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FuelType, FuelGrade, EnhancedCreateFuelLogRequest } from './fuel-logs.types';
|
||||
import { FuelGradeService } from './fuel-grade.service';
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class EnhancedValidationService {
|
||||
static validateFuelLogData(data: Partial<EnhancedCreateFuelLogRequest>): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Distance requirement
|
||||
const hasOdo = data.odometerReading && data.odometerReading > 0;
|
||||
const hasTrip = data.tripDistance && data.tripDistance > 0;
|
||||
if (!hasOdo && !hasTrip) errors.push('Either odometer reading or trip distance is required');
|
||||
if (hasOdo && hasTrip) errors.push('Cannot specify both odometer reading and trip distance');
|
||||
|
||||
// Fuel type/grade
|
||||
if (!data.fuelType) errors.push('Fuel type is required');
|
||||
if (data.fuelType && !Object.values(FuelType).includes(data.fuelType)) {
|
||||
errors.push(`Invalid fuel type: ${data.fuelType}`);
|
||||
} else if (data.fuelType && !FuelGradeService.isValidGradeForFuelType(data.fuelType, data.fuelGrade as FuelGrade)) {
|
||||
errors.push(`Invalid fuel grade '${data.fuelGrade}' for fuel type '${data.fuelType}'`);
|
||||
}
|
||||
|
||||
// Numeric
|
||||
if (data.fuelUnits !== undefined && data.fuelUnits <= 0) errors.push('Fuel units must be positive');
|
||||
if (data.costPerUnit !== undefined && data.costPerUnit <= 0) errors.push('Cost per unit must be positive');
|
||||
if (data.odometerReading !== undefined && data.odometerReading <= 0) errors.push('Odometer reading must be positive');
|
||||
if (data.tripDistance !== undefined && data.tripDistance <= 0) errors.push('Trip distance must be positive');
|
||||
|
||||
// Date/time
|
||||
if (data.dateTime) {
|
||||
const dt = new Date(data.dateTime);
|
||||
const now = new Date();
|
||||
if (isNaN(dt.getTime())) errors.push('Invalid date/time format');
|
||||
if (dt > now) errors.push('Cannot create fuel logs in the future');
|
||||
}
|
||||
|
||||
// Heuristics warnings
|
||||
if (data.fuelUnits && data.fuelUnits > 100) warnings.push('Fuel amount seems unusually high (>100 units)');
|
||||
if (data.costPerUnit && data.costPerUnit > 10) warnings.push('Cost per unit seems unusually high (>$10)');
|
||||
if (data.tripDistance && data.tripDistance > 1000) warnings.push('Trip distance seems unusually high (>1000 miles)');
|
||||
|
||||
return { isValid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
}
|
||||
50
backend/src/features/fuel-logs/domain/fuel-grade.service.ts
Normal file
50
backend/src/features/fuel-logs/domain/fuel-grade.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { FuelType, FuelGrade } from './fuel-logs.types';
|
||||
|
||||
export interface FuelGradeOption {
|
||||
value: FuelGrade;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class FuelGradeService {
|
||||
static getFuelGradeOptions(fuelType: FuelType): FuelGradeOption[] {
|
||||
switch (fuelType) {
|
||||
case FuelType.GASOLINE:
|
||||
return [
|
||||
{ value: '87', label: '87 (Regular)', description: 'Regular unleaded gasoline' },
|
||||
{ value: '88', label: '88 (Mid-Grade)' },
|
||||
{ value: '89', label: '89 (Mid-Grade Plus)' },
|
||||
{ value: '91', label: '91 (Premium)' },
|
||||
{ value: '93', label: '93 (Premium Plus)' }
|
||||
];
|
||||
case FuelType.DIESEL:
|
||||
return [
|
||||
{ value: '#1', label: '#1 Diesel', description: 'Light diesel fuel' },
|
||||
{ value: '#2', label: '#2 Diesel', description: 'Standard diesel fuel' }
|
||||
];
|
||||
case FuelType.ELECTRIC:
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static isValidGradeForFuelType(fuelType: FuelType, fuelGrade?: FuelGrade): boolean {
|
||||
if (!fuelGrade) return fuelType === FuelType.ELECTRIC;
|
||||
return this.getFuelGradeOptions(fuelType).some(opt => opt.value === fuelGrade);
|
||||
}
|
||||
|
||||
static getDefaultGrade(fuelType: FuelType): FuelGrade {
|
||||
switch (fuelType) {
|
||||
case FuelType.GASOLINE:
|
||||
return '87';
|
||||
case FuelType.DIESEL:
|
||||
return '#2';
|
||||
case FuelType.ELECTRIC:
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface FuelLog {
|
||||
station?: string;
|
||||
location?: string;
|
||||
notes?: string;
|
||||
mpg?: number; // Calculated field
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -55,7 +54,55 @@ export interface FuelLogResponse {
|
||||
station?: string;
|
||||
location?: string;
|
||||
notes?: string;
|
||||
mpg?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Enhanced types for upgraded schema (Phase 2/3)
|
||||
export enum FuelType {
|
||||
GASOLINE = 'gasoline',
|
||||
DIESEL = 'diesel',
|
||||
ELECTRIC = 'electric'
|
||||
}
|
||||
|
||||
export type FuelGrade = '87' | '88' | '89' | '91' | '93' | '#1' | '#2' | null;
|
||||
|
||||
export interface LocationData {
|
||||
address?: string;
|
||||
coordinates?: { latitude: number; longitude: number };
|
||||
googlePlaceId?: string;
|
||||
stationName?: string;
|
||||
}
|
||||
|
||||
export interface EnhancedCreateFuelLogRequest {
|
||||
vehicleId: string;
|
||||
dateTime: string; // ISO
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
locationData?: LocationData;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface EnhancedFuelLogResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
dateTime: string;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
totalCost: number;
|
||||
locationData?: LocationData;
|
||||
efficiency?: number;
|
||||
efficiencyLabel: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -99,4 +146,4 @@ export interface FuelLogParams {
|
||||
|
||||
export interface VehicleParams {
|
||||
vehicleId: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { UnitSystem as CoreUnitSystem } from '../../../shared-minimal/utils/units';
|
||||
|
||||
export type UnitSystem = CoreUnitSystem;
|
||||
|
||||
export class UnitConversionService {
|
||||
private static readonly MPG_TO_L100KM = 235.214;
|
||||
|
||||
static getUnitLabels(unitSystem: UnitSystem) {
|
||||
return unitSystem === 'metric'
|
||||
? { fuelUnits: 'liters', distanceUnits: 'kilometers', efficiencyUnits: 'L/100km' }
|
||||
: { fuelUnits: 'gallons', distanceUnits: 'miles', efficiencyUnits: 'mpg' };
|
||||
}
|
||||
|
||||
static calculateEfficiency(distance: number, fuelUnits: number, unitSystem: UnitSystem): number {
|
||||
if (fuelUnits <= 0 || distance <= 0) return 0;
|
||||
return unitSystem === 'metric'
|
||||
? (fuelUnits / distance) * 100
|
||||
: distance / fuelUnits;
|
||||
}
|
||||
|
||||
static convertEfficiency(efficiency: number, from: UnitSystem, to: UnitSystem): number {
|
||||
if (from === to) return efficiency;
|
||||
if (from === 'imperial' && to === 'metric') {
|
||||
return efficiency > 0 ? this.MPG_TO_L100KM / efficiency : 0;
|
||||
}
|
||||
if (from === 'metric' && to === 'imperial') {
|
||||
return efficiency > 0 ? this.MPG_TO_L100KM / efficiency : 0;
|
||||
}
|
||||
return efficiency;
|
||||
}
|
||||
}
|
||||
|
||||
37
backend/src/features/fuel-logs/external/user-settings.service.ts
vendored
Normal file
37
backend/src/features/fuel-logs/external/user-settings.service.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @ai-summary User settings facade for fuel-logs feature
|
||||
* @ai-context Reads user preferences (unit system, currency, timezone) from app DB
|
||||
*/
|
||||
|
||||
import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository';
|
||||
import pool from '../../../core/config/database';
|
||||
import { UnitSystem } from '../../../core/user-preferences/user-preferences.types';
|
||||
|
||||
export interface UserSettings {
|
||||
unitSystem: UnitSystem;
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
export class UserSettingsService {
|
||||
private static repo = new UserPreferencesRepository(pool);
|
||||
|
||||
static async getUserSettings(userId: string): Promise<UserSettings> {
|
||||
const existing = await this.repo.findByUserId(userId);
|
||||
if (existing) {
|
||||
return {
|
||||
unitSystem: existing.unitSystem,
|
||||
currencyCode: existing.currencyCode || 'USD',
|
||||
timeZone: existing.timeZone || 'UTC',
|
||||
};
|
||||
}
|
||||
// Upsert with sensible defaults if missing
|
||||
const created = await this.repo.upsert({ userId, unitSystem: 'imperial' });
|
||||
return {
|
||||
unitSystem: created.unitSystem,
|
||||
currencyCode: created.currencyCode || 'USD',
|
||||
timeZone: created.timeZone || 'UTC',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ CREATE TABLE IF NOT EXISTS fuel_logs (
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_fuel_logs_user_id ON fuel_logs(user_id);
|
||||
CREATE INDEX idx_fuel_logs_vehicle_id ON fuel_logs(vehicle_id);
|
||||
CREATE INDEX idx_fuel_logs_date ON fuel_logs(date DESC);
|
||||
CREATE INDEX idx_fuel_logs_created_at ON fuel_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_user_id ON fuel_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_vehicle_id ON fuel_logs(vehicle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_date ON fuel_logs(date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_created_at ON fuel_logs(created_at DESC);
|
||||
|
||||
-- Add trigger for updated_at
|
||||
DROP TRIGGER IF EXISTS update_fuel_logs_updated_at ON fuel_logs;
|
||||
CREATE TRIGGER update_fuel_logs_updated_at
|
||||
BEFORE UPDATE ON fuel_logs
|
||||
FOR EACH ROW
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Migration: 002_enhance_fuel_logs_schema.sql
|
||||
-- Enhance fuel_logs schema with new fields and constraints
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add new columns (nullable initially for backfill)
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS trip_distance INTEGER;
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_type VARCHAR(20);
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_grade VARCHAR(10);
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_units DECIMAL(8,3);
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS cost_per_unit DECIMAL(6,3);
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS location_data JSONB;
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS date_time TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- Backfill existing data
|
||||
UPDATE fuel_logs SET
|
||||
fuel_type = 'gasoline',
|
||||
fuel_units = gallons,
|
||||
cost_per_unit = price_per_gallon,
|
||||
date_time = (date::timestamp AT TIME ZONE 'UTC') + interval '12 hours'
|
||||
WHERE fuel_type IS NULL;
|
||||
|
||||
-- Set NOT NULL and defaults where applicable
|
||||
ALTER TABLE fuel_logs ALTER COLUMN fuel_type SET NOT NULL;
|
||||
ALTER TABLE fuel_logs ALTER COLUMN fuel_type SET DEFAULT 'gasoline';
|
||||
|
||||
-- Check constraints
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fuel_type_check'
|
||||
) THEN
|
||||
ALTER TABLE fuel_logs ADD CONSTRAINT fuel_type_check
|
||||
CHECK (fuel_type IN ('gasoline', 'diesel', 'electric'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Either trip_distance OR odometer required (> 0)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'distance_required_check'
|
||||
) THEN
|
||||
ALTER TABLE fuel_logs ADD CONSTRAINT distance_required_check
|
||||
CHECK ((trip_distance IS NOT NULL AND trip_distance > 0) OR
|
||||
(odometer IS NOT NULL AND odometer > 0));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Fuel grade validation trigger
|
||||
CREATE OR REPLACE FUNCTION validate_fuel_grade()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Gasoline
|
||||
IF NEW.fuel_type = 'gasoline' AND NEW.fuel_grade IS NOT NULL AND
|
||||
NEW.fuel_grade NOT IN ('87', '88', '89', '91', '93') THEN
|
||||
RAISE EXCEPTION 'Invalid fuel grade % for gasoline', NEW.fuel_grade;
|
||||
END IF;
|
||||
|
||||
-- Diesel
|
||||
IF NEW.fuel_type = 'diesel' AND NEW.fuel_grade IS NOT NULL AND
|
||||
NEW.fuel_grade NOT IN ('#1', '#2') THEN
|
||||
RAISE EXCEPTION 'Invalid fuel grade % for diesel', NEW.fuel_grade;
|
||||
END IF;
|
||||
|
||||
-- Electric: no grade allowed
|
||||
IF NEW.fuel_type = 'electric' AND NEW.fuel_grade IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Electric fuel type cannot have a grade';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'fuel_grade_validation_trigger'
|
||||
) THEN
|
||||
CREATE TRIGGER fuel_grade_validation_trigger
|
||||
BEFORE INSERT OR UPDATE ON fuel_logs
|
||||
FOR EACH ROW EXECUTE FUNCTION validate_fuel_grade();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_fuel_type ON fuel_logs(fuel_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_date_time ON fuel_logs(date_time);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration: 003_drop_mpg_column.sql
|
||||
-- Remove deprecated mpg column; efficiency is computed dynamically
|
||||
|
||||
ALTER TABLE fuel_logs DROP COLUMN IF EXISTS mpg;
|
||||
|
||||
Reference in New Issue
Block a user