const crypto = require('crypto'); const Event = require('../models/Event'); const User = require('../models/User'); const Organization = require('../models/Organization'); const OrganizationUserMap = require('../models/OrganizationUserMap'); const EventInvite = require('../models/EventInvite'); const Session = require('../models/Session'); const jwt = require('jsonwebtoken'); const { success, error } = require('../utils/response'); const { sendEventInviteEmail } = require('../utils/mail'); const JWT_SECRET = process.env.JWT_SECRET || 'default-secret'; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'default-refresh-secret'; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m'; const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; async function getEventOr404(eventId) { return Event.findById(eventId).populate('organization'); } function canManageStaff(user, event) { if (!event) return false; if (user.role === 'super_admin') return true; const orgId = user.organization?._id || user.organization; if (!orgId || event.organization._id?.toString() !== orgId.toString()) return false; return ['admin', 'moderator'].includes(user.role); } function staffListFromMaps(maps, eventId) { const eid = eventId.toString(); const members = []; for (const m of maps) { const u = m.user; if (!u || !u._id) continue; const ea = (m.eventAccess || []).find((x) => x.event && x.event.toString() === eid); if (!ea) continue; members.push({ userId: u._id, email: u.email, firstName: u.firstName, lastName: u.lastName, role: u.role, sessionIds: (ea.sessionIds || []).map((id) => id.toString()), eventRole: ea.eventRole || 'Viewer', }); } return members; } const listStaff = async (request, h) => { const { eventId } = request.params; const user = request.auth.credentials.user; const event = await getEventOr404(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const orgId = event.organization._id || event.organization; const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user', '-password').lean(); const members = staffListFromMaps(maps, eventId); const invites = await EventInvite.find({ event: eventId, status: 'pending' }) .select('email eventRole sessionIds createdAt expiresAt') .lean(); return h.response( success({ members, invites: invites.map((i) => ({ id: i._id, email: i.email, eventRole: i.eventRole, sessionIds: (i.sessionIds || []).map((id) => id.toString()), expiresAt: i.expiresAt, createdAt: i.createdAt, })), }) ); }; const listAvailableUsers = async (request, h) => { const { eventId } = request.params; const user = request.auth.credentials.user; const event = await getEventOr404(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const orgId = event.organization._id || event.organization; const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user', '-password').lean(); const assigned = new Set(staffListFromMaps(maps, eventId).map((m) => m.userId.toString())); const pendingEmails = new Set( (await EventInvite.find({ event: eventId, status: 'pending' }).distinct('email')).map((e) => String(e).toLowerCase()) ); const orgUsers = await User.find({ organization: orgId }).select('-password').lean(); const available = orgUsers.filter((u) => !assigned.has(u._id.toString()) && !pendingEmails.has(String(u.email).toLowerCase())); return h.response(success({ users: available })); }; const addStaff = async (request, h) => { const { eventId } = request.params; const { userIds, sessionIds = [], eventRole = 'Viewer' } = request.payload; const user = request.auth.credentials.user; const event = await getEventOr404(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const orgId = event.organization._id || event.organization; const sid = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : []; for (const uid of userIds || []) { const target = await User.findById(uid); if (!target || target.organization?.toString() !== orgId.toString()) continue; let map = await OrganizationUserMap.findOne({ user: uid, organization: orgId }); if (!map) { map = await OrganizationUserMap.create({ user: uid, organization: orgId, role: target.role || 'viewer', eventAccess: [], }); } const idx = map.eventAccess.findIndex((e) => e.event.toString() === eventId); if (idx >= 0) { map.eventAccess[idx].sessionIds = sid; map.eventAccess[idx].eventRole = eventRole; } else { map.eventAccess.push({ event: eventId, sessionIds: sid, eventRole }); } await map.save(); } return h.response(success(null, 'Users added to event')); }; const updateStaff = async (request, h) => { const { eventId, userId } = request.params; const { sessionIds = [], eventRole } = request.payload; const user = request.auth.credentials.user; const event = await getEventOr404(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const orgId = event.organization._id || event.organization; const map = await OrganizationUserMap.findOne({ user: userId, organization: orgId }); if (!map) return h.response(error('NOT_FOUND', 'User not on this event')).code(404); const idx = map.eventAccess.findIndex((e) => e.event.toString() === eventId); if (idx < 0) return h.response(error('NOT_FOUND', 'User not on this event')).code(404); map.eventAccess[idx].sessionIds = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : []; if (eventRole != null) map.eventAccess[idx].eventRole = eventRole; await map.save(); return h.response(success(null, 'Updated')); }; const removeStaff = async (request, h) => { const { eventId, userId } = request.params; const user = request.auth.credentials.user; const event = await getEventOr404(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const orgId = event.organization._id || event.organization; const map = await OrganizationUserMap.findOne({ user: userId, organization: orgId }); if (!map) return h.response(success(null)); map.eventAccess = map.eventAccess.filter((e) => e.event.toString() !== eventId); await map.save(); return h.response(success(null, 'Removed from event')); }; const sendInvites = async (request, h) => { const { eventId } = request.params; const { invites: inviteList, sessionIds = [] } = request.payload; const user = request.auth.credentials.user; const event = await getEventOr404(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const orgId = event.organization._id || event.organization; const org = await Organization.findById(orgId); const orgName = org?.name || ''; const sid = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : []; const baseUrl = (process.env.FRONTEND_URL || process.env.CORS_ORIGIN || 'http://localhost:5173').split(',')[0].trim(); let sent = 0; const errors = []; for (const row of inviteList || []) { const email = String(row.email || '').trim().toLowerCase(); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { errors.push({ email, message: 'Invalid email' }); continue; } const existingUser = await User.findOne({ email }); if (existingUser && existingUser.organization?.toString() === orgId.toString()) { const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user').lean(); if (staffListFromMaps(maps, eventId).some((m) => m.userId.toString() === existingUser._id.toString())) { errors.push({ email, message: 'Already on this event' }); continue; } } await EventInvite.updateMany({ event: eventId, email, status: 'pending' }, { status: 'cancelled' }); const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); await EventInvite.create({ email, event: eventId, organization: orgId, invitedBy: user._id, token, sessionIds: sid, eventRole: row.eventRole || 'Viewer', expiresAt, }); const acceptUrl = `${baseUrl.replace(/\/$/, '')}/invite/event?token=${token}`; const ok = await sendEventInviteEmail({ to: email, acceptUrl, eventName: event.name, orgName, role: row.eventRole || 'Viewer', }); if (ok) sent += 1; else errors.push({ email, message: 'Email could not be sent (check SMTP or logs)' }); } return h.response(success({ sent, errors })); }; const cancelInvite = async (request, h) => { const { eventId, inviteId } = request.params; const user = request.auth.credentials.user; const event = await getEventOr404(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); await EventInvite.updateOne({ _id: inviteId, event: eventId }, { status: 'cancelled' }); return h.response(success(null)); }; const resendInvite = async (request, h) => { const { eventId, inviteId } = request.params; const user = request.auth.credentials.user; const event = await getEventOr404(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const inv = await EventInvite.findOne({ _id: inviteId, event: eventId, status: 'pending' }); if (!inv) return h.response(error('NOT_FOUND', 'Invite not found')).code(404); const org = await Organization.findById(event.organization); const baseUrl = (process.env.FRONTEND_URL || process.env.CORS_ORIGIN || 'http://localhost:5173').split(',')[0].trim(); const acceptUrl = `${baseUrl.replace(/\/$/, '')}/invite/event?token=${inv.token}`; await sendEventInviteEmail({ to: inv.email, acceptUrl, eventName: event.name, orgName: org?.name, role: inv.eventRole, }); return h.response(success(null, 'Invitation resent')); }; const invitePreview = async (request, h) => { const { token } = request.params; const inv = await EventInvite.findOne({ token, status: 'pending' }).populate('event', 'name').populate('organization', 'name'); if (!inv || inv.expiresAt < new Date()) { return h.response(error('NOT_FOUND', 'Invalid or expired invitation')).code(404); } const existing = await User.findOne({ email: inv.email }); return h.response( success({ email: inv.email, eventName: inv.event?.name, organizationName: inv.organization?.name || (await Organization.findById(inv.organization))?.name, eventRole: inv.eventRole, accountExists: !!existing, sameOrganization: existing ? existing.organization?.toString() === inv.organization.toString() : false, }) ); }; const inviteAccept = async (request, h) => { const { token, password, firstName, lastName } = request.payload; const inv = await EventInvite.findOne({ token, status: 'pending' }).populate('event'); if (!inv || inv.expiresAt < new Date()) { return h.response(error('NOT_FOUND', 'Invalid or expired invitation')).code(400); } const orgId = inv.organization; let user = await User.findOne({ email: inv.email }); if (user) { if (user.organization?.toString() !== orgId.toString()) { return h.response(error('FORBIDDEN', 'This email belongs to another organization. Use the email you were invited with.')).code(403); } if (!password || !(await user.comparePassword(password))) { return h.response(error('UNAUTHORIZED', 'Invalid password')).code(401); } } else { if (!password || String(password).length < 6) { return h.response(error('VALIDATION_ERROR', 'Password must be at least 6 characters')).code(400); } user = await User.create({ email: inv.email, password, firstName: firstName || '', lastName: lastName || '', organization: orgId, role: 'viewer', }); await OrganizationUserMap.create({ user: user._id, organization: orgId, role: 'viewer', eventAccess: [], }); } let map = await OrganizationUserMap.findOne({ user: user._id, organization: orgId }); if (!map) { map = await OrganizationUserMap.create({ user: user._id, organization: orgId, role: user.role, eventAccess: [], }); } const eid = inv.event._id.toString(); const idx = map.eventAccess.findIndex((e) => e.event.toString() === eid); const entry = { event: inv.event._id, sessionIds: inv.sessionIds || [], eventRole: inv.eventRole || 'Viewer', }; if (idx >= 0) map.eventAccess[idx] = entry; else map.eventAccess.push(entry); await map.save(); inv.status = 'accepted'; await inv.save(); const populated = await User.findById(user._id).select('-password').populate('organization'); const accessToken = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); const refreshToken = jwt.sign({ id: user._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); return h.response( success({ user: populated, token: accessToken, refreshToken, expiresIn: JWT_EXPIRES_IN, }, 'Welcome! You now have access to this event.') ); }; const agendaTree = async (request, h) => { const { eventId } = request.params; const user = request.auth.credentials.user; const event = await Event.findById(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const Track = require('../models/Track'); const tracks = await Track.find({ event: eventId }).sort({ sortOrder: 1, name: 1 }).lean(); const sessions = await Session.find({ event: eventId }).sort({ startTime: 1 }).lean(); const byTrack = new Map(); tracks.forEach((t) => byTrack.set(t._id.toString(), { trackId: t._id, name: t.name, sessions: [] })); const unassigned = { trackId: null, name: 'Unassigned', sessions: [] }; sessions.forEach((s) => { const tid = s.track ? s.track.toString() : null; const row = { sessionId: s._id, title: s.title, startTime: s.startTime, }; if (tid && byTrack.has(tid)) byTrack.get(tid).sessions.push(row); else unassigned.sessions.push(row); }); const trackList = [...byTrack.values()]; if (unassigned.sessions.length) trackList.push(unassigned); return h.response(success({ tracks: trackList })); }; module.exports = { listStaff, listAvailableUsers, addStaff, updateStaff, removeStaff, sendInvites, cancelInvite, resendInvite, invitePreview, inviteAccept, agendaTree, };