MVP Build

This commit is contained in:
Eric Gullickson
2025-08-09 12:47:15 -05:00
parent 2e8816df7f
commit 8f5117a4e2
92 changed files with 5910 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
# UfuelUlogs Feature Capsule
## Quick Summary (50 tokens)
[AI: Complete feature description, main operations, dependencies, caching strategy]
## API Endpoints
- GET /api/fuel-logs - List all fuel-logs
- GET /api/fuel-logs/:id - Get specific lUfuelUlogs
- POST /api/fuel-logs - Create new lUfuelUlogs
- PUT /api/fuel-logs/:id - Update lUfuelUlogs
- DELETE /api/fuel-logs/:id - Delete lUfuelUlogs
## Structure
- **api/** - HTTP endpoints, routes, validators
- **domain/** - Business logic, types, rules
- **data/** - Repository, database queries
- **migrations/** - Feature-specific schema
- **external/** - External API integrations
- **events/** - Event handlers
- **tests/** - All feature tests
- **docs/** - Detailed documentation
## Dependencies
- Internal: core/auth, core/cache
- External: [List any external APIs]
- Database: fuel-logs table
## Quick Commands
```bash
# Run feature tests
npm test -- features/fuel-logs
# Run feature migrations
npm run migrate:feature fuel-logs
```

View File

@@ -0,0 +1,186 @@
/**
* @ai-summary HTTP request handlers for fuel logs
*/
import { Request, Response, NextFunction } from 'express';
import { FuelLogsService } from '../domain/fuel-logs.service';
import { validateCreateFuelLog, validateUpdateFuelLog } from './fuel-logs.validators';
import { logger } from '../../../core/logging/logger';
export class FuelLogsController {
constructor(private service: FuelLogsService) {}
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const validation = validateCreateFuelLog(req.body);
if (!validation.success) {
return res.status(400).json({
error: 'Validation failed',
details: validation.error.errors
});
}
const result = await this.service.createFuelLog(validation.data, userId);
res.status(201).json(result);
} catch (error: any) {
logger.error('Error creating fuel log', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message.includes('Unauthorized')) {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
listByVehicle = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { vehicleId } = req.params;
const result = await this.service.getFuelLogsByVehicle(vehicleId, userId);
res.json(result);
} catch (error: any) {
logger.error('Error listing fuel logs', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message.includes('Unauthorized')) {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
listAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const result = await this.service.getUserFuelLogs(userId);
res.json(result);
} catch (error: any) {
logger.error('Error listing all fuel logs', { error: error.message });
return next(error);
}
}
get = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { id } = req.params;
const result = await this.service.getFuelLog(id, userId);
res.json(result);
} catch (error: any) {
logger.error('Error getting fuel log', { error: error.message });
if (error.message === 'Fuel log not found') {
return res.status(404).json({ error: error.message });
}
if (error.message === 'Unauthorized') {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { id } = req.params;
const validation = validateUpdateFuelLog(req.body);
if (!validation.success) {
return res.status(400).json({
error: 'Validation failed',
details: validation.error.errors
});
}
const result = await this.service.updateFuelLog(id, validation.data, userId);
res.json(result);
} catch (error: any) {
logger.error('Error updating fuel log', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message === 'Unauthorized') {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { id } = req.params;
await this.service.deleteFuelLog(id, userId);
res.status(204).send();
} catch (error: any) {
logger.error('Error deleting fuel log', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message === 'Unauthorized') {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
getStats = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { vehicleId } = req.params;
const result = await this.service.getVehicleStats(vehicleId, userId);
res.json(result);
} catch (error: any) {
logger.error('Error getting fuel stats', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message === 'Unauthorized') {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
}

View File

@@ -0,0 +1,32 @@
/**
* @ai-summary Route definitions for fuel logs API
*/
import { Router } from 'express';
import { FuelLogsController } from './fuel-logs.controller';
import { FuelLogsService } from '../domain/fuel-logs.service';
import { FuelLogsRepository } from '../data/fuel-logs.repository';
import { authMiddleware } from '../../../core/security/auth.middleware';
import pool from '../../../core/config/database';
export function registerFuelLogsRoutes(): Router {
const router = Router();
// Initialize layers
const repository = new FuelLogsRepository(pool);
const service = new FuelLogsService(repository);
const controller = new FuelLogsController(service);
// Define routes
router.get('/api/fuel-logs', authMiddleware, controller.listAll);
router.get('/api/fuel-logs/:id', authMiddleware, controller.get);
router.post('/api/fuel-logs', authMiddleware, controller.create);
router.put('/api/fuel-logs/:id', authMiddleware, controller.update);
router.delete('/api/fuel-logs/:id', authMiddleware, controller.delete);
// Vehicle-specific routes
router.get('/api/vehicles/:vehicleId/fuel-logs', authMiddleware, controller.listByVehicle);
router.get('/api/vehicles/:vehicleId/fuel-stats', authMiddleware, controller.getStats);
return router;
}

View File

@@ -0,0 +1,38 @@
/**
* @ai-summary Input validation for fuel logs API
*/
import { z } from 'zod';
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(),
notes: z.string().max(1000).optional(),
});
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(),
notes: z.string().max(1000).optional(),
}).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);
}
export function validateUpdateFuelLog(data: unknown) {
return updateFuelLogSchema.safeParse(data);
}

View File

@@ -0,0 +1,249 @@
/**
* @ai-summary Business logic for fuel logs feature
* @ai-context Handles MPG calculations and vehicle validation
*/
import { FuelLogsRepository } from '../data/fuel-logs.repository';
import {
FuelLog,
CreateFuelLogRequest,
UpdateFuelLogRequest,
FuelLogResponse,
FuelStats
} from './fuel-logs.types';
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 fuel log', { userId, vehicleId: data.vehicleId });
// 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 (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,
userId,
mpg
});
// 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 => 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 => 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 (previousLog) {
const odometer = data.odometer || existing.odometer;
const gallons = data.gallons || existing.gallons;
const milesDriven = odometer - previousLog.odometer;
mpg = milesDriven / gallons;
}
}
// 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);
}
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> {
await Promise.all([
cacheService.del(`${this.cachePrefix}:user:${userId}`),
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}`)
]);
}
private toResponse(log: FuelLog): FuelLogResponse {
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(),
};
}
}

View File

@@ -0,0 +1,70 @@
/**
* @ai-summary Type definitions for fuel logs feature
* @ai-context Tracks fuel purchases and calculates MPG
*/
export interface FuelLog {
id: string;
userId: string;
vehicleId: string;
date: Date;
odometer: number;
gallons: number;
pricePerGallon: number;
totalCost: number;
station?: string;
location?: string;
notes?: string;
mpg?: number; // Calculated field
createdAt: Date;
updatedAt: Date;
}
export interface CreateFuelLogRequest {
vehicleId: string;
date: string; // ISO date string
odometer: number;
gallons: number;
pricePerGallon: number;
totalCost: number;
station?: string;
location?: string;
notes?: string;
}
export interface UpdateFuelLogRequest {
date?: string;
odometer?: number;
gallons?: number;
pricePerGallon?: number;
totalCost?: number;
station?: string;
location?: string;
notes?: string;
}
export interface FuelLogResponse {
id: string;
userId: string;
vehicleId: string;
date: string;
odometer: number;
gallons: number;
pricePerGallon: number;
totalCost: number;
station?: string;
location?: string;
notes?: string;
mpg?: number;
createdAt: string;
updatedAt: string;
}
export interface FuelStats {
totalGallons: number;
totalCost: number;
averagePricePerGallon: number;
averageMPG: number;
totalMiles: number;
logCount: number;
}

View File

@@ -0,0 +1,18 @@
/**
* @ai-summary Public API for fuel-logs feature capsule
*/
// Export service for use by other features
export { FuelLogsService } from './domain/fuel-logs.service';
// Export types
export type {
FuelLog,
CreateFuelLogRequest,
UpdateFuelLogRequest,
FuelLogResponse,
FuelStats
} from './domain/fuel-logs.types';
// Internal: Register routes
export { registerFuelLogsRoutes } from './api/fuel-logs.routes';

View File

@@ -0,0 +1,34 @@
-- Create fuel_logs table
CREATE TABLE IF NOT EXISTS fuel_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
date DATE NOT NULL,
odometer INTEGER NOT NULL,
gallons DECIMAL(10, 3) NOT NULL,
price_per_gallon DECIMAL(10, 3) NOT NULL,
total_cost DECIMAL(10, 2) NOT NULL,
station VARCHAR(200),
location VARCHAR(200),
notes TEXT,
mpg DECIMAL(10, 2), -- Calculated based on previous log
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_fuel_logs_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE
);
-- 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);
-- Add trigger for updated_at
CREATE TRIGGER update_fuel_logs_updated_at
BEFORE UPDATE ON fuel_logs
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();