MVP Build
This commit is contained in:
35
backend/src/features/stations/README.md
Normal file
35
backend/src/features/stations/README.md
Normal 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
|
||||
```
|
||||
105
backend/src/features/stations/api/stations.controller.ts
Normal file
105
backend/src/features/stations/api/stations.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
backend/src/features/stations/api/stations.routes.ts
Normal file
27
backend/src/features/stations/api/stations.routes.ts
Normal 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;
|
||||
}
|
||||
90
backend/src/features/stations/domain/stations.service.ts
Normal file
90
backend/src/features/stations/domain/stations.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
49
backend/src/features/stations/domain/stations.types.ts
Normal file
49
backend/src/features/stations/domain/stations.types.ts
Normal 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;
|
||||
}
|
||||
112
backend/src/features/stations/external/google-maps/google-maps.client.ts
vendored
Normal file
112
backend/src/features/stations/external/google-maps/google-maps.client.ts
vendored
Normal 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();
|
||||
55
backend/src/features/stations/external/google-maps/google-maps.types.ts
vendored
Normal file
55
backend/src/features/stations/external/google-maps/google-maps.types.ts
vendored
Normal 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;
|
||||
}
|
||||
17
backend/src/features/stations/index.ts
Normal file
17
backend/src/features/stations/index.ts
Normal 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';
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user