diff --git a/.gitignore b/.gitignore index 8e18115..f5ea313 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ coverage/ bin/ obj/ data/ +!backend/src/features/*/data/*.ts MotoVaultPro.csproj.user Properties/launchSettings.json diff --git a/backend/src/features/fuel-logs/data/fuel-logs.repository.ts b/backend/src/features/fuel-logs/data/fuel-logs.repository.ts new file mode 100644 index 0000000..54a04df --- /dev/null +++ b/backend/src/features/fuel-logs/data/fuel-logs.repository.ts @@ -0,0 +1,209 @@ +/** + * @ai-summary Data access layer for fuel logs + * @ai-context Handles database operations and MPG calculations + */ + +import { Pool } from 'pg'; +import { FuelLog, CreateFuelLogRequest, FuelStats } from '../domain/fuel-logs.types'; + +export class FuelLogsRepository { + constructor(private pool: Pool) {} + + async create(data: CreateFuelLogRequest & { userId: string, mpg?: number }): Promise { + const query = ` + INSERT INTO fuel_logs ( + user_id, vehicle_id, date, odometer, gallons, + price_per_gallon, total_cost, station, location, notes, mpg + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `; + + const values = [ + data.userId, + data.vehicleId, + data.date, + data.odometer, + data.gallons, + data.pricePerGallon, + data.totalCost, + data.station, + data.location, + data.notes, + data.mpg + ]; + + const result = await this.pool.query(query, values); + return this.mapRow(result.rows[0]); + } + + async findByVehicleId(vehicleId: string): Promise { + const query = ` + SELECT * FROM fuel_logs + WHERE vehicle_id = $1 + ORDER BY date DESC, created_at DESC + `; + + const result = await this.pool.query(query, [vehicleId]); + return result.rows.map(row => this.mapRow(row)); + } + + async findByUserId(userId: string): Promise { + const query = ` + SELECT * FROM fuel_logs + WHERE user_id = $1 + ORDER BY date DESC, created_at DESC + `; + + const result = await this.pool.query(query, [userId]); + return result.rows.map(row => this.mapRow(row)); + } + + async findById(id: string): Promise { + const query = 'SELECT * FROM fuel_logs WHERE id = $1'; + const result = await this.pool.query(query, [id]); + + if (result.rows.length === 0) { + return null; + } + + return this.mapRow(result.rows[0]); + } + + async getPreviousLog(vehicleId: string, date: string, odometer: number): Promise { + const query = ` + SELECT * FROM fuel_logs + WHERE vehicle_id = $1 + AND (date < $2 OR (date = $2 AND odometer < $3)) + ORDER BY date DESC, odometer DESC + LIMIT 1 + `; + + const result = await this.pool.query(query, [vehicleId, date, odometer]); + + if (result.rows.length === 0) { + return null; + } + + return this.mapRow(result.rows[0]); + } + + async update(id: string, data: Partial): Promise { + const fields = []; + const values = []; + let paramCount = 1; + + // Build dynamic update query + if (data.date !== undefined) { + fields.push(`date = $${paramCount++}`); + values.push(data.date); + } + if (data.odometer !== undefined) { + fields.push(`odometer = $${paramCount++}`); + values.push(data.odometer); + } + if (data.gallons !== undefined) { + fields.push(`gallons = $${paramCount++}`); + values.push(data.gallons); + } + if (data.pricePerGallon !== undefined) { + fields.push(`price_per_gallon = $${paramCount++}`); + values.push(data.pricePerGallon); + } + if (data.totalCost !== undefined) { + fields.push(`total_cost = $${paramCount++}`); + values.push(data.totalCost); + } + if (data.station !== undefined) { + fields.push(`station = $${paramCount++}`); + values.push(data.station); + } + if (data.location !== undefined) { + fields.push(`location = $${paramCount++}`); + values.push(data.location); + } + if (data.notes !== undefined) { + fields.push(`notes = $${paramCount++}`); + values.push(data.notes); + } + if (data.mpg !== undefined) { + fields.push(`mpg = $${paramCount++}`); + values.push(data.mpg); + } + + if (fields.length === 0) { + return this.findById(id); + } + + values.push(id); + const query = ` + UPDATE fuel_logs + SET ${fields.join(', ')} + WHERE id = $${paramCount} + RETURNING * + `; + + const result = await this.pool.query(query, values); + + if (result.rows.length === 0) { + return null; + } + + return this.mapRow(result.rows[0]); + } + + async delete(id: string): Promise { + const query = 'DELETE FROM fuel_logs WHERE id = $1'; + const result = await this.pool.query(query, [id]); + return (result.rowCount ?? 0) > 0; + } + + async getStats(vehicleId: string): Promise { + const query = ` + SELECT + COUNT(*) as log_count, + 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 + `; + + const result = await this.pool.query(query, [vehicleId]); + + if (result.rows.length === 0 || result.rows[0].log_count === '0') { + return null; + } + + const row = result.rows[0]; + return { + logCount: parseInt(row.log_count), + 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, + totalMiles: parseInt(row.total_miles) || 0, + }; + } + + private mapRow(row: any): FuelLog { + return { + id: row.id, + userId: row.user_id, + vehicleId: row.vehicle_id, + date: row.date, + odometer: row.odometer, + gallons: parseFloat(row.gallons), + pricePerGallon: parseFloat(row.price_per_gallon), + totalCost: parseFloat(row.total_cost), + station: row.station, + location: row.location, + notes: row.notes, + mpg: row.mpg ? parseFloat(row.mpg) : undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} \ No newline at end of file diff --git a/backend/src/features/stations/data/stations.repository.ts b/backend/src/features/stations/data/stations.repository.ts new file mode 100644 index 0000000..7c3db03 --- /dev/null +++ b/backend/src/features/stations/data/stations.repository.ts @@ -0,0 +1,117 @@ +/** + * @ai-summary Data access layer for stations + */ + +import { Pool } from 'pg'; +import { Station, SavedStation } from '../domain/stations.types'; + +export class StationsRepository { + constructor(private pool: Pool) {} + + async cacheStation(station: Station): Promise { + const query = ` + INSERT INTO station_cache ( + place_id, name, address, latitude, longitude, + price_regular, price_premium, price_diesel, rating, photo_url + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (place_id) DO UPDATE + SET name = $2, address = $3, latitude = $4, longitude = $5, + rating = $9, photo_url = $10, cached_at = NOW() + `; + + await this.pool.query(query, [ + station.placeId, + station.name, + station.address, + station.latitude, + station.longitude, + station.priceRegular, + station.pricePremium, + station.priceDiesel, + station.rating, + station.photoUrl + ]); + } + + async getCachedStation(placeId: string): Promise { + const query = 'SELECT * FROM station_cache WHERE place_id = $1'; + const result = await this.pool.query(query, [placeId]); + + if (result.rows.length === 0) { + return null; + } + + return this.mapCacheRow(result.rows[0]); + } + + async saveStation(userId: string, placeId: string, data?: { nickname?: string; notes?: string; isFavorite?: boolean }): Promise { + const query = ` + INSERT INTO saved_stations (user_id, place_id, nickname, notes, is_favorite) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, place_id) DO UPDATE + SET nickname = COALESCE($3, saved_stations.nickname), + notes = COALESCE($4, saved_stations.notes), + is_favorite = COALESCE($5, saved_stations.is_favorite), + updated_at = NOW() + RETURNING * + `; + + const result = await this.pool.query(query, [ + userId, + placeId, + data?.nickname, + data?.notes, + data?.isFavorite || false + ]); + + return this.mapSavedRow(result.rows[0]); + } + + async getUserSavedStations(userId: string): Promise { + const query = ` + SELECT * FROM saved_stations + WHERE user_id = $1 + ORDER BY is_favorite DESC, created_at DESC + `; + + const result = await this.pool.query(query, [userId]); + return result.rows.map(row => this.mapSavedRow(row)); + } + + async deleteSavedStation(userId: string, placeId: string): Promise { + const query = 'DELETE FROM saved_stations WHERE user_id = $1 AND place_id = $2'; + const result = await this.pool.query(query, [userId, placeId]); + return (result.rowCount ?? 0) > 0; + } + + private mapCacheRow(row: any): Station { + return { + id: row.id, + placeId: row.place_id, + name: row.name, + address: row.address, + latitude: parseFloat(row.latitude), + longitude: parseFloat(row.longitude), + priceRegular: row.price_regular ? parseFloat(row.price_regular) : undefined, + pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined, + priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined, + rating: row.rating ? parseFloat(row.rating) : undefined, + photoUrl: row.photo_url, + lastUpdated: row.cached_at + }; + } + + private mapSavedRow(row: any): SavedStation { + return { + id: row.id, + userId: row.user_id, + stationId: row.place_id, + nickname: row.nickname, + notes: row.notes, + isFavorite: row.is_favorite, + createdAt: row.created_at, + updatedAt: row.updated_at + }; + } +} \ No newline at end of file diff --git a/backend/src/features/vehicles/data/vehicles.repository.ts b/backend/src/features/vehicles/data/vehicles.repository.ts new file mode 100644 index 0000000..f241a61 --- /dev/null +++ b/backend/src/features/vehicles/data/vehicles.repository.ts @@ -0,0 +1,177 @@ +/** + * @ai-summary Data access layer for vehicles + * @ai-context All database operations, no business logic + */ + +import { Pool } from 'pg'; +import { Vehicle, CreateVehicleRequest } from '../domain/vehicles.types'; + +export class VehiclesRepository { + constructor(private pool: Pool) {} + + async create(data: CreateVehicleRequest & { userId: string, make?: string, model?: string, year?: number }): Promise { + const query = ` + INSERT INTO vehicles ( + user_id, vin, make, model, year, + nickname, color, license_plate, odometer_reading + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `; + + const values = [ + data.userId, + data.vin, + data.make, + data.model, + data.year, + data.nickname, + data.color, + data.licensePlate, + data.odometerReading || 0 + ]; + + const result = await this.pool.query(query, values); + return this.mapRow(result.rows[0]); + } + + async findByUserId(userId: string): Promise { + const query = ` + SELECT * FROM vehicles + WHERE user_id = $1 AND is_active = true + ORDER BY created_at DESC + `; + + const result = await this.pool.query(query, [userId]); + return result.rows.map(row => this.mapRow(row)); + } + + async findById(id: string): Promise { + const query = 'SELECT * FROM vehicles WHERE id = $1'; + const result = await this.pool.query(query, [id]); + + if (result.rows.length === 0) { + return null; + } + + return this.mapRow(result.rows[0]); + } + + async findByUserAndVIN(userId: string, vin: string): Promise { + const query = 'SELECT * FROM vehicles WHERE user_id = $1 AND vin = $2'; + const result = await this.pool.query(query, [userId, vin]); + + if (result.rows.length === 0) { + return null; + } + + return this.mapRow(result.rows[0]); + } + + async update(id: string, data: Partial): Promise { + const fields = []; + const values = []; + let paramCount = 1; + + // Build dynamic update query + if (data.nickname !== undefined) { + fields.push(`nickname = $${paramCount++}`); + values.push(data.nickname); + } + if (data.color !== undefined) { + fields.push(`color = $${paramCount++}`); + values.push(data.color); + } + if (data.licensePlate !== undefined) { + fields.push(`license_plate = $${paramCount++}`); + values.push(data.licensePlate); + } + if (data.odometerReading !== undefined) { + fields.push(`odometer_reading = $${paramCount++}`); + values.push(data.odometerReading); + } + + if (fields.length === 0) { + return this.findById(id); + } + + values.push(id); + const query = ` + UPDATE vehicles + SET ${fields.join(', ')} + WHERE id = $${paramCount} + RETURNING * + `; + + const result = await this.pool.query(query, values); + + if (result.rows.length === 0) { + return null; + } + + return this.mapRow(result.rows[0]); + } + + async softDelete(id: string): Promise { + const query = ` + UPDATE vehicles + SET is_active = false, deleted_at = NOW() + WHERE id = $1 + `; + + const result = await this.pool.query(query, [id]); + return (result.rowCount ?? 0) > 0; + } + + // Cache VIN decode results + async cacheVINDecode(vin: string, data: any): Promise { + const query = ` + INSERT INTO vin_cache (vin, make, model, year, engine_type, body_type, raw_data) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (vin) DO UPDATE + SET make = $2, model = $3, year = $4, + engine_type = $5, body_type = $6, raw_data = $7, + cached_at = NOW() + `; + + await this.pool.query(query, [ + vin, + data.make, + data.model, + data.year, + data.engineType, + data.bodyType, + JSON.stringify(data.rawData) + ]); + } + + async getVINFromCache(vin: string): Promise { + const query = 'SELECT * FROM vin_cache WHERE vin = $1'; + const result = await this.pool.query(query, [vin]); + + if (result.rows.length === 0) { + return null; + } + + return result.rows[0]; + } + + private mapRow(row: any): Vehicle { + return { + id: row.id, + userId: row.user_id, + vin: row.vin, + make: row.make, + model: row.model, + year: row.year, + nickname: row.nickname, + color: row.color, + licensePlate: row.license_plate, + odometerReading: row.odometer_reading, + isActive: row.is_active, + deletedAt: row.deleted_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} \ No newline at end of file