Community 93 Premium feature complete
This commit is contained in:
@@ -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>;
|
||||
Reference in New Issue
Block a user