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

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