672 lines
23 KiB
JavaScript
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,
|
|
};
|