EventBuzz_Backend/src/controllers/eventStaff.controller.js

414 lines
16 KiB
JavaScript

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,
};