const express = require('express'); const { prisma } = require('../utils/db'); const { authenticate } = require('../middleware/auth'); const { getIO } = require('../socket'); const router = express.Router(); // GET /api/events - List all events router.get('/', authenticate, async (req, res, next) => { try { const userId = req.user.id; // Fetch all events with participation info const events = await prisma.event.findMany({ select: { id: true, slug: true, name: true, location: true, startDate: true, endDate: true, worldsdcId: true, description: true, createdAt: true, participants: { where: { userId: userId, }, select: { joinedAt: true, }, }, _count: { select: { participants: true, }, }, }, }); // Transform data and add isJoined flag const eventsWithJoinedStatus = events.map(event => ({ id: event.id, slug: event.slug, name: event.name, location: event.location, startDate: event.startDate, endDate: event.endDate, worldsdcId: event.worldsdcId, participantsCount: event._count.participants, description: event.description, createdAt: event.createdAt, isJoined: event.participants.length > 0, joinedAt: event.participants[0]?.joinedAt || null, })); // Sort: joined events first, then by start date eventsWithJoinedStatus.sort((a, b) => { // First, sort by joined status (joined events first) if (a.isJoined && !b.isJoined) return -1; if (!a.isJoined && b.isJoined) return 1; // Then sort by start date return new Date(a.startDate) - new Date(b.startDate); }); res.json({ success: true, count: eventsWithJoinedStatus.length, data: eventsWithJoinedStatus, }); } catch (error) { next(error); } }); // GET /api/events/:slug - Get event by slug router.get('/:slug', async (req, res, next) => { try { const { slug } = req.params; const event = await prisma.event.findUnique({ where: { slug: slug, }, include: { chatRooms: true, _count: { select: { matches: true, }, }, }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } res.json({ success: true, data: event, }); } catch (error) { next(error); } }); // GET /api/events/:slug/messages - Get event chat messages with pagination router.get('/:slug/messages', authenticate, async (req, res, next) => { try { const { slug } = req.params; const { before, limit = 20 } = req.query; // Find event by slug const event = await prisma.event.findUnique({ where: { slug }, select: { id: true }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Find event chat room const chatRoom = await prisma.chatRoom.findFirst({ where: { eventId: event.id, type: 'event', }, }); if (!chatRoom) { return res.status(404).json({ success: false, error: 'Chat room not found', }); } // Build query with pagination const where = { roomId: chatRoom.id }; if (before) { where.id = { lt: parseInt(before) }; } const messages = await prisma.message.findMany({ where, include: { user: { select: { id: true, username: true, avatar: true, }, }, }, orderBy: { createdAt: 'desc' }, take: parseInt(limit), }); // Return in chronological order (oldest first) res.json({ success: true, data: messages.reverse().map(msg => ({ id: msg.id, roomId: msg.roomId, userId: msg.user.id, username: msg.user.username, avatar: msg.user.avatar, content: msg.content, type: msg.type, createdAt: msg.createdAt, })), hasMore: messages.length === parseInt(limit), }); } catch (error) { next(error); } }); // POST /api/events/checkin/:token - Check-in to event using QR code token router.post('/checkin/:token', authenticate, async (req, res, next) => { try { const { token } = req.params; const userId = req.user.id; // Find check-in token const checkinToken = await prisma.eventCheckinToken.findUnique({ where: { token }, include: { event: true, }, }); if (!checkinToken) { return res.status(404).json({ success: false, error: 'Invalid check-in token', }); } const event = checkinToken.event; // Validate dates (only in production) const isProduction = process.env.NODE_ENV === 'production'; if (isProduction) { const now = new Date(); const validFrom = new Date(event.startDate); validFrom.setDate(validFrom.getDate() - 1); const validUntil = new Date(event.endDate); validUntil.setDate(validUntil.getDate() + 1); if (now < validFrom || now > validUntil) { return res.status(400).json({ success: false, error: 'Check-in is not available for this event at this time', }); } } // Check if user is already participating const existingParticipant = await prisma.eventParticipant.findUnique({ where: { userId_eventId: { userId, eventId: event.id, }, }, }); if (existingParticipant) { // User already checked in - return event info return res.json({ success: true, alreadyCheckedIn: true, data: { event: { id: event.id, slug: event.slug, name: event.name, location: event.location, startDate: event.startDate, endDate: event.endDate, }, joinedAt: existingParticipant.joinedAt, }, }); } // Ensure event chat room exists (create if missing) const existingChatRoom = await prisma.chatRoom.findFirst({ where: { eventId: event.id, type: 'event', }, }); if (!existingChatRoom) { await prisma.chatRoom.create({ data: { eventId: event.id, type: 'event', }, }); console.log(`✅ Created missing chat room for event: ${event.slug}`); } // Add user to event participants const participant = await prisma.eventParticipant.create({ data: { userId, eventId: event.id, }, }); // Update participants count await prisma.event.update({ where: { id: event.id }, data: { participantsCount: { increment: 1, }, }, }); res.json({ success: true, alreadyCheckedIn: false, data: { event: { id: event.id, slug: event.slug, name: event.name, location: event.location, startDate: event.startDate, endDate: event.endDate, }, joinedAt: participant.joinedAt, }, }); } catch (error) { next(error); } }); // GET /api/events/:slug/details - Get event details with check-in token and participants router.get('/:slug/details', authenticate, async (req, res, next) => { try { const { slug } = req.params; // Find event by slug with participants const event = await prisma.event.findUnique({ where: { slug }, include: { participants: { include: { user: { select: { id: true, username: true, avatar: true, firstName: true, lastName: true, }, }, }, orderBy: { joinedAt: 'desc', }, }, }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Find or create check-in token (on-demand generation) let checkinToken = await prisma.eventCheckinToken.findUnique({ where: { eventId: event.id }, }); if (!checkinToken) { checkinToken = await prisma.eventCheckinToken.create({ data: { eventId: event.id, }, }); } // Calculate valid dates (startDate - 1 day to endDate + 1 day) const validFrom = new Date(event.startDate); validFrom.setDate(validFrom.getDate() - 1); const validUntil = new Date(event.endDate); validUntil.setDate(validUntil.getDate() + 1); // Build check-in URL const baseUrl = process.env.FRONTEND_URL || 'http://localhost:8080'; const checkinUrl = `${baseUrl}/events/checkin/${checkinToken.token}`; res.json({ success: true, data: { event: { id: event.id, slug: event.slug, name: event.name, location: event.location, startDate: event.startDate, endDate: event.endDate, description: event.description, }, checkin: { token: checkinToken.token, url: checkinUrl, validFrom, validUntil, }, participants: event.participants.map(p => ({ userId: p.user.id, username: p.user.username, avatar: p.user.avatar, firstName: p.user.firstName, lastName: p.user.lastName, joinedAt: p.joinedAt, })), stats: { totalParticipants: event.participants.length, }, }, }); } catch (error) { next(error); } }); // DELETE /api/events/:slug/leave - Leave an event (remove from participants) router.delete('/:slug/leave', authenticate, async (req, res, next) => { try { const { slug } = req.params; const userId = req.user.id; // Find event by slug const event = await prisma.event.findUnique({ where: { slug }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Check if user is participating const participant = await prisma.eventParticipant.findUnique({ where: { userId_eventId: { userId, eventId: event.id, }, }, }); if (!participant) { return res.status(400).json({ success: false, error: 'You are not a participant of this event', }); } // Remove from participants await prisma.eventParticipant.delete({ where: { userId_eventId: { userId, eventId: event.id, }, }, }); // Update participants count await prisma.event.update({ where: { id: event.id }, data: { participantsCount: { decrement: 1, }, }, }); res.json({ success: true, message: 'Successfully left the event', }); } catch (error) { next(error); } }); // POST /api/events/:slug/heats - Add/update user's heats for event router.post('/:slug/heats', authenticate, async (req, res, next) => { try { const { slug } = req.params; const userId = req.user.id; const { heats } = req.body; // Validation if (!Array.isArray(heats) || heats.length === 0) { return res.status(400).json({ success: false, error: 'Heats must be a non-empty array', }); } // Validate heat structure for (const heat of heats) { if (!heat.divisionId || !heat.competitionTypeId || !heat.heatNumber) { return res.status(400).json({ success: false, error: 'Each heat must have divisionId, competitionTypeId, and heatNumber', }); } if (heat.heatNumber < 1 || heat.heatNumber > 9) { return res.status(400).json({ success: false, error: 'Heat number must be between 1 and 9', }); } if (heat.role && !['Leader', 'Follower'].includes(heat.role)) { return res.status(400).json({ success: false, error: 'Role must be either Leader or Follower', }); } } // Find event const event = await prisma.event.findUnique({ where: { slug }, select: { id: true }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Check if user is participant const participant = await prisma.eventParticipant.findUnique({ where: { userId_eventId: { userId, eventId: event.id, }, }, }); if (!participant) { return res.status(403).json({ success: false, error: 'You must be a participant of this event', }); } // Delete existing heats and create new ones (transaction) const result = await prisma.$transaction(async (tx) => { // Delete existing heats for this user and event await tx.eventUserHeat.deleteMany({ where: { userId, eventId: event.id, }, }); // Create new heats const created = await tx.eventUserHeat.createMany({ data: heats.map((heat) => ({ userId, eventId: event.id, divisionId: heat.divisionId, competitionTypeId: heat.competitionTypeId, heatNumber: heat.heatNumber, role: heat.role || null, })), }); // Fetch created heats with relations const userHeats = await tx.eventUserHeat.findMany({ where: { userId, eventId: event.id, }, include: { division: { select: { id: true, name: true, abbreviation: true, }, }, competitionType: { select: { id: true, name: true, abbreviation: true, }, }, }, }); return userHeats; }); // Broadcast heats update to all users in event room try { const io = getIO(); const roomName = `event_${event.id}`; io.to(roomName).emit('heats_updated', { userId: req.user.id, username: req.user.username, heats: result.map(heat => ({ id: heat.id, divisionId: heat.divisionId, division: heat.division, competitionTypeId: heat.competitionTypeId, competitionType: heat.competitionType, heatNumber: heat.heatNumber, role: heat.role, })), }); } catch (socketError) { // Don't fail the request if socket broadcast fails console.error('Failed to broadcast heats update:', socketError); } res.json({ success: true, count: result.length, data: result, }); } catch (error) { // Handle unique constraint violation if (error.code === 'P2002') { return res.status(400).json({ success: false, error: 'Cannot have duplicate heats with same role in same division and competition type', }); } next(error); } }); // GET /api/events/:slug/heats/me - Get current user's heats router.get('/:slug/heats/me', authenticate, async (req, res, next) => { try { const { slug } = req.params; const userId = req.user.id; // Find event const event = await prisma.event.findUnique({ where: { slug }, select: { id: true }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Get user's participation (for competitor number) const participation = await prisma.eventParticipant.findUnique({ where: { userId_eventId: { userId, eventId: event.id }, }, select: { competitorNumber: true }, }); // Get user's heats const heats = await prisma.eventUserHeat.findMany({ where: { userId, eventId: event.id, }, include: { division: { select: { id: true, name: true, abbreviation: true, }, }, competitionType: { select: { id: true, name: true, abbreviation: true, }, }, }, orderBy: [ { competitionTypeId: 'asc' }, { divisionId: 'asc' }, { heatNumber: 'asc' }, ], }); res.json({ success: true, count: heats.length, competitorNumber: participation?.competitorNumber || null, data: heats, }); } catch (error) { next(error); } }); // GET /api/events/:slug/heats/all - Get all users' heats for event router.get('/:slug/heats/all', authenticate, async (req, res, next) => { try { const { slug } = req.params; // Find event const event = await prisma.event.findUnique({ where: { slug }, select: { id: true }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Get all participants with competitor numbers const participants = await prisma.eventParticipant.findMany({ where: { eventId: event.id }, select: { userId: true, competitorNumber: true, }, }); const competitorNumbers = new Map( participants.map((p) => [p.userId, p.competitorNumber]) ); // Get all heats with user info const heats = await prisma.eventUserHeat.findMany({ where: { eventId: event.id, }, include: { user: { select: { id: true, username: true, avatar: true, }, }, division: { select: { id: true, name: true, abbreviation: true, }, }, competitionType: { select: { id: true, name: true, abbreviation: true, }, }, }, }); // Group by user const userHeatsMap = new Map(); for (const heat of heats) { const userId = heat.user.id; if (!userHeatsMap.has(userId)) { userHeatsMap.set(userId, { userId: heat.user.id, username: heat.user.username, avatar: heat.user.avatar, competitorNumber: competitorNumbers.get(userId) || null, heats: [], }); } userHeatsMap.get(userId).heats.push({ id: heat.id, divisionId: heat.divisionId, division: heat.division, competitionTypeId: heat.competitionTypeId, competitionType: heat.competitionType, heatNumber: heat.heatNumber, role: heat.role, }); } const result = Array.from(userHeatsMap.values()); res.json({ success: true, count: result.length, data: result, }); } catch (error) { next(error); } }); // PUT /api/events/:slug/competitor-number - Set competitor number (bib number) router.put('/:slug/competitor-number', authenticate, async (req, res, next) => { try { const { slug } = req.params; const { competitorNumber } = req.body; const userId = req.user.id; // Validate competitor number (positive integer or null) if (competitorNumber !== null && competitorNumber !== undefined) { const num = parseInt(competitorNumber, 10); if (isNaN(num) || num < 1 || num > 9999) { return res.status(400).json({ success: false, error: 'Competitor number must be between 1 and 9999', }); } } // Find event const event = await prisma.event.findUnique({ where: { slug }, select: { id: true }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Check if user is participant const participant = await prisma.eventParticipant.findUnique({ where: { userId_eventId: { userId, eventId: event.id }, }, }); if (!participant) { return res.status(403).json({ success: false, error: 'You must be a participant to set competitor number', }); } // Update competitor number const updated = await prisma.eventParticipant.update({ where: { userId_eventId: { userId, eventId: event.id }, }, data: { competitorNumber: competitorNumber ? parseInt(competitorNumber, 10) : null, }, }); res.json({ success: true, competitorNumber: updated.competitorNumber, }); } catch (error) { next(error); } }); // DELETE /api/events/:slug/heats/:id - Delete specific heat router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => { try { const { slug, id } = req.params; const userId = req.user.id; // Find event const event = await prisma.event.findUnique({ where: { slug }, select: { id: true }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Find heat const heat = await prisma.eventUserHeat.findUnique({ where: { id: parseInt(id) }, }); if (!heat) { return res.status(404).json({ success: false, error: 'Heat not found', }); } // Check ownership if (heat.userId !== userId) { return res.status(403).json({ success: false, error: 'You can only delete your own heats', }); } // Delete heat await prisma.eventUserHeat.delete({ where: { id: parseInt(id) }, }); res.json({ success: true, message: 'Heat deleted successfully', }); } catch (error) { next(error); } }); module.exports = router;