Initial Commit

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

View File

@@ -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

View 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' });
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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;
}
}

View File

@@ -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 };
}
}

View File

@@ -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 };
}
}

View 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;
}
}
}

View File

@@ -1,249 +1,192 @@
/**
* @ai-summary Business logic for fuel logs feature
* @ai-context Handles MPG calculations and vehicle validation
* @ai-summary Enhanced business logic for fuel logs feature
* @ai-context Unit-agnostic efficiency and user preferences integration
*/
import { FuelLogsRepository } from '../data/fuel-logs.repository';
import {
FuelLog,
CreateFuelLogRequest,
UpdateFuelLogRequest,
FuelLogResponse,
FuelStats
} from './fuel-logs.types';
import { EnhancedCreateFuelLogRequest, EnhancedFuelLogResponse, FuelType } from './fuel-logs.types';
import { logger } from '../../../core/logging/logger';
import { cacheService } from '../../../core/config/redis';
import pool from '../../../core/config/database';
import { EnhancedValidationService } from './enhanced-validation.service';
import { UnitConversionService } from './unit-conversion.service';
import { EfficiencyCalculationService } from './efficiency-calculation.service';
import { UserSettingsService } from '../external/user-settings.service';
export class FuelLogsService {
private readonly cachePrefix = 'fuel-logs';
private readonly cacheTTL = 300; // 5 minutes
constructor(private repository: FuelLogsRepository) {}
async createFuelLog(data: CreateFuelLogRequest, userId: string): Promise<FuelLogResponse> {
logger.info('Creating fuel log', { userId, vehicleId: data.vehicleId });
async createFuelLog(data: EnhancedCreateFuelLogRequest, userId: string): Promise<EnhancedFuelLogResponse> {
logger.info('Creating enhanced fuel log', { userId, vehicleId: data.vehicleId, fuelType: data.fuelType });
const userSettings = await UserSettingsService.getUserSettings(userId);
const validation = EnhancedValidationService.validateFuelLogData(data);
if (!validation.isValid) {
throw new Error(validation.errors.join(', '));
}
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[data.vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
// Calculate MPG based on previous log
let mpg: number | undefined;
const previousLog = await this.repository.getPreviousLog(
data.vehicleId,
data.date,
data.odometer
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
const totalCost = data.fuelUnits * data.costPerUnit;
// Previous log for efficiency
const prev = data.odometerReading
? await this.repository.getPreviousLogByOdometer(data.vehicleId, data.odometerReading)
: await this.repository.getLatestLogForVehicle(data.vehicleId);
const eff = EfficiencyCalculationService.calculateEfficiency(
{ odometerReading: data.odometerReading, tripDistance: data.tripDistance, fuelUnits: data.fuelUnits },
prev?.odometer ?? null,
userSettings.unitSystem
);
if (previousLog && previousLog.odometer < data.odometer) {
const milesDriven = data.odometer - previousLog.odometer;
mpg = milesDriven / data.gallons;
}
// Create fuel log
const fuelLog = await this.repository.create({
...data,
const inserted = await this.repository.createEnhanced({
userId,
mpg
vehicleId: data.vehicleId,
dateTime: new Date(data.dateTime),
odometerReading: data.odometerReading,
tripDistance: data.tripDistance,
fuelType: data.fuelType,
fuelGrade: data.fuelGrade ?? null,
fuelUnits: data.fuelUnits,
costPerUnit: data.costPerUnit,
totalCost,
locationData: data.locationData ?? null,
notes: data.notes
});
// Update vehicle odometer
await pool.query(
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND odometer_reading < $1',
[data.odometer, data.vehicleId]
);
// Invalidate caches
await this.invalidateCaches(userId, data.vehicleId);
return this.toResponse(fuelLog);
}
async getFuelLogsByVehicle(vehicleId: string, userId: string): Promise<FuelLogResponse[]> {
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}`;
// Check cache
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
if (cached) {
return cached;
}
// Get from database
const logs = await this.repository.findByVehicleId(vehicleId);
const response = logs.map((log: FuelLog) => this.toResponse(log));
// Cache result
await cacheService.set(cacheKey, response, this.cacheTTL);
return response;
}
async getUserFuelLogs(userId: string): Promise<FuelLogResponse[]> {
const cacheKey = `${this.cachePrefix}:user:${userId}`;
// Check cache
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
if (cached) {
return cached;
}
// Get from database
const logs = await this.repository.findByUserId(userId);
const response = logs.map((log: FuelLog) => this.toResponse(log));
// Cache result
await cacheService.set(cacheKey, response, this.cacheTTL);
return response;
}
async getFuelLog(id: string, userId: string): Promise<FuelLogResponse> {
const log = await this.repository.findById(id);
if (!log) {
throw new Error('Fuel log not found');
}
if (log.userId !== userId) {
throw new Error('Unauthorized');
}
return this.toResponse(log);
}
async updateFuelLog(
id: string,
data: UpdateFuelLogRequest,
userId: string
): Promise<FuelLogResponse> {
// Verify ownership
const existing = await this.repository.findById(id);
if (!existing) {
throw new Error('Fuel log not found');
}
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
// Recalculate MPG if odometer or gallons changed
let mpg = existing.mpg;
if (data.odometer || data.gallons) {
const previousLog = await this.repository.getPreviousLog(
existing.vehicleId,
data.date || existing.date.toISOString(),
data.odometer || existing.odometer
if (data.odometerReading) {
await pool.query(
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND (odometer_reading IS NULL OR odometer_reading < $1)',
[data.odometerReading, data.vehicleId]
);
if (previousLog) {
const odometer = data.odometer || existing.odometer;
const gallons = data.gallons || existing.gallons;
const milesDriven = odometer - previousLog.odometer;
mpg = milesDriven / gallons;
}
await this.invalidateCaches(userId, data.vehicleId, userSettings.unitSystem);
return this.toEnhancedResponse(inserted, eff?.value ?? undefined, userSettings.unitSystem);
}
async getFuelLogsByVehicle(vehicleId: string, userId: string): Promise<EnhancedFuelLogResponse[]> {
const vehicleCheck = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`;
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
if (cached) return cached;
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
await cacheService.set(cacheKey, response, this.cacheTTL);
return response;
}
async getUserFuelLogs(userId: string): Promise<EnhancedFuelLogResponse[]> {
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
const cacheKey = `${this.cachePrefix}:user:${userId}:${unitSystem}`;
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
if (cached) return cached;
const rows = await this.repository.findByUserIdEnhanced(userId);
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
await cacheService.set(cacheKey, response, this.cacheTTL);
return response;
}
async getFuelLog(id: string, userId: string): Promise<EnhancedFuelLogResponse> {
const row = await this.repository.findByIdEnhanced(id);
if (!row) throw new Error('Fuel log not found');
if (row.user_id !== userId) throw new Error('Unauthorized');
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
return this.toEnhancedResponse(row, undefined, unitSystem);
}
async updateFuelLog(): Promise<any> { throw new Error('Not Implemented'); }
async deleteFuelLog(id: string, userId: string): Promise<void> {
const existing = await this.repository.findByIdEnhanced(id);
if (!existing) throw new Error('Fuel log not found');
if (existing.user_id !== userId) throw new Error('Unauthorized');
await this.repository.delete(id);
await this.invalidateCaches(userId, existing.vehicle_id, 'imperial'); // cache keys include unit; simple sweep below
}
async getVehicleStats(vehicleId: string, userId: string): Promise<any> {
const vehicleCheck = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
const labels = UnitConversionService.getUnitLabels(unitSystem);
if (rows.length === 0) {
return { logCount: 0, totalFuelUnits: 0, totalCost: 0, averageCostPerUnit: 0, totalDistance: 0, averageEfficiency: 0, unitLabels: labels };
}
const totalFuelUnits = rows.reduce((s, r) => s + (Number(r.fuel_units) || 0), 0);
const totalCost = rows.reduce((s, r) => s + (Number(r.total_cost) || 0), 0);
const averageCostPerUnit = totalFuelUnits > 0 ? totalCost / totalFuelUnits : 0;
const sorted = [...rows].sort((a, b) => (new Date(b.date_time || b.date)).getTime() - (new Date(a.date_time || a.date)).getTime());
let totalDistance = 0;
for (let i = 0; i < sorted.length; i++) {
const cur = sorted[i];
const prev = sorted[i + 1];
if (Number(cur.trip_distance) > 0) totalDistance += Number(cur.trip_distance);
else if (prev && cur.odometer != null && prev.odometer != null) {
const d = Number(cur.odometer) - Number(prev.odometer);
if (d > 0) totalDistance += d;
}
}
// Prepare update data with proper types
const updateData: Partial<FuelLog> = {
...data,
date: data.date ? new Date(data.date) : undefined,
mpg
};
// Update
const updated = await this.repository.update(id, updateData);
if (!updated) {
throw new Error('Update failed');
}
// Invalidate caches
await this.invalidateCaches(userId, existing.vehicleId);
return this.toResponse(updated);
const efficiencies: number[] = sorted.map(l => {
const e = EfficiencyCalculationService.calculateEfficiency(
{ odometerReading: l.odometer ?? undefined, tripDistance: l.trip_distance ?? undefined, fuelUnits: l.fuel_units ?? undefined },
null,
unitSystem
);
return e?.value || 0;
}).filter(v => v > 0);
const averageEfficiency = efficiencies.length ? (efficiencies.reduce((a, b) => a + b, 0) / efficiencies.length) : 0;
return { logCount: rows.length, totalFuelUnits, totalCost, averageCostPerUnit, totalDistance, averageEfficiency, unitLabels: labels };
}
async deleteFuelLog(id: string, userId: string): Promise<void> {
// Verify ownership
const existing = await this.repository.findById(id);
if (!existing) {
throw new Error('Fuel log not found');
}
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
await this.repository.delete(id);
// Invalidate caches
await this.invalidateCaches(userId, existing.vehicleId);
}
async getVehicleStats(vehicleId: string, userId: string): Promise<FuelStats> {
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
const stats = await this.repository.getStats(vehicleId);
if (!stats) {
return {
logCount: 0,
totalGallons: 0,
totalCost: 0,
averagePricePerGallon: 0,
averageMPG: 0,
totalMiles: 0,
};
}
return stats;
}
private async invalidateCaches(userId: string, vehicleId: string): Promise<void> {
private async invalidateCaches(userId: string, vehicleId: string, unitSystem: 'imperial' | 'metric'): Promise<void> {
await Promise.all([
cacheService.del(`${this.cachePrefix}:user:${userId}`),
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}`)
cacheService.del(`${this.cachePrefix}:user:${userId}:${unitSystem}`),
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`)
]);
}
private toResponse(log: FuelLog): FuelLogResponse {
private toEnhancedResponse(row: any, efficiency: number | undefined, unitSystem: 'imperial' | 'metric'): EnhancedFuelLogResponse {
const labels = UnitConversionService.getUnitLabels(unitSystem);
const dateTime = row.date_time ? new Date(row.date_time) : (row.date ? new Date(row.date) : new Date());
return {
id: log.id,
userId: log.userId,
vehicleId: log.vehicleId,
date: log.date.toISOString().split('T')[0],
odometer: log.odometer,
gallons: log.gallons,
pricePerGallon: log.pricePerGallon,
totalCost: log.totalCost,
station: log.station,
location: log.location,
notes: log.notes,
mpg: log.mpg,
createdAt: log.createdAt.toISOString(),
updatedAt: log.updatedAt.toISOString(),
id: row.id,
userId: row.user_id,
vehicleId: row.vehicle_id,
dateTime: dateTime.toISOString(),
odometerReading: row.odometer ?? undefined,
tripDistance: row.trip_distance ?? undefined,
fuelType: row.fuel_type as FuelType,
fuelGrade: row.fuel_grade ?? undefined,
fuelUnits: row.fuel_units,
costPerUnit: row.cost_per_unit,
totalCost: Number(row.total_cost),
locationData: row.location_data ?? undefined,
notes: row.notes ?? undefined,
efficiency: efficiency,
efficiencyLabel: labels.efficiencyUnits,
createdAt: new Date(row.created_at).toISOString(),
updatedAt: new Date(row.updated_at).toISOString(),
};
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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',
};
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;