Community 93 Premium feature complete

This commit is contained in:
Eric Gullickson
2025-12-21 11:31:10 -06:00
parent 1bde31247f
commit 95f5e89e48
60 changed files with 8061 additions and 350 deletions

View File

@@ -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)
});
};

View File

@@ -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
```

View File

@@ -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.

View 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

View File

@@ -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'
});
}
}
}

View File

@@ -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');
}

View File

@@ -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>;

View File

@@ -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;
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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;
$$;

View File

@@ -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();

View File

@@ -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
});
});

View File

@@ -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);
});
});
});