import { Request, Response } from 'express'; import Event, { IEvent } from '../model/event.model'; import EventDay from '../model/eventDay.model'; import User from '../model/user.model'; import crypto from 'crypto'; import sendMail from '../util/mailer.util'; import mongoose from 'mongoose'; import { deleteEventDays } from '../util/eventDay.util'; interface IEventRequest extends Request { userId?: string; } // Helper: Generate unique event code const generateEventCode = async (orgName: string, theme: string): Promise => { const orgPrefix = orgName.substring(0, 4).toUpperCase().replace(/[^A-Z]/g, ''); const themePrefix = theme.substring(0, 4).toUpperCase().replace(/[^A-Z]/g, ''); const random = Math.floor(1000 + Math.random() * 9000); let code = `${orgPrefix}-${themePrefix}-${random}`; let exists = await Event.findOne({ code }); while (exists) { const newRandom = Math.floor(1000 + Math.random() * 9000); code = `${orgPrefix}-${themePrefix}-${newRandom}`; exists = await Event.findOne({ code }); } return code; }; // Helper: Calculate distance between two coordinates (Haversine formula) const calculateDistance = ( lat1: number, lon1: number, lat2: number, lon2: number ): number => { const R = 6371e3; // Earth radius in meters const φ1 = lat1 * Math.PI / 180; const φ2 = lat2 * Math.PI / 180; const Δφ = (lat2 - lat1) * Math.PI / 180; const Δλ = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; // Distance in meters }; // ==================== CREATE EVENT ==================== export const createEvent = async (req: IEventRequest, res: Response) => { try { const userId = req.userId; const user = await User.findById(userId).populate('organization'); if (!user) { return res.status(404).json({ message: 'User not found' }); } // Allow trainers, organizations, and admins to create events const allowedRoles = ['trainer', 'organization', 'admin']; if (!allowedRoles.includes(user.role)) { return res.status(403).json({ message: 'Only trainers, organizations, and admins can create events' }); } // Verification checks based on role if (user.role === 'trainer') { // Check if trainer is verified if (user.organizationVerificationStatus !== 'verified') { return res.status(403).json({ message: 'Only verified trainers can create events', verificationStatus: user.organizationVerificationStatus }); } } else if (user.role === 'organization') { // Check if organization is verified if (user.verificationStatus !== 'approved') { return res.status(403).json({ message: 'Only verified organizations can create events', verificationStatus: user.verificationStatus }); } } // Admins don't need verification checks const { title, theme, description, capacity, startDate, endDate, type, venue, longitude, latitude, geofenceRadius, onlineLink, allowSelfJoin = true, defaultStartTime = "09:00", defaultEndTime = "17:00" } = req.body; // Validation if (!title || !theme || !capacity || !startDate || !endDate || !type) { return res.status(400).json({ message: 'Missing required fields' }); } if (new Date(endDate) <= new Date(startDate)) { return res.status(400).json({ message: 'End date must be after start date' }); } // Validate time format (HH:MM) const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/; if (!timeRegex.test(defaultStartTime) || !timeRegex.test(defaultEndTime)) { return res.status(400).json({ message: 'Invalid time format. Use HH:MM (e.g., 16:00)' }); } // Validate end time is after start time const [startHour, startMin] = defaultStartTime.split(':').map(Number); const [endHour, endMin] = defaultEndTime.split(':').map(Number); if (endHour < startHour || (endHour === startHour && endMin <= startMin)) { return res.status(400).json({ message: 'End time must be after start time' }); } if ((type === 'in-person' || type === 'hybrid') && (!longitude || !latitude || !venue)) { return res.status(400).json({ message: 'Location (longitude, latitude) and venue required for in-person/hybrid events' }); } if ((type === 'online' || type === 'hybrid') && !onlineLink) { return res.status(400).json({ message: 'Online link required for online/hybrid events' }); } // Generate unique code and join token // Determine organization based on user role let eventOrganization: any; let orgName: string; if (user.role === 'trainer') { // Trainers: use their organization eventOrganization = user.organization; orgName = (user.organization as any)?.username || 'ORG'; } else if (user.role === 'organization') { // Organizations: use themselves as the organization eventOrganization = userId; orgName = user.username || 'ORG'; } else { // Admins: use their organization if they have one, otherwise use their own ID eventOrganization = user.organization || userId; orgName = user.organization ? (user.organization as any)?.username : user.username || 'ORG'; } const code = await generateEventCode(orgName, theme); const joinToken = crypto.randomBytes(32).toString('hex'); const eventData: any = { title, code, theme, description, organization: eventOrganization, createdBy: userId, capacity, startDate: new Date(startDate), endDate: new Date(endDate), defaultStartTime, defaultEndTime, type, joinToken, allowSelfJoin, status: 'published' }; if (type === 'in-person' || type === 'hybrid') { eventData.venue = venue; eventData.location = { type: 'Point', coordinates: [parseFloat(longitude), parseFloat(latitude)] }; eventData.geofenceRadius = geofenceRadius || 100; } if (type === 'online' || type === 'hybrid') { eventData.onlineLink = onlineLink; } const event = new Event(eventData); await event.save(); return res.status(201).json({ message: 'Event created successfully', event: { _id: event._id, title: event.title, code: event.code, theme: event.theme, startDate: event.startDate, endDate: event.endDate, type: event.type, capacity: event.capacity, joinToken: event.joinToken } }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== SEARCH EVENTS ==================== export const searchEvents = async (req: IEventRequest, res: Response) => { try { const { query, // text search theme, organization, type, status = 'published', startDate, endDate, page = 1, limit = 20 } = req.query; const filter: any = { status }; // Text search on title, theme, description if (query) { filter.$text = { $search: query as string }; } if (theme) filter.theme = theme; if (organization) filter.organization = organization; if (type) filter.type = type; // Date range filter if (startDate || endDate) { filter.startDate = {}; if (startDate) filter.startDate.$gte = new Date(startDate as string); if (endDate) filter.startDate.$lte = new Date(endDate as string); } const skip = (Number(page) - 1) * Number(limit); const events = await Event.find(filter) .populate('organization', 'username organizationType') .populate('createdBy', 'username profilePhoto') .select('-joinToken -participants.attendance') .sort({ startDate: 1 }) .skip(skip) .limit(Number(limit)); const total = await Event.countDocuments(filter); // Add participant count to each event const eventsWithCount = events.map(event => ({ ...event.toObject(), participantCount: event.participants.length, isCapacityFull: event.participants.length >= event.capacity })); return res.json({ events: eventsWithCount, pagination: { total, page: Number(page), limit: Number(limit), totalPages: Math.ceil(total / Number(limit)) } }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== GET NEARBY EVENTS ==================== export const getNearbyEvents = async (req: IEventRequest, res: Response) => { try { const { longitude, latitude, maxDistance = 50000 } = req.query; // default 50km if (!longitude || !latitude) { return res.status(400).json({ message: 'Location (longitude, latitude) required' }); } const events = await Event.find({ status: 'published', location: { $near: { $geometry: { type: 'Point', coordinates: [parseFloat(longitude as string), parseFloat(latitude as string)] }, $maxDistance: Number(maxDistance) } }, startDate: { $gte: new Date() } // Only upcoming events }) .populate('organization', 'username organizationType') .populate('createdBy', 'username profilePhoto') .select('-joinToken -participants.attendance') .limit(20); const eventsWithDistance = events.map(event => { const distance = event.location ? calculateDistance( parseFloat(latitude as string), parseFloat(longitude as string), event.location.coordinates[1], event.location.coordinates[0] ) : null; return { ...event.toObject(), distance: distance ? Math.round(distance) : null, participantCount: event.participants.length, isCapacityFull: event.participants.length >= event.capacity }; }); return res.json({ events: eventsWithDistance }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== GET EVENT DASHBOARD ==================== export const getEventDashboard = async (req: IEventRequest, res: Response) => { try { const userId = req.userId; const now = new Date(); // User's upcoming events const upcomingEvents = await Event.find({ 'participants.user': userId, startDate: { $gt: now }, status: 'published' }) .populate('organization', 'username') .sort({ startDate: 1 }) .limit(10); // User's past events const pastEvents = await Event.find({ 'participants.user': userId, endDate: { $lt: now }, status: { $in: ['completed', 'published'] } }) .populate('organization', 'username') .sort({ endDate: -1 }) .limit(10); // Recommended events (same themes as user's past events) const userThemes = [...new Set(pastEvents.map(e => e.theme))]; const recommended = await Event.find({ status: 'published', startDate: { $gt: now }, theme: { $in: userThemes }, 'participants.user': { $ne: userId } }) .populate('organization', 'username') .limit(5); return res.json({ upcoming: upcomingEvents.map(e => ({ ...e.toObject(), participantCount: e.participants.length })), past: pastEvents.map(e => { const participantCount = e.participants.length; return { ...e.toObject(), participantCount }; }), recommended: recommended.map(e => ({ ...e.toObject(), participantCount: e.participants.length })) }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== JOIN EVENT BY CODE ==================== export const joinEventByCode = async (req: IEventRequest, res: Response) => { try { const userId = req.userId; const { code } = req.body; if (!code) { return res.status(400).json({ message: 'Event code required' }); } const event = await Event.findOne({ code: code.toUpperCase(), status: 'published' }); if (!event) { return res.status(404).json({ message: 'Event not found or not published' }); } // Check if already joined if (event.participants.find(p => p.user.toString() === userId)) { return res.status(400).json({ message: 'Already joined this event' }); } // Check capacity if (event.participants.length >= event.capacity) { // Add to waitlist if (!event.waitlist.includes(userId as any)) { event.waitlist.push(userId as any); await event.save(); } return res.status(400).json({ message: 'Event is full. You have been added to the waitlist.', waitlisted: true }); } // Add participant event.participants.push({ user: userId as any, joinedAt: new Date(), joinMethod: 'code' } as any); await event.save(); // Send confirmation email const user = await User.findById(userId); if (user) { await sendMail({ to: user.email, subject: `Registered for ${event.title}`, text: `You have successfully registered for ${event.title} (${event.code})`, html: `

You have successfully registered for ${event.title}

Code: ${event.code}

Start: ${event.startDate.toLocaleString()}

` }); } return res.json({ message: 'Successfully joined event', event: { _id: event._id, title: event.title, code: event.code, startDate: event.startDate, endDate: event.endDate, type: event.type, venue: event.venue, onlineLink: event.onlineLink } }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== JOIN EVENT BY LINK ==================== export const joinEventByLink = async (req: IEventRequest, res: Response) => { try { const userId = req.userId; const { eventId, token } = req.body; if (!eventId || !token) { return res.status(400).json({ message: 'Event ID and token required' }); } const event = await Event.findOne({ _id: eventId, joinToken: token, status: 'published' }); if (!event) { return res.status(404).json({ message: 'Invalid event link' }); } // Check if already joined if (event.participants.find(p => p.user.toString() === userId)) { return res.status(400).json({ message: 'Already joined this event' }); } // Check capacity if (event.participants.length >= event.capacity) { if (!event.waitlist.includes(userId as any)) { event.waitlist.push(userId as any); await event.save(); } return res.status(400).json({ message: 'Event is full. You have been added to the waitlist.', waitlisted: true }); } // Add participant event.participants.push({ user: userId as any, joinedAt: new Date(), joinMethod: 'link' } as any); await event.save(); return res.json({ message: 'Successfully joined event', event: { _id: event._id, title: event.title, code: event.code, startDate: event.startDate, endDate: event.endDate } }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== GET EVENT DETAILS ==================== export const getEventDetails = async (req: IEventRequest, res: Response) => { try { const { eventId } = req.params; const userId = req.userId; const event = await Event.findById(eventId) .populate('organization', 'username email organizationType') .populate('createdBy', 'username email profilePhoto workDesignation') .populate('participants.user', 'username profilePhoto'); if (!event) { return res.status(404).json({ message: 'Event not found' }); } const isParticipant = event.participants.find(p => p.user._id.toString() === userId); const isCreator = event.createdBy._id.toString() === userId; const response: any = { ...event.toObject(), participantCount: event.participants.length, isCapacityFull: event.participants.length >= event.capacity, isJoined: !!isParticipant, isCreator }; // Only show join token to creator if (!isCreator) { delete response.joinToken; } // Only show participant details if joined or creator if (!isParticipant && !isCreator) { response.participants = undefined; } return res.json(response); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== GET CREATOR'S EVENTS ==================== export const getTrainerEvents = async (req: IEventRequest, res: Response) => { try { const creatorId = req.userId; const { status } = req.query; const filter: any = { createdBy: creatorId }; if (status) filter.status = status; const events = await Event.find(filter) .populate('organization', 'username') .sort({ startDate: -1 }); // Get day-level statistics for each event const eventsWithStats = await Promise.all(events.map(async event => { const eventDays = await EventDay.find({ event: event._id }); const totalDays = eventDays.length; const completedDays = eventDays.filter(day => day.isCompleted).length; // Calculate total attendance across all days const totalAttendance = eventDays.reduce((sum, day) => sum + day.attendance.length, 0); const averageAttendancePerDay = totalDays > 0 ? totalAttendance / totalDays : 0; return { ...event.toObject(), participantCount: event.participants.length, totalDays, completedDays, averageAttendancePerDay: Math.round(averageAttendancePerDay * 100) / 100, waitlistCount: event.waitlist.length }; })); return res.json({ events: eventsWithStats }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== UPDATE EVENT ==================== export const updateEvent = async (req: IEventRequest, res: Response) => { try { const creatorId = req.userId; const { eventId } = req.params; const updates = req.body; const event = await Event.findOne({ _id: eventId, createdBy: creatorId }); if (!event) { return res.status(404).json({ message: 'Event not found or unauthorized' }); } // Don't allow changing certain fields delete updates.code; delete updates.joinToken; delete updates.organization; delete updates.createdBy; delete updates.participants; Object.assign(event, updates); await event.save(); return res.json({ message: 'Event updated successfully', event }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== DELETE EVENT ==================== export const deleteEvent = async (req: IEventRequest, res: Response) => { try { const creatorId = req.userId; const { eventId } = req.params; const event = await Event.findOne({ _id: eventId, createdBy: creatorId }); if (!event) { return res.status(404).json({ message: 'Event not found or unauthorized' }); } // Delete all associated EventDay records and photos await deleteEventDays(eventId); // Delete the event await Event.findByIdAndDelete(eventId); return res.json({ message: 'Event deleted successfully' }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== GET ORGANIZATION EVENT STATS ==================== export const getOrganizationEventStats = async (req: IEventRequest, res: Response) => { try { const { organizationId } = req.params; // Calculate date range (last 365 days) const endDate = new Date(); const startDate = new Date(); startDate.setFullYear(startDate.getFullYear() - 1); const events = await Event.aggregate([ { $match: { organization: new mongoose.Types.ObjectId(organizationId), startDate: { $gte: startDate, $lte: endDate }, status: { $in: ['published', 'ongoing', 'completed'] } } }, { $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$startDate" } }, count: { $sum: 1 } } }, { $project: { date: "$_id", count: 1, _id: 0 } } ]); return res.json({ stats: events }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== GET LIVE EVENTS ==================== // Returns events that are currently ongoing (within date range) but don't have started training sessions export const getLiveEvents = async (req: IEventRequest, res: Response) => { try { const now = new Date(); // Find all events that have started training sessions const startedTrainingDays = await EventDay.find({ hasStarted: true, isCompleted: false }).distinct('event'); // Find events that are within their date range but NOT in the started training list const ongoingEvents = await Event.find({ status: { $in: ['published', 'ongoing'] }, startDate: { $lte: now }, endDate: { $gte: now }, _id: { $nin: startedTrainingDays }, // Exclude events with started training location: { $exists: true, $ne: null } // Only events with location }) .populate('organization', 'username organizationType') .populate('createdBy', 'username profilePhoto') .select('-joinToken'); const eventsWithCount = ongoingEvents.map(event => ({ ...event.toObject(), participantCount: event.participants.length, isCapacityFull: event.participants.length >= event.capacity })); return res.json({ events: eventsWithCount }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== GET LIVE TRAINING EVENTS ==================== export const getLiveTrainingEvents = async (req: IEventRequest, res: Response) => { try { const now = new Date(); // Find all event days that have started but not completed const liveTrainingDays = await EventDay.find({ hasStarted: true, isCompleted: false }).populate({ path: 'event', populate: [ { path: 'organization', select: 'username organizationType' }, { path: 'createdBy', select: 'username profilePhoto' } ] }); // Filter and map to events with location data const liveTrainingEvents = liveTrainingDays .filter(eventDay => { const event = eventDay.event as any; // Only include events with location data return event && event.location && event.location.coordinates; }) .map(eventDay => { const event = eventDay.event as any; return { ...event.toObject(), currentEventDay: { _id: eventDay._id, date: eventDay.date, dayNumber: eventDay.dayNumber, startTime: eventDay.startTime, endTime: eventDay.endTime, hasStarted: eventDay.hasStarted, isCompleted: eventDay.isCompleted }, participantCount: event.participants.length, isCapacityFull: event.participants.length >= event.capacity, isLiveTraining: true }; }); // Remove duplicates (same event can have multiple live days) const uniqueEvents = Array.from( new Map(liveTrainingEvents.map(event => [event._id.toString(), event])).values() ); return res.json({ events: uniqueEvents }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } }; // ==================== GET ALL EVENTS (ADMIN) ==================== export const getAllEventsForAdmin = async (req: IEventRequest, res: Response) => { try { const { status, page = 1, limit = 20 } = req.query; const filter: any = {}; if (status) filter.status = status; const skip = (Number(page) - 1) * Number(limit); const events = await Event.find(filter) .populate('organization', 'username organizationType') .populate('createdBy', 'username profilePhoto') .sort({ createdAt: -1 }) .skip(skip) .limit(Number(limit)); const total = await Event.countDocuments(filter); // Get day-level statistics for each event const eventsWithStats = await Promise.all(events.map(async event => { const eventDays = await EventDay.find({ event: event._id }); const totalDays = eventDays.length; const completedDays = eventDays.filter(day => day.isCompleted).length; const liveDays = eventDays.filter(day => { const now = new Date(); return day.hasStarted && !day.isCompleted && now >= day.startTime && now <= day.endTime; }).length; return { ...event.toObject(), participantCount: event.participants.length, totalDays, completedDays, liveDays, waitlistCount: event.waitlist.length }; })); return res.json({ events: eventsWithStats, pagination: { total, page: Number(page), limit: Number(limit), totalPages: Math.ceil(total / Number(limit)) } }); } catch (err: any) { console.error(err); return res.status(500).json({ message: 'Server error', error: err.message }); } };