Added comprehensive activity logging to 14 integration points: Auth Controller (4 actions): - AUTH_REGISTER: User registration - AUTH_LOGIN: User login - AUTH_VERIFY_EMAIL: Email verification (token & code) - AUTH_PASSWORD_RESET: Password reset Events Routes (2 actions): - EVENT_CHECKIN: User checks into event - EVENT_LEAVE: User leaves event Socket Handlers (3 actions): - EVENT_JOIN_CHAT: User joins event chat room - EVENT_LEAVE_CHAT: User leaves event chat room - CHAT_JOIN_ROOM: User joins private match chat Matches Routes (3 actions): - MATCH_CREATE: Match request created - MATCH_ACCEPT: Match request accepted - MATCH_REJECT: Match request rejected/cancelled Admin Routes (1 action + security): - ADMIN_MATCHING_RUN: Admin runs matching algorithm - Added requireAdmin middleware to all admin routes All logs include: - User ID and username (denormalized) - IP address (X-Forwarded-For aware) - Action type and resource ID - HTTP method and path (or SOCKET for WebSocket) - Contextual metadata (event slugs, match IDs, etc.) Fire-and-forget pattern ensures logging never blocks requests.
1433 lines
36 KiB
JavaScript
1433 lines
36 KiB
JavaScript
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 { ACTIONS, log: activityLog } = require('../services/activityLog');
|
|
const { getClientIP } = require('../utils/request');
|
|
|
|
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,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Log checkin activity
|
|
activityLog({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
ipAddress: getClientIP(req),
|
|
action: ACTIONS.EVENT_CHECKIN,
|
|
resource: `event:${event.id}`,
|
|
method: req.method,
|
|
path: req.path,
|
|
metadata: {
|
|
eventSlug: event.slug,
|
|
eventName: event.name,
|
|
},
|
|
});
|
|
|
|
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,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Log leave event activity
|
|
activityLog({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
ipAddress: getClientIP(req),
|
|
action: ACTIONS.EVENT_LEAVE,
|
|
resource: `event:${event.id}`,
|
|
method: req.method,
|
|
path: req.path,
|
|
metadata: {
|
|
eventSlug: event.slug,
|
|
eventName: event.name,
|
|
},
|
|
});
|
|
|
|
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
|
|
|
|
const startedAt = new Date();
|
|
let runRow = null;
|
|
|
|
try {
|
|
// Create run audit row
|
|
runRow = await prisma.matchingRun.create({
|
|
data: {
|
|
eventId: event.id,
|
|
trigger: 'manual',
|
|
status: 'running',
|
|
startedAt,
|
|
},
|
|
});
|
|
|
|
// Run matching algorithm
|
|
const suggestions = await matchingService.runMatching(event.id);
|
|
|
|
// Save results with runId for audit trail
|
|
const count = await matchingService.saveMatchingResults(event.id, suggestions, runRow.id);
|
|
|
|
// 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;
|
|
|
|
// Update run audit row with success
|
|
await prisma.matchingRun.update({
|
|
where: { id: runRow.id },
|
|
data: {
|
|
status: 'success',
|
|
endedAt: new Date(),
|
|
matchedCount,
|
|
notFoundCount,
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
totalHeats: suggestions.length,
|
|
matched: matchedCount,
|
|
notFound: notFoundCount,
|
|
runAt: new Date(),
|
|
runId: runRow.id,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
// Update run audit row with failure
|
|
if (runRow) {
|
|
await prisma.matchingRun.update({
|
|
where: { id: runRow.id },
|
|
data: {
|
|
status: 'failed',
|
|
endedAt: new Date(),
|
|
errorMessage: error.message,
|
|
},
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
} 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;
|