/* * ML Integration & Drive Structure (Planned/Current): * * 1. Event Recommendation: * - Endpoint will call external ML API to fetch personalized event recommendations for users (for dashboard). * - Fallback logic should be present if ML API is unavailable. * * 2. Certificate Generation: * - Host selects/uploads certificate template for event. * - Certificate generation endpoint will call ML API, restricted to users marked as 'attended'. * - Generated certificates will be stored and linked to users. * - (Planned) All certificates for an event will be stored in a Drive folder named after the event. * * 3. Event Images: * - Logos and banners are uploaded to separate Drive folders (see driveService.js for details). * * These comments are for documentation and planning only; they do not affect code execution. */ const mongoose = require('mongoose'); const Event = require('../Models/Event'); const User = require('../Models/User'); const EventParticipationLog = require('../Models/EventParticipationLog'); const { uploadEventImage: uploadEventImageLegacy, deleteEventImage, } = require('../Services/driveService'); const qrcode = require('qrcode'); const { createEmailService } = require('../Services/email'); const emailService = createEmailService(); const { notifyUser, notifyUsers } = require('../Services/notification'); const { logger, asyncHandler } = require('../Middleware/errorHandler'); // Create a new event (host/co-host) const createEvent = asyncHandler(async (req, res) => { // Validate required fields const requiredFields = ['title', 'description', 'type', 'location', 'capacity', 'date']; for (const field of requiredFields) { if (!req.body[field]) { return res.status(400).json({ error: `Missing required field: ${field}` }); } } // Validate capacity is a valid positive number const validatedCapacity = parseInt(req.body.capacity); if (isNaN(validatedCapacity) || validatedCapacity < 1) { return res.status(400).json({ error: 'Capacity must be a valid positive number.' }); } // Extract and parse fields (middleware handles JSON parsing for objects/arrays) let { title, description, type, organizationName, date, isPaid, price, audienceType, about, tags, location, requirements, socialLinks, features, sessions, } = req.body; // Handle tags & requirements splits if they came as raw strings not handled by middleware if (typeof tags === 'string') { tags = tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0); } if (typeof requirements === 'string') { requirements = requirements.split('\n').map(req => req.trim()).filter(req => req.length > 0); } if (typeof sessions === 'string') { try { const parsedSessions = JSON.parse(sessions); sessions = Array.isArray(parsedSessions) ? parsedSessions : []; } catch { sessions = []; } } let logoURL, bannerURL; // File upload using storage service if (req.files && req.files['logo']) { const f = req.files['logo'][0]; logoURL = await uploadEventImageLegacy(f.buffer, f.originalname, 'logo', f.mimetype); } if (req.files && req.files['banner']) { const f = req.files['banner'][0]; bannerURL = await uploadEventImageLegacy(f.buffer, f.originalname, 'banner', f.mimetype); } // Validate isPaid and price const eventIsPaid = isPaid === true || isPaid === 'true'; const eventPrice = eventIsPaid ? (typeof price === 'number' ? price : parseFloat(price) || 0) : 0; if (eventIsPaid && eventPrice <= 0) { return res.status(400).json({ error: 'Paid events must have a valid price.' }); } // Parse date from datetime-local input // datetime-local gives us a string like "2025-12-31T12:59" without timezone // We need to treat this as the local time the user intended let eventDate; if (date.includes('T') && !date.includes('Z') && !date.includes('+')) { // datetime-local format (YYYY-MM-DDTHH:MM) - treat as-is without timezone conversion eventDate = new Date(`${date }:00`); // Add seconds if missing } else { eventDate = new Date(date); } if (isNaN(eventDate.getTime())) { return res.status(400).json({ error: 'Invalid event date format.' }); } const event = await Event.create({ title, description, tags, type, organizationName, location, capacity: validatedCapacity, date: eventDate, // Store as Date object in UTC isPaid: eventIsPaid, price: eventPrice, requirements, socialLinks, audienceType: audienceType || 'public', features: features || { certificateEnabled: false, chatEnabled: false }, sessions: sessions || [], about: about || '', logoURL, bannerURL, registeredCount: 0, hostUserId: req.user.id, institutionId: req.user.institutionId, }); // Notify verifiers about new event pending verification try { const User = require('../Models/User'); const verifiers = await User.find({ roles: { $in: ['verifier', 'platformAdmin'] } }).select('_id email name notificationPreferences'); const { notifyUser } = require('../Services/notification'); const host = await User.findById(req.user.id).select('name email'); for (const verifier of verifiers) { await notifyUser({ userId: verifier._id, type: 'event_verification', message: `New event "${event.title}" submitted by ${host?.name || 'Unknown'} requires verification`, link: `/verifier/event-queue?eventId=${event._id}`, data: { eventId: event._id, eventTitle: event.title, hostName: host?.name, hostEmail: host?.email, status: 'pending' }, emailOptions: verifier.notificationPreferences?.email?.event_verification !== false ? { to: verifier.email, subject: `New Event Pending Verification: ${event.title}`, html: `

New Event Requires Verification

Hi ${verifier.name},

A new event has been submitted and requires your verification:

Event Title: ${event.title}

Host: ${host?.name || 'Unknown'} (${host?.email || ''})

Event Type: ${event.type}

Event Date: ${new Date(event.date).toLocaleString()}

Status: Pending Verification

Please review this event in the verifier dashboard and approve or reject it.

Best regards,
CampVerse Team

`, } : null, }); } } catch (notifErr) { // Don't fail event creation if notification fails logger && logger.error ? logger.error('Error sending event verification notifications:', notifErr) : null; } res.status(201).json({ success: true, message: 'Event created successfully', event }); }); // Get event by ID const getEventById = asyncHandler(async (req, res) => { const eventId = req.params.id; const userId = req.user?.id; // Get user ID if authenticated const event = await Event.findById(eventId) .populate('hostUserId', 'name email') // Populate host user details .populate('coHosts', 'name email'); // Populate co-hosts too if (!event) { return res.status(404).json({ success: false, error: 'Event not found.' }); } // Calculate participant count (registered + attended) const participantCount = await EventParticipationLog.countDocuments({ eventId: event._id, status: { $in: ['registered', 'attended'] } }); let userRegistration = null; if (userId) { // Check if user is registered for this event userRegistration = await EventParticipationLog.findOne({ eventId, userId }); } res.json({ success: true, data: { ...event.toObject(), participants: participantCount, // Add calculated count userRegistration: userRegistration ? { status: userRegistration.status, registeredAt: userRegistration.registeredAt, qrCode: userRegistration.qrCode, // Include QR code object qrToken: userRegistration.qrToken // Include legacy token } : null } }); }); // Update event (host/co-host) const updateEvent = asyncHandler(async (req, res) => { logger.info('Update Event Request Body:', JSON.stringify(req.body, null, 2)); // Whitelist-based update construction const allowedFields = [ 'title', 'description', 'type', 'organizationName', 'location', 'capacity', 'date', 'isPaid', 'price', 'tags', 'requirements', 'audienceType', 'features', 'sessions', 'about', 'socialLinks' ]; const updateData = {}; // Only copy allowed fields from req.body allowedFields.forEach(field => { if (req.body[field] !== undefined) { updateData[field] = req.body[field]; } }); // Validate required fields for update (if present in updateData) const updatableFields = ['title', 'description', 'type', 'organizationName', 'location', 'capacity', 'date']; for (const field of updatableFields) { if (field in updateData && !updateData[field]) { return res.status(400).json({ error: `Field cannot be empty: ${field}` }); } } // Handle tags & requirements splits if they came as raw strings if (typeof updateData.tags === 'string') { updateData.tags = updateData.tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0); } if (typeof updateData.requirements === 'string') { updateData.requirements = updateData.requirements.split('\n').map(req => req.trim()).filter(req => req.length > 0); } const event = await Event.findById(req.params.id); if (!event) return res.status(404).json({ error: 'Event not found.' }); if (req.files && req.files['logo']) { if (event.logoURL) await deleteEventImage(event.logoURL); const f = req.files['logo'][0]; updateData.logoURL = await uploadEventImageLegacy(f.buffer, f.originalname, 'logo', f.mimetype); } if (req.files && req.files['banner']) { if (event.bannerURL) await deleteEventImage(event.bannerURL); const f = req.files['banner'][0]; updateData.bannerURL = await uploadEventImageLegacy(f.buffer, f.originalname, 'banner', f.mimetype); } updateData.updatedAt = new Date(); // Validate isPaid and price if present if ('isPaid' in updateData) { updateData.isPaid = updateData.isPaid === true || updateData.isPaid === 'true'; // Use submitted price or default to 0 if not valid number if (updateData.isPaid) { updateData.price = typeof updateData.price === 'number' ? updateData.price : parseFloat(updateData.price) || 0; if (updateData.price <= 0) return res.status(400).json({ error: 'Paid events must have a valid price.' }); } else { updateData.price = 0; } } logger.info('📝 Safe update object:', JSON.stringify(updateData, null, 2)); const updatedEvent = await Event.findByIdAndUpdate(req.params.id, { $set: updateData }, { new: true }); logger.info('✅ Updated event features:', JSON.stringify(updatedEvent.features, null, 2)); res.json(updatedEvent); }); // Delete event (host/co-host) async function deleteEvent(req, res) { try { const event = await Event.findByIdAndDelete(req.params.id); if (!event) return res.status(404).json({ error: 'Event not found.' }); // Delete associated files from storage if (event.logoURL) await deleteEventImage(event.logoURL); if (event.bannerURL) await deleteEventImage(event.bannerURL); res.json({ message: 'Event deleted.' }); } catch (err) { res.status(500).json({ error: 'Error deleting event.' }); } } // RSVP/register for event (user) - Without transactions for standalone MongoDB async function ensureRegisteredCount(event) { if (event.registeredCount === undefined || event.registeredCount === null) { const count = await EventParticipationLog.countDocuments({ eventId: event._id, status: { $in: ['registered', 'attended'] } }); await Event.findByIdAndUpdate(event._id, { $set: { registeredCount: count } }); event.registeredCount = count; } return event.registeredCount; } async function rsvpEvent(req, res) { let seatReserved = false; let eventId; try { ({ eventId } = req.body); const userId = req.user.id; logger && logger.info ? logger.info('🎯 RSVP Request:', { eventId, userId }) : null; // Validate event exists first const event = await Event.findById(eventId); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', message: 'The event you are trying to register for does not exist' }); } // Check if user is already registered const existingLog = await EventParticipationLog.findOne({ userId, eventId }); if (existingLog) { logger && logger.warn ? logger.warn('⚠️ User already registered:', { eventId, userId }) : null; return res.status(409).json({ success: false, error: 'User already registered for this event', message: 'You have already registered for this event' }); } // Check capacity and determine status atomically let status = 'registered'; let qrToken = null; if (event.capacity) { await ensureRegisteredCount(event); const updatedEvent = await Event.findOneAndUpdate( { _id: eventId, registeredCount: { $lt: event.capacity } }, { $inc: { registeredCount: 1 } }, { new: true } ); if (!updatedEvent) { status = 'waitlisted'; } else { seatReserved = true; event.registeredCount = updatedEvent.registeredCount; } } // Generate secure QR token using crypto const crypto = require('crypto'); qrToken = crypto.randomBytes(32).toString('hex'); // Calculate QR expiration (event end + 2 hours) const qrExpiresAt = new Date(event.date); if (event.endDate) { qrExpiresAt.setTime(new Date(event.endDate).getTime() + 2 * 60 * 60 * 1000); } else { qrExpiresAt.setTime(event.date.getTime() + 2 * 60 * 60 * 1000); } // Create new participation log const participationLog = await EventParticipationLog.create({ userId, eventId, status, qrToken, // Legacy field registeredAt: new Date(), qrCode: { token: qrToken, generatedAt: new Date(), expiresAt: qrExpiresAt, isUsed: false } }); logger && logger.info ? logger.info('✅ RSVP Created:', { eventId, userId, status, participationLogId: participationLog._id }) : null; // Update event waitlist if (status === 'waitlisted') { await Event.findByIdAndUpdate( eventId, { $addToSet: { waitlist: userId } } ); } else { // Remove from waitlist if previously waitlisted await Event.findByIdAndUpdate( eventId, { $pull: { waitlist: userId } } ); } // Generate QR code image ONLY for registered users let qrImage = null; let emailSent = false; if (status === 'registered') { try { qrImage = await qrcode.toDataURL(qrToken); logger && logger.info ? logger.info('📧 QR image for email:', { qrImage: typeof qrImage === 'string' ? qrImage.slice(0, 50) + '...' : qrImage }) : null; // Email QR code to user (non-blocking) if (!req.user.email || typeof req.user.email !== 'string' || !req.user.email.includes('@')) { logger && logger.error ? logger.error('❌ Cannot send QR email: recipient email missing or invalid', { userId, eventId, email: req.user.email }) : null; } else { try { await sendQrEmail(req.user.email, qrImage, event.title, eventId); emailSent = true; logger && logger.info ? logger.info('✅ QR code email sent successfully') : null; } catch (emailErr) { logger && logger.error ? logger.error('❌ Failed to send QR email:', emailErr) : null; // Don't fail the RSVP, but inform user } } } catch (qrError) { logger && logger.error ? logger.error('❌ QR generation failed:', qrError) : null; // Continue without QR } } // Send response with appropriate message based on status const responseMessage = status === 'registered' ? (emailSent ? 'RSVP successful! You are registered. QR code sent to email.' : 'RSVP successful! You are registered. Note: Email delivery failed, but QR code is shown below.') : 'RSVP successful! You are on the waitlist for this event.'; res.status(201).json({ success: true, message: responseMessage, qrImage: status === 'registered' ? qrImage : null, status, eventId, emailSent: status === 'registered' ? emailSent : false }); // Async notifications (non-blocking) setImmediate(async () => { try { // Notify user (confirmation) with public event link const publicEventLink = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/events/${eventId}`; await notifyUser({ userId: req.user.id, type: 'rsvp', message: `You have successfully registered for ${event.title}.`, data: { eventId: event._id, eventTitle: event.title, publicEventLink }, link: `/events/${eventId}`, // Link to event details emailOptions: { to: req.user.email, subject: `RSVP Confirmation for ${event.title}`, html: `

Registration Confirmed!

Hi ${req.user.name},

You have successfully registered for ${event.title}.

Event Link:

${publicEventLink}

💡 Visit this link anytime to view your registration details and QR code.

${status === 'registered' ? `

✅ You are confirmed for this event!

Your QR code has been sent in a separate email. You can also view it by clicking the link above.

` : `

⏳ You are currently on the waitlist.

We'll notify you if a spot becomes available.

`}

This is an automated message from CampVerse.

`, }, }); // DO NOT notify host/co-hosts about new registrations (as per requirement) // Hosts can view registrations in their dashboard } catch (notificationError) { logger && logger.error ? logger.error('Notification error:', notificationError) : null; } }); } catch (err) { logger && logger.error ? logger.error('RSVP error:', err) : null; // Roll back reserved seat if log creation failed if (seatReserved && eventId) { try { await Event.findByIdAndUpdate(eventId, { $inc: { registeredCount: -1 } }); } catch (rollbackErr) { logger && logger.error ? logger.error('RSVP rollback error:', rollbackErr) : null; } } // Handle duplicate key error specifically if (err.code === 11000) { return res.status(409).json({ success: false, error: 'User already registered for this event', message: 'You have already registered for this event' }); } res.status(500).json({ success: false, error: 'Error registering for event.', message: 'Failed to register for the event. Please try again.' }); } } // Cancel RSVP for event (user) async function cancelRsvp(req, res) { try { const { eventId } = req.body; const userId = req.user.id; logger && logger.info ? logger.info('🎯 Cancel RSVP Request:', { eventId, userId }) : null; const event = await Event.findById(eventId); if (!event) { return res.status(404).json({ success: false, error: 'Event not found.', message: 'The event you are trying to cancel RSVP for does not exist' }); } // Ensure registeredCount is initialized for atomic updates await ensureRegisteredCount(event); // Find participation log const log = await EventParticipationLog.findOne({ eventId, userId, }); if (!log) { return res.status(404).json({ success: false, error: 'No RSVP found for this user.', message: 'You have not registered for this event' }); } // Check if attendance was already marked (QR was scanned) if (log.status === 'attended' || (log.qrCode && log.qrCode.isUsed)) { return res.status(403).json({ success: false, error: 'Cannot cancel RSVP after attendance has been marked.', message: 'Your attendance has already been recorded. You cannot cancel your registration.' }); } // Delete the participation log await EventParticipationLog.findByIdAndDelete(log._id); const freedSeat = log.status === 'registered'; // Remove from event waitlist and decrement registeredCount if needed const cancelUpdate = { $pull: { waitlist: userId } }; if (freedSeat) { cancelUpdate.$inc = { registeredCount: -1 }; } await Event.findByIdAndUpdate(eventId, cancelUpdate); // If user was registered, promote first waitlisted user if (freedSeat && event.waitlist.length > 0) { const nextUserId = event.waitlist[0]; // Update their participation log const nextLog = await EventParticipationLog.findOne({ eventId, userId: nextUserId, status: 'waitlisted', }); if (nextLog) { nextLog.status = 'registered'; // Generate QR code for promoted user const crypto = require('crypto'); const newQrToken = crypto.randomBytes(32).toString('hex'); nextLog.qrToken = newQrToken; // Calculate QR expiration const qrExpiresAt = new Date(event.date); if (event.endDate) { qrExpiresAt.setTime(new Date(event.endDate).getTime() + 2 * 60 * 60 * 1000); } else { qrExpiresAt.setTime(event.date.getTime() + 2 * 60 * 60 * 1000); } nextLog.qrCode = { token: newQrToken, generatedAt: new Date(), expiresAt: qrExpiresAt, isUsed: false }; await nextLog.save(); // Remove promoted user from waitlist and restore registeredCount await Event.findByIdAndUpdate(eventId, { $pull: { waitlist: nextUserId }, $inc: { registeredCount: 1 } }); // Notify promoted user const promotedUser = await User.findById(nextUserId); if (promotedUser) { // Generate and send QR code try { const qrImage = await qrcode.toDataURL(newQrToken); await sendQrEmail(promotedUser.email, qrImage, event.title, eventId); } catch (qrError) { logger && logger.error ? logger.error('❌ Failed to send QR to promoted user:', qrError) : null; } await notifyUser({ userId: nextUserId, type: 'rsvp', message: `You have been promoted from waitlist to registered for ${event.title}.`, data: { eventId, eventTitle: event.title }, emailOptions: { to: promotedUser.email, subject: `You're Registered for ${event.title}!`, html: `

🎉 You're Registered!

Hi ${promotedUser.name},

You have been promoted from the waitlist and are now registered for ${event.title}!

View Event Details

Best regards,
CampVerse Team

`, }, }); } // Remove from waitlist event.waitlist = event.waitlist.slice(1); } } await event.save(); logger && logger.info ? logger.info('✅ RSVP Cancelled:', { eventId, userId, wasRegistered: log.status === 'registered' }) : null; // Notify user of cancellation setImmediate(async () => { try { await notifyUser({ userId: req.user.id, type: 'rsvp', message: `You have cancelled your RSVP for ${event.title}.`, data: { eventId: event._id, eventTitle: event.title }, emailOptions: { to: req.user.email, subject: `RSVP Cancelled for ${event.title}`, html: `

Hi ${req.user.name},
Your RSVP for ${event.title} has been cancelled.

`, }, }); } catch (notificationError) { logger && logger.error ? logger.error('Notification error:', notificationError) : null; } }); res.json({ success: true, message: 'RSVP cancelled successfully.' }); } catch (err) { logger && logger.error ? logger.error('Cancel RSVP error:', err) : null; res.status(500).json({ success: false, error: 'Error cancelling RSVP.', message: 'Failed to cancel RSVP. Please try again.' }); } } // Email QR code to user async function sendQrEmail(to, qrImage, eventTitle, eventId) { const publicEventLink = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/events/${eventId}`; const qrViewLink = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/events/${eventId}/qr`; // Convert data URL to buffer for attachment const base64Data = qrImage.replace(/^data:image\/png;base64,/, ''); const qrBuffer = Buffer.from(base64Data, 'base64'); await emailService.sendMail({ from: `CampVerse <${process.env.EMAIL_USER}>`, to, subject: `Your QR Code for ${eventTitle}`, html: `

Your Event Ticket

Hi there!

Here is your QR code for ${eventTitle}:

Event QR Code

Important: This QR code is unique to you and this event only.

📍 Event Page:

${publicEventLink}

💡 Visit this link anytime to view your registration details and QR code.

Or view your QR code directly: ${qrViewLink}

Show this QR code at the event entrance for check-in.

⚠️ This QR code expires 2 hours after the event ends and can only be used once.


This is an automated message from CampVerse. Please do not reply to this email.

`, attachments: [ { filename: 'qr-code.png', content: qrBuffer, contentType: 'image/png', cid: 'qr-code.png' // Content-ID for inline embedding } ] }); } // Get participants (host/co-host only) async function getParticipants(req, res) { try { const eventId = req.params.id; const page = parseInt(req.query.page || '1', 10); const limit = parseInt(req.query.limit || '50', 10); const mode = req.query.mode; const usePagination = Number.isFinite(page) || Number.isFinite(limit) || mode; if (!usePagination || (!req.query.page && !req.query.limit && !mode)) { const logs = await EventParticipationLog.find({ eventId }); const users = await User.find({ _id: { $in: logs.map((l) => l.userId) } }); const participants = logs.map((log) => { const user = users.find((u) => u._id.equals(log.userId)); return { userId: log.userId, name: user?.name, email: user?.email, phone: user?.phone, profilePhoto: user?.profilePhoto, paymentType: log.paymentType, paymentStatus: log.paymentStatus, status: log.status, attended: log.status === 'attended', attendanceTimestamp: log.attendanceTimestamp, }; }); return res.json(participants); } const pageNumber = Math.max(page, 1); const limitNumber = Math.min(Math.max(limit, 1), 200); const skip = (pageNumber - 1) * limitNumber; const total = await EventParticipationLog.countDocuments({ eventId }); const totalPages = Math.max(Math.ceil(total / limitNumber), 1); let logsQuery = EventParticipationLog.find({ eventId }) .sort({ registeredAt: -1 }) .skip(skip) .limit(limitNumber); if (mode !== 'ids') { logsQuery = logsQuery.populate('userId', 'name email phone profilePhoto'); } const logs = await logsQuery; const participants = logs.map((log) => { if (mode === 'ids') { return { userId: log.userId, status: log.status, attended: log.status === 'attended', }; } const user = log.userId || {}; return { userId: user._id || log.userId, name: user.name, email: user.email, phone: user.phone, profilePhoto: user.profilePhoto, paymentType: log.paymentType, paymentStatus: log.paymentStatus, status: log.status, attended: log.status === 'attended', attendanceTimestamp: log.attendanceTimestamp, }; }); const attendedCount = await EventParticipationLog.countDocuments({ eventId, status: 'attended' }); return res.json({ participants, total, totalPages, page: pageNumber, limit: limitNumber, stats: { attended: attendedCount, notAttended: Math.max(total - attendedCount, 0), }, }); } catch (err) { res.status(500).json({ error: 'Error fetching participants.' }); } } // Host/co-host scans QR to mark attendance async function scanQr(req, res) { try { const { eventId, qrToken } = req.body; logger && logger.info ? logger.info('📱 QR Scan Request:', { eventId, qrToken: qrToken ? qrToken.substring(0, 10) + '...' : 'missing', body: req.body }) : null; if (!eventId || !qrToken) { return res.status(400).json({ success: false, error: 'eventId and qrToken are required.', message: 'Missing required fields', debug: { receivedEventId: !!eventId, receivedQrToken: !!qrToken } }); } // Find event and check permissions const event = await Event.findById(eventId); if (!event) { return res.status(404).json({ success: false, error: 'Event not found.', message: 'The event does not exist' }); } const userId = req.user.id; const isHost = event.hostUserId && event.hostUserId.toString() === userId; const isCoHost = event.coHosts && event.coHosts.map((id) => id.toString()).includes(userId); if (!isHost && !isCoHost) { return res.status(403).json({ success: false, error: 'Only host or approved co-host can scan attendance for this event.', message: 'You do not have permission to scan QR codes for this event' }); } // Find participation log first by token // This allows us to give better error messages (e.g. "Ticket is for a different event") const log = await EventParticipationLog.findOne({ $or: [ { qrToken }, { 'qrCode.token': qrToken } ] }); if (!log) { return res.status(404).json({ success: false, error: 'Invalid QR code.', message: 'QR code not found or invalid' }); } // Verify event ID matches if (log.eventId.toString() !== eventId) { const ticketEvent = await Event.findById(log.eventId).select('title'); return res.status(400).json({ success: false, error: 'Event mismatch.', message: `This ticket is for a different event: "${ticketEvent ? ticketEvent.title : 'Unknown Event'}". Please scan a ticket for the current event.` }); } // Check if QR code is already used (enhanced system) if (log.qrCode && log.qrCode.isUsed) { return res.status(409).json({ success: false, error: 'QR code has already been used.', message: `This QR code has already been scanned by ${log.qrCode.usedBy ? 'a host' : 'unknown'} on ${new Date(log.qrCode.usedAt).toLocaleString()}` }); } // Check if QR code is expired (enhanced system) if (log.qrCode && log.qrCode.expiresAt && new Date() > log.qrCode.expiresAt) { return res.status(410).json({ success: false, error: 'QR code has expired.', message: 'This QR code has expired' }); } // Check timing window (Allow scanning 2 hours before and up to 12 hours after event start) const now = new Date(); const eventTime = new Date(event.date); const windowStart = new Date(eventTime.getTime() - 2 * 60 * 60 * 1000); const windowEnd = new Date(eventTime.getTime() + 12 * 60 * 60 * 1000); if (now < windowStart) { return res.status(400).json({ success: false, error: 'Event not yet started.', message: `Attendance scanning will open at ${windowStart.toLocaleTimeString()} on ${windowStart.toLocaleDateString()}` }); } if (now > windowEnd) { return res.status(400).json({ success: false, error: 'Event has ended.', message: 'Attendance scanning window has closed for this event.' }); } // Check if attendance already marked (legacy check) if (log.status === 'attended') { return res.status(409).json({ success: false, error: 'Attendance already marked for this user.', message: `Attendance has already been marked for this user at ${new Date(log.attendanceTimestamp).toLocaleTimeString()}` }); } // Only registered users can have attendance marked if (log.status !== 'registered') { return res.status(400).json({ success: false, error: 'Only registered participants can check in.', message: 'User must be registered (not waitlisted) to check in' }); } // Mark attendance log.status = 'attended'; log.attendanceTimestamp = new Date(); log.attendanceMarkedBy = userId; log.attendanceMarkedAt = new Date(); // Invalidate QR code (enhanced system) if (log.qrCode) { log.qrCode.isUsed = true; log.qrCode.usedAt = new Date(); log.qrCode.usedBy = userId; } await log.save(); logger && logger.info ? logger.info('✅ Attendance marked:', { eventId, participantId: log.userId, scannedBy: userId }) : null; // Fetch participant user for notification const participant = await User.findById(log.userId); if (participant) { setImmediate(async () => { try { await notifyUser({ userId: log.userId, type: 'attendance', message: `Your attendance for ${event.title} has been marked. Thank you for participating!`, data: { eventId, eventTitle: event.title }, emailOptions: { to: participant.email, subject: `Attendance Confirmed for ${event.title}`, html: `

Hi ${participant.name},
Your attendance for ${event.title} has been successfully marked. Thank you for participating!

`, }, }); } catch (notificationError) { logger && logger.error ? logger.error('Notification error:', notificationError) : null; } }); } res.json({ success: true, message: 'Attendance marked successfully. QR code invalidated.', participantName: participant?.name || 'Unknown', user: { id: participant?._id, name: participant?.name, email: participant?.email, profilePhoto: participant?.profilePhoto } }); } catch (err) { logger && logger.error ? logger.error('Error in scanQr:', err) : null; res.status(500).json({ success: false, error: 'Error marking attendance.', message: 'Failed to mark attendance. Please try again.' }); } } // Event analytics (host/co-host only) // Event analytics (host/co-host only) async function getEventAnalytics(req, res) { try { const eventId = req.params.id; // Validate Event ID if (!eventId || !mongoose.Types.ObjectId.isValid(eventId)) { return res.status(400).json({ error: 'Invalid Event ID' }); } const pipeline = [ { $match: { eventId: new mongoose.Types.ObjectId(eventId) } }, { $facet: { "stats": [ { $group: { _id: null, totalRegistered: { $sum: 1 }, totalAttended: { $sum: { $cond: [{ $eq: ['$status', 'attended'] }, 1, 0] } }, totalWaitlisted: { $sum: { $cond: [{ $eq: ['$status', 'waitlisted'] }, 1, 0] } }, totalPaid: { $sum: { $cond: [{ $eq: ['$paymentType', 'paid'] }, 1, 0] } }, totalFree: { $sum: { $cond: [{ $eq: ['$paymentType', 'free'] }, 1, 0] } }, paymentSuccess: { $sum: { $cond: [{ $eq: ['$paymentStatus', 'success'] }, 1, 0] } }, paymentPending: { $sum: { $cond: [{ $eq: ['$paymentStatus', 'pending'] }, 1, 0] } }, } } ], "gender": [ { $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }, { $unwind: { path: '$user', preserveNullAndEmptyArrays: false } }, { $group: { _id: "$user.gender", count: { $sum: 1 } } }, { $sort: { count: -1 } } ], "institution": [ { $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }, { $unwind: { path: '$user', preserveNullAndEmptyArrays: false } }, { $lookup: { from: 'institutions', localField: 'user.institutionId', foreignField: '_id', as: 'institution' } }, { $project: { institutionName: { $arrayElemAt: ["$institution.name", 0] } } }, { $group: { _id: "$institutionName", count: { $sum: 1 } } }, { $sort: { count: -1 } }, { $limit: 5 } ] } } ]; const finalResult = await EventParticipationLog.aggregate(pipeline); // Safety check for empty results if (!finalResult || finalResult.length === 0 || !finalResult[0].stats || finalResult[0].stats.length === 0) { return res.json({ success: true, data: { totalRegistered: 0, totalAttended: 0, totalWaitlisted: 0, totalPaid: 0, totalFree: 0, paymentSuccess: 0, paymentPending: 0, attendanceRate: 0, demographics: { gender: [], institution: [] } } }); } const stats = finalResult[0].stats[0]; const gender = finalResult[0].gender || []; const institution = finalResult[0].institution || []; return res.json({ success: true, data: { totalRegistered: stats.totalRegistered || 0, totalAttended: stats.totalAttended || 0, totalWaitlisted: stats.totalWaitlisted || 0, totalPaid: stats.totalPaid || 0, totalFree: stats.totalFree || 0, paymentSuccess: stats.paymentSuccess || 0, paymentPending: stats.paymentPending || 0, attendanceRate: (stats.totalRegistered || 0) > 0 ? ((stats.totalAttended || 0) / (stats.totalRegistered || 1) * 100).toFixed(2) : 0, demographics: { gender: gender, institution: institution } } }); } catch (err) { logger.error('Error in getEventAnalytics:', err); res.status(500).json({ success: false, error: 'Error fetching analytics.', details: err.message }); } } // Host-wide analytics (aggregated across all events) async function getHostAnalytics(req, res) { try { let userId = req.user.id; // Allow admin/verifier to view other host's analytics if ((req.user.role === 'admin' || req.user.role === 'verifier') && req.query.hostId) { userId = req.query.hostId; } // Find all events hosted by user const events = await Event.find({ $or: [{ hostUserId: userId }, { coHosts: userId }] }).select('_id status title isPaid price'); const eventIds = events.map(e => e._id); const pipeline = [ { $match: { eventId: { $in: eventIds } } }, { $facet: { "overview": [ { $group: { _id: null, totalParticipants: { $sum: 1 }, totalRevenue: { $sum: { $cond: [{ $eq: ['$paymentStatus', 'success'] }, '$amount', 0] } } } } ], "genderDemographics": [ { $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }, { $unwind: '$user' }, { $group: { _id: "$user.gender", count: { $sum: 1 } } }, { $sort: { count: -1 } } ], "institutionDemographics": [ { $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }, { $unwind: '$user' }, { $lookup: { from: 'institutions', localField: 'user.institutionId', foreignField: '_id', as: 'institution' } }, { $project: { institutionName: { $arrayElemAt: ["$institution.name", 0] } } }, { $group: { _id: "$institutionName", count: { $sum: 1 } } }, { $sort: { count: -1 } }, { $limit: 5 } ], "year": [ { $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }, { $unwind: '$user' }, { $group: { _id: { $year: "$user.createdAt" }, count: { $sum: 1 } } } ], "recentActivity": [ { $sort: { createdAt: -1 } }, { $limit: 4 }, { $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }, { $unwind: '$user' }, { $lookup: { from: 'events', localField: 'eventId', foreignField: '_id', as: 'event' } }, { $unwind: '$event' } ] } } ]; const hostResult = await EventParticipationLog.aggregate(pipeline); // Safety check for empty results if (!hostResult || hostResult.length === 0 || !hostResult[0].overview || hostResult[0].overview.length === 0) { return res.json({ success: true, data: { overview: { totalEvents: events.length, totalParticipants: 0, totalRevenue: 0, avgRating: 0, completionRate: 0, growthRate: 0 }, eventPerformance: [], demographics: { gender: [], institution: [] }, registrationTrends: { labels: [], datasets: [] }, recentActivity: [], eventStats: { completed: 0, ongoing: 0, upcoming: 0 } } }); } const overview = hostResult[0].overview[0]; const gender = hostResult[0].genderDemographics || []; const institution = hostResult[0].institutionDemographics || []; // Calculate Per Event Stats const eventStatsAggregate = await EventParticipationLog.aggregate([ { $match: { eventId: { $in: eventIds } } }, { $group: { _id: "$eventId", count: { $sum: 1 }, revenue: { $sum: { $cond: [{ $eq: ['$paymentStatus', 'success'] }, '$amount', 0] } } } } ]); const eventPerformance = events.map(e => { const stat = eventStatsAggregate.find(s => s._id.equals(e._id)) || { count: 0, revenue: 0 }; return { name: e.title, participants: stat.count, revenue: stat.revenue || (e.isPaid ? e.price * stat.count : 0), status: e.status || 'upcoming', rating: 4.5 }; }).sort((a,b) => b.participants - a.participants).slice(0, 5); return res.json({ success: true, data: { overview: { totalEvents: events.length, totalParticipants: overview.totalParticipants || 0, totalRevenue: overview.totalRevenue || 0, avgRating: 4.5, completionRate: events.length > 0 ? Math.round((events.filter(e => e.status === 'completed').length / events.length) * 100) : 0, growthRate: 12 }, eventPerformance, demographics: { gender: gender, institution: institution }, registrationTrends: [], recentActivity: [], eventStats: { completed: events.filter(e => e.status === 'completed').length, ongoing: events.filter(e => e.status === 'ongoing').length, upcoming: events.filter(e => e.status === 'upcoming').length } } }); } catch (err) { logger.error('Error in getHostAnalytics:', err); res.status(500).json({ success: false, error: 'Error fetching host analytics.', details: err.message }); } } // Nominate co-host (main host) async function nominateCoHost(req, res) { try { const { eventId, userId } = req.body; if (!eventId || !userId) { return res .status(400) .json({ error: 'eventId and userId are required.' }); } const event = await Event.findById(eventId); if (!event) return res.status(404).json({ error: 'Event not found.' }); // Validate that the user exists and is registered on the platform const nominatedUser = await User.findById(userId); if (!nominatedUser) { return res .status(404) .json({ error: 'User not found. Please ensure the user is registered on the platform.' }); } // Only host can nominate if (event.hostUserId.toString() !== req.user.id) { return res .status(403) .json({ error: 'Only the main host can nominate co-hosts.' }); } // Validate domain match for institutional events if host is from specific domain (e.g. cuj.ac.in) if (event.audienceType === 'institution' && event.institutionId) { const hostUser = await User.findById(event.hostUserId); if (hostUser && hostUser.email.endsWith('@cuj.ac.in')) { if (!nominatedUser.email.endsWith('@cuj.ac.in')) { return res.status(400).json({ error: 'For institutional events at Central University of Jharkhand, co-hosts must also be from the @cuj.ac.in domain.' }); } } } // NEW: Check if already a host or co-host if (event.hostUserId.toString() === userId || event.coHosts?.some(id => id.toString() === userId)) { return res.status(400).json({ error: 'User is already a host or co-host for this event.' }); } // Prevent duplicate nominations if ( event.coHostRequests.some( (r) => r.userId.toString() === userId && r.status === 'pending', ) ) { return res .status(400) .json({ error: 'Co-host nomination already pending.' }); } const crypto = require('crypto'); const token = crypto.randomBytes(32).toString('hex'); event.coHostRequests.push({ userId, status: 'pending', requestedBy: req.user.id, requestedAt: new Date(), token, }); await event.save(); // Notify nominated user - only if not already pending notification or has different status const isHost = nominatedUser.canHost; const needsIdCard = !isHost && !nominatedUser.hostRequestIdCardPhoto; const acceptanceLink = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/accept-nomination?token=${token}&eventId=${eventId}`; // Check if we should send notification (prevent spam) const lastRequest = event.coHostRequests .filter(r => r.userId.toString() === userId) .sort((a, b) => b.requestedAt - a.requestedAt)[0]; const shouldNotify = !lastRequest || (new Date() - new Date(lastRequest.requestedAt) > 24 * 60 * 60 * 1000); // 24h cooldown if (shouldNotify) { await notifyUser({ userId, type: 'cohost_request', message: `You have been nominated as a co-host for ${event.title}.${needsIdCard ? ' Please upload your ID card to complete the process.' : ' Please click the link in your email to accept or reject.'}`, data: { eventId, eventTitle: event.title, needsIdCard, isHost }, link: needsIdCard ? `/host/registration` : `/accept-nomination?token=${token}&eventId=${eventId}`, emailOptions: nominatedUser.notificationPreferences?.email?.cohost !== false ? { to: nominatedUser.email, subject: `Co-host Nomination for ${event.title}`, html: `

Co-host Nomination

Hi ${nominatedUser.name},

You have been nominated as a co-host for the event: ${event.title}.

Would you like to join as a co-host?

View Invitation
${needsIdCard ? `

⚠️ Note: You'll need to upload your ID card in your profile settings to be fully verified as a co-host after accepting.

` : ''}

If you didn't expect this invitation, you can simply ignore this email or reject it via the link above.


CampVerse Team

`, } : null, }); // Notify verifiers (approval needed) if nominee not a host if (!isHost) { const verifiers = await User.find({ roles: 'verifier' }); await notifyUsers({ userIds: verifiers.map((v) => v._id), type: 'cohost', message: `A new co-host nomination for event: ${event.title} requires your approval.`, data: { eventId, userId }, emailOptionsFn: async (verifierId) => { const verifier = verifiers.find( (v) => v._id.toString() === verifierId.toString(), ); return verifier ? { to: verifier.email, subject: `Co-host Nomination Approval Needed for ${event.title}`, html: `

Hi ${verifier.name},
A new co-host nomination for ${event.title} requires your approval.

`, } : null; }, }); } } res.json({ message: 'Co-host nomination submitted.' }); } catch (err) { logger && logger.error ? logger.error('Error in nominateCoHost:', err) : null; res.status(500).json({ error: 'Error nominating co-host.' }); } } // Approve co-host (verifier) async function approveCoHost(req, res) { try { const { eventId, userId } = req.body; if (!eventId || !userId) { return res .status(400) .json({ error: 'eventId and userId are required.' }); } const event = await Event.findById(eventId); if (!event) return res.status(404).json({ error: 'Event not found.' }); // Only verifier can approve if ( !req.user.roles.includes('verifier') && !req.user.roles.includes('platformAdmin') ) { return res .status(403) .json({ error: 'Only verifiers or platform admins can approve co-hosts.', }); } const reqIndex = event.coHostRequests.findIndex( (r) => r.userId.toString() === userId && r.status === 'pending', ); if (reqIndex === -1) return res.status(404).json({ error: 'Co-host request not found.' }); event.coHostRequests[reqIndex].status = 'approved'; event.coHostRequests[reqIndex].approvedBy = req.user.id; event.coHostRequests[reqIndex].approvedAt = new Date(); // Add to coHosts if not already present if (!event.coHosts.map((id) => id.toString()).includes(userId)) { event.coHosts.push(userId); } await event.save(); // Notify approved user const approvedUser = await User.findById(userId); if (approvedUser) { await notifyUser({ userId, type: 'cohost', message: `Your co-host nomination for ${event.title} has been approved.`, data: { eventId, eventTitle: event.title }, emailOptions: { to: approvedUser.email, subject: `Co-host Nomination Approved for ${event.title}`, html: `

Hi ${approvedUser.name},
Your nomination as a co-host for ${event.title} has been approved.

`, }, }); } // Audit log // Audit: Approve co-host: verifier ${req.user.id} approved user ${userId} for event ${eventId} res.json({ message: 'Co-host approved.' }); } catch (err) { logger && logger.error ? logger.error('Error in approveCoHost:', err) : null; res.status(500).json({ error: 'Error approving co-host.' }); } } // Reject co-host (verifier) async function rejectCoHost(req, res) { try { const { eventId, userId } = req.body; if (!eventId || !userId) { return res .status(400) .json({ error: 'eventId and userId are required.' }); } const event = await Event.findById(eventId); if (!event) return res.status(404).json({ error: 'Event not found.' }); // Only verifier can reject if ( !req.user.roles.includes('verifier') && !req.user.roles.includes('platformAdmin') ) { return res .status(403) .json({ error: 'Only verifiers or platform admins can reject co-hosts.', }); } const reqIndex = event.coHostRequests.findIndex( (r) => r.userId.toString() === userId && r.status === 'pending', ); if (reqIndex === -1) return res.status(404).json({ error: 'Co-host request not found.' }); event.coHostRequests[reqIndex].status = 'rejected'; event.coHostRequests[reqIndex].approvedBy = req.user.id; event.coHostRequests[reqIndex].approvedAt = new Date(); await event.save(); // Notify rejected user const rejectedUser = await User.findById(userId); if (rejectedUser) { await notifyUser({ userId, type: 'cohost', message: `Your co-host nomination for ${event.title} has been rejected.`, link: '/settings', data: { eventId, eventTitle: event.title }, emailOptions: { to: rejectedUser.email, subject: `Co-host Nomination Rejected for ${event.title}`, html: `

Nomination Rejected

Hi ${rejectedUser.name},

Your nomination as a co-host for ${event.title} has been rejected by a verifier.

If you have any questions, please contact the event host or our support team.

View Your Profile

Best regards,
CampVerse Team

`, }, }); } // Audit log // Audit: Reject co-host: verifier ${req.user.id} rejected user ${userId} for event ${eventId} res.json({ message: 'Co-host rejected.' }); } catch (err) { logger && logger.error ? logger.error('Error in rejectCoHost:', err) : null; res.status(500).json({ error: 'Error rejecting co-host.' }); } } // Event verification (verifier) async function verifyEvent(req, res) { try { const event = await Event.findById(req.params.id); if (!event) return res.status(404).json({ error: 'Event not found.' }); event.verificationStatus = 'approved'; event.verifiedBy = req.user._id; event.verifiedAt = new Date(); await event.save(); res.json({ message: 'Event verified.' }); // Notify host of event verification result const host = await User.findById(event.hostUserId); if (host) { await notifyUser({ userId: event.hostUserId, type: 'event_verification', message: `Your event '${event.title}' has been approved.`, link: `/host/manage-events`, data: { eventId: event._id, status: 'approved' }, emailOptions: { to: host.email, subject: `Event Verification Approved: ${event.title}`, html: `

✅ Event Approved

Hi ${host.name},

Your event ${event.title} has been approved by a verifier.

Manage Your Events

Best regards,
CampVerse Team

`, }, }); } } catch (err) { res.status(500).json({ error: 'Error verifying event.' }); } } // Event rejection (verifier) async function rejectEvent(req, res) { try { const { reason } = req.body; const event = await Event.findById(req.params.id); if (!event) return res.status(404).json({ error: 'Event not found.' }); // Check if user is verifier or platformAdmin if (!req.user.roles.includes('verifier') && !req.user.roles.includes('platformAdmin')) { return res.status(403).json({ error: 'Only verifiers can reject events' }); } event.verificationStatus = 'rejected'; event.rejectionReason = reason || 'Event verification failed'; event.rejectedBy = req.user.id; event.rejectedAt = new Date(); await event.save(); res.json({ message: 'Event rejected.', event: { id: event._id, verificationStatus: event.verificationStatus, rejectionReason: event.rejectionReason, rejectedAt: event.rejectedAt } }); // Notify host of event rejection const host = await User.findById(event.hostUserId); if (host) { await notifyUser({ userId: event.hostUserId, type: 'event_verification', message: `Your event '${event.title}' has been rejected.`, link: `/host/manage-events`, data: { eventId: event._id, status: 'rejected', reason: event.rejectionReason }, emailOptions: { to: host.email, subject: `Event Verification Rejected: ${event.title}`, html: `

❌ Event Rejected

Hi ${host.name},

Your event ${event.title} has been rejected by a verifier.

Reason for rejection:

${event.rejectionReason || 'No specific reason provided.'}

Please address these concerns and update your event for re-verification.

Edit Your Event

Best regards,
CampVerse Team

`, }, }); } } catch (err) { res.status(500).json({ error: 'Error rejecting event.' }); } } // Google Calendar link endpoint async function getGoogleCalendarLink(req, res) { try { const event = await Event.findById(req.params.id); if (!event) return res.status(404).json({ error: 'Event not found.' }); // Treat as an all-day event on the given date const startDate = new Date(event.date); const startAllDay = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())); const endAllDay = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() + 1)); const formatYmd = (d) => d.toISOString().slice(0,10).replace(/-/g, ''); const start = formatYmd(startAllDay); const end = formatYmd(endAllDay); const locationText = event.organizer?.name || ''; const link = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(event.title)}&dates=${start}/${end}&details=${encodeURIComponent(event.description)}&location=${encodeURIComponent(locationText)}`; res.json({ calendarLink: link }); } catch (err) { res.status(500).json({ error: 'Error generating calendar link.' }); } } /** * Get public event by ID (for sharing, only approved events, no auth required) */ async function getPublicEventById(req, res) { try { const eventId = req.params.id; const event = await Event.findOne({ _id: eventId, verificationStatus: 'approved' }); if (!event) { return res.status(404).json({ success: false, error: 'Event not found or not approved.' }); } // Check if user is authenticated and registered for this event let userRegistration = null; let userId = null; if (req.headers.authorization) { try { // Try to extract user from token (optional authentication) const token = req.headers.authorization.split(' ')[1]; const jwt = require('jsonwebtoken'); const decoded = jwt.verify(token, process.env.JWT_SECRET); userId = decoded.id; // Check if user is registered for this event userRegistration = await EventParticipationLog.findOne({ eventId: new mongoose.Types.ObjectId(eventId), userId: new mongoose.Types.ObjectId(userId) }); logger.info('📊 Public Event Check:', { eventId, userId, hasRegistration: !!userRegistration, registrationStatus: userRegistration?.status, registrationDetails: userRegistration ? { id: userRegistration._id.toString(), status: userRegistration.status, registeredAt: userRegistration.registeredAt } : 'No registration found' }); } catch (authErr) { // Authentication failed - user not logged in, continue without userRegistration logger.info('⚠️ Optional auth failed for public event (user not logged in)', { error: authErr.message }); } } else { logger.info('📊 Public Event - No auth header provided'); } // Calculate participant count const participantCount = await EventParticipationLog.countDocuments({ eventId: event._id, status: { $in: ['registered', 'attended'] } }); res.json({ success: true, data: { ...event.toObject(), participants: participantCount, userRegistration: userRegistration ? { status: userRegistration.status, registeredAt: userRegistration.registeredAt, qrCode: userRegistration.qrCode, qrToken: userRegistration.qrToken } : null } }); } catch (err) { logger.error('❌ Error fetching public event:', err); res.status(500).json({ success: false, error: 'Error fetching public event.' }); } } /** * Get attendance list for an event * @route GET /api/events/:id/attendance */ async function getAttendance(req, res) { try { const { id } = req.params; const userId = req.user.id; // Check if event exists const event = await Event.findById(id); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Check if user is host or co-host const isHost = event.hostUserId.toString() === userId; const isCoHost = event.coHosts && event.coHosts.some(ch => ch.toString() === userId); if (!isHost && !isCoHost) { return res.status(403).json({ success: false, error: 'You do not have permission to view attendance for this event', }); } // Get all participants who are either registered or already attended const allLogs = await EventParticipationLog.find({ eventId: id, status: { $in: ['registered', 'attended'] } }).populate('userId', 'name email profilePhoto'); // Filter to get those who have already attended const attendedLogs = allLogs.filter(log => log.status === 'attended'); res.json({ success: true, totalRegistered: allLogs.length, attendees: attendedLogs.map(p => ({ _id: p._id, userId: p.userId, scanTime: p.attendanceTimestamp || p.qrCode?.usedAt || p.createdAt, status: p.status })), stats: { total: allLogs.length, attended: attendedLogs.length, percentage: allLogs.length > 0 ? Math.round((attendedLogs.length / allLogs.length) * 100) : 0 } }); } catch (err) { logger.error('Error getting attendance:', err); res.status(500).json({ success: false, error: 'Failed to get attendance', message: err.message }); } } /** * Bulk mark attendance for multiple users * @route POST /api/events/:id/bulk-attendance */ async function bulkMarkAttendance(req, res) { try { const { id } = req.params; const userId = req.user.id; const { userIds } = req.body; if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { return res.status(400).json({ success: false, error: 'User IDs array is required', }); } // Check if event exists const event = await Event.findById(id); if (!event) { return res.status(404).json({ success: false, error: 'Event not found', }); } // Check if user is host or co-host const isHost = event.hostUserId.toString() === userId; const isCoHost = event.coHosts && event.coHosts.some(ch => ch.toString() === userId); if (!isHost && !isCoHost) { return res.status(403).json({ success: false, error: 'You do not have permission to mark attendance for this event', }); } // Mark attendance for all specified users const result = await EventParticipationLog.updateMany( { eventId: id, userId: { $in: userIds }, status: 'registered' }, { $set: { attended: true, 'qrCode.isUsed': true, 'qrCode.usedAt': new Date(), 'qrCode.usedBy': `bulk_${userId}` } } ); // Send notifications to users (async, non-blocking) setImmediate(async () => { try { const users = await User.find({ _id: { $in: userIds } }); for (const user of users) { await notifyUser(user._id, { type: 'attendance_marked', title: 'Attendance Marked', message: `Your attendance has been marked for "${event.title}"`, eventId: event._id, eventTitle: event.title, }); } } catch (err) { logger.error('Error sending bulk attendance notifications:', err); } }); res.json({ success: true, message: `Successfully marked attendance for ${result.modifiedCount} participant(s)`, marked: result.modifiedCount }); } catch (err) { logger.error('Error marking bulk attendance:', err); res.status(500).json({ success: false, error: 'Failed to mark bulk attendance', message: err.message }); } } /** * Regenerate QR code for a user's registration * @route POST /api/events/:id/regenerate-qr */ async function regenerateQR(req, res) { try { const { id } = req.params; const userId = req.user.id; // Find user's registration const log = await EventParticipationLog.findOne({ eventId: id, userId, status: 'registered' }).populate('eventId'); if (!log) { return res.status(404).json({ success: false, error: 'No active registration found for this event', }); } // Check if event has ended const event = log.eventId; const eventEndTime = new Date(event.date); const now = new Date(); if (now > eventEndTime) { return res.status(410).json({ success: false, error: 'Cannot regenerate QR code for past events', message: 'This event has already ended' }); } // Generate new QR token const crypto = require('crypto'); const qrToken = crypto.randomBytes(32).toString('hex'); // Calculate expiration (event end + 2 hours) const qrExpiration = new Date(eventEndTime.getTime() + 2 * 60 * 60 * 1000); // Generate QR code image const qrImage = await qrcode.toDataURL(qrToken); // Update log with new QR code log.qrCode = { token: qrToken, imageUrl: qrImage, generatedAt: new Date(), expiresAt: qrExpiration, isUsed: false, usedAt: null, usedBy: null }; // Also update legacy qrToken for backwards compatibility log.qrToken = qrToken; await log.save(); // Send new QR code via email (async) setImmediate(async () => { try { const user = await User.findById(userId); if (user && user.email) { await emailService.sendMail({ from: `CampVerse <${process.env.EMAIL_USER}>`, to: user.email, subject: `New QR Code - ${event.title}`, html: `

🎫 New QR Code Generated

Dear ${user.name},

A new QR code has been generated for your registration:

${event.title}

Date: ${new Date(event.date).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}

Time: ${event.time || 'TBD'}

Location: ${event.location || 'TBD'}

QR Code

Show this QR code at the event entrance

⚠️ Important: Your previous QR code is no longer valid. Please use this new QR code.

View QR Code Online

` }); } } catch (err) { logger.error('Error sending regenerated QR email:', err); } }); res.json({ success: true, message: 'QR code regenerated successfully. Check your email for the new QR code.', qrCode: { image: qrImage, expiresAt: qrExpiration } }); } catch (err) { logger.error('Error regenerating QR code:', err); res.status(500).json({ success: false, error: 'Failed to regenerate QR code', message: err.message }); } } /** * Publicly accept or reject a co-host nomination via token * POST /api/events/cohost-response */ async function respondToCoHostNomination(req, res) { try { const { eventId, token, action, remarks } = req.body; if (!eventId || !token || !action) { return res.status(400).json({ error: 'eventId, token, and action (accept/reject) are required.' }); } const event = await Event.findById(eventId); if (!event) return res.status(404).json({ error: 'Event not found.' }); const requestIndex = event.coHostRequests.findIndex(r => r.token === token && r.status === 'pending'); if (requestIndex === -1) { return res.status(404).json({ error: 'Invalid or expired invitation.' }); } const request = event.coHostRequests[requestIndex]; if (action === 'accept') { // Keep it as pending but marked as accepted by user? // Actually, the original code had verifiers approve co-hosts. // Let's mark it as 'accepted_by_user' or something, or just proceed to verification. // The user said: "Implement a public-facing page where potential co-hosts can accept or reject invitations." request.status = 'approved'; // Let's simplify: once they accept, they ARE co-hosts if they canHost. request.remarks = remarks || 'Accepted by nominee'; request.approvedBy = request.userId; // Self-approved in terms of nomination request.approvedAt = new Date(); // Add to coHosts array if (!event.coHosts.includes(request.userId)) { event.coHosts.push(request.userId); } await event.save(); // Notify the original host await notifyUser({ userId: event.hostUserId, type: 'cohost_accepted', message: `Your co-host nomination for ${event.title} has been accepted.`, data: { eventId, nomineeId: request.userId } }); return res.json({ success: true, message: 'invitation accepted successfully!' }); } else if (action === 'reject') { request.status = 'rejected'; request.remarks = remarks || 'Rejected by nominee'; request.approvedAt = new Date(); // Rejection date await event.save(); // Notify host await notifyUser({ userId: event.hostUserId, type: 'cohost_rejected', message: `Your co-host nomination for ${event.title} has been rejected.`, data: { eventId, nomineeId: request.userId } }); return res.json({ success: true, message: 'Invitation rejected.' }); } else { return res.status(400).json({ error: 'Invalid action. Must be accept or reject.' }); } } catch (error) { logger.error('Error in respondToCoHostNomination:', error); res.status(500).json({ error: 'Server error processing invitation.' }); } } module.exports = { createEvent, getEventById, updateEvent, deleteEvent, rsvpEvent, cancelRsvp, getParticipants, scanQr, getEventAnalytics, getHostAnalytics, nominateCoHost, approveCoHost, rejectCoHost, verifyEvent, rejectEvent, getGoogleCalendarLink, getPublicEventById, getAttendance, bulkMarkAttendance, regenerateQR, respondToCoHostNomination, };