414 lines
16 KiB
JavaScript
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,
|
|
};
|