Community 93 Premium feature complete
This commit is contained in:
@@ -24,6 +24,7 @@ import { CatalogImportService } from '../domain/catalog-import.service';
|
||||
import { PlatformCacheService } from '../../platform/domain/platform-cache.service';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { CommunityStationsController } from '../../stations/api/community-stations.controller';
|
||||
|
||||
export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const adminController = new AdminController();
|
||||
@@ -33,6 +34,9 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const stationOversightService = new StationOversightService(pool, adminRepository);
|
||||
const stationsController = new StationsController(stationOversightService);
|
||||
|
||||
// Initialize community stations dependencies
|
||||
const communityStationsController = new CommunityStationsController();
|
||||
|
||||
// Initialize catalog dependencies
|
||||
const platformCacheService = new PlatformCacheService(cacheService);
|
||||
const catalogService = new VehicleCatalogService(pool, platformCacheService);
|
||||
@@ -294,4 +298,24 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: stationsController.removeUserSavedStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// Phase 5: Community gas station submission oversight
|
||||
|
||||
// GET /api/admin/community-stations - List all submissions with filters
|
||||
fastify.get('/admin/community-stations', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: communityStationsController.listAllSubmissions.bind(communityStationsController)
|
||||
});
|
||||
|
||||
// GET /api/admin/community-stations/pending - Get pending review queue
|
||||
fastify.get('/admin/community-stations/pending', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: communityStationsController.getPendingQueue.bind(communityStationsController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/community-stations/:id/review - Approve or reject submission
|
||||
fastify.patch('/admin/community-stations/:id/review', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: communityStationsController.reviewStation.bind(communityStationsController)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,20 +85,30 @@ fuel-logs/
|
||||
├── api/ # HTTP layer
|
||||
│ ├── fuel-logs.controller.ts
|
||||
│ ├── fuel-logs.routes.ts
|
||||
│ └── fuel-logs.validators.ts
|
||||
│ ├── fuel-logs.validators.ts
|
||||
│ └── fuel-grade.controller.ts # Fuel grade lookup endpoints
|
||||
├── domain/ # Business logic
|
||||
│ ├── fuel-logs.service.ts
|
||||
│ └── fuel-logs.types.ts
|
||||
│ ├── fuel-logs.service.ts # Core fuel log operations
|
||||
│ ├── fuel-logs.types.ts # Type definitions
|
||||
│ ├── fuel-grade.service.ts # Fuel grade management
|
||||
│ ├── efficiency-calculation.service.ts # MPG/L per 100km calculations
|
||||
│ ├── unit-conversion.service.ts # Imperial/metric conversions
|
||||
│ └── enhanced-validation.service.ts # Complex validation rules
|
||||
├── data/ # Database layer
|
||||
│ └── fuel-logs.repository.ts
|
||||
├── external/ # External service integrations
|
||||
│ └── user-settings.service.ts # User preference lookups
|
||||
├── migrations/ # Feature schema
|
||||
│ └── 001_create_fuel_logs_table.sql
|
||||
│ ├── 001_create_fuel_logs_table.sql
|
||||
│ ├── 002_enhance_fuel_logs_schema.sql
|
||||
│ ├── 003_drop_mpg_column.sql
|
||||
│ └── 004_relax_odometer_and_trip_precision.sql
|
||||
├── tests/ # All tests
|
||||
│ ├── unit/
|
||||
│ │ └── fuel-logs.service.test.ts
|
||||
│ └── integration/
|
||||
│ └── fuel-logs.integration.test.ts
|
||||
└── docs/ # Additional docs
|
||||
└── fixtures/ # Test fixtures (empty - uses inline mocks)
|
||||
```
|
||||
|
||||
## Key Features
|
||||
@@ -183,16 +193,16 @@ fuel-logs/
|
||||
### Run Tests
|
||||
```bash
|
||||
# All fuel log tests
|
||||
npm test -- features/fuel-logs
|
||||
npm test -- --testPathPattern=src/features/fuel-logs
|
||||
|
||||
# Unit tests only
|
||||
npm test -- features/fuel-logs/tests/unit
|
||||
npm test -- --testPathPattern=src/features/fuel-logs/tests/unit
|
||||
|
||||
# Integration tests only
|
||||
npm test -- features/fuel-logs/tests/integration
|
||||
# Integration tests only
|
||||
npm test -- --testPathPattern=src/features/fuel-logs/tests/integration
|
||||
|
||||
# With coverage
|
||||
npm test -- features/fuel-logs --coverage
|
||||
npm test -- --testPathPattern=src/features/fuel-logs --coverage
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
@@ -224,5 +234,5 @@ make logs-backend | grep fuel-logs
|
||||
make shell-backend
|
||||
|
||||
# Inside container - run feature tests
|
||||
npm test -- features/fuel-logs
|
||||
npm test -- --testPathPattern=src/features/fuel-logs
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Platform Feature Capsule
|
||||
|
||||
## Quick Summary (50 tokens)
|
||||
Extensible platform service for vehicle hierarchical data lookups and VIN decoding. Replaces Python FastAPI platform service. PostgreSQL-first with vPIC fallback, Redis caching (6hr vehicle data, 7-day VIN), circuit breaker pattern for resilience.
|
||||
Extensible platform service for vehicle hierarchical data lookups. Replaces Python FastAPI platform service. PostgreSQL-first with Redis caching (6hr vehicle data). VIN decoding is planned but not yet implemented.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -12,8 +12,8 @@ Extensible platform service for vehicle hierarchical data lookups and VIN decodi
|
||||
- `GET /api/platform/trims?year={year}&model_id={id}` - Get trims for year and model
|
||||
- `GET /api/platform/engines?year={year}&model_id={id}&trim_id={id}` - Get engines for trim
|
||||
|
||||
### VIN Decoding
|
||||
- `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details
|
||||
### VIN Decoding (Planned/Future - Not Yet Implemented)
|
||||
- `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details (planned)
|
||||
|
||||
## Authentication
|
||||
- All platform endpoints require valid JWT (Auth0)
|
||||
@@ -56,11 +56,12 @@ Response (200):
|
||||
}
|
||||
```
|
||||
|
||||
### Decode VIN
|
||||
### Decode VIN (Planned/Future)
|
||||
The following endpoint is planned but not yet implemented:
|
||||
```json
|
||||
GET /api/platform/vehicle?vin=1HGCM82633A123456
|
||||
|
||||
Response (200):
|
||||
Planned Response (200):
|
||||
{
|
||||
"vin": "1HGCM82633A123456",
|
||||
"success": true,
|
||||
@@ -70,30 +71,11 @@ Response (200):
|
||||
"year": 2003,
|
||||
"trim_name": "LX",
|
||||
"engine_description": "2.4L I4",
|
||||
"transmission_description": "5-Speed Automatic",
|
||||
"horsepower": 160,
|
||||
"torque": 161,
|
||||
"top_speed": null,
|
||||
"fuel": "Gasoline",
|
||||
"confidence_score": 0.95,
|
||||
"vehicle_type": "Passenger Car"
|
||||
"transmission_description": "5-Speed Automatic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### VIN Decode Error
|
||||
```json
|
||||
GET /api/platform/vehicle?vin=INVALID
|
||||
|
||||
Response (400):
|
||||
{
|
||||
"vin": "INVALID",
|
||||
"success": false,
|
||||
"result": null,
|
||||
"error": "VIN must be exactly 17 characters"
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Architecture
|
||||
|
||||
### Complete Self-Contained Structure
|
||||
@@ -105,47 +87,43 @@ platform/
|
||||
│ ├── platform.controller.ts
|
||||
│ └── platform.routes.ts
|
||||
├── domain/ # Business logic
|
||||
│ ├── vehicle-data.service.ts
|
||||
│ ├── vin-decode.service.ts
|
||||
│ └── platform-cache.service.ts
|
||||
├── data/ # Database and external APIs
|
||||
│ ├── vehicle-data.repository.ts
|
||||
│ └── vpic-client.ts
|
||||
│ ├── vehicle-data.service.ts # Hierarchical vehicle lookups
|
||||
│ └── platform-cache.service.ts # Redis caching layer
|
||||
├── data/ # Database layer
|
||||
│ └── vehicle-data.repository.ts
|
||||
├── models/ # DTOs
|
||||
│ ├── requests.ts
|
||||
│ └── responses.ts
|
||||
├── migrations/ # Database schema
|
||||
│ └── 001_create_vehicle_lookup_schema.sql
|
||||
├── tests/ # All tests
|
||||
│ ├── unit/
|
||||
│ │ └── vehicle-data.service.test.ts
|
||||
│ └── integration/
|
||||
│ └── platform.integration.test.ts
|
||||
└── docs/ # Additional docs
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### VIN Decoding Strategy
|
||||
1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures)
|
||||
2. **PostgreSQL**: Use `vehicles.f_decode_vin()` function for high-confidence decode
|
||||
3. **vPIC Fallback**: NHTSA vPIC API via circuit breaker (5s timeout, 50% error threshold)
|
||||
4. **Graceful Degradation**: Return meaningful errors when all sources fail
|
||||
|
||||
### Circuit Breaker Pattern
|
||||
- **Library**: opossum
|
||||
- **Timeout**: 6 seconds
|
||||
- **Error Threshold**: 50%
|
||||
- **Reset Timeout**: 30 seconds
|
||||
- **Monitoring**: Logs state transitions (open/half-open/close)
|
||||
|
||||
### Hierarchical Vehicle Data
|
||||
- **PostgreSQL Queries**: Normalized schema (vehicles.make, vehicles.model, etc.)
|
||||
- **Caching**: 6-hour TTL for all dropdown data
|
||||
### Hierarchical Vehicle Data (Implemented)
|
||||
- **PostgreSQL Queries**: Uses `vehicle_options` table for hierarchical lookups
|
||||
- **Caching**: 6-hour TTL for all dropdown data via Redis
|
||||
- **Performance**: < 100ms response times via caching
|
||||
- **Validation**: Year (1950-2100), positive integer IDs
|
||||
- **Validation**: Year (1950-2100), string-based parameters
|
||||
- **Endpoints**: years, makes, models, trims, engines, transmissions
|
||||
|
||||
### VIN Decoding Strategy (Planned/Future)
|
||||
When implemented, VIN decoding will use:
|
||||
1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures)
|
||||
2. **PostgreSQL**: Database function for high-confidence decode
|
||||
3. **vPIC Fallback**: NHTSA vPIC API with circuit breaker protection
|
||||
4. **Graceful Degradation**: Return meaningful errors when all sources fail
|
||||
|
||||
### Database Schema
|
||||
- **Uses Existing Schema**: `vehicles` schema in PostgreSQL
|
||||
- **Tables**: make, model, model_year, trim, engine, trim_engine
|
||||
- **Function**: `vehicles.f_decode_vin(vin text)` for VIN decoding
|
||||
- **No Migrations**: Uses existing platform database structure
|
||||
- **Tables**: `vehicle_options` table for hierarchical lookups
|
||||
- **Migrations**: `001_create_vehicle_lookup_schema.sql`
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
@@ -158,25 +136,24 @@ platform/
|
||||
- **TTL**: 21600 seconds (6 hours)
|
||||
- **Invalidation**: Natural expiration via TTL
|
||||
|
||||
#### VIN Decode (7 days success, 1 hour failure)
|
||||
#### VIN Decode Caching (Planned/Future)
|
||||
When VIN decoding is implemented:
|
||||
- **Keys**: `mvp:platform:vin-decode:{VIN}`
|
||||
- **Examples**: `mvp:platform:vin-decode:1HGCM82633A123456`
|
||||
- **TTL**: 604800 seconds (7 days) for success, 3600 seconds (1 hour) for failures
|
||||
- **Invalidation**: Natural expiration via TTL
|
||||
|
||||
## Business Rules
|
||||
|
||||
### VIN Validation
|
||||
### Query Parameter Validation
|
||||
- **Year**: Integer between 1950 and 2100
|
||||
- **Make/Model/Trim**: String-based parameters (not IDs)
|
||||
|
||||
### VIN Validation (Planned/Future)
|
||||
When VIN decoding is implemented:
|
||||
- Must be exactly 17 characters
|
||||
- Cannot contain letters I, O, or Q
|
||||
- Must be alphanumeric
|
||||
- Auto-uppercase normalization
|
||||
|
||||
### Query Parameter Validation
|
||||
- **Year**: Integer between 1950 and 2100
|
||||
- **IDs**: Positive integers (make_id, model_id, trim_id)
|
||||
- **VIN**: 17 alphanumeric characters (no I, O, Q)
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal Core Services
|
||||
@@ -185,75 +162,44 @@ platform/
|
||||
- `core/auth` - JWT authentication middleware
|
||||
- `core/logging` - Winston structured logging
|
||||
|
||||
### External APIs
|
||||
- **NHTSA vPIC**: https://vpic.nhtsa.dot.gov/api
|
||||
- VIN decoding fallback
|
||||
- 5-second timeout
|
||||
- Circuit breaker protected
|
||||
- Free public API
|
||||
### External APIs (Planned/Future)
|
||||
When VIN decoding is implemented:
|
||||
- **NHTSA vPIC**: https://vpic.nhtsa.dot.gov/api (VIN decoding fallback)
|
||||
|
||||
### Database Schema
|
||||
- **vehicles.make** - Vehicle manufacturers
|
||||
- **vehicles.model** - Vehicle models
|
||||
- **vehicles.model_year** - Year-specific models
|
||||
- **vehicles.trim** - Model trims
|
||||
- **vehicles.engine** - Engine configurations
|
||||
- **vehicles.trim_engine** - Trim-engine relationships
|
||||
- **vehicles.f_decode_vin(text)** - VIN decode function
|
||||
### Database Tables
|
||||
- **vehicle_options** - Hierarchical vehicle data (years, makes, models, trims, engines, transmissions)
|
||||
|
||||
### NPM Packages
|
||||
- `opossum` - Circuit breaker implementation
|
||||
- `axios` - HTTP client for vPIC API
|
||||
- `zod` - Request validation schemas
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Caching Strategy
|
||||
- **6-hour TTL**: Vehicle data rarely changes
|
||||
- **7-day TTL**: VIN decode results are immutable
|
||||
- **1-hour TTL**: Failed VIN decode (prevent repeated failures)
|
||||
- **6-hour TTL**: Vehicle hierarchical data (rarely changes)
|
||||
- **Cache Prefix**: `mvp:platform:` for isolation
|
||||
|
||||
### Circuit Breaker
|
||||
- Prevents cascading failures to vPIC API
|
||||
- 30-second cooldown after opening
|
||||
- Automatic recovery via half-open state
|
||||
- Detailed logging for monitoring
|
||||
|
||||
### Database Optimization
|
||||
- Uses existing indexes on vehicles schema
|
||||
- Prepared statements via node-postgres
|
||||
- Connection pooling (max 10 connections)
|
||||
- Connection pooling via backend pool
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Client Errors (4xx)
|
||||
- `400` - Invalid VIN format, validation errors
|
||||
- `400` - Validation errors (invalid year, missing parameters)
|
||||
- `401` - Missing or invalid JWT token
|
||||
- `404` - VIN not found in database or API
|
||||
- `503` - vPIC API unavailable (circuit breaker open)
|
||||
- `404` - Vehicle data not found
|
||||
|
||||
### Server Errors (5xx)
|
||||
- `500` - Database errors, unexpected failures
|
||||
- Graceful degradation when external APIs unavailable
|
||||
- Detailed logging without exposing internal details
|
||||
|
||||
### Error Response Format
|
||||
```json
|
||||
{
|
||||
"vin": "1HGCM82633A123456",
|
||||
"success": false,
|
||||
"result": null,
|
||||
"error": "VIN not found in database and external API unavailable"
|
||||
}
|
||||
```
|
||||
|
||||
## Extensibility Design
|
||||
|
||||
### Future Lookup Types
|
||||
The platform feature is designed to accommodate additional lookup types beyond vehicle data:
|
||||
|
||||
**Current**: Vehicle hierarchical data, VIN decoding
|
||||
**Current**: Vehicle hierarchical data (years, makes, models, trims, engines, transmissions)
|
||||
**Planned**: VIN decoding
|
||||
**Future Examples**:
|
||||
- Part number lookups
|
||||
- Service bulletins
|
||||
@@ -275,9 +221,6 @@ The platform feature is designed to accommodate additional lookup types beyond v
|
||||
|
||||
### Unit Tests
|
||||
- `vehicle-data.service.test.ts` - Business logic with mocked dependencies
|
||||
- `vin-decode.service.test.ts` - VIN decode logic with mocked API
|
||||
- `vpic-client.test.ts` - vPIC client with mocked HTTP
|
||||
- `platform-cache.service.test.ts` - Cache operations
|
||||
|
||||
### Integration Tests
|
||||
- `platform.integration.test.ts` - Complete API workflow with test database
|
||||
@@ -285,41 +228,21 @@ The platform feature is designed to accommodate additional lookup types beyond v
|
||||
### Run Tests
|
||||
```bash
|
||||
# All platform tests
|
||||
npm test -- features/platform
|
||||
npm test -- --testPathPattern=src/features/platform
|
||||
|
||||
# Unit tests only
|
||||
npm test -- features/platform/tests/unit
|
||||
npm test -- --testPathPattern=src/features/platform/tests/unit
|
||||
|
||||
# Integration tests only
|
||||
npm test -- features/platform/tests/integration
|
||||
# Integration tests only
|
||||
npm test -- --testPathPattern=src/features/platform/tests/integration
|
||||
|
||||
# With coverage
|
||||
npm test -- features/platform --coverage
|
||||
npm test -- --testPathPattern=src/features/platform --coverage
|
||||
```
|
||||
|
||||
## Migration from Python Service
|
||||
## Migration History
|
||||
|
||||
### What Changed
|
||||
- **Language**: Python FastAPI -> TypeScript Fastify
|
||||
- **Feature Name**: "vehicles" -> "platform" (extensibility)
|
||||
- **API Routes**: `/vehicles/*` -> `/api/platform/*`
|
||||
- **VIN Decode**: Kept and migrated (PostgreSQL + vPIC fallback)
|
||||
- **Caching**: Redis implementation adapted to TypeScript
|
||||
- **Circuit Breaker**: Python timeout -> opossum circuit breaker
|
||||
|
||||
### What Stayed the Same
|
||||
- Database schema (vehicles.*)
|
||||
- Cache TTLs (6hr vehicle data, 7-day VIN)
|
||||
- VIN validation logic
|
||||
- Hierarchical query structure
|
||||
- Response formats
|
||||
|
||||
### Deprecation Plan
|
||||
1. Deploy TypeScript platform feature
|
||||
2. Update frontend to use `/api/platform/*` endpoints
|
||||
3. Monitor traffic to Python service
|
||||
4. Deprecate Python service when traffic drops to zero
|
||||
5. Remove Python container from docker-compose
|
||||
The platform feature was migrated from a separate Python FastAPI service (mvp-platform container) to a TypeScript feature module within the backend. The Python container has been removed and platform capabilities are now fully integrated into the mvp-backend container.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -334,7 +257,7 @@ make logs-backend | grep platform
|
||||
make shell-backend
|
||||
|
||||
# Inside container - run feature tests
|
||||
npm test -- features/platform
|
||||
npm test -- --testPathPattern=src/features/platform
|
||||
|
||||
# Type check
|
||||
npm run type-check
|
||||
@@ -345,35 +268,33 @@ npm run lint
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Planned Features
|
||||
- VIN decoding endpoint with PostgreSQL + vPIC fallback
|
||||
- Circuit breaker pattern for external API resilience
|
||||
|
||||
### Potential Enhancements
|
||||
- Batch VIN decode endpoint (decode multiple VINs)
|
||||
- Admin endpoint to invalidate cache patterns
|
||||
- VIN decode history tracking
|
||||
- Alternative VIN decode APIs (CarMD, Edmunds)
|
||||
- Real-time vehicle data updates
|
||||
- Part number cross-reference lookups
|
||||
- Service bulletin integration
|
||||
- Recall information integration
|
||||
|
||||
### Performance Monitoring
|
||||
- Track cache hit rates
|
||||
- Monitor circuit breaker state
|
||||
- Log slow queries (> 200ms)
|
||||
- Alert on high error rates
|
||||
- Dashboard for vPIC API health
|
||||
|
||||
## Related Features
|
||||
|
||||
### Vehicles Feature
|
||||
- **Path**: `backend/src/features/vehicles/`
|
||||
- **Relationship**: Consumes platform VIN decode endpoint
|
||||
- **Integration**: Uses `/api/platform/vehicle?vin={vin}` for VIN decode
|
||||
- **Relationship**: Consumes platform hierarchical data
|
||||
- **Integration**: Uses dropdown endpoints for vehicle year/make/model selection
|
||||
|
||||
### Frontend Integration
|
||||
- **Dropdown Components**: Use hierarchical vehicle data endpoints
|
||||
- **VIN Scanner**: Use VIN decode endpoint for auto-population
|
||||
- **Dropdown Components**: Use hierarchical vehicle data endpoints for year/make/model/trim selection
|
||||
- **Vehicle Forms**: Leverage platform data for validation
|
||||
- **VIN Scanner**: Will use VIN decode endpoint when implemented (Planned/Future)
|
||||
|
||||
---
|
||||
|
||||
**Platform Feature**: Extensible foundation for vehicle data and future platform capabilities. Production-ready with PostgreSQL, Redis caching, circuit breaker resilience, and comprehensive error handling.
|
||||
**Platform Feature**: Extensible foundation for vehicle hierarchical data. Production-ready with PostgreSQL, Redis caching, and comprehensive error handling. VIN decoding is planned for future implementation.
|
||||
|
||||
411
backend/src/features/stations/COMMUNITY-STATIONS.md
Normal file
411
backend/src/features/stations/COMMUNITY-STATIONS.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# Community Gas Stations Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Community Gas Stations feature allows MotoVaultPro users to submit and discover community-verified 93 octane gas stations. Users can submit new station locations with details about fuel availability and pricing, and admins review submissions before they become publicly visible.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **API Layer**: `api/community-stations.controller.ts`, `api/community-stations.routes.ts`, `api/community-stations.validation.ts`
|
||||
- **Business Logic**: `domain/community-stations.service.ts`
|
||||
- **Data Access**: `data/community-stations.repository.ts`
|
||||
- **Database**: `community_stations` table
|
||||
- **Types**: `domain/community-stations.types.ts`
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE community_stations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
submitted_by VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
city VARCHAR(100),
|
||||
state VARCHAR(50),
|
||||
zip_code VARCHAR(20),
|
||||
latitude DECIMAL(10, 8) NOT NULL,
|
||||
longitude DECIMAL(11, 8) NOT NULL,
|
||||
brand VARCHAR(100),
|
||||
has_93_octane BOOLEAN DEFAULT true,
|
||||
has_93_octane_ethanol_free BOOLEAN DEFAULT false,
|
||||
price_93 DECIMAL(5, 3),
|
||||
notes TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
reviewed_by VARCHAR(255),
|
||||
reviewed_at TIMESTAMP WITH TIME ZONE,
|
||||
rejection_reason TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_community_stations_status ON community_stations(status);
|
||||
CREATE INDEX idx_community_stations_location ON community_stations(latitude, longitude);
|
||||
CREATE INDEX idx_community_stations_submitted_by ON community_stations(submitted_by);
|
||||
CREATE INDEX idx_community_stations_created_at ON community_stations(created_at DESC);
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### User Endpoints (Authentication Required)
|
||||
|
||||
#### Submit a New Station
|
||||
```
|
||||
POST /api/stations/community
|
||||
Authorization: Bearer <JWT>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Shell Gas Station",
|
||||
"address": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"zipCode": "62701",
|
||||
"latitude": 39.7817,
|
||||
"longitude": -89.6501,
|
||||
"brand": "Shell",
|
||||
"has93Octane": true,
|
||||
"has93OctaneEthanolFree": false,
|
||||
"price93": 3.50,
|
||||
"notes": "Excellent customer service"
|
||||
}
|
||||
|
||||
Response: 201 Created
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"submittedBy": "auth0|user123",
|
||||
"name": "Shell Gas Station",
|
||||
"address": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"zipCode": "62701",
|
||||
"latitude": 39.7817,
|
||||
"longitude": -89.6501,
|
||||
"brand": "Shell",
|
||||
"has93Octane": true,
|
||||
"has93OctaneEthanolFree": false,
|
||||
"price93": 3.50,
|
||||
"notes": "Excellent customer service",
|
||||
"status": "pending",
|
||||
"createdAt": "2024-01-15T10:30:00Z",
|
||||
"updatedAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get User's Submissions
|
||||
```
|
||||
GET /api/stations/community/mine?limit=100&offset=0
|
||||
Authorization: Bearer <JWT>
|
||||
|
||||
Response: 200 OK
|
||||
{
|
||||
"total": 3,
|
||||
"stations": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"submittedBy": "auth0|user123",
|
||||
"name": "Shell Gas Station",
|
||||
"address": "123 Main St",
|
||||
"status": "pending",
|
||||
"createdAt": "2024-01-15T10:30:00Z",
|
||||
"updatedAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Withdraw a Pending Submission
|
||||
```
|
||||
DELETE /api/stations/community/:id
|
||||
Authorization: Bearer <JWT>
|
||||
|
||||
Response: 204 No Content
|
||||
```
|
||||
|
||||
Conditions:
|
||||
- User can only withdraw their own submissions
|
||||
- Can only withdraw submissions in 'pending' status
|
||||
- Approved or rejected submissions cannot be withdrawn
|
||||
|
||||
#### Get Approved Stations
|
||||
```
|
||||
GET /api/stations/community/approved?limit=100&offset=0
|
||||
Authorization: Bearer <JWT>
|
||||
|
||||
Response: 200 OK
|
||||
{
|
||||
"total": 42,
|
||||
"stations": [
|
||||
{
|
||||
"id": "223e4567-e89b-12d3-a456-426614174000",
|
||||
"submittedBy": "auth0|user456",
|
||||
"name": "Chevron Station",
|
||||
"address": "456 Oak Ave",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"latitude": 39.7850,
|
||||
"longitude": -89.6480,
|
||||
"brand": "Chevron",
|
||||
"has93Octane": true,
|
||||
"has93OctaneEthanolFree": true,
|
||||
"price93": 3.45,
|
||||
"status": "approved",
|
||||
"reviewedBy": "auth0|admin123",
|
||||
"reviewedAt": "2024-01-14T15:20:00Z",
|
||||
"createdAt": "2024-01-10T08:00:00Z",
|
||||
"updatedAt": "2024-01-14T15:20:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Find Nearby Approved Stations
|
||||
```
|
||||
POST /api/stations/community/nearby
|
||||
Authorization: Bearer <JWT>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"latitude": 39.7817,
|
||||
"longitude": -89.6501,
|
||||
"radiusKm": 50
|
||||
}
|
||||
|
||||
Response: 200 OK
|
||||
{
|
||||
"stations": [
|
||||
{
|
||||
"id": "223e4567-e89b-12d3-a456-426614174000",
|
||||
"submittedBy": "auth0|user456",
|
||||
"name": "Chevron Station",
|
||||
"address": "456 Oak Ave",
|
||||
"latitude": 39.7850,
|
||||
"longitude": 39.7850,
|
||||
"has93Octane": true,
|
||||
"status": "approved"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `latitude`: Required. Must be between -90 and 90
|
||||
- `longitude`: Required. Must be between -180 and 180
|
||||
- `radiusKm`: Optional. Defaults to 50 km. Must be between 1 and 500 km
|
||||
|
||||
### Admin Endpoints (Admin Role Required)
|
||||
|
||||
#### List All Submissions
|
||||
```
|
||||
GET /api/admin/community-stations?status=pending&submittedBy=auth0|user123&limit=100&offset=0
|
||||
Authorization: Bearer <JWT>
|
||||
X-Admin-Required: true
|
||||
|
||||
Response: 200 OK
|
||||
{
|
||||
"total": 15,
|
||||
"stations": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Filter parameters:
|
||||
- `status`: Optional. Values: 'pending', 'approved', 'rejected'
|
||||
- `submittedBy`: Optional. User ID to filter by submitter
|
||||
- `limit`: Optional. Default 100, max 1000
|
||||
- `offset`: Optional. Default 0
|
||||
|
||||
#### Get Pending Review Queue
|
||||
```
|
||||
GET /api/admin/community-stations/pending?limit=100&offset=0
|
||||
Authorization: Bearer <JWT>
|
||||
X-Admin-Required: true
|
||||
|
||||
Response: 200 OK
|
||||
{
|
||||
"total": 8,
|
||||
"stations": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"submittedBy": "auth0|user123",
|
||||
"name": "Shell Gas Station",
|
||||
"address": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"latitude": 39.7817,
|
||||
"longitude": -89.6501,
|
||||
"brand": "Shell",
|
||||
"has93Octane": true,
|
||||
"has93OctaneEthanolFree": false,
|
||||
"price93": 3.50,
|
||||
"notes": "Excellent customer service",
|
||||
"status": "pending",
|
||||
"createdAt": "2024-01-15T10:30:00Z",
|
||||
"updatedAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Approve or Reject a Submission
|
||||
```
|
||||
PATCH /api/admin/community-stations/:id/review
|
||||
Authorization: Bearer <JWT>
|
||||
X-Admin-Required: true
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "approved"
|
||||
}
|
||||
|
||||
OR (for rejection)
|
||||
|
||||
{
|
||||
"status": "rejected",
|
||||
"rejectionReason": "Location data appears to be in another city. Please resubmit with correct address."
|
||||
}
|
||||
|
||||
Response: 200 OK
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"submittedBy": "auth0|user123",
|
||||
"name": "Shell Gas Station",
|
||||
"status": "approved",
|
||||
"reviewedBy": "auth0|admin123",
|
||||
"reviewedAt": "2024-01-16T09:15:00Z",
|
||||
"updatedAt": "2024-01-16T09:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
- For approval: Just set `status` to "approved"
|
||||
- For rejection: Must provide `rejectionReason` explaining why the submission was rejected
|
||||
- All review actions are logged in audit logs
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Station Submission
|
||||
- `name`: Required, 1-200 characters
|
||||
- `address`: Required
|
||||
- `city`: Optional, max 100 characters
|
||||
- `state`: Optional, max 50 characters
|
||||
- `zipCode`: Optional, max 20 characters
|
||||
- `latitude`: Required, must be between -90 and 90
|
||||
- `longitude`: Required, must be between -180 and 180
|
||||
- `brand`: Optional, max 100 characters
|
||||
- `has93Octane`: Optional boolean, defaults to true
|
||||
- `has93OctaneEthanolFree`: Optional boolean, defaults to false
|
||||
- `price93`: Optional positive number
|
||||
- `notes`: Optional string
|
||||
|
||||
### Review
|
||||
- `status`: Required. Must be either 'approved' or 'rejected'
|
||||
- `rejectionReason`: Required if status is 'rejected', optional if 'approved'
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
The service implements Redis caching with 5-minute TTL for:
|
||||
- Approved stations list (paginated)
|
||||
- Nearby approved stations searches
|
||||
- Pending submissions count
|
||||
|
||||
Cache is invalidated on:
|
||||
- New submission
|
||||
- Station review (approval or rejection)
|
||||
|
||||
Cache keys:
|
||||
- `mvp:community-stations:approved:{limit}:{offset}`
|
||||
- `mvp:community-stations:nearby:{latitude}:{longitude}:{radius}`
|
||||
- `mvp:community-stations:pending:*` (pattern for invalidation)
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Status | Code | Message |
|
||||
|--------|------|---------|
|
||||
| 400 | Validation error | Invalid request parameters |
|
||||
| 400 | Bad Request | Cannot withdraw non-pending submission |
|
||||
| 400 | Bad Request | Rejection reason required when rejecting |
|
||||
| 401 | Unauthorized | Missing or invalid authentication |
|
||||
| 403 | Forbidden | Admin role required |
|
||||
| 404 | Not Found | Station not found |
|
||||
| 404 | Not Found | Submission not found |
|
||||
| 500 | Internal Server Error | Unexpected error |
|
||||
|
||||
## Audit Logging
|
||||
|
||||
All admin review actions are logged with:
|
||||
- Admin ID (reviewer)
|
||||
- Action type: 'REVIEW'
|
||||
- Resource type: 'community_station'
|
||||
- Context: { status, submittedBy, name }
|
||||
- Timestamp
|
||||
|
||||
Admin can access audit logs via: `GET /api/admin/audit-logs`
|
||||
|
||||
## Features Implemented
|
||||
|
||||
- [x] User submission of gas station locations
|
||||
- [x] Validation of coordinates and required fields
|
||||
- [x] User withdrawal of pending submissions
|
||||
- [x] Public listing of approved stations
|
||||
- [x] Location-based search for nearby approved stations
|
||||
- [x] Admin approval/rejection workflow
|
||||
- [x] Rejection reason documentation
|
||||
- [x] Redis caching for performance
|
||||
- [x] Audit logging for admin actions
|
||||
- [x] User ownership validation
|
||||
- [x] Comprehensive error handling
|
||||
- [x] Pagination support
|
||||
- [x] TypeScript type safety with Zod validation
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- Service business logic
|
||||
- Cache invalidation
|
||||
- Validation rules
|
||||
- User ownership checks
|
||||
|
||||
Location: `tests/unit/community-stations.service.test.ts`
|
||||
|
||||
### Integration Tests
|
||||
- Full API workflows
|
||||
- Database interactions
|
||||
- Authentication and authorization
|
||||
- Error handling
|
||||
|
||||
Location: `tests/integration/community-stations.api.test.ts`
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
npm test -- features/stations/tests/unit/community-stations.service.test.ts
|
||||
npm test -- features/stations/tests/integration/community-stations.api.test.ts
|
||||
```
|
||||
|
||||
### Database Migration
|
||||
Migration file: `migrations/004_create_community_stations.sql`
|
||||
|
||||
Apply with:
|
||||
```bash
|
||||
make migrate
|
||||
```
|
||||
|
||||
### Feature Capsule Pattern
|
||||
This feature follows the modular monolith pattern:
|
||||
- Self-contained within `backend/src/features/stations/`
|
||||
- No cross-feature imports (except through shared-minimal)
|
||||
- Complete API layer with validation
|
||||
- Business logic in service layer
|
||||
- Data access isolated in repository
|
||||
- Comprehensive test coverage
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Image uploads for gas station photos
|
||||
- User ratings/reviews of gas stations
|
||||
- Integration with fuel price APIs
|
||||
- Duplicate detection for same location
|
||||
- Bulk import from external APIs
|
||||
- Advanced search (filtering by brand, features)
|
||||
- User moderation/reputation system
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @ai-summary HTTP request handlers for community stations API
|
||||
* @ai-context Handles user submissions and admin review operations
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { CommunityStationsService } from '../domain/community-stations.service';
|
||||
import { CommunityStationsRepository } from '../data/community-stations.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { AdminRepository } from '../../admin/data/admin.repository';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
SubmitCommunityStationInput,
|
||||
ReviewStationInput,
|
||||
CommunityStationFiltersInput,
|
||||
NearbyStationsInput,
|
||||
StationIdInput,
|
||||
PaginationInput,
|
||||
BoundsStationsInput,
|
||||
RemovalReportInput,
|
||||
submitCommunityStationSchema,
|
||||
reviewStationSchema,
|
||||
communityStationFiltersSchema,
|
||||
nearbyStationsSchema,
|
||||
stationIdSchema,
|
||||
paginationSchema,
|
||||
boundsStationsSchema,
|
||||
removalReportSchema
|
||||
} from './community-stations.validation';
|
||||
|
||||
export class CommunityStationsController {
|
||||
private service: CommunityStationsService;
|
||||
private adminRepository: AdminRepository;
|
||||
|
||||
constructor() {
|
||||
const repository = new CommunityStationsRepository(pool);
|
||||
this.service = new CommunityStationsService(repository);
|
||||
this.adminRepository = new AdminRepository(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/stations/community
|
||||
* Submit a new community gas station
|
||||
*/
|
||||
async submitStation(
|
||||
request: FastifyRequest<{ Body: SubmitCommunityStationInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
// Validate request body
|
||||
const validation = submitCommunityStationSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const station = await this.service.submitStation(userId, validation.data);
|
||||
|
||||
return reply.code(201).send(station);
|
||||
} catch (error: any) {
|
||||
logger.error('Error submitting station', { error, userId: (request as any).user?.sub });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to submit station'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/stations/community/mine
|
||||
* Get user's own station submissions
|
||||
*/
|
||||
async getMySubmissions(
|
||||
request: FastifyRequest<{ Querystring: PaginationInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
// Validate query params
|
||||
const validation = paginationSchema.safeParse(request.query);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.service.getMySubmissions(userId, validation.data.limit, validation.data.offset);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting user submissions', { error, userId: (request as any).user?.sub });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve submissions'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/stations/community/:id
|
||||
* Withdraw a pending submission
|
||||
*/
|
||||
async withdrawSubmission(
|
||||
request: FastifyRequest<{ Params: StationIdInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
// Validate params
|
||||
const validation = stationIdSchema.safeParse(request.params);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
await this.service.withdrawSubmission(userId, validation.data.id);
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error: any) {
|
||||
logger.error('Error withdrawing submission', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
stationId: request.params.id
|
||||
});
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: 'Station not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('Unauthorized') || error.message.includes('pending')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to withdraw submission'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/stations/community/approved
|
||||
* Get list of approved community stations (public)
|
||||
*/
|
||||
async getApprovedStations(
|
||||
request: FastifyRequest<{ Querystring: PaginationInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Validate query params
|
||||
const validation = paginationSchema.safeParse(request.query);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.service.getApprovedStations(validation.data.limit, validation.data.offset);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting approved stations', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve stations'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/stations/community/nearby
|
||||
* Find approved stations near a location
|
||||
*/
|
||||
async getNearbyStations(
|
||||
request: FastifyRequest<{ Body: NearbyStationsInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Validate request body
|
||||
const validation = nearbyStationsSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const stations = await this.service.getApprovedNearby(validation.data);
|
||||
|
||||
return reply.code(200).send({ stations });
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting nearby stations', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve nearby stations'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/stations/community/bounds
|
||||
* Find approved stations within map bounds
|
||||
*/
|
||||
async getStationsInBounds(
|
||||
request: FastifyRequest<{ Body: BoundsStationsInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Validate request body
|
||||
const validation = boundsStationsSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const stations = await this.service.getApprovedInBounds(validation.data);
|
||||
|
||||
return reply.code(200).send({ stations });
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting stations in bounds', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve stations'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/stations/community/:id/report-removal
|
||||
* Report that a station no longer has Premium 93
|
||||
*/
|
||||
async reportRemoval(
|
||||
request: FastifyRequest<{ Params: StationIdInput; Body: RemovalReportInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
// Validate params
|
||||
const paramsValidation = stationIdSchema.safeParse(request.params);
|
||||
if (!paramsValidation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: paramsValidation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body
|
||||
const bodyValidation = removalReportSchema.safeParse(request.body);
|
||||
if (!bodyValidation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: bodyValidation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.service.submitRemovalReport(
|
||||
userId,
|
||||
paramsValidation.data.id,
|
||||
bodyValidation.data.reason
|
||||
);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error reporting removal', { error, userId: (request as any).user?.sub });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: 'Station not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('already reported')) {
|
||||
return reply.code(409).send({
|
||||
error: 'Conflict',
|
||||
message: 'You have already reported this station'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('already been removed')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: 'Station has already been removed'
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to submit removal report'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/community-stations
|
||||
* List all submissions with filters (admin only)
|
||||
*/
|
||||
async listAllSubmissions(
|
||||
request: FastifyRequest<{ Querystring: CommunityStationFiltersInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Validate query params
|
||||
const validation = communityStationFiltersSchema.safeParse(request.query);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.service.getStationsForAdmin(validation.data);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error listing submissions', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to list submissions'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/community-stations/pending
|
||||
* Get pending submissions queue (admin only)
|
||||
*/
|
||||
async getPendingQueue(
|
||||
request: FastifyRequest<{ Querystring: PaginationInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Validate query params
|
||||
const validation = paginationSchema.safeParse(request.query);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.service.getPendingReview(validation.data.limit, validation.data.offset);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting pending queue', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve pending submissions'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/community-stations/:id/review
|
||||
* Approve or reject a submission (admin only)
|
||||
*/
|
||||
async reviewStation(
|
||||
request: FastifyRequest<{ Params: StationIdInput; Body: ReviewStationInput }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const adminId = (request as any).user.sub;
|
||||
|
||||
// Validate params
|
||||
const paramsValidation = stationIdSchema.safeParse(request.params);
|
||||
if (!paramsValidation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: paramsValidation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
// Validate body
|
||||
const bodyValidation = reviewStationSchema.safeParse(request.body);
|
||||
if (!bodyValidation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
details: bodyValidation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const station = await this.service.reviewStation(
|
||||
adminId,
|
||||
paramsValidation.data.id,
|
||||
bodyValidation.data.status,
|
||||
bodyValidation.data.rejectionReason
|
||||
);
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
adminId,
|
||||
'REVIEW',
|
||||
undefined,
|
||||
'community_station',
|
||||
paramsValidation.data.id,
|
||||
{
|
||||
status: bodyValidation.data.status,
|
||||
submittedBy: station.submittedBy,
|
||||
name: station.name
|
||||
}
|
||||
);
|
||||
|
||||
return reply.code(200).send(station);
|
||||
} catch (error: any) {
|
||||
logger.error('Error reviewing station', { error, adminId: (request as any).user?.sub });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: 'Station not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('Rejection reason')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to review station'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @ai-summary Fastify routes for community stations API
|
||||
* @ai-context Route definitions with Fastify plugin pattern and authentication guards
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { CommunityStationsController } from './community-stations.controller';
|
||||
import {
|
||||
SubmitCommunityStationInput,
|
||||
NearbyStationsInput,
|
||||
StationIdInput,
|
||||
PaginationInput,
|
||||
BoundsStationsInput,
|
||||
RemovalReportInput
|
||||
} from './community-stations.validation';
|
||||
|
||||
export const communityStationsRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const controller = new CommunityStationsController();
|
||||
|
||||
// User endpoints (require JWT authentication)
|
||||
|
||||
// POST /api/stations/community - Submit new station
|
||||
fastify.post<{ Body: SubmitCommunityStationInput }>('/stations/community', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.submitStation.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/stations/community/mine - Get user's submissions
|
||||
fastify.get<{ Querystring: PaginationInput }>('/stations/community/mine', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getMySubmissions.bind(controller)
|
||||
});
|
||||
|
||||
// DELETE /api/stations/community/:id - Withdraw pending submission
|
||||
fastify.delete<{ Params: StationIdInput }>('/stations/community/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.withdrawSubmission.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/stations/community/approved - List approved stations (public)
|
||||
fastify.get<{ Querystring: PaginationInput }>('/stations/community/approved', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getApprovedStations.bind(controller)
|
||||
});
|
||||
|
||||
// POST /api/stations/community/nearby - Find nearby approved stations
|
||||
fastify.post<{ Body: NearbyStationsInput }>('/stations/community/nearby', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getNearbyStations.bind(controller)
|
||||
});
|
||||
|
||||
// POST /api/stations/community/bounds - Find approved stations within map bounds
|
||||
fastify.post<{ Body: BoundsStationsInput }>('/stations/community/bounds', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getStationsInBounds.bind(controller)
|
||||
});
|
||||
|
||||
// POST /api/stations/community/:id/report-removal - Report station no longer has 93
|
||||
fastify.post<{ Params: StationIdInput; Body: RemovalReportInput }>('/stations/community/:id/report-removal', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.reportRemoval.bind(controller)
|
||||
});
|
||||
|
||||
// Admin endpoints are registered in admin.routes.ts to avoid duplication
|
||||
};
|
||||
|
||||
// For backward compatibility during migration
|
||||
export function registerCommunityStationsRoutes() {
|
||||
throw new Error('registerCommunityStationsRoutes is deprecated - use communityStationsRoutes Fastify plugin instead');
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for community stations API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const submitCommunityStationSchema = z.object({
|
||||
name: z.string().min(1, 'Station name is required').max(200, 'Station name too long'),
|
||||
address: z.string().min(1, 'Address is required'),
|
||||
city: z.string().max(100, 'City too long').optional(),
|
||||
state: z.string().max(50, 'State too long').optional(),
|
||||
zipCode: z.string().max(20, 'Zip code too long').optional(),
|
||||
latitude: z.number().min(-90).max(90, 'Invalid latitude'),
|
||||
longitude: z.number().min(-180).max(180, 'Invalid longitude'),
|
||||
brand: z.string().max(100, 'Brand too long').optional(),
|
||||
has93Octane: z.boolean().optional().default(true),
|
||||
has93OctaneEthanolFree: z.boolean().optional().default(false),
|
||||
price93: z.number().min(0, 'Price must be positive').optional(),
|
||||
notes: z.string().optional()
|
||||
});
|
||||
|
||||
export const reviewStationSchema = z.object({
|
||||
status: z.enum(['approved', 'rejected'], {
|
||||
errorMap: () => ({ message: 'Status must be either approved or rejected' })
|
||||
}),
|
||||
rejectionReason: z.string().optional()
|
||||
});
|
||||
|
||||
export const communityStationFiltersSchema = z.object({
|
||||
status: z.enum(['pending', 'approved', 'rejected', 'removed']).optional(),
|
||||
submittedBy: z.string().optional(),
|
||||
limit: z.coerce.number().min(1).max(1000).default(100),
|
||||
offset: z.coerce.number().min(0).default(0)
|
||||
});
|
||||
|
||||
export const nearbyStationsSchema = z.object({
|
||||
latitude: z.number().min(-90).max(90, 'Invalid latitude'),
|
||||
longitude: z.number().min(-180).max(180, 'Invalid longitude'),
|
||||
radiusKm: z.number().min(1).max(500, 'Radius must be between 1 and 500 km').optional().default(50)
|
||||
});
|
||||
|
||||
export const stationIdSchema = z.object({
|
||||
id: z.string().uuid('Invalid station ID')
|
||||
});
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
limit: z.coerce.number().min(1).max(1000).default(100),
|
||||
offset: z.coerce.number().min(0).default(0)
|
||||
});
|
||||
|
||||
export const boundsStationsSchema = z.object({
|
||||
north: z.number().min(-90).max(90, 'Invalid north latitude'),
|
||||
south: z.number().min(-90).max(90, 'Invalid south latitude'),
|
||||
east: z.number().min(-180).max(180, 'Invalid east longitude'),
|
||||
west: z.number().min(-180).max(180, 'Invalid west longitude')
|
||||
});
|
||||
|
||||
export const removalReportSchema = z.object({
|
||||
reason: z.string().optional().default('No longer has Premium 93')
|
||||
});
|
||||
|
||||
// Type exports for use in controllers and routes
|
||||
export type SubmitCommunityStationInput = z.infer<typeof submitCommunityStationSchema>;
|
||||
export type ReviewStationInput = z.infer<typeof reviewStationSchema>;
|
||||
export type CommunityStationFiltersInput = z.infer<typeof communityStationFiltersSchema>;
|
||||
export type NearbyStationsInput = z.infer<typeof nearbyStationsSchema>;
|
||||
export type StationIdInput = z.infer<typeof stationIdSchema>;
|
||||
export type PaginationInput = z.infer<typeof paginationSchema>;
|
||||
export type BoundsStationsInput = z.infer<typeof boundsStationsSchema>;
|
||||
export type RemovalReportInput = z.infer<typeof removalReportSchema>;
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* @ai-summary Data access layer for community-submitted gas stations
|
||||
* @ai-context Parameterized SQL queries for CRUD operations and filtering
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
CommunityStation,
|
||||
CommunityStationFilters,
|
||||
CommunityStationListResult,
|
||||
SubmitCommunityStationBody,
|
||||
StationBounds,
|
||||
RemovalReportResult
|
||||
} from '../domain/community-stations.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class CommunityStationsRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async submitStation(
|
||||
userId: string,
|
||||
data: SubmitCommunityStationBody
|
||||
): Promise<CommunityStation> {
|
||||
const query = `
|
||||
INSERT INTO community_stations (
|
||||
submitted_by,
|
||||
name,
|
||||
address,
|
||||
city,
|
||||
state,
|
||||
zip_code,
|
||||
latitude,
|
||||
longitude,
|
||||
brand,
|
||||
has_93_octane,
|
||||
has_93_octane_ethanol_free,
|
||||
price_93,
|
||||
notes,
|
||||
status
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 'approved')
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [
|
||||
userId,
|
||||
data.name,
|
||||
data.address,
|
||||
data.city || null,
|
||||
data.state || null,
|
||||
data.zipCode || null,
|
||||
data.latitude,
|
||||
data.longitude,
|
||||
data.brand || null,
|
||||
data.has93Octane ?? true,
|
||||
data.has93OctaneEthanolFree ?? false,
|
||||
data.price93 || null,
|
||||
data.notes || null
|
||||
]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Failed to submit station');
|
||||
}
|
||||
|
||||
return this.mapRow(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error submitting station', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getStationById(stationId: string): Promise<CommunityStation | null> {
|
||||
const query = `
|
||||
SELECT * FROM community_stations
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [stationId]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRow(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching station by id', { error, stationId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserSubmissions(userId: string, limit: number = 100, offset: number = 0): Promise<CommunityStationListResult> {
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total FROM community_stations
|
||||
WHERE submitted_by = $1
|
||||
`;
|
||||
|
||||
const dataQuery = `
|
||||
SELECT * FROM community_stations
|
||||
WHERE submitted_by = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`;
|
||||
|
||||
try {
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
this.pool.query(countQuery, [userId]),
|
||||
this.pool.query(dataQuery, [userId, limit, offset])
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
const stations = dataResult.rows.map(row => this.mapRow(row));
|
||||
|
||||
return { total, stations };
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user submissions', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getApprovedStations(limit: number = 100, offset: number = 0): Promise<CommunityStationListResult> {
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total FROM community_stations
|
||||
WHERE status = 'approved'
|
||||
`;
|
||||
|
||||
const dataQuery = `
|
||||
SELECT * FROM community_stations
|
||||
WHERE status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`;
|
||||
|
||||
try {
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
this.pool.query(countQuery),
|
||||
this.pool.query(dataQuery, [limit, offset])
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
const stations = dataResult.rows.map(row => this.mapRow(row));
|
||||
|
||||
return { total, stations };
|
||||
} catch (error) {
|
||||
logger.error('Error fetching approved stations', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getNearbyApprovedStations(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
radiusKm: number = 50
|
||||
): Promise<CommunityStation[]> {
|
||||
// Convert km to degrees (approximately 1 degree = 111 km)
|
||||
const radiusDegrees = radiusKm / 111;
|
||||
|
||||
const query = `
|
||||
SELECT *,
|
||||
(6371 * acos(cos(radians($1)) * cos(radians(latitude)) *
|
||||
cos(radians(longitude) - radians($2)) +
|
||||
sin(radians($1)) * sin(radians(latitude)))) AS distance_km
|
||||
FROM community_stations
|
||||
WHERE status = 'approved'
|
||||
AND latitude BETWEEN $1 - $3 AND $1 + $3
|
||||
AND longitude BETWEEN $2 - $3 AND $2 + $3
|
||||
ORDER BY distance_km ASC
|
||||
LIMIT 50
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [latitude, longitude, radiusDegrees]);
|
||||
return result.rows.map(row => this.mapRow(row));
|
||||
} catch (error) {
|
||||
logger.error('Error fetching nearby stations', { error, latitude, longitude });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingStations(limit: number = 100, offset: number = 0): Promise<CommunityStationListResult> {
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total FROM community_stations
|
||||
WHERE status = 'pending'
|
||||
`;
|
||||
|
||||
const dataQuery = `
|
||||
SELECT * FROM community_stations
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $1 OFFSET $2
|
||||
`;
|
||||
|
||||
try {
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
this.pool.query(countQuery),
|
||||
this.pool.query(dataQuery, [limit, offset])
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
const stations = dataResult.rows.map(row => this.mapRow(row));
|
||||
|
||||
return { total, stations };
|
||||
} catch (error) {
|
||||
logger.error('Error fetching pending stations', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllStationsWithFilters(filters: CommunityStationFilters): Promise<CommunityStationListResult> {
|
||||
const limit = filters.limit || 100;
|
||||
const offset = filters.offset || 0;
|
||||
|
||||
const params: any[] = [];
|
||||
let whereConditions = '';
|
||||
|
||||
if (filters.status) {
|
||||
whereConditions += `status = $${params.length + 1}`;
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
if (filters.submittedBy) {
|
||||
if (whereConditions) whereConditions += ' AND ';
|
||||
whereConditions += `submitted_by = $${params.length + 1}`;
|
||||
params.push(filters.submittedBy);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions ? `WHERE ${whereConditions}` : '';
|
||||
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total FROM community_stations
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const dataQuery = `
|
||||
SELECT * FROM community_stations
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`;
|
||||
|
||||
try {
|
||||
const countParams = whereConditions ? params.slice() : [];
|
||||
const dataParams = [...params, limit, offset];
|
||||
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
this.pool.query(countQuery, countParams),
|
||||
this.pool.query(dataQuery, dataParams)
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
const stations = dataResult.rows.map(row => this.mapRow(row));
|
||||
|
||||
return { total, stations };
|
||||
} catch (error) {
|
||||
logger.error('Error fetching stations with filters', { error, filters });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reviewStation(
|
||||
stationId: string,
|
||||
adminId: string,
|
||||
status: 'approved' | 'rejected',
|
||||
rejectionReason?: string
|
||||
): Promise<CommunityStation> {
|
||||
const query = `
|
||||
UPDATE community_stations
|
||||
SET status = $1,
|
||||
reviewed_by = $2,
|
||||
reviewed_at = CURRENT_TIMESTAMP,
|
||||
rejection_reason = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [
|
||||
status,
|
||||
adminId,
|
||||
rejectionReason || null,
|
||||
stationId
|
||||
]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Station not found');
|
||||
}
|
||||
|
||||
return this.mapRow(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error reviewing station', { error, stationId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteStation(stationId: string): Promise<boolean> {
|
||||
const query = 'DELETE FROM community_stations WHERE id = $1';
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [stationId]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
} catch (error) {
|
||||
logger.error('Error deleting station', { error, stationId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private mapRow(row: any): CommunityStation {
|
||||
return {
|
||||
id: row.id,
|
||||
submittedBy: row.submitted_by,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
city: row.city,
|
||||
state: row.state,
|
||||
zipCode: row.zip_code,
|
||||
latitude: parseFloat(row.latitude),
|
||||
longitude: parseFloat(row.longitude),
|
||||
brand: row.brand,
|
||||
has93Octane: row.has_93_octane ?? false,
|
||||
has93OctaneEthanolFree: row.has_93_octane_ethanol_free ?? false,
|
||||
price93: row.price_93 ? parseFloat(row.price_93) : undefined,
|
||||
notes: row.notes,
|
||||
status: row.status,
|
||||
reviewedBy: row.reviewed_by,
|
||||
reviewedAt: row.reviewed_at ? new Date(row.reviewed_at) : undefined,
|
||||
rejectionReason: row.rejection_reason,
|
||||
removalReportCount: row.removal_report_count ?? 0,
|
||||
removedAt: row.removed_at ? new Date(row.removed_at) : undefined,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a removal report for a station
|
||||
* Returns the new count and whether the station was removed (2+ reports)
|
||||
*/
|
||||
async submitRemovalReport(
|
||||
userId: string,
|
||||
stationId: string,
|
||||
reason: string = 'No longer has Premium 93'
|
||||
): Promise<RemovalReportResult> {
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Insert removal report (will fail if duplicate due to unique constraint)
|
||||
await client.query(`
|
||||
INSERT INTO station_removal_reports (station_id, reported_by, reason)
|
||||
VALUES ($1, $2, $3)
|
||||
`, [stationId, userId, reason]);
|
||||
|
||||
// Get updated count (trigger updates it automatically)
|
||||
const countResult = await client.query(`
|
||||
SELECT removal_report_count FROM community_stations WHERE id = $1
|
||||
`, [stationId]);
|
||||
|
||||
const reportCount = countResult.rows[0]?.removal_report_count ?? 0;
|
||||
let stationRemoved = false;
|
||||
|
||||
// If 2+ reports, mark as removed
|
||||
if (reportCount >= 2) {
|
||||
await client.query(`
|
||||
UPDATE community_stations
|
||||
SET status = 'removed',
|
||||
removed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [stationId]);
|
||||
stationRemoved = true;
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return { reportCount, stationRemoved };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Error submitting removal report', { error, userId, stationId });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has already reported a station
|
||||
*/
|
||||
async hasUserReportedStation(userId: string, stationId: string): Promise<boolean> {
|
||||
const query = `
|
||||
SELECT 1 FROM station_removal_reports
|
||||
WHERE station_id = $1 AND reported_by = $2
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [stationId, userId]);
|
||||
return result.rows.length > 0;
|
||||
} catch (error) {
|
||||
logger.error('Error checking user report status', { error, userId, stationId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a station by its location (latitude/longitude)
|
||||
* Uses a small tolerance for coordinate matching
|
||||
*/
|
||||
async findStationByLocation(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
toleranceDegrees: number = 0.0001
|
||||
): Promise<CommunityStation | null> {
|
||||
const query = `
|
||||
SELECT * FROM community_stations
|
||||
WHERE latitude BETWEEN $1 - $3 AND $1 + $3
|
||||
AND longitude BETWEEN $2 - $3 AND $2 + $3
|
||||
AND status = 'approved'
|
||||
ORDER BY
|
||||
(latitude - $1) * (latitude - $1) + (longitude - $2) * (longitude - $2) ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [latitude, longitude, toleranceDegrees]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRow(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error finding station by location', { error, latitude, longitude });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the octane flags for an existing station
|
||||
* Used when a duplicate submission is detected
|
||||
*/
|
||||
async updateStationOctaneFlags(
|
||||
stationId: string,
|
||||
has93Octane: boolean,
|
||||
has93OctaneEthanolFree: boolean
|
||||
): Promise<CommunityStation> {
|
||||
const query = `
|
||||
UPDATE community_stations
|
||||
SET
|
||||
has_93_octane = $2,
|
||||
has_93_octane_ethanol_free = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [
|
||||
stationId,
|
||||
has93Octane,
|
||||
has93OctaneEthanolFree
|
||||
]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Station not found');
|
||||
}
|
||||
|
||||
return this.mapRow(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating station octane flags', { error, stationId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get approved stations within map bounds
|
||||
*/
|
||||
async getApprovedStationsInBounds(bounds: StationBounds): Promise<CommunityStation[]> {
|
||||
const query = `
|
||||
SELECT * FROM community_stations
|
||||
WHERE status = 'approved'
|
||||
AND latitude BETWEEN $1 AND $2
|
||||
AND longitude BETWEEN $3 AND $4
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [
|
||||
bounds.south,
|
||||
bounds.north,
|
||||
bounds.west,
|
||||
bounds.east
|
||||
]);
|
||||
return result.rows.map(row => this.mapRow(row));
|
||||
} catch (error) {
|
||||
logger.error('Error fetching stations in bounds', { error, bounds });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @ai-summary Business logic for community-submitted gas stations
|
||||
* @ai-context Service layer handling approval workflow, filtering, and caching
|
||||
*/
|
||||
|
||||
import { CommunityStationsRepository } from '../data/community-stations.repository';
|
||||
import {
|
||||
CommunityStation,
|
||||
SubmitCommunityStationBody,
|
||||
CommunityStationFilters,
|
||||
CommunityStationListResult,
|
||||
NearbyStationParams,
|
||||
StationBounds,
|
||||
RemovalReportResult
|
||||
} from './community-stations.types';
|
||||
import { redis } from '../../../core/config/redis';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class CommunityStationsService {
|
||||
private readonly CACHE_TTL = 300; // 5 minutes
|
||||
private readonly CACHE_KEY_PREFIX = 'mvp:community-stations';
|
||||
|
||||
constructor(private repository: CommunityStationsRepository) {}
|
||||
|
||||
async submitStation(userId: string, data: SubmitCommunityStationBody): Promise<CommunityStation> {
|
||||
logger.info('User submitting community station', { userId, name: data.name });
|
||||
|
||||
// Check if station already exists at this location (within ~11 meters)
|
||||
const existingStation = await this.repository.findStationByLocation(
|
||||
data.latitude,
|
||||
data.longitude,
|
||||
0.0001 // ~11 meters tolerance
|
||||
);
|
||||
|
||||
if (existingStation) {
|
||||
// Station exists - update octane flags instead of creating duplicate
|
||||
logger.info('Updating existing community station octane flags', {
|
||||
stationId: existingStation.id,
|
||||
userId,
|
||||
newHas93Octane: data.has93Octane,
|
||||
newHas93OctaneEthanolFree: data.has93OctaneEthanolFree
|
||||
});
|
||||
|
||||
const updated = await this.repository.updateStationOctaneFlags(
|
||||
existingStation.id,
|
||||
data.has93Octane ?? true,
|
||||
data.has93OctaneEthanolFree ?? false
|
||||
);
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCache('approved');
|
||||
await this.invalidateCache('nearby');
|
||||
await this.invalidateCache('bounds');
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// No existing station - create new (auto-approved)
|
||||
const station = await this.repository.submitStation(userId, data);
|
||||
|
||||
// Invalidate approved and nearby caches since stations are now auto-approved
|
||||
await this.invalidateCache('approved');
|
||||
await this.invalidateCache('nearby');
|
||||
await this.invalidateCache('bounds');
|
||||
|
||||
return station;
|
||||
}
|
||||
|
||||
async getMySubmissions(userId: string, limit: number = 100, offset: number = 0): Promise<CommunityStationListResult> {
|
||||
logger.info('Fetching user submissions', { userId, limit, offset });
|
||||
|
||||
return this.repository.getUserSubmissions(userId, limit, offset);
|
||||
}
|
||||
|
||||
async withdrawSubmission(userId: string, stationId: string): Promise<void> {
|
||||
logger.info('User withdrawing submission', { userId, stationId });
|
||||
|
||||
// Verify ownership
|
||||
const station = await this.repository.getStationById(stationId);
|
||||
if (!station) {
|
||||
throw new Error('Station not found');
|
||||
}
|
||||
|
||||
if (station.submittedBy !== userId) {
|
||||
throw new Error('Unauthorized: You can only withdraw your own submissions');
|
||||
}
|
||||
|
||||
// Can only withdraw pending submissions
|
||||
if (station.status !== 'pending') {
|
||||
throw new Error('Can only withdraw pending submissions');
|
||||
}
|
||||
|
||||
await this.repository.deleteStation(stationId);
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCache('pending');
|
||||
}
|
||||
|
||||
async getApprovedStations(limit: number = 100, offset: number = 0): Promise<CommunityStationListResult> {
|
||||
logger.info('Fetching approved stations', { limit, offset });
|
||||
|
||||
const cacheKey = `${this.CACHE_KEY_PREFIX}:approved:${limit}:${offset}`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const result = await this.repository.getApprovedStations(limit, offset);
|
||||
|
||||
// Cache the result
|
||||
await redis.setex(cacheKey, this.CACHE_TTL, JSON.stringify(result));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getApprovedNearby(params: NearbyStationParams): Promise<CommunityStation[]> {
|
||||
const { latitude, longitude, radiusKm = 50 } = params;
|
||||
|
||||
logger.info('Fetching nearby approved stations', { latitude, longitude, radiusKm });
|
||||
|
||||
const cacheKey = `${this.CACHE_KEY_PREFIX}:nearby:${latitude}:${longitude}:${radiusKm}`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const stations = await this.repository.getNearbyApprovedStations(latitude, longitude, radiusKm);
|
||||
|
||||
// Cache the result
|
||||
await redis.setex(cacheKey, this.CACHE_TTL, JSON.stringify(stations));
|
||||
|
||||
return stations;
|
||||
}
|
||||
|
||||
async getPendingReview(limit: number = 100, offset: number = 0): Promise<CommunityStationListResult> {
|
||||
logger.info('Fetching pending stations for review', { limit, offset });
|
||||
|
||||
return this.repository.getPendingStations(limit, offset);
|
||||
}
|
||||
|
||||
async getStationsForAdmin(filters: CommunityStationFilters): Promise<CommunityStationListResult> {
|
||||
logger.info('Fetching stations with admin filters', { filters });
|
||||
|
||||
return this.repository.getAllStationsWithFilters(filters);
|
||||
}
|
||||
|
||||
async reviewStation(
|
||||
adminId: string,
|
||||
stationId: string,
|
||||
status: 'approved' | 'rejected',
|
||||
rejectionReason?: string
|
||||
): Promise<CommunityStation> {
|
||||
logger.info('Admin reviewing station', { adminId, stationId, status });
|
||||
|
||||
// Verify rejection reason if rejecting
|
||||
if (status === 'rejected' && !rejectionReason) {
|
||||
throw new Error('Rejection reason required when rejecting a station');
|
||||
}
|
||||
|
||||
const station = await this.repository.reviewStation(stationId, adminId, status, rejectionReason);
|
||||
|
||||
// Invalidate related caches
|
||||
await this.invalidateCache('pending');
|
||||
if (status === 'approved') {
|
||||
await this.invalidateCache('approved');
|
||||
await this.invalidateCache('nearby');
|
||||
await this.invalidateCache('bounds');
|
||||
}
|
||||
|
||||
return station;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a removal report for a station that no longer has Premium 93
|
||||
* When 2+ users report the same station, it is marked as 'removed'
|
||||
*/
|
||||
async submitRemovalReport(
|
||||
userId: string,
|
||||
stationId: string,
|
||||
reason?: string
|
||||
): Promise<RemovalReportResult> {
|
||||
logger.info('User submitting removal report', { userId, stationId });
|
||||
|
||||
// Verify station exists
|
||||
const station = await this.repository.getStationById(stationId);
|
||||
if (!station) {
|
||||
throw new Error('Station not found');
|
||||
}
|
||||
|
||||
// Check if station is already removed
|
||||
if (station.status === 'removed') {
|
||||
throw new Error('Station has already been removed');
|
||||
}
|
||||
|
||||
// Check if user already reported this station
|
||||
const alreadyReported = await this.repository.hasUserReportedStation(userId, stationId);
|
||||
if (alreadyReported) {
|
||||
throw new Error('You have already reported this station');
|
||||
}
|
||||
|
||||
const result = await this.repository.submitRemovalReport(userId, stationId, reason);
|
||||
|
||||
// Invalidate caches if station was removed
|
||||
if (result.stationRemoved) {
|
||||
await this.invalidateCache('approved');
|
||||
await this.invalidateCache('nearby');
|
||||
await this.invalidateCache('bounds');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a station by coordinates (for linking reports to existing stations)
|
||||
*/
|
||||
async findStationByLocation(latitude: number, longitude: number): Promise<CommunityStation | null> {
|
||||
return this.repository.findStationByLocation(latitude, longitude);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get approved stations within map bounds
|
||||
*/
|
||||
async getApprovedInBounds(bounds: StationBounds): Promise<CommunityStation[]> {
|
||||
logger.info('Fetching approved stations in bounds', { bounds });
|
||||
|
||||
// Create cache key with rounded bounds for efficiency
|
||||
const roundedBounds = {
|
||||
north: Math.round(bounds.north * 100) / 100,
|
||||
south: Math.round(bounds.south * 100) / 100,
|
||||
east: Math.round(bounds.east * 100) / 100,
|
||||
west: Math.round(bounds.west * 100) / 100
|
||||
};
|
||||
|
||||
const cacheKey = `${this.CACHE_KEY_PREFIX}:bounds:${roundedBounds.north}:${roundedBounds.south}:${roundedBounds.east}:${roundedBounds.west}`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const stations = await this.repository.getApprovedStationsInBounds(bounds);
|
||||
|
||||
await redis.setex(cacheKey, this.CACHE_TTL, JSON.stringify(stations));
|
||||
|
||||
return stations;
|
||||
}
|
||||
|
||||
private async invalidateCache(type: string): Promise<void> {
|
||||
try {
|
||||
if (type === 'pending') {
|
||||
const pendingKeys = await redis.keys(`${this.CACHE_KEY_PREFIX}:pending:*`);
|
||||
if (pendingKeys.length > 0) {
|
||||
await redis.del(...pendingKeys);
|
||||
}
|
||||
} else if (type === 'approved') {
|
||||
const approvedKeys = await redis.keys(`${this.CACHE_KEY_PREFIX}:approved:*`);
|
||||
if (approvedKeys.length > 0) {
|
||||
await redis.del(...approvedKeys);
|
||||
}
|
||||
} else if (type === 'nearby') {
|
||||
const nearbyKeys = await redis.keys(`${this.CACHE_KEY_PREFIX}:nearby:*`);
|
||||
if (nearbyKeys.length > 0) {
|
||||
await redis.del(...nearbyKeys);
|
||||
}
|
||||
} else if (type === 'bounds') {
|
||||
const boundsKeys = await redis.keys(`${this.CACHE_KEY_PREFIX}:bounds:*`);
|
||||
if (boundsKeys.length > 0) {
|
||||
await redis.del(...boundsKeys);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error invalidating cache', { error, type });
|
||||
// Don't throw - cache invalidation failure shouldn't fail the operation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for community-submitted gas stations feature
|
||||
* @ai-context 93 octane gas station submissions with auto-approval and removal tracking
|
||||
*/
|
||||
|
||||
/** Status for community station submissions */
|
||||
export type CommunityStationStatus = 'pending' | 'approved' | 'rejected' | 'removed';
|
||||
|
||||
/** Octane submission type for radio button selection */
|
||||
export type OctaneSubmissionType =
|
||||
| 'has_93_with_ethanol'
|
||||
| 'has_93_without_ethanol'
|
||||
| 'no_longer_has_93';
|
||||
|
||||
export interface CommunityStation {
|
||||
id: string;
|
||||
submittedBy: string;
|
||||
name: string;
|
||||
address: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zipCode?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
brand?: string;
|
||||
has93Octane: boolean;
|
||||
has93OctaneEthanolFree: boolean;
|
||||
price93?: number;
|
||||
notes?: string;
|
||||
status: CommunityStationStatus;
|
||||
reviewedBy?: string;
|
||||
reviewedAt?: Date;
|
||||
rejectionReason?: string;
|
||||
removalReportCount?: number;
|
||||
removedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SubmitCommunityStationBody {
|
||||
name: string;
|
||||
address: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zipCode?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
brand?: string;
|
||||
has93Octane?: boolean;
|
||||
has93OctaneEthanolFree?: boolean;
|
||||
price93?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ReviewStationBody {
|
||||
status: 'approved' | 'rejected';
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export interface CommunityStationFilters {
|
||||
status?: CommunityStationStatus;
|
||||
submittedBy?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CommunityStationListResult {
|
||||
total: number;
|
||||
stations: CommunityStation[];
|
||||
}
|
||||
|
||||
export interface NearbyStationParams {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
radiusKm?: number;
|
||||
}
|
||||
|
||||
/** Bounding box for map-based station queries */
|
||||
export interface StationBounds {
|
||||
north: number;
|
||||
south: number;
|
||||
east: number;
|
||||
west: number;
|
||||
}
|
||||
|
||||
/** Removal report record */
|
||||
export interface RemovalReport {
|
||||
id: string;
|
||||
stationId: string;
|
||||
reportedBy: string;
|
||||
reason: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** Result of submitting a removal report */
|
||||
export interface RemovalReportResult {
|
||||
reportCount: number;
|
||||
stationRemoved: boolean;
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
* @ai-summary Public API for stations feature capsule
|
||||
*/
|
||||
|
||||
// Export service
|
||||
// Export services
|
||||
export { StationsService } from './domain/stations.service';
|
||||
export { CommunityStationsService } from './domain/community-stations.service';
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
@@ -13,5 +14,15 @@ export type {
|
||||
SavedStation
|
||||
} from './domain/stations.types';
|
||||
|
||||
export type {
|
||||
CommunityStation,
|
||||
SubmitCommunityStationBody,
|
||||
ReviewStationBody,
|
||||
CommunityStationFilters,
|
||||
CommunityStationListResult,
|
||||
NearbyStationParams
|
||||
} from './domain/community-stations.types';
|
||||
|
||||
// Internal: Register routes with Fastify app
|
||||
export { stationsRoutes, registerStationsRoutes } from './api/stations.routes';
|
||||
export { communityStationsRoutes, registerCommunityStationsRoutes } from './api/community-stations.routes';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Community-submitted gas stations with 93 octane
|
||||
-- Requires admin approval before public visibility
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community_stations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
submitted_by VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Station details
|
||||
name VARCHAR(200) NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
city VARCHAR(100),
|
||||
state VARCHAR(50),
|
||||
zip_code VARCHAR(20),
|
||||
latitude DECIMAL(10, 8) NOT NULL,
|
||||
longitude DECIMAL(11, 8) NOT NULL,
|
||||
brand VARCHAR(100),
|
||||
|
||||
-- 93 Octane specifics (core feature)
|
||||
has_93_octane BOOLEAN DEFAULT true,
|
||||
has_93_octane_ethanol_free BOOLEAN DEFAULT false,
|
||||
price_93 DECIMAL(5, 3),
|
||||
|
||||
-- User notes
|
||||
notes TEXT,
|
||||
|
||||
-- Approval workflow
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
reviewed_by VARCHAR(255),
|
||||
reviewed_at TIMESTAMP WITH TIME ZONE,
|
||||
rejection_reason TEXT,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_community_stations_status ON community_stations(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_community_stations_location ON community_stations(latitude, longitude);
|
||||
CREATE INDEX IF NOT EXISTS idx_community_stations_submitted_by ON community_stations(submitted_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_community_stations_created_at ON community_stations(created_at DESC);
|
||||
|
||||
-- Add trigger for updated_at
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'update_community_stations_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER update_community_stations_updated_at
|
||||
BEFORE UPDATE ON community_stations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Migration: 005_add_removal_tracking
|
||||
-- Description: Add 'removed' status support and removal tracking for community stations
|
||||
-- Date: 2025-12-21
|
||||
|
||||
-- Step 1: Drop and recreate status check constraint to add 'removed' status
|
||||
ALTER TABLE community_stations
|
||||
DROP CONSTRAINT IF EXISTS community_stations_status_check;
|
||||
|
||||
ALTER TABLE community_stations
|
||||
ADD CONSTRAINT community_stations_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'removed'));
|
||||
|
||||
-- Step 2: Create removal_reports table for tracking "No longer has Premium 93" reports
|
||||
CREATE TABLE IF NOT EXISTS station_removal_reports (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
station_id UUID NOT NULL REFERENCES community_stations(id) ON DELETE CASCADE,
|
||||
reported_by VARCHAR(255) NOT NULL,
|
||||
reason TEXT DEFAULT 'No longer has Premium 93',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Each user can only report a station once
|
||||
CONSTRAINT unique_user_station_report UNIQUE (station_id, reported_by)
|
||||
);
|
||||
|
||||
-- Step 3: Create indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_removal_reports_station_id ON station_removal_reports(station_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_removal_reports_reported_by ON station_removal_reports(reported_by);
|
||||
|
||||
-- Step 4: Add columns for removal tracking to community_stations
|
||||
ALTER TABLE community_stations
|
||||
ADD COLUMN IF NOT EXISTS removal_report_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE community_stations
|
||||
ADD COLUMN IF NOT EXISTS removed_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- Step 5: Create trigger to update removal_report_count when reports are added
|
||||
CREATE OR REPLACE FUNCTION update_removal_report_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE community_stations
|
||||
SET removal_report_count = removal_report_count + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = NEW.station_id;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE community_stations
|
||||
SET removal_report_count = GREATEST(removal_report_count - 1, 0),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = OLD.station_id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_update_removal_report_count ON station_removal_reports;
|
||||
|
||||
CREATE TRIGGER trigger_update_removal_report_count
|
||||
AFTER INSERT OR DELETE ON station_removal_reports
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_removal_report_count();
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @ai-summary Integration tests for community stations API
|
||||
* @ai-context Tests full API workflow with database
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { Pool } from 'pg';
|
||||
import { buildApp } from '../../../../app';
|
||||
import { pool as pgPool } from '../../../../core/config/database';
|
||||
|
||||
describe('Community Stations API Integration Tests', () => {
|
||||
let app: FastifyInstance;
|
||||
let pool: Pool;
|
||||
|
||||
const testUserId = 'auth0|test-user-123';
|
||||
const testAdminId = 'auth0|test-admin-123';
|
||||
|
||||
const mockStationData = {
|
||||
name: 'Test Gas Station',
|
||||
address: '123 Main St',
|
||||
city: 'Springfield',
|
||||
state: 'IL',
|
||||
zipCode: '62701',
|
||||
latitude: 39.7817,
|
||||
longitude: -89.6501,
|
||||
brand: 'Shell',
|
||||
has93Octane: true,
|
||||
has93OctaneEthanolFree: false,
|
||||
price93: 3.50,
|
||||
notes: 'Great service'
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = pgPool;
|
||||
app = await buildApp();
|
||||
|
||||
// Clean up test data
|
||||
await pool.query('DELETE FROM community_stations WHERE submitted_by IN ($1, $2)', [testUserId, testAdminId]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test data
|
||||
await pool.query('DELETE FROM community_stations WHERE submitted_by IN ($1, $2)', [testUserId, testAdminId]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('POST /api/stations/community - Submit station', () => {
|
||||
it('should submit a new community station', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
},
|
||||
payload: mockStationData
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.id).toBeDefined();
|
||||
expect(body.status).toBe('pending');
|
||||
expect(body.submittedBy).toBe(testUserId);
|
||||
expect(body.name).toBe(mockStationData.name);
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const incompleteData = {
|
||||
name: 'Test Station',
|
||||
address: '123 Main St'
|
||||
// Missing latitude and longitude
|
||||
};
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
},
|
||||
payload: incompleteData
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.error).toBe('Validation error');
|
||||
});
|
||||
|
||||
it('should validate latitude bounds', async () => {
|
||||
const invalidData = {
|
||||
...mockStationData,
|
||||
latitude: 91 // Invalid: must be between -90 and 90
|
||||
};
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
},
|
||||
payload: invalidData
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.error).toBe('Validation error');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community',
|
||||
payload: mockStationData
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/stations/community/mine - Get user submissions', () => {
|
||||
let submittedStationId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Submit a test station
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
},
|
||||
payload: mockStationData
|
||||
});
|
||||
|
||||
const body = JSON.parse(response.body);
|
||||
submittedStationId = body.id;
|
||||
});
|
||||
|
||||
it('should retrieve user submissions', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/community/mine',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.total).toBeGreaterThan(0);
|
||||
expect(Array.isArray(body.stations)).toBe(true);
|
||||
expect(body.stations.some((s: any) => s.id === submittedStationId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/community/mine?limit=10&offset=0',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.stations).toBeDefined();
|
||||
expect(body.total).toBeDefined();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/community/mine'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/stations/community/:id - Withdraw submission', () => {
|
||||
let pendingStationId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Submit a test station
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
},
|
||||
payload: {
|
||||
...mockStationData,
|
||||
name: 'Pending Station to Withdraw'
|
||||
}
|
||||
});
|
||||
|
||||
const body = JSON.parse(response.body);
|
||||
pendingStationId = body.id;
|
||||
});
|
||||
|
||||
it('should allow user to withdraw own pending submission', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/stations/community/${pendingStationId}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(204);
|
||||
|
||||
// Verify it's deleted
|
||||
const checkResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/community/mine',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
const body = JSON.parse(checkResponse.body);
|
||||
expect(body.stations.some((s: any) => s.id === pendingStationId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject withdrawal of non-existent station', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/stations/community/invalid-id',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/stations/community/${pendingStationId}`
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/stations/community/approved - Get approved stations', () => {
|
||||
it('should retrieve approved stations', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/community/approved',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.total).toBeDefined();
|
||||
expect(Array.isArray(body.stations)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return only approved stations', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/community/approved',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
const body = JSON.parse(response.body);
|
||||
body.stations.forEach((station: any) => {
|
||||
expect(station.status).toBe('approved');
|
||||
});
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/community/approved?limit=10&offset=0',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stations/community/nearby - Find nearby stations', () => {
|
||||
it('should find nearby approved stations', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community/nearby',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
},
|
||||
payload: {
|
||||
latitude: 39.7817,
|
||||
longitude: -89.6501,
|
||||
radiusKm: 50
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(Array.isArray(body.stations)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate location coordinates', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community/nearby',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
},
|
||||
payload: {
|
||||
latitude: 91, // Invalid
|
||||
longitude: -89.6501,
|
||||
radiusKm: 50
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should validate radius', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/community/nearby',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
},
|
||||
payload: {
|
||||
latitude: 39.7817,
|
||||
longitude: -89.6501,
|
||||
radiusKm: 1000 // Too large
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin endpoints', () => {
|
||||
it('should require admin role for admin endpoints', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/admin/community-stations',
|
||||
headers: {
|
||||
authorization: `Bearer ${testUserId}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
// Note: Full admin endpoint testing would require proper admin role setup
|
||||
// These tests verify the routes are protected
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for community stations service
|
||||
* @ai-context Tests business logic without database dependencies
|
||||
*/
|
||||
|
||||
import { CommunityStationsService } from '../../domain/community-stations.service';
|
||||
import { CommunityStationsRepository } from '../../data/community-stations.repository';
|
||||
import { SubmitCommunityStationBody, CommunityStation } from '../../domain/community-stations.types';
|
||||
import { redis } from '../../../../core/config/redis';
|
||||
|
||||
// Mock repository and redis
|
||||
jest.mock('../../data/community-stations.repository');
|
||||
jest.mock('../../../../core/config/redis');
|
||||
|
||||
describe('CommunityStationsService', () => {
|
||||
let service: CommunityStationsService;
|
||||
let mockRepository: jest.Mocked<CommunityStationsRepository>;
|
||||
let mockRedis: jest.Mocked<typeof redis>;
|
||||
|
||||
const testUserId = 'test-user-123';
|
||||
const testAdminId = 'admin-123';
|
||||
const testStationId = 'station-123';
|
||||
|
||||
const mockStationData: SubmitCommunityStationBody = {
|
||||
name: 'Test Gas Station',
|
||||
address: '123 Main St',
|
||||
city: 'Springfield',
|
||||
state: 'IL',
|
||||
zipCode: '62701',
|
||||
latitude: 39.7817,
|
||||
longitude: -89.6501,
|
||||
brand: 'Shell',
|
||||
has93Octane: true,
|
||||
has93OctaneEthanolFree: false,
|
||||
price93: 3.50,
|
||||
notes: 'Great service'
|
||||
};
|
||||
|
||||
const mockStation: CommunityStation = {
|
||||
id: testStationId,
|
||||
submittedBy: testUserId,
|
||||
...mockStationData,
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRepository = CommunityStationsRepository as jest.Mocked<typeof CommunityStationsRepository>;
|
||||
mockRedis = redis as jest.Mocked<typeof redis>;
|
||||
|
||||
// Setup default mock implementations
|
||||
(mockRedis.get as jest.Mock).mockResolvedValue(null);
|
||||
(mockRedis.setex as jest.Mock).mockResolvedValue(true);
|
||||
(mockRedis.del as jest.Mock).mockResolvedValue(1);
|
||||
(mockRedis.keys as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
service = new CommunityStationsService(mockRepository as any);
|
||||
});
|
||||
|
||||
describe('submitStation', () => {
|
||||
it('should successfully submit a new station', async () => {
|
||||
(mockRepository.prototype.submitStation as jest.Mock).mockResolvedValue(mockStation);
|
||||
|
||||
const result = await service.submitStation(testUserId, mockStationData);
|
||||
|
||||
expect(result).toEqual(mockStation);
|
||||
expect(mockRepository.prototype.submitStation).toHaveBeenCalledWith(testUserId, mockStationData);
|
||||
expect(mockRedis.keys).toHaveBeenCalledWith('mvp:community-stations:pending:*');
|
||||
});
|
||||
|
||||
it('should invalidate pending cache after submission', async () => {
|
||||
(mockRepository.prototype.submitStation as jest.Mock).mockResolvedValue(mockStation);
|
||||
(mockRedis.keys as jest.Mock).mockResolvedValue(['pending-key-1']);
|
||||
|
||||
await service.submitStation(testUserId, mockStationData);
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('pending-key-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMySubmissions', () => {
|
||||
it('should retrieve user submissions', async () => {
|
||||
const mockResult = { total: 1, stations: [mockStation] };
|
||||
(mockRepository.prototype.getUserSubmissions as jest.Mock).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.getMySubmissions(testUserId, 100, 0);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockRepository.prototype.getUserSubmissions).toHaveBeenCalledWith(testUserId, 100, 0);
|
||||
});
|
||||
|
||||
it('should use default pagination values', async () => {
|
||||
const mockResult = { total: 1, stations: [mockStation] };
|
||||
(mockRepository.prototype.getUserSubmissions as jest.Mock).mockResolvedValue(mockResult);
|
||||
|
||||
await service.getMySubmissions(testUserId);
|
||||
|
||||
expect(mockRepository.prototype.getUserSubmissions).toHaveBeenCalledWith(testUserId, 100, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withdrawSubmission', () => {
|
||||
it('should allow user to withdraw own pending submission', async () => {
|
||||
(mockRepository.prototype.getStationById as jest.Mock).mockResolvedValue(mockStation);
|
||||
(mockRepository.prototype.deleteStation as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
await service.withdrawSubmission(testUserId, testStationId);
|
||||
|
||||
expect(mockRepository.prototype.deleteStation).toHaveBeenCalledWith(testStationId);
|
||||
expect(mockRedis.keys).toHaveBeenCalledWith('mvp:community-stations:pending:*');
|
||||
});
|
||||
|
||||
it('should prevent withdrawal of non-existent station', async () => {
|
||||
(mockRepository.prototype.getStationById as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(service.withdrawSubmission(testUserId, testStationId))
|
||||
.rejects.toThrow('Station not found');
|
||||
});
|
||||
|
||||
it('should prevent withdrawal by non-owner', async () => {
|
||||
(mockRepository.prototype.getStationById as jest.Mock).mockResolvedValue(mockStation);
|
||||
|
||||
await expect(service.withdrawSubmission('other-user', testStationId))
|
||||
.rejects.toThrow('Unauthorized: You can only withdraw your own submissions');
|
||||
});
|
||||
|
||||
it('should prevent withdrawal of non-pending submission', async () => {
|
||||
const approvedStation = { ...mockStation, status: 'approved' as const };
|
||||
(mockRepository.prototype.getStationById as jest.Mock).mockResolvedValue(approvedStation);
|
||||
|
||||
await expect(service.withdrawSubmission(testUserId, testStationId))
|
||||
.rejects.toThrow('Can only withdraw pending submissions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApprovedStations', () => {
|
||||
it('should retrieve approved stations from cache if available', async () => {
|
||||
const cachedResult = { total: 1, stations: [{ ...mockStation, status: 'approved' as const }] };
|
||||
(mockRedis.get as jest.Mock).mockResolvedValue(JSON.stringify(cachedResult));
|
||||
|
||||
const result = await service.getApprovedStations(100, 0);
|
||||
|
||||
expect(result).toEqual(cachedResult);
|
||||
expect(mockRepository.prototype.getApprovedStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch from repository and cache if not in cache', async () => {
|
||||
const mockResult = { total: 1, stations: [{ ...mockStation, status: 'approved' as const }] };
|
||||
(mockRedis.get as jest.Mock).mockResolvedValue(null);
|
||||
(mockRepository.prototype.getApprovedStations as jest.Mock).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.getApprovedStations(100, 0);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockRepository.prototype.getApprovedStations).toHaveBeenCalledWith(100, 0);
|
||||
expect(mockRedis.setex).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApprovedNearby', () => {
|
||||
it('should find nearby approved stations', async () => {
|
||||
const nearbyStations = [{ ...mockStation, status: 'approved' as const }];
|
||||
(mockRedis.get as jest.Mock).mockResolvedValue(null);
|
||||
(mockRepository.prototype.getNearbyApprovedStations as jest.Mock).mockResolvedValue(nearbyStations);
|
||||
|
||||
const result = await service.getApprovedNearby({
|
||||
latitude: 39.7817,
|
||||
longitude: -89.6501,
|
||||
radiusKm: 50
|
||||
});
|
||||
|
||||
expect(result).toEqual(nearbyStations);
|
||||
expect(mockRepository.prototype.getNearbyApprovedStations).toHaveBeenCalledWith(39.7817, -89.6501, 50);
|
||||
});
|
||||
|
||||
it('should use default radius if not provided', async () => {
|
||||
(mockRedis.get as jest.Mock).mockResolvedValue(null);
|
||||
(mockRepository.prototype.getNearbyApprovedStations as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
await service.getApprovedNearby({
|
||||
latitude: 39.7817,
|
||||
longitude: -89.6501
|
||||
});
|
||||
|
||||
expect(mockRepository.prototype.getNearbyApprovedStations).toHaveBeenCalledWith(39.7817, -89.6501, 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingReview', () => {
|
||||
it('should retrieve pending submissions for review', async () => {
|
||||
const mockResult = { total: 1, stations: [mockStation] };
|
||||
(mockRepository.prototype.getPendingStations as jest.Mock).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.getPendingReview(100, 0);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockRepository.prototype.getPendingStations).toHaveBeenCalledWith(100, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewStation', () => {
|
||||
it('should approve a pending station', async () => {
|
||||
const approvedStation = { ...mockStation, status: 'approved' as const, reviewedBy: testAdminId, reviewedAt: new Date() };
|
||||
(mockRepository.prototype.reviewStation as jest.Mock).mockResolvedValue(approvedStation);
|
||||
(mockRedis.keys as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await service.reviewStation(testAdminId, testStationId, 'approved');
|
||||
|
||||
expect(result).toEqual(approvedStation);
|
||||
expect(mockRepository.prototype.reviewStation).toHaveBeenCalledWith(
|
||||
testStationId,
|
||||
testAdminId,
|
||||
'approved',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a station with reason', async () => {
|
||||
const rejectionReason = 'Invalid location';
|
||||
const rejectedStation = { ...mockStation, status: 'rejected' as const, rejectionReason };
|
||||
(mockRepository.prototype.reviewStation as jest.Mock).mockResolvedValue(rejectedStation);
|
||||
(mockRedis.keys as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await service.reviewStation(testAdminId, testStationId, 'rejected', rejectionReason);
|
||||
|
||||
expect(result).toEqual(rejectedStation);
|
||||
expect(mockRepository.prototype.reviewStation).toHaveBeenCalledWith(
|
||||
testStationId,
|
||||
testAdminId,
|
||||
'rejected',
|
||||
rejectionReason
|
||||
);
|
||||
});
|
||||
|
||||
it('should require rejection reason when rejecting', async () => {
|
||||
await expect(service.reviewStation(testAdminId, testStationId, 'rejected'))
|
||||
.rejects.toThrow('Rejection reason required when rejecting a station');
|
||||
});
|
||||
|
||||
it('should invalidate appropriate caches on approval', async () => {
|
||||
const approvedStation = { ...mockStation, status: 'approved' as const };
|
||||
(mockRepository.prototype.reviewStation as jest.Mock).mockResolvedValue(approvedStation);
|
||||
(mockRedis.keys as jest.Mock).mockResolvedValue(['key1', 'key2']);
|
||||
|
||||
await service.reviewStation(testAdminId, testStationId, 'approved');
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('key1', 'key2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache invalidation', () => {
|
||||
it('should handle cache invalidation errors gracefully', async () => {
|
||||
(mockRepository.prototype.submitStation as jest.Mock).mockResolvedValue(mockStation);
|
||||
(mockRedis.keys as jest.Mock).mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
// Should not throw
|
||||
const result = await service.submitStation(testUserId, mockStationData);
|
||||
expect(result).toEqual(mockStation);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user