Fix GitHub Actions build by adding missing repository files
The build was failing because repository files were ignored by .gitignore: - backend/src/features/*/data/*.repository.ts files were excluded by 'data/' pattern - These files exist locally but were missing in CI, causing TS2307 module errors - Controllers and services import these repositories, causing cascade failures Changes: - Updated .gitignore to allow TypeScript files in feature data directories - Added fuel-logs.repository.ts, stations.repository.ts, vehicles.repository.ts - Docker build now succeeds (tested with --no-cache) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,5 +14,6 @@ coverage/
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
data/
|
data/
|
||||||
|
!backend/src/features/*/data/*.ts
|
||||||
MotoVaultPro.csproj.user
|
MotoVaultPro.csproj.user
|
||||||
Properties/launchSettings.json
|
Properties/launchSettings.json
|
||||||
|
|||||||
209
backend/src/features/fuel-logs/data/fuel-logs.repository.ts
Normal file
209
backend/src/features/fuel-logs/data/fuel-logs.repository.ts
Normal file
@@ -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<FuelLog> {
|
||||||
|
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<FuelLog[]> {
|
||||||
|
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<FuelLog[]> {
|
||||||
|
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<FuelLog | null> {
|
||||||
|
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<FuelLog | null> {
|
||||||
|
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<FuelLog>): Promise<FuelLog | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<FuelStats | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
117
backend/src/features/stations/data/stations.repository.ts
Normal file
117
backend/src/features/stations/data/stations.repository.ts
Normal file
@@ -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<void> {
|
||||||
|
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<Station | null> {
|
||||||
|
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<SavedStation> {
|
||||||
|
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<SavedStation[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
177
backend/src/features/vehicles/data/vehicles.repository.ts
Normal file
177
backend/src/features/vehicles/data/vehicles.repository.ts
Normal file
@@ -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<Vehicle> {
|
||||||
|
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<Vehicle[]> {
|
||||||
|
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<Vehicle | null> {
|
||||||
|
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<Vehicle | null> {
|
||||||
|
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<Vehicle>): Promise<Vehicle | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<any | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user