From 88117567abcfb3a1de190ea31f8b156f3e8716f6 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 16 Nov 2025 20:15:41 +0000 Subject: [PATCH] feat: implement backend API for event management (task 13) - Create eventController with CRUD operations and registration endpoints - Implement eventService with Prisma database operations - Add event routes with authentication middleware - Support event creation, listing, filtering, registration/unregistration - Include spatial data handling for event locations - Add participant count tracking and registration status checks - Requirements: 4.1, 4.2, 4.3, 4.4 --- server/src/controllers/eventController.ts | 296 ++++++++++++++++++++ server/src/index.ts | 2 + server/src/routes/eventRoutes.ts | 20 ++ server/src/services/eventService.ts | 327 ++++++++++++++++++++++ 4 files changed, 645 insertions(+) create mode 100644 server/src/controllers/eventController.ts create mode 100644 server/src/routes/eventRoutes.ts create mode 100644 server/src/services/eventService.ts diff --git a/server/src/controllers/eventController.ts b/server/src/controllers/eventController.ts new file mode 100644 index 0000000..c19dbc2 --- /dev/null +++ b/server/src/controllers/eventController.ts @@ -0,0 +1,296 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import { eventService } from '../services/eventService'; + +export const eventController = { + // Get all events with optional filters + async getEvents(req: AuthRequest, res: Response) { + try { + const { status, startDate, endDate, latitude, longitude, radius } = req.query; + + const filters: any = {}; + if (status) filters.status = status as string; + if (startDate) filters.startDate = new Date(startDate as string); + if (endDate) filters.endDate = new Date(endDate as string); + if (latitude && longitude) { + filters.latitude = parseFloat(latitude as string); + filters.longitude = parseFloat(longitude as string); + if (radius) filters.radius = parseFloat(radius as string); + } + + const events = await eventService.getEvents(filters); + res.json(events); + } catch (error: any) { + console.error('Error fetching events:', error); + res.status(500).json({ + code: 'FETCH_EVENTS_ERROR', + message: 'Failed to fetch events', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Get single event by ID + async getEvent(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + const event = await eventService.getEventById(id); + + if (!event) { + return res.status(404).json({ + code: 'EVENT_NOT_FOUND', + message: 'Event not found', + timestamp: new Date().toISOString(), + }); + } + + res.json(event); + } catch (error: any) { + console.error('Error fetching event:', error); + res.status(500).json({ + code: 'FETCH_EVENT_ERROR', + message: 'Failed to fetch event', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Create new event + async createEvent(req: AuthRequest, res: Response) { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }); + } + + const { title, description, latitude, longitude, locationName, scheduledDate, duration } = req.body; + + // Validation + if (!title || !description || !latitude || !longitude || !locationName || !scheduledDate || !duration) { + return res.status(400).json({ + code: 'VALIDATION_ERROR', + message: 'Missing required fields', + timestamp: new Date().toISOString(), + }); + } + + const eventData = { + organizerId: userId, + title, + description, + latitude: parseFloat(latitude), + longitude: parseFloat(longitude), + locationName, + scheduledDate: new Date(scheduledDate), + duration: parseInt(duration), + }; + + const event = await eventService.createEvent(eventData); + res.status(201).json(event); + } catch (error: any) { + console.error('Error creating event:', error); + res.status(500).json({ + code: 'CREATE_EVENT_ERROR', + message: 'Failed to create event', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Update event + async updateEvent(req: AuthRequest, res: Response) { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }); + } + + const { id } = req.params; + const event = await eventService.getEventById(id); + + if (!event) { + return res.status(404).json({ + code: 'EVENT_NOT_FOUND', + message: 'Event not found', + timestamp: new Date().toISOString(), + }); + } + + // Check if user is the organizer + if (event.organizerId !== userId) { + return res.status(403).json({ + code: 'FORBIDDEN', + message: 'Only the event organizer can update this event', + timestamp: new Date().toISOString(), + }); + } + + const updatedEvent = await eventService.updateEvent(id, req.body); + res.json(updatedEvent); + } catch (error: any) { + console.error('Error updating event:', error); + res.status(500).json({ + code: 'UPDATE_EVENT_ERROR', + message: 'Failed to update event', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Delete event + async deleteEvent(req: AuthRequest, res: Response) { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }); + } + + const { id } = req.params; + const event = await eventService.getEventById(id); + + if (!event) { + return res.status(404).json({ + code: 'EVENT_NOT_FOUND', + message: 'Event not found', + timestamp: new Date().toISOString(), + }); + } + + // Check if user is the organizer + if (event.organizerId !== userId) { + return res.status(403).json({ + code: 'FORBIDDEN', + message: 'Only the event organizer can delete this event', + timestamp: new Date().toISOString(), + }); + } + + await eventService.deleteEvent(id); + res.status(204).send(); + } catch (error: any) { + console.error('Error deleting event:', error); + res.status(500).json({ + code: 'DELETE_EVENT_ERROR', + message: 'Failed to delete event', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Register for event + async registerForEvent(req: AuthRequest, res: Response) { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }); + } + + const { id } = req.params; + const registration = await eventService.registerForEvent(id, userId); + res.status(201).json(registration); + } catch (error: any) { + console.error('Error registering for event:', error); + + if (error.message.includes('already registered')) { + return res.status(409).json({ + code: 'ALREADY_REGISTERED', + message: error.message, + timestamp: new Date().toISOString(), + }); + } + + res.status(500).json({ + code: 'REGISTER_EVENT_ERROR', + message: 'Failed to register for event', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Unregister from event + async unregisterFromEvent(req: AuthRequest, res: Response) { + try { + const userId = req.userId; + if (!userId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }); + } + + const { id } = req.params; + await eventService.unregisterFromEvent(id, userId); + res.status(204).send(); + } catch (error: any) { + console.error('Error unregistering from event:', error); + res.status(500).json({ + code: 'UNREGISTER_EVENT_ERROR', + message: 'Failed to unregister from event', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Get event registrations + async getEventRegistrations(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + const registrations = await eventService.getEventRegistrations(id); + res.json(registrations); + } catch (error: any) { + console.error('Error fetching event registrations:', error); + res.status(500).json({ + code: 'FETCH_REGISTRATIONS_ERROR', + message: 'Failed to fetch event registrations', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Check registration status + async getRegistrationStatus(req: AuthRequest, res: Response) { + try { + const userId = req.userId; + if (!userId) { + return res.json({ registered: false }); + } + + const { id } = req.params; + const isRegistered = await eventService.isUserRegistered(id, userId); + res.json({ registered: isRegistered }); + } catch (error: any) { + console.error('Error checking registration status:', error); + res.status(500).json({ + code: 'CHECK_REGISTRATION_ERROR', + message: 'Failed to check registration status', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, +}; diff --git a/server/src/index.ts b/server/src/index.ts index a2e1cbf..7e08548 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import helmet from 'helmet'; import dotenv from 'dotenv'; import path from 'path'; import reportRoutes from './routes/reportRoutes'; +import eventRoutes from './routes/eventRoutes'; // Load environment variables dotenv.config(); @@ -33,6 +34,7 @@ app.get('/health', (req, res) => { // API Routes app.use('/api/reports', reportRoutes); +app.use('/api/events', eventRoutes); // Error handling middleware app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { diff --git a/server/src/routes/eventRoutes.ts b/server/src/routes/eventRoutes.ts new file mode 100644 index 0000000..fd82321 --- /dev/null +++ b/server/src/routes/eventRoutes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { eventController } from '../controllers/eventController'; +import { requireAuth } from '../middleware/auth'; + +const router = Router(); + +// Public routes +router.get('/', eventController.getEvents); +router.get('/:id', eventController.getEvent); +router.get('/:id/registrations', eventController.getEventRegistrations); + +// Protected routes (require authentication) +router.post('/', requireAuth, eventController.createEvent); +router.patch('/:id', requireAuth, eventController.updateEvent); +router.delete('/:id', requireAuth, eventController.deleteEvent); +router.post('/:id/register', requireAuth, eventController.registerForEvent); +router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent); +router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus); + +export default router; diff --git a/server/src/services/eventService.ts b/server/src/services/eventService.ts new file mode 100644 index 0000000..1bde609 --- /dev/null +++ b/server/src/services/eventService.ts @@ -0,0 +1,327 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +interface EventFilters { + status?: string; + startDate?: Date; + endDate?: Date; + latitude?: number; + longitude?: number; + radius?: number; +} + +interface CreateEventData { + organizerId: string; + title: string; + description: string; + latitude: number; + longitude: number; + locationName: string; + scheduledDate: Date; + duration: number; +} + +interface UpdateEventData { + title?: string; + description?: string; + scheduledDate?: Date; + duration?: number; + status?: 'upcoming' | 'completed' | 'cancelled'; + litterCollected?: number; + photos?: string[]; +} + +export const eventService = { + async getEvents(filters: EventFilters = {}) { + const where: any = {}; + + // Filter by status + if (filters.status && filters.status !== 'all') { + where.status = filters.status; + } + + // Filter by date range + if (filters.startDate || filters.endDate) { + where.scheduledDate = {}; + if (filters.startDate) { + where.scheduledDate.gte = filters.startDate; + } + if (filters.endDate) { + where.scheduledDate.lte = filters.endDate; + } + } + + const events = await prisma.event.findMany({ + where, + orderBy: { + scheduledDate: 'asc', + }, + include: { + organizer: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + _count: { + select: { + registrations: true, + }, + }, + }, + }); + + // Map to frontend format + return events.map((event) => ({ + id: event.id, + organizerId: event.organizerId, + title: event.title, + description: event.description, + latitude: parseFloat(event.latitude.toString()), + longitude: parseFloat(event.longitude.toString()), + locationName: event.locationName, + scheduledDate: event.scheduledDate, + duration: event.duration, + status: event.status || 'upcoming', + participantCount: event._count.registrations, + litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined, + photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined, + createdAt: event.createdAt, + updatedAt: event.updatedAt, + })); + }, + + async getEventById(id: string) { + const event = await prisma.event.findUnique({ + where: { id }, + include: { + organizer: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + _count: { + select: { + registrations: true, + }, + }, + }, + }); + + if (!event) return null; + + return { + id: event.id, + organizerId: event.organizerId, + title: event.title, + description: event.description, + latitude: parseFloat(event.latitude.toString()), + longitude: parseFloat(event.longitude.toString()), + locationName: event.locationName, + scheduledDate: event.scheduledDate, + duration: event.duration, + status: event.status || 'upcoming', + participantCount: event._count.registrations, + litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined, + photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined, + createdAt: event.createdAt, + updatedAt: event.updatedAt, + }; + }, + + async createEvent(data: CreateEventData) { + // Create point geometry for spatial queries + const locationPoint = `POINT(${data.longitude} ${data.latitude})`; + + const event = await prisma.$executeRaw` + INSERT INTO events ( + id, organizer_id, title, description, location, latitude, longitude, + location_name, scheduled_date, duration, status, participant_count + ) VALUES ( + UUID(), ${data.organizerId}, ${data.title}, ${data.description}, + ST_GeomFromText(${locationPoint}, 4326), + ${data.latitude}, ${data.longitude}, ${data.locationName}, + ${data.scheduledDate}, ${data.duration}, 'upcoming', 0 + ) + `; + + // Fetch the created event + const createdEvent = await prisma.event.findFirst({ + where: { + organizerId: data.organizerId, + title: data.title, + scheduledDate: data.scheduledDate, + }, + orderBy: { + createdAt: 'desc', + }, + include: { + _count: { + select: { + registrations: true, + }, + }, + }, + }); + + if (!createdEvent) { + throw new Error('Failed to retrieve created event'); + } + + return { + id: createdEvent.id, + organizerId: createdEvent.organizerId, + title: createdEvent.title, + description: createdEvent.description, + latitude: parseFloat(createdEvent.latitude.toString()), + longitude: parseFloat(createdEvent.longitude.toString()), + locationName: createdEvent.locationName, + scheduledDate: createdEvent.scheduledDate, + duration: createdEvent.duration, + status: createdEvent.status || 'upcoming', + participantCount: createdEvent._count.registrations, + litterCollected: createdEvent.litterCollected ? parseFloat(createdEvent.litterCollected.toString()) : undefined, + photos: createdEvent.photos ? (Array.isArray(createdEvent.photos) ? createdEvent.photos : []) : undefined, + createdAt: createdEvent.createdAt, + updatedAt: createdEvent.updatedAt, + }; + }, + + async updateEvent(id: string, data: UpdateEventData) { + const updateData: any = {}; + + if (data.title !== undefined) updateData.title = data.title; + if (data.description !== undefined) updateData.description = data.description; + if (data.scheduledDate !== undefined) updateData.scheduledDate = data.scheduledDate; + if (data.duration !== undefined) updateData.duration = data.duration; + if (data.status !== undefined) updateData.status = data.status; + if (data.litterCollected !== undefined) updateData.litterCollected = data.litterCollected; + if (data.photos !== undefined) updateData.photos = data.photos; + + const event = await prisma.event.update({ + where: { id }, + data: updateData, + include: { + _count: { + select: { + registrations: true, + }, + }, + }, + }); + + return { + id: event.id, + organizerId: event.organizerId, + title: event.title, + description: event.description, + latitude: parseFloat(event.latitude.toString()), + longitude: parseFloat(event.longitude.toString()), + locationName: event.locationName, + scheduledDate: event.scheduledDate, + duration: event.duration, + status: event.status || 'upcoming', + participantCount: event._count.registrations, + litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined, + photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined, + createdAt: event.createdAt, + updatedAt: event.updatedAt, + }; + }, + + async deleteEvent(id: string) { + await prisma.event.delete({ + where: { id }, + }); + }, + + async registerForEvent(eventId: string, userId: string) { + // Check if already registered + const existing = await prisma.eventRegistration.findUnique({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + }); + + if (existing) { + throw new Error('User is already registered for this event'); + } + + // Create registration + const registration = await prisma.eventRegistration.create({ + data: { + userId, + eventId, + }, + }); + + return { + id: registration.id, + userId: registration.userId, + eventId: registration.eventId, + registeredAt: registration.registeredAt || new Date(), + attended: registration.attended || false, + }; + }, + + async unregisterFromEvent(eventId: string, userId: string) { + await prisma.eventRegistration.delete({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + }); + }, + + async getEventRegistrations(eventId: string) { + const registrations = await prisma.eventRegistration.findMany({ + where: { eventId }, + include: { + user: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + }, + }); + + return registrations.map((reg) => ({ + id: reg.id, + userId: reg.userId, + eventId: reg.eventId, + registeredAt: reg.registeredAt || new Date(), + attended: reg.attended || false, + user: reg.user, + })); + }, + + async isUserRegistered(eventId: string, userId: string): Promise { + const registration = await prisma.eventRegistration.findUnique({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + }); + + return !!registration; + }, +};