EventBuzz_Backend/src/controllers/visitorAuth.controller.js

672 lines
23 KiB
JavaScript

const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const mongoose = require('mongoose');
const Event = require('../models/Event');
const Participant = require('../models/Participant');
const VisitorAuthToken = require('../models/VisitorAuthToken');
const { success, error } = require('../utils/response');
const { sendVisitorOtpEmail, sendVisitorMagicLinkEmail } = require('../utils/mail');
const { getMembership, membershipGuestQuery } = require('../utils/participantMembership');
const { mergeRules } = require('../utils/rewardRulesResolve');
const { awardIfNew } = require('../services/rewardAward');
/** @returns {number} loyalty points actually awarded (0 if duplicate / disabled) */
async function awardRegistrationIfNew(participant, eventId, eventLean) {
const rules = mergeRules(eventLean.rewardRules || {}, {}, {});
const pts = rules.registrationComplete;
if (!participant?._id) return 0;
const r = await awardIfNew({
participantId: participant._id,
eventId,
points: pts,
source: 'registration',
sourceId: `registration:${participant._id}:${eventId}`,
description: 'Event registration',
metadata: { ruleSnapshot: rules },
});
return r.pointsAwarded || 0;
}
const ATTENDEE_SECRET =
process.env.ATTENDEE_JWT_SECRET || process.env.JWT_SECRET || 'default-secret-change-in-production';
const OTP_TTL_MS = 10 * 60 * 1000;
const LINK_TTL_MS = 15 * 60 * 1000;
const rateBuckets = new Map();
function rateLimit(key, max, windowMs) {
const now = Date.now();
let b = rateBuckets.get(key);
if (!b || now > b.resetAt) {
b = { count: 0, resetAt: now + windowMs };
rateBuckets.set(key, b);
}
b.count += 1;
return b.count <= max;
}
function clientKey(request) {
return (
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
request.info.remoteAddress ||
'unknown'
);
}
function getSettings(event) {
return event.settings || {};
}
function deadlinePassed(event) {
const d = getSettings(event).registrationDeadline;
if (!d) return false;
return new Date(d).getTime() <= Date.now();
}
function allowsNewRegistration(event) {
const s = getSettings(event);
if (s.registrationOpen === false) return false;
if (deadlinePassed(event)) return false;
return true;
}
function registrationMode(event) {
return String(getSettings(event).registrationMode || 'Open').trim();
}
function checkInviteForNewReg(event, inviteToken) {
const mode = registrationMode(event);
if (mode !== 'Invite Only') return { ok: true };
const expected = String(getSettings(event).inviteCode || '').trim();
if (!expected) {
return {
ok: false,
err: error('INVITE_NOT_CONFIGURED', 'Invite-only registration is not configured for this event'),
};
}
if (String(inviteToken || '').trim() !== expected) {
return {
ok: false,
err: error('REGISTRATION_INVITE_REQUIRED', 'A valid invite code is required'),
};
}
return { ok: true };
}
function defaultParticipantStatus(event) {
const s = getSettings(event);
if (s.requiresApproval === true) return 'pending';
if (registrationMode(event) === 'Approval Required') return 'pending';
return 'confirmed';
}
function authTypeFromEvent(event) {
const v = String(getSettings(event).authenticationType || 'otp_email').trim();
if (['otp_email', 'link_email', 'none'].includes(v)) return v;
return 'otp_email';
}
async function assertCapacity(event) {
const max = getSettings(event).maxParticipants;
if (!max || max <= 0) return { ok: true };
const n = await Participant.countDocuments({
eventMemberships: { $elemMatch: { event: event._id } },
});
if (n >= max) {
return {
ok: false,
err: error('REGISTRATION_FULL', 'This event has reached maximum participants'),
};
}
return { ok: true };
}
function normalizeEmail(e) {
return String(e || '').trim().toLowerCase();
}
function hashOtp(otp, eventId, email) {
const pepper = process.env.OTP_PEPPER || 'eventbuzz-otp';
return crypto.createHash('sha256').update(`${pepper}|${otp}|${eventId}|${email}`).digest('hex');
}
function hashLinkToken(raw) {
return crypto.createHash('sha256').update(raw).digest('hex');
}
function generateOtp() {
return String(Math.floor(100000 + Math.random() * 900000));
}
function signAttendeeJwt(participant, eventId, joinType, sessionId, trackId) {
return jwt.sign(
{
aud: 'attendee',
pid: String(participant._id),
eid: String(eventId),
scope: joinType || 'event',
sid: sessionId ? String(sessionId) : undefined,
tid: trackId ? String(trackId) : undefined,
},
ATTENDEE_SECRET,
{ expiresIn: '30d', algorithm: 'HS256' }
);
}
function customFieldsToObject(raw) {
if (!raw || typeof raw !== 'object') return {};
if (raw instanceof Map) return Object.fromEntries(raw);
return { ...raw };
}
function sanitizeParticipant(p, eventId) {
if (!p) return null;
const o = p.toObject ? p.toObject() : { ...p };
const m = getMembership(o, eventId);
const customFields = customFieldsToObject(o.customFields);
return {
_id: String(o._id),
event: String(eventId),
email: o.email,
firstName: o.firstName,
lastName: o.lastName,
displayName: o.displayName,
phone: o.phone,
guestToken: m?.guestToken,
status: m?.status,
emailVerified: o.emailVerified,
customFields: Object.keys(customFields).length ? customFields : undefined,
};
}
function customRegistrationFieldsIncomplete(event, participant) {
const defs = event.metadata && event.metadata.registrationCustomFields;
if (!Array.isArray(defs) || defs.length === 0) return false;
const po = participant.toObject ? participant.toObject() : participant;
const map = customFieldsToObject(po.customFields);
for (const def of defs) {
const code = String(def.code || '').trim();
if (!code) continue;
const v = map[code];
const ft = String(def.fieldType || 'text');
if (ft === 'checkboxes') {
if (!Array.isArray(v) || v.length === 0) return true;
continue;
}
if (v === undefined || v === null) return true;
if (typeof v === 'string' && !v.trim()) return true;
if (typeof v === 'number' && Number.isNaN(v)) return true;
}
return false;
}
function computeNeedsProfile(event, participant) {
if (customRegistrationFieldsIncomplete(event, participant)) return true;
const base = String(getSettings(event).registrationBaseFields || 'name_email');
const po = participant.toObject ? participant.toObject() : participant;
if (base === 'name_email') {
return !(String(po.firstName || '').trim() && String(po.lastName || '').trim());
}
if (base === 'name_email_phone') {
return !(
String(po.firstName || '').trim() &&
String(po.lastName || '').trim() &&
String(po.phone || '').trim()
);
}
if (base === 'anonymous_name') {
return !String(po.displayName || '').trim();
}
return false;
}
function oidStr(id) {
if (!id) return undefined;
const s = typeof id === 'string' ? id : id.toString();
if (!mongoose.Types.ObjectId.isValid(s)) return undefined;
return s;
}
function authPayload(participant, event, eventId, joinType, sessionId, trackId) {
const token = signAttendeeJwt(participant, eventId, joinType, oidStr(sessionId), oidStr(trackId));
const m = getMembership(participant, eventId);
return {
token,
participant: sanitizeParticipant(participant, eventId),
needsProfile: computeNeedsProfile(event, participant),
pendingApproval: m?.status === 'pending',
};
}
async function loadEvent(eventId) {
if (!mongoose.Types.ObjectId.isValid(eventId)) return null;
return Event.findById(eventId).lean();
}
async function startOtp(request, h) {
const { eventId } = request.params;
const { email, joinType = 'event', sessionId, trackId, inviteToken } = request.payload || {};
const em = normalizeEmail(email);
if (!em) return h.response(error('VALIDATION_ERROR', 'Email is required')).code(400);
if (!rateLimit(`visitorAuth:${clientKey(request)}`, 60, 60 * 60 * 1000)) {
return h.response(error('RATE_LIMIT', 'Too many requests. Try again later.')).code(429);
}
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (authTypeFromEvent(event) !== 'otp_email') {
return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use email OTP sign-in')).code(400);
}
const orgId = event.organization;
const existing = await Participant.findOne({ tenantOrganization: orgId, email: em }).lean();
const inEvent = existing && getMembership(existing, eventId);
if (!inEvent && !allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
if (!inEvent) {
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
}
const otp = generateOtp();
const otpHash = hashOtp(otp, eventId, em);
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
await VisitorAuthToken.deleteMany({ eventId, email: em, kind: 'otp' });
await VisitorAuthToken.create({
kind: 'otp',
eventId,
email: em,
otpHash,
joinType,
sessionId:
sessionId && mongoose.Types.ObjectId.isValid(sessionId)
? new mongoose.Types.ObjectId(sessionId)
: undefined,
trackId:
trackId && mongoose.Types.ObjectId.isValid(trackId)
? new mongoose.Types.ObjectId(trackId)
: undefined,
expiresAt,
});
await sendVisitorOtpEmail({ to: em, otp, eventName: event.name });
return h.response(success({ ok: true }));
}
async function verifyOtp(request, h) {
const { eventId } = request.params;
const { email, otp, inviteToken, firstName, lastName, phone, displayName } = request.payload || {};
const em = normalizeEmail(email);
const code = String(otp || '').replace(/\D/g, '').slice(0, 6);
if (!em || code.length !== 6) {
return h.response(error('VALIDATION_ERROR', 'Valid email and 6-digit OTP are required')).code(400);
}
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (authTypeFromEvent(event) !== 'otp_email') {
return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use email OTP sign-in')).code(400);
}
const doc = await VisitorAuthToken.findOne({
eventId,
email: em,
kind: 'otp',
expiresAt: { $gt: new Date() },
}).sort({ createdAt: -1 });
if (!doc || doc.otpHash !== hashOtp(code, eventId, em)) {
return h.response(error('OTP_INVALID', 'Invalid or expired OTP')).code(400);
}
await VisitorAuthToken.deleteOne({ _id: doc._id });
const orgId = event.organization;
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
const inEvent = participant && getMembership(participant, eventId);
let registrationRewardPoints = 0;
if (!inEvent) {
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
const status = defaultParticipantStatus(event);
if (!participant) {
participant = await Participant.create({
tenantOrganization: orgId,
email: em,
firstName: firstName ? String(firstName).trim() : undefined,
lastName: lastName ? String(lastName).trim() : undefined,
phone: phone ? String(phone).trim() : undefined,
displayName: displayName ? String(displayName).trim() : undefined,
emailVerified: true,
eventMemberships: [
{
event: eventId,
status,
registrationDate: new Date(),
},
],
});
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
} else {
participant.eventMemberships.push({
event: eventId,
status,
registrationDate: new Date(),
});
participant.emailVerified = true;
if (firstName) participant.firstName = String(firstName).trim();
if (lastName) participant.lastName = String(lastName).trim();
if (phone) participant.phone = String(phone).trim();
if (displayName) participant.displayName = String(displayName).trim();
await participant.save();
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
}
} else {
participant.emailVerified = true;
if (firstName) participant.firstName = String(firstName).trim();
if (lastName) participant.lastName = String(lastName).trim();
if (phone) participant.phone = String(phone).trim();
if (displayName) participant.displayName = String(displayName).trim();
await participant.save();
}
const joinType = doc.joinType || 'event';
const sid = doc.sessionId;
const tid = doc.trackId;
const base = authPayload(participant, event, eventId, joinType, sid, tid);
const payload =
registrationRewardPoints > 0 ? { ...base, reward: { pointsEarned: registrationRewardPoints } } : base;
return h.response(success(payload));
}
async function startLink(request, h) {
const { eventId } = request.params;
const { email, joinType = 'event', sessionId, trackId, inviteToken } = request.payload || {};
const em = normalizeEmail(email);
if (!em) return h.response(error('VALIDATION_ERROR', 'Email is required')).code(400);
if (!rateLimit(`visitorAuth:${clientKey(request)}`, 60, 60 * 60 * 1000)) {
return h.response(error('RATE_LIMIT', 'Too many requests. Try again later.')).code(429);
}
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (authTypeFromEvent(event) !== 'link_email') {
return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use magic link sign-in')).code(400);
}
const orgId = event.organization;
const existing = await Participant.findOne({ tenantOrganization: orgId, email: em }).lean();
const inEvent = existing && getMembership(existing, eventId);
if (!inEvent && !allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
if (!inEvent) {
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
}
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = hashLinkToken(rawToken);
const expiresAt = new Date(Date.now() + LINK_TTL_MS);
await VisitorAuthToken.create({
kind: 'link',
eventId,
email: em,
tokenHash,
joinType,
sessionId:
sessionId && mongoose.Types.ObjectId.isValid(sessionId)
? new mongoose.Types.ObjectId(sessionId)
: undefined,
trackId:
trackId && mongoose.Types.ObjectId.isValid(trackId)
? new mongoose.Types.ObjectId(trackId)
: undefined,
expiresAt,
});
const baseUrl = process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174';
const magicLinkUrl = `${String(baseUrl).replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(rawToken)}`;
await sendVisitorMagicLinkEmail({ to: em, magicLinkUrl, eventName: event.name });
return h.response(success({ ok: true }));
}
async function exchangeLink(request, h) {
const { token } = request.payload || {};
const raw = String(token || '').trim();
if (!raw) return h.response(error('VALIDATION_ERROR', 'Token is required')).code(400);
const tokenHash = hashLinkToken(raw);
const doc = await VisitorAuthToken.findOne({
kind: 'link',
tokenHash,
expiresAt: { $gt: new Date() },
});
if (!doc) return h.response(error('LINK_INVALID', 'Invalid or expired link')).code(400);
const eventId = doc.eventId.toString();
const event = await loadEvent(eventId);
if (!event) {
await VisitorAuthToken.deleteOne({ _id: doc._id });
return h.response(error('NOT_FOUND', 'Event not found')).code(404);
}
if (authTypeFromEvent(event) !== 'link_email') {
await VisitorAuthToken.deleteOne({ _id: doc._id });
return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use magic link sign-in')).code(400);
}
const em = doc.email;
await VisitorAuthToken.deleteOne({ _id: doc._id });
const orgId = event.organization;
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
const inEvent = participant && getMembership(participant, eventId);
let registrationRewardPoints = 0;
if (!inEvent) {
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
const status = defaultParticipantStatus(event);
if (!participant) {
participant = await Participant.create({
tenantOrganization: orgId,
email: em,
emailVerified: true,
eventMemberships: [
{
event: eventId,
status,
registrationDate: new Date(),
},
],
});
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
} else {
participant.eventMemberships.push({
event: eventId,
status,
registrationDate: new Date(),
});
participant.emailVerified = true;
await participant.save();
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
}
} else {
participant.emailVerified = true;
await participant.save();
}
const joinType = doc.joinType || 'event';
const base = authPayload(participant, event, eventId, joinType, doc.sessionId, doc.trackId);
const payload =
registrationRewardPoints > 0 ? { ...base, reward: { pointsEarned: registrationRewardPoints } } : base;
return h.response(success(payload));
}
async function guestStart(request, h) {
const { eventId } = request.params;
const { joinType = 'event', sessionId, trackId, inviteToken, guestToken, displayName } =
request.payload || {};
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (authTypeFromEvent(event) !== 'none') {
return h.response(error('AUTH_FLOW_MISMATCH', 'This event requires email sign-in')).code(400);
}
const orgId = event.organization;
const gt = String(guestToken || '').trim();
if (gt) {
const existing = await Participant.findOne(membershipGuestQuery(orgId, eventId, gt));
if (existing) {
const out = authPayload(
existing,
event,
eventId,
joinType,
oidStr(sessionId),
oidStr(trackId)
);
const m = getMembership(existing, eventId);
return h.response(success({ ...out, guestToken: m?.guestToken }));
}
}
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
const newGuestToken = crypto.randomBytes(16).toString('hex');
const status = defaultParticipantStatus(event);
const participant = await Participant.create({
tenantOrganization: orgId,
displayName: displayName ? String(displayName).trim() : undefined,
emailVerified: false,
eventMemberships: [
{
event: eventId,
guestToken: newGuestToken,
status,
registrationDate: new Date(),
},
],
});
const registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
const out = authPayload(
participant,
event,
eventId,
joinType,
oidStr(sessionId),
oidStr(trackId)
);
const payload =
registrationRewardPoints > 0
? { ...out, guestToken: newGuestToken, reward: { pointsEarned: registrationRewardPoints } }
: { ...out, guestToken: newGuestToken };
return h.response(success(payload));
}
async function registrationStatus(request, h) {
const { eventId } = request.params;
const email = normalizeEmail(request.query.email);
if (!email) return h.response(error('VALIDATION_ERROR', 'email query parameter is required')).code(400);
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const p = await Participant.findOne({ tenantOrganization: event.organization, email }).lean();
const m = p && getMembership(p, eventId);
if (!m) return h.response(success({ registered: false }));
return h.response(success({ registered: true, status: m.status }));
}
async function updateProfile(request, h) {
const { participant: credP } = request.auth.credentials;
const pid = credP._id || credP.id;
const { firstName, lastName, phone, displayName, customFields: incomingCustom } = request.payload || {};
const participant = await Participant.findById(pid);
if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404);
const eventIdCtx = request.auth.credentials.attendee?.eid;
const event = await loadEvent(eventIdCtx);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (firstName !== undefined) participant.firstName = String(firstName).trim() || undefined;
if (lastName !== undefined) participant.lastName = String(lastName).trim() || undefined;
if (phone !== undefined) participant.phone = String(phone).trim() || undefined;
if (displayName !== undefined) participant.displayName = String(displayName).trim() || undefined;
if (incomingCustom && typeof incomingCustom === 'object' && !Array.isArray(incomingCustom)) {
const defs = (event.metadata && event.metadata.registrationCustomFields) || [];
const allowed = new Set(defs.map((d) => String(d.code || '').trim()).filter(Boolean));
if (!participant.customFields || !(participant.customFields instanceof Map)) {
participant.customFields = new Map();
}
for (const [key, val] of Object.entries(incomingCustom)) {
const k = String(key).trim();
if (!allowed.has(k)) continue;
if (val === null || val === undefined || val === '') {
participant.customFields.delete(k);
continue;
}
if (Array.isArray(val)) {
participant.customFields.set(
k,
val.map((x) => String(x)).filter((s) => s.length > 0)
);
} else if (typeof val === 'boolean' || typeof val === 'number') {
participant.customFields.set(k, val);
} else {
participant.customFields.set(k, String(val).trim());
}
}
participant.markModified('customFields');
}
await participant.save();
const decoded = request.auth.credentials.attendee;
return h.response(
success(
authPayload(
participant,
event,
decoded.eid,
decoded.scope,
decoded.sid,
decoded.tid
)
)
);
}
module.exports = {
startOtp,
verifyOtp,
startLink,
exchangeLink,
guestStart,
registrationStatus,
updateProfile,
};