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