const express = require('express'); const { prisma } = require('../utils/db'); const { authenticate } = require('../middleware/auth'); const { getIO } = require('../socket'); const matchingService = require('../services/matching'); const { SUGGESTION_STATUS, MATCH_STATUS } = require('../constants'); 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, country: true, }, }, }, orderBy: { createdAt: 'desc' }, take: parseInt(limit), }); // Get competitor numbers for all users in this event const userIds = [...new Set(messages.map(msg => msg.user.id))]; const eventParticipants = await prisma.eventParticipant.findMany({ where: { eventId: event.id, userId: { in: userIds }, }, select: { userId: true, competitorNumber: true, }, }); // Create a map of userId to competitorNumber const competitorNumberMap = new Map( eventParticipants.map(ep => [ep.userId, ep.competitorNumber]) ); // 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, content: msg.content, type: msg.type, createdAt: msg.createdAt, // Nested user data for caching user: { id: msg.user.id, username: msg.user.username, avatar: msg.user.avatar, country: msg.user.country, }, // Nested participant data for caching participant: { competitorNumber: competitorNumberMap.get(msg.user.id), }, })), 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, registrationDeadline: event.registrationDeadline, matchingRunAt: event.matchingRunAt, scheduleConfig: event.scheduleConfig, }, 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); } }); // ============================================ // AUTO-MATCHING ENDPOINTS // ============================================ // PUT /api/events/:slug/schedule-config - Set schedule configuration (division order and collision groups) router.put('/:slug/schedule-config', authenticate, async (req, res, next) => { try { const { slug } = req.params; const { scheduleConfig } = req.body; // Validate schedule config structure if (scheduleConfig !== null && scheduleConfig !== undefined) { if (!scheduleConfig.slots || !Array.isArray(scheduleConfig.slots)) { return res.status(400).json({ success: false, error: 'scheduleConfig must have a "slots" array', }); } // Validate each slot for (let i = 0; i < scheduleConfig.slots.length; i++) { const slot = scheduleConfig.slots[i]; if (typeof slot.order !== 'number' || !Array.isArray(slot.divisionIds)) { return res.status(400).json({ success: false, error: `Slot ${i} must have "order" (number) and "divisionIds" (array)`, }); } // Validate divisionIds are numbers if (!slot.divisionIds.every(id => typeof id === 'number')) { return res.status(400).json({ success: false, error: `Slot ${i} divisionIds must be an array of numbers`, }); } } } // 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', }); } // Update schedule config const updated = await prisma.event.update({ where: { id: event.id }, data: { scheduleConfig: scheduleConfig || null, }, select: { id: true, slug: true, scheduleConfig: true, }, }); res.json({ success: true, data: updated, }); } catch (error) { next(error); } }); // PUT /api/events/:slug/registration-deadline - Set registration deadline router.put('/:slug/registration-deadline', authenticate, async (req, res, next) => { try { const { slug } = req.params; const { registrationDeadline } = req.body; // 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', }); } // Update registration deadline const updated = await prisma.event.update({ where: { id: event.id }, data: { registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null, }, select: { id: true, slug: true, registrationDeadline: true, }, }); res.json({ success: true, data: updated, }); } catch (error) { next(error); } }); // PUT /api/events/:slug/recorder-opt-out - Set recorder opt-out preference router.put('/:slug/recorder-opt-out', authenticate, async (req, res, next) => { try { const { slug } = req.params; const userId = req.user.id; const { optOut } = req.body; if (typeof optOut !== 'boolean') { return res.status(400).json({ success: false, error: 'optOut must be a boolean', }); } // Find event and participant const event = await prisma.event.findUnique({ where: { slug }, select: { id: true }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } const participant = await prisma.eventParticipant.findUnique({ where: { userId_eventId: { userId, eventId: event.id, }, }, }); if (!participant) { return res.status(403).json({ success: false, error: 'You are not a participant of this event', }); } // Update opt-out preference const updated = await prisma.eventParticipant.update({ where: { id: participant.id }, data: { recorderOptOut: optOut }, select: { id: true, recorderOptOut: true, }, }); res.json({ success: true, data: { recorderOptOut: updated.recorderOptOut, }, }); } catch (error) { next(error); } }); // POST /api/events/:slug/run-matching - Run the auto-matching algorithm router.post('/:slug/run-matching', authenticate, async (req, res, next) => { try { const { slug } = req.params; // Find event const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, registrationDeadline: true, matchingRunAt: true, }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // TODO: In production, add admin check or deadline validation // For now, allow anyone to run matching for testing // Run matching algorithm const suggestions = await matchingService.runMatching(event.id); // Save results const count = await matchingService.saveMatchingResults(event.id, suggestions); // Get statistics const notFoundCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND).length; const matchedCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.PENDING).length; res.json({ success: true, data: { totalHeats: suggestions.length, matched: matchedCount, notFound: notFoundCount, runAt: new Date(), }, }); } catch (error) { next(error); } }); // GET /api/events/:slug/match-suggestions - Get matching suggestions for current user router.get('/:slug/match-suggestions', 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, matchingRunAt: true, }, }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Get user's suggestions const suggestions = await matchingService.getUserSuggestions(event.id, userId); res.json({ success: true, data: { matchingRunAt: event.matchingRunAt, ...suggestions, }, }); } catch (error) { next(error); } }); // PUT /api/events/:slug/match-suggestions/:suggestionId/status - Accept/reject suggestion router.put('/:slug/match-suggestions/:suggestionId/status', authenticate, async (req, res, next) => { try { const { slug, suggestionId } = req.params; const userId = req.user.id; const { status } = req.body; if (!['accepted', 'rejected'].includes(status)) { return res.status(400).json({ success: false, error: 'Status must be "accepted" or "rejected"', }); } // 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 suggestion const suggestion = await prisma.recordingSuggestion.findUnique({ where: { id: parseInt(suggestionId) }, include: { heat: { select: { userId: true }, }, }, }); if (!suggestion || suggestion.eventId !== event.id) { return res.status(404).json({ success: false, error: 'Suggestion not found', }); } // Check authorization: only recorder can update status (MVP decision) // Dancer can only view who is assigned to record them const isRecorder = suggestion.recorderId === userId; if (!isRecorder) { return res.status(403).json({ success: false, error: 'Only the assigned recorder can accept or reject this suggestion', }); } // If accepted, create Match (if doesn't exist) and chat room if (status === 'accepted') { const result = await prisma.$transaction(async (tx) => { // Update suggestion status const updatedSuggestion = await tx.recordingSuggestion.update({ where: { id: parseInt(suggestionId) }, data: { status }, }); // Check if Match already exists for this dancer-recorder pair at this event // Important: Multiple heats may exist for the same pair, but we want only ONE match // This ensures one collaboration = one chat room = one stats increment const existingMatch = await tx.match.findFirst({ where: { eventId: event.id, OR: [ // Convention: user1 = dancer, user2 = recorder { user1Id: suggestion.heat.userId, user2Id: suggestion.recorderId, }, // Also check reverse (in case of manual matches or inconsistencies) { user1Id: suggestion.recorderId, user2Id: suggestion.heat.userId, }, ], }, }); if (existingMatch) { // Match already exists for this pair - reuse it // Update suggestion to link to existing match (if not already linked) if (existingMatch.suggestionId !== suggestion.id) { // Multiple suggestions for same pair - link to first created match // Note: Only first suggestion gets suggestionId link, others reference via user IDs } return { suggestion: updatedSuggestion, match: existingMatch }; } // Create private chat room const chatRoom = await tx.chatRoom.create({ data: { type: 'private', eventId: event.id, }, }); // Create Match with convention: user1 = dancer, user2 = recorder const match = await tx.match.create({ data: { user1Id: suggestion.heat.userId, // dancer user2Id: suggestion.recorderId, // recorder eventId: event.id, suggestionId: suggestion.id, source: 'auto', status: MATCH_STATUS.ACCEPTED, roomId: chatRoom.id, statsApplied: false, }, }); return { suggestion: updatedSuggestion, match }; }); res.json({ success: true, data: { id: result.suggestion.id, status: result.suggestion.status, updatedAt: result.suggestion.updatedAt, matchId: result.match.id, matchSlug: result.match.slug, }, }); } else { // Rejected - just update status const updated = await prisma.recordingSuggestion.update({ where: { id: parseInt(suggestionId) }, data: { status }, select: { id: true, status: true, updatedAt: true, }, }); res.json({ success: true, data: updated, }); } } catch (error) { next(error); } }); module.exports = router;