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 @@
# Ustations Feature Capsule
## Quick Summary (50 tokens)
[AI: Complete feature description, main operations, dependencies, caching strategy]
## API Endpoints
- GET /api/stations - List all stations
- GET /api/stations/:id - Get specific lUstations
- POST /api/stations - Create new lUstations
- PUT /api/stations/:id - Update lUstations
- DELETE /api/stations/:id - Delete lUstations
## 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: stations table
## Quick Commands
```bash
# Run feature tests
npm test -- features/stations
# Run feature migrations
npm run migrate:feature stations
```

View File

@@ -0,0 +1,105 @@
/**
* @ai-summary HTTP request handlers for stations
*/
import { Request, Response, NextFunction } from 'express';
import { StationsService } from '../domain/stations.service';
import { logger } from '../../../core/logging/logger';
export class StationsController {
constructor(private service: StationsService) {}
search = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { latitude, longitude, radius, fuelType } = req.body;
if (!latitude || !longitude) {
return res.status(400).json({ error: 'Latitude and longitude are required' });
}
const result = await this.service.searchNearbyStations({
latitude,
longitude,
radius,
fuelType
}, userId);
res.json(result);
} catch (error: any) {
logger.error('Error searching stations', { error: error.message });
return next(error);
}
}
save = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { placeId, nickname, notes, isFavorite } = req.body;
if (!placeId) {
return res.status(400).json({ error: 'Place ID is required' });
}
const result = await this.service.saveStation(placeId, userId, {
nickname,
notes,
isFavorite
});
res.status(201).json(result);
} catch (error: any) {
logger.error('Error saving station', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
return next(error);
}
}
getSaved = 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.getUserSavedStations(userId);
res.json(result);
} catch (error: any) {
logger.error('Error getting saved stations', { error: error.message });
return next(error);
}
}
removeSaved = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { placeId } = req.params;
await this.service.removeSavedStation(placeId, userId);
res.status(204).send();
} catch (error: any) {
logger.error('Error removing saved station', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
return next(error);
}
}
}

View File

@@ -0,0 +1,27 @@
/**
* @ai-summary Route definitions for stations API
*/
import { Router } from 'express';
import { StationsController } from './stations.controller';
import { StationsService } from '../domain/stations.service';
import { StationsRepository } from '../data/stations.repository';
import { authMiddleware } from '../../../core/security/auth.middleware';
import pool from '../../../core/config/database';
export function registerStationsRoutes(): Router {
const router = Router();
// Initialize layers
const repository = new StationsRepository(pool);
const service = new StationsService(repository);
const controller = new StationsController(service);
// Define routes
router.post('/api/stations/search', authMiddleware, controller.search);
router.post('/api/stations/save', authMiddleware, controller.save);
router.get('/api/stations/saved', authMiddleware, controller.getSaved);
router.delete('/api/stations/saved/:placeId', authMiddleware, controller.removeSaved);
return router;
}

View File

@@ -0,0 +1,90 @@
/**
* @ai-summary Business logic for stations feature
*/
import { StationsRepository } from '../data/stations.repository';
import { googleMapsClient } from '../external/google-maps/google-maps.client';
import { StationSearchRequest, StationSearchResponse } from './stations.types';
import { logger } from '../../../core/logging/logger';
export class StationsService {
constructor(private repository: StationsRepository) {}
async searchNearbyStations(
request: StationSearchRequest,
userId: string
): Promise<StationSearchResponse> {
logger.info('Searching for stations', { userId, ...request });
// Search via Google Maps
const stations = await googleMapsClient.searchNearbyStations(
request.latitude,
request.longitude,
request.radius || 5000
);
// Cache stations for future reference
for (const station of stations) {
await this.repository.cacheStation(station);
}
// Sort by distance
stations.sort((a, b) => (a.distance || 0) - (b.distance || 0));
return {
stations,
searchLocation: {
latitude: request.latitude,
longitude: request.longitude
},
searchRadius: request.radius || 5000,
timestamp: new Date().toISOString()
};
}
async saveStation(
placeId: string,
userId: string,
data?: { nickname?: string; notes?: string; isFavorite?: boolean }
) {
// Get station details from cache
const station = await this.repository.getCachedStation(placeId);
if (!station) {
throw new Error('Station not found. Please search for stations first.');
}
// Save to user's saved stations
const saved = await this.repository.saveStation(userId, placeId, data);
return {
...saved,
station
};
}
async getUserSavedStations(userId: string) {
const savedStations = await this.repository.getUserSavedStations(userId);
// Enrich with cached station data
const enriched = await Promise.all(
savedStations.map(async (saved) => {
const station = await this.repository.getCachedStation(saved.stationId);
return {
...saved,
station
};
})
);
return enriched;
}
async removeSavedStation(placeId: string, userId: string) {
const removed = await this.repository.deleteSavedStation(userId, placeId);
if (!removed) {
throw new Error('Saved station not found');
}
}
}

View File

@@ -0,0 +1,49 @@
/**
* @ai-summary Type definitions for stations feature
* @ai-context Gas station discovery and caching
*/
export interface Station {
id: string;
placeId: string; // Google Places ID
name: string;
address: string;
latitude: number;
longitude: number;
priceRegular?: number;
pricePremium?: number;
priceDiesel?: number;
lastUpdated?: Date;
distance?: number; // Distance from search point in meters
isOpen?: boolean;
rating?: number;
photoUrl?: string;
}
export interface StationSearchRequest {
latitude: number;
longitude: number;
radius?: number; // Radius in meters (default 5000)
fuelType?: 'regular' | 'premium' | 'diesel';
}
export interface StationSearchResponse {
stations: Station[];
searchLocation: {
latitude: number;
longitude: number;
};
searchRadius: number;
timestamp: string;
}
export interface SavedStation {
id: string;
userId: string;
stationId: string;
nickname?: string;
notes?: string;
isFavorite: boolean;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,112 @@
/**
* @ai-summary Google Maps client for station discovery
* @ai-context Searches for gas stations and caches results
*/
import axios from 'axios';
import { env } from '../../../../core/config/environment';
import { logger } from '../../../../core/logging/logger';
import { cacheService } from '../../../../core/config/redis';
import { GooglePlacesResponse, GooglePlace } from './google-maps.types';
import { Station } from '../../domain/stations.types';
export class GoogleMapsClient {
private readonly apiKey = env.GOOGLE_MAPS_API_KEY;
private readonly baseURL = 'https://maps.googleapis.com/maps/api/place';
private readonly cacheTTL = 3600; // 1 hour
async searchNearbyStations(
latitude: number,
longitude: number,
radius: number = 5000
): Promise<Station[]> {
const cacheKey = `stations:${latitude.toFixed(4)},${longitude.toFixed(4)},${radius}`;
try {
// Check cache
const cached = await cacheService.get<Station[]>(cacheKey);
if (cached) {
logger.debug('Station search cache hit', { latitude, longitude });
return cached;
}
// Search Google Places
logger.info('Searching Google Places for stations', { latitude, longitude, radius });
const response = await axios.get<GooglePlacesResponse>(
`${this.baseURL}/nearbysearch/json`,
{
params: {
location: `${latitude},${longitude}`,
radius,
type: 'gas_station',
key: this.apiKey
}
}
);
if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') {
throw new Error(`Google Places API error: ${response.data.status}`);
}
// Transform results
const stations = response.data.results.map(place =>
this.transformPlaceToStation(place, latitude, longitude)
);
// Cache results
await cacheService.set(cacheKey, stations, this.cacheTTL);
return stations;
} catch (error) {
logger.error('Station search failed', { error, latitude, longitude });
throw error;
}
}
private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station {
// Calculate distance from search point
const distance = this.calculateDistance(
searchLat,
searchLng,
place.geometry.location.lat,
place.geometry.location.lng
);
// Generate photo URL if available
let photoUrl: string | undefined;
if (place.photos && place.photos.length > 0) {
photoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photo_reference=${place.photos[0].photo_reference}&key=${this.apiKey}`;
}
return {
id: place.place_id,
placeId: place.place_id,
name: place.name,
address: place.vicinity,
latitude: place.geometry.location.lat,
longitude: place.geometry.location.lng,
distance,
isOpen: place.opening_hours?.open_now,
rating: place.rating,
photoUrl
};
}
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371e3; // Earth's radius in meters
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return Math.round(R * c); // Distance in meters
}
}
export const googleMapsClient = new GoogleMapsClient();

View File

@@ -0,0 +1,55 @@
/**
* @ai-summary Google Maps API types
*/
export interface GooglePlacesResponse {
results: GooglePlace[];
status: string;
next_page_token?: string;
}
export interface GooglePlace {
place_id: string;
name: string;
vicinity: string;
geometry: {
location: {
lat: number;
lng: number;
};
};
opening_hours?: {
open_now: boolean;
};
rating?: number;
photos?: Array<{
photo_reference: string;
}>;
price_level?: number;
types: string[];
}
export interface GooglePlaceDetails {
result: {
place_id: string;
name: string;
formatted_address: string;
geometry: {
location: {
lat: number;
lng: number;
};
};
opening_hours?: {
open_now: boolean;
weekday_text: string[];
};
rating?: number;
photos?: Array<{
photo_reference: string;
}>;
formatted_phone_number?: string;
website?: string;
};
status: string;
}

View File

@@ -0,0 +1,17 @@
/**
* @ai-summary Public API for stations feature capsule
*/
// Export service
export { StationsService } from './domain/stations.service';
// Export types
export type {
Station,
StationSearchRequest,
StationSearchResponse,
SavedStation
} from './domain/stations.types';
// Internal: Register routes
export { registerStationsRoutes } from './api/stations.routes';

View File

@@ -0,0 +1,44 @@
-- Create station cache table
CREATE TABLE IF NOT EXISTS station_cache (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
place_id VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
address TEXT NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
price_regular DECIMAL(10, 2),
price_premium DECIMAL(10, 2),
price_diesel DECIMAL(10, 2),
rating DECIMAL(2, 1),
photo_url TEXT,
raw_data JSONB,
cached_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create saved stations table for user favorites
CREATE TABLE IF NOT EXISTS saved_stations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
place_id VARCHAR(255) NOT NULL,
nickname VARCHAR(100),
notes TEXT,
is_favorite BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_user_station UNIQUE(user_id, place_id)
);
-- Create indexes
CREATE INDEX idx_station_cache_place_id ON station_cache(place_id);
CREATE INDEX idx_station_cache_location ON station_cache(latitude, longitude);
CREATE INDEX idx_station_cache_cached_at ON station_cache(cached_at);
CREATE INDEX idx_saved_stations_user_id ON saved_stations(user_id);
CREATE INDEX idx_saved_stations_is_favorite ON saved_stations(is_favorite);
-- Add trigger for updated_at
CREATE TRIGGER update_saved_stations_updated_at
BEFORE UPDATE ON saved_stations
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();