const mongoose = require('mongoose'); const Form = require('../models/Form'); const FormSubmission = require('../models/FormSubmission'); const Event = require('../models/Event'); const EventMicrosite = require('../models/EventMicrosite'); const Participant = require('../models/Participant'); const ParticipantList = require('../models/ParticipantList'); const { success, error } = require('../utils/response'); const { computeEventRatingsSummary } = require('../utils/eventRatingsSummary'); const { extractObjectIdString, buildPublicEventPayload, resolveEventForPublicForm, } = require('../utils/formPublicHelpers'); const { verifyCaptchaChallenge } = require('../utils/publicCaptcha'); const { getMembership } = require('../utils/participantMembership'); const { publicRegistrationFields } = require('../utils/registrationFormFields'); const { processFormSubmissionFileUploads } = require('../utils/formSubmissionFiles'); const { buildSubmissionReplyEmail } = require('../utils/formReplyMail'); const { sendOrgMail } = require('../utils/mail'); function throwRegError(message, code) { const err = new Error(message); err.code = code; throw err; } function eventRegistrationStatus(event) { const settings = event.settings || {}; if (settings.requiresApproval === true) return 'pending'; const mode = String(settings.registrationMode || 'Open').trim(); if (mode === 'Approval Required') return 'pending'; return 'confirmed'; } async function assertCanRegisterForEvent(event) { const settings = event.settings || {}; if (settings.registrationOpen === false) { throwRegError('Registration is closed for this event', 'REGISTRATION_CLOSED'); } const deadline = settings.registrationDeadline; if (deadline && new Date(deadline).getTime() <= Date.now()) { throwRegError('Registration deadline has passed', 'REGISTRATION_CLOSED'); } const max = settings.maxParticipants; if (max && max > 0) { const count = await Participant.countDocuments({ eventMemberships: { $elemMatch: { event: event._id } }, }); if (count >= max) { throwRegError('This event has reached maximum participants', 'REGISTRATION_FULL'); } } } async function ensureEventMembershipForRegistrationForm(form, participant) { const { ev: resolvedEvent } = await resolveEventForPublicForm(form); if (!resolvedEvent?._id) return; const event = await Event.findById(resolvedEvent._id).select('settings').lean(); if (!event) return; const eventId = event._id; if (getMembership(participant, eventId)) return; await assertCanRegisterForEvent(event); participant.eventMemberships = participant.eventMemberships || []; participant.eventMemberships.push({ event: eventId, status: eventRegistrationStatus(event), registrationDate: new Date(), }); await participant.save(); } async function resolveSubmissionEventId(form) { const directId = extractObjectIdString(form.event); if (directId) return directId; const { ev } = await resolveEventForPublicForm(form); return ev?._id ? String(ev._id) : null; } function normalizeSubmitData(raw) { if (!raw) return {}; if (raw instanceof Map) return Object.fromEntries(raw); return { ...raw }; } function fieldDataKey(f) { return String(f?.name || f?.label || '').trim(); } function csvColumnsFromFormFields(fields) { const cols = []; for (const f of fields || []) { if (f.type === 'section' || !(f.label || f.name)) continue; const base = fieldDataKey(f); if (f.type === 'country_state') { const label = f.label || f.name; cols.push({ header: `${label} - Country`, key: `${base} - Country` }); cols.push({ header: `${label} - State`, key: `${base} - State` }); } else { cols.push({ header: f.label || f.name, key: base }); } } return cols; } async function upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj) { const fields = form.fields || []; const orgOid = tenantOrganization instanceof mongoose.Types.ObjectId ? tenantOrganization : new mongoose.Types.ObjectId(String(tenantOrganization)); function throwRegValidation(message, code) { const err = new Error(message); err.code = code; throw err; } const requireMappedValue = (mapsTo, emptyMessage, missingConfigMessage) => { const fld = fields.find((f) => f.mapsTo === mapsTo); if (!fld?.label) throwRegValidation(missingConfigMessage, 'FORM_FIELD_CONFIG'); const raw = dataObj[fieldDataKey(fld)]; const val = raw != null ? String(raw).trim() : ''; if (!val) throwRegValidation(emptyMessage, 'REGISTRATION_REQUIRED'); }; const emailField = fields.find((f) => f.mapsTo === 'email'); if (!emailField?.label) { throwRegValidation('Form missing email field mapping', 'FORM_FIELD_CONFIG'); } const emailRaw = dataObj[fieldDataKey(emailField)]; const email = emailRaw != null ? String(emailRaw).trim().toLowerCase() : ''; if (!email) { throwRegValidation('Email is required', 'EMAIL_REQUIRED'); } requireMappedValue('firstName', 'First name is required', 'Form missing first name field mapping'); requireMappedValue('lastName', 'Last name is required', 'Form missing last name field mapping'); const updates = {}; const customKv = {}; const allowed = ['firstName', 'lastName', 'email', 'phone', 'organization', 'designation', 'notes', 'displayName']; for (const f of fields) { if (f.type === 'section' || !f.label) continue; const key = fieldDataKey(f); const val = dataObj[key]; if (val === undefined || val === null || String(val).trim() === '') continue; const map = f.mapsTo; if (!map) continue; if (map === 'custom') { const ck = f.customMapsKey?.trim(); if (ck) customKv[ck] = val; continue; } if (!allowed.includes(map)) continue; updates[map] = map === 'email' ? String(val).trim().toLowerCase() : String(val).trim(); } let p = await Participant.findOne({ tenantOrganization: orgOid, email }); if (!p) { p = await Participant.create({ tenantOrganization: orgOid, email, firstName: updates.firstName, lastName: updates.lastName, phone: updates.phone, organization: updates.organization, designation: updates.designation, notes: updates.notes, displayName: updates.displayName, customFields: Object.keys(customKv).length ? new Map(Object.entries(customKv)) : undefined, }); } else { if (updates.firstName) p.firstName = updates.firstName; if (updates.lastName) p.lastName = updates.lastName; if (updates.phone) p.phone = updates.phone; if (updates.organization) p.organization = updates.organization; if (updates.designation) p.designation = updates.designation; if (updates.notes) p.notes = updates.notes; if (updates.displayName) p.displayName = updates.displayName; if (Object.keys(customKv).length) { if (!p.customFields) p.customFields = new Map(); for (const [k, v] of Object.entries(customKv)) p.customFields.set(k, v); } await p.save(); } const tagList = (form.settings?.onSubmitAddTags || []) .map((t) => String(t).trim()) .filter(Boolean); if (tagList.length) { p.tags = [...new Set([...(p.tags || []).map(String), ...tagList])]; await p.save(); } const listId = form.settings?.onSubmitParticipantListId; if (listId && mongoose.Types.ObjectId.isValid(String(listId))) { const list = await ParticipantList.findById(listId).lean(); if (list && list.type === 'fixed' && String(list.tenantOrganization) === String(orgOid)) { await ParticipantList.updateOne({ _id: listId }, { $addToSet: { participantIds: p._id } }); } } await ensureEventMembershipForRegistrationForm(form, p); return p._id; } function userOrgId(user) { return user.organization?._id || user.organization; } async function formBelongsToUserOrg(formDoc, user) { if (user.role === 'super_admin') return true; const uo = userOrgId(user); if (!uo) return false; const uos = uo.toString(); if (formDoc.tenantOrganization && formDoc.tenantOrganization.toString() === uos) return true; if (!formDoc.event) return false; const ev = await Event.findById(formDoc.event).select('organization').lean(); return ev && ev.organization?.toString() === uos; } /** List forms for an event (legacy). */ const list = async (request, h) => { const { eventId } = request.params; const event = await Event.findById(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const user = request.auth.credentials.user; if (user.role !== 'super_admin' && event.organization?.toString() !== userOrgId(user)?.toString()) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const forms = await Form.find({ event: eventId }).sort({ createdAt: -1 }).lean(); const formIds = forms.map((f) => f._id); const counts = await FormSubmission.aggregate([ { $match: { form: { $in: formIds } } }, { $group: { _id: '$form', count: { $sum: 1 } } }, ]); const countByForm = Object.fromEntries(counts.map((c) => [c._id.toString(), c.count])); const formsWithCount = forms.map((f) => ({ ...f, submissionCount: countByForm[f._id.toString()] ?? 0 })); return h.response(success({ forms: formsWithCount })); }; /** Portfolio: all forms for the signed-in user's organization. */ const listByOrganization = async (request, h) => { const user = request.auth.credentials.user; let tenantOidRaw = user.role === 'super_admin' && request.query.organization ? request.query.organization : userOrgId(user); if (!tenantOidRaw) { return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); } if (!mongoose.Types.ObjectId.isValid(String(tenantOidRaw))) { return h.response(error('VALIDATION_ERROR', 'Invalid organization')).code(400); } const oid = new mongoose.Types.ObjectId(String(tenantOidRaw)); const forms = await Form.find({ tenantOrganization: oid }) .sort({ createdAt: -1 }) .populate('event', 'name') .lean(); const formIds = forms.map((f) => f._id); const counts = await FormSubmission.aggregate([ { $match: { form: { $in: formIds } } }, { $group: { _id: '$form', count: { $sum: 1 } } }, ]); const countByForm = Object.fromEntries(counts.map((c) => [c._id.toString(), c.count])); const formsWithCount = forms.map((f) => ({ ...f, submissionCount: countByForm[f._id.toString()] ?? 0 })); return h.response(success({ forms: formsWithCount })); }; const create = async (request, h) => { const { eventId } = request.params; const event = await Event.findById(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const user = request.auth.credentials.user; if (user.role !== 'super_admin' && event.organization?.toString() !== userOrgId(user)?.toString()) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const form = await Form.create({ ...request.payload, event: eventId, tenantOrganization: event.organization, }); return h.response(success(form, 'Form created', 201)).code(201); }; /** Create form at organization scope (optional event link). */ const createByOrganization = async (request, h) => { const user = request.auth.credentials.user; const body = request.payload; let tenantOidRaw = user.role === 'super_admin' && body.tenantOrganization ? body.tenantOrganization : userOrgId(user); if (!tenantOidRaw) { return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); } if (!mongoose.Types.ObjectId.isValid(String(tenantOidRaw))) { return h.response(error('VALIDATION_ERROR', 'Invalid organization')).code(400); } const toid = new mongoose.Types.ObjectId(String(tenantOidRaw)); if (!body.event || !mongoose.Types.ObjectId.isValid(String(body.event))) { return h.response(error('VALIDATION_ERROR', 'Event is required')).code(400); } const ev = await Event.findById(body.event).lean(); if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (user.role !== 'super_admin' && ev.organization?.toString() !== toid.toString()) { return h.response(error('FORBIDDEN', 'Event does not belong to your organization')).code(403); } const { event: _e, tenantOrganization: _t, ...rest } = body; const form = await Form.create({ ...rest, tenantOrganization: toid, event: body.event, }); return h.response(success(form, 'Form created', 201)).code(201); }; const get = async (request, h) => { const form = await Form.findById(request.params.id).populate('event'); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); return h.response(success(form)); }; const update = async (request, h) => { const form = await Form.findById(request.params.id); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); const user = request.auth.credentials.user; const allowed = await formBelongsToUserOrg(form, user); if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const payload = { ...request.payload }; delete payload.tenantOrganization; const { event: eventId, ...rest } = payload; Object.assign(form, rest); if (eventId !== undefined && eventId !== null) { if (!mongoose.Types.ObjectId.isValid(String(eventId))) { return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400); } const ev = await Event.findById(eventId).lean(); if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (user.role !== 'super_admin' && ev.organization?.toString() !== form.tenantOrganization?.toString()) { return h.response(error('FORBIDDEN', 'Event does not belong to this form organization')).code(403); } form.event = eventId; } await form.save(); const updated = await Form.findById(form._id).populate('event', 'name'); return h.response(success(updated)); }; const remove = async (request, h) => { const form = await Form.findById(request.params.id); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); await form.deleteOne(); return h.response(success(null, 'Form deleted')).code(200); }; const submit = async (request, h) => { const { formId } = request.params; const form = await Form.findById(formId).lean(); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); if (!form.settings?.isActive) return h.response(error('VALIDATION_ERROR', 'Form is not accepting submissions')).code(400); const user = request.auth?.credentials?.user; if (!user) { if (request.payload._gotcha) { return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400); } const captchaToken = request.payload.captchaToken; if (captchaToken) { const captchaOk = verifyCaptchaChallenge(captchaToken, request.payload.captchaAnswer); if (!captchaOk) { return h.response(error('VALIDATION_ERROR', 'Captcha verification failed. Please try again.')).code(400); } } else if (process.env.PUBLIC_FORM_CAPTCHA_REQUIRED === 'true') { return h.response(error('VALIDATION_ERROR', 'Captcha verification failed. Please try again.')).code(400); } } const tenantOrganization = form.tenantOrganization; const submissionEventId = await resolveSubmissionEventId(form); if (!tenantOrganization) { return h.response(error('VALIDATION_ERROR', 'Form is not configured for submissions (missing organization)')).code(400); } const dataObj = normalizeSubmitData(request.payload.data); let processedData = dataObj; try { processedData = await processFormSubmissionFileUploads(form, dataObj, { orgId: String(tenantOrganization), eventId: submissionEventId, }); } catch (err) { if (err.code === 'FILE_TOO_LARGE') { return h.response(error('VALIDATION_ERROR', err.message || 'File too large')).code(400); } throw err; } let participantId = request.payload.participantId; if (form.type === 'registration') { try { participantId = await upsertParticipantFromRegistrationForm(form, tenantOrganization, processedData); } catch (err) { if ( err.code === 'EMAIL_REQUIRED' || err.code === 'REGISTRATION_REQUIRED' || err.code === 'FORM_FIELD_CONFIG' || err.code === 'REGISTRATION_CLOSED' || err.code === 'REGISTRATION_FULL' ) { const statusCode = err.code === 'REGISTRATION_CLOSED' || err.code === 'REGISTRATION_FULL' ? 403 : 400; return h.response(error(err.code, err.message || 'Validation failed')).code(statusCode); } throw err; } } const submission = await FormSubmission.create({ form: formId, tenantOrganization, event: submissionEventId || undefined, user: user?._id, participant: participantId, data: processedData, }); if (submissionEventId) { const summary = await computeEventRatingsSummary(submissionEventId); await Event.findByIdAndUpdate(submissionEventId, { $set: { 'metadata.rating': summary.average, 'metadata.ratingCount': summary.count, }, }); } return h.response(success(submission, 'Submission received', 201)).code(201); }; function dataMapToObject(data) { if (!data) return {}; if (data instanceof Map) return Object.fromEntries(data); return typeof data === 'object' ? { ...data } : {}; } function submissionSearchBlob(sub) { const parts = []; const o = dataMapToObject(sub.data); Object.values(o).forEach((v) => { if (v != null && v !== '') parts.push(String(v)); }); const p = sub.participant; if (p && typeof p === 'object') { parts.push(p.email || '', p.firstName || '', p.lastName || ''); } return parts.join(' ').toLowerCase(); } /** Wildcard * splits segments; every segment must appear as substring (case-insensitive). */ function matchesSubmissionQuery(sub, qRaw) { if (!qRaw || !String(qRaw).trim()) return true; const q = String(qRaw).trim(); const segments = q.split('*').map((s) => s.trim().toLowerCase()).filter(Boolean); if (segments.length === 0) return true; const blob = submissionSearchBlob(sub); return segments.every((seg) => blob.includes(seg)); } function escapeCsvCell(val) { const s = val == null ? '' : String(val); if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; } function sanitizeExportFilenamePart(value, fallback) { const trimmed = String(value || '').trim(); if (!trimmed) return fallback; return ( trimmed .replace(/[\\/:*?"<>|]+/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 80) || fallback ); } function formatExportDateTime(date = new Date()) { const pad = (n) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`; } function buildFormSubmissionsExportFilename(formName, eventName) { const eventPart = sanitizeExportFilenamePart(eventName, 'Event'); const formPart = sanitizeExportFilenamePart(formName, 'Form'); return `${eventPart}-${formPart}-${formatExportDateTime()}.csv`; } async function resolveFormEventName(form) { if (form.event && typeof form.event === 'object' && form.event.name) { return String(form.event.name).trim() || 'Event'; } const eventId = extractObjectIdString(form.event); if (!eventId) return 'Event'; const ev = await Event.findById(eventId).select('name').lean(); return ev?.name?.trim() || 'Event'; } function submissionsToCsv(form, rows) { const cols = csvColumnsFromFormFields(form.fields); const header = ['Submitted At', 'Participant Email', ...cols.map((c) => c.header)]; const lines = [header.map(escapeCsvCell).join(',')]; for (const s of rows) { const o = dataMapToObject(s.data); const email = s.participant && typeof s.participant === 'object' ? s.participant.email || '' : ''; const vals = cols.map((c) => o[c.key] ?? ''); const row = [new Date(s.submittedAt).toISOString(), email, ...vals]; lines.push(row.map(escapeCsvCell).join(',')); } return lines.join('\n'); } const listSubmissions = async (request, h) => { const form = await Form.findById(request.params.formId); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const { q, dateFrom, dateTo, page: pageStr, limit: limitStr, export: exportStr } = request.query; const exportCsv = exportStr === 'csv' || exportStr === '1'; const match = { form: form._id }; if (dateFrom) { const d = new Date(dateFrom); if (!Number.isNaN(d.getTime())) match.submittedAt = { $gte: d }; } if (dateTo) { const d = new Date(dateTo); if (!Number.isNaN(d.getTime())) { d.setHours(23, 59, 59, 999); match.submittedAt = { ...match.submittedAt, $lte: d }; } } let all = await FormSubmission.find(match).populate('participant', 'firstName lastName email').sort({ submittedAt: -1 }).lean(); if (q && String(q).trim()) { all = all.filter((s) => matchesSubmissionQuery(s, q)); } if (exportCsv) { const eventName = await resolveFormEventName(form); const filename = buildFormSubmissionsExportFilename(form.name, eventName); const csv = submissionsToCsv(form, all); return h .response(csv) .type('text/csv; charset=utf-8') .header('Content-Disposition', `attachment; filename="${filename}"`) .code(200); } const page = Math.max(1, parseInt(String(pageStr || '1'), 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(String(limitStr || '20'), 10) || 20)); const total = all.length; const skip = (page - 1) * limit; const submissions = all.slice(skip, skip + limit); return h.response(success({ submissions, total, page, limit })); }; /** Public: form definition for rendering (no auth). Only when form accepts responses. */ const replyToSubmission = async (request, h) => { const form = await Form.findById(request.params.formId); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const { to, subject, body } = request.payload || {}; if (!to) return h.response(error('VALIDATION_ERROR', 'Recipient email is required')).code(400); const orgId = form.tenantOrganization || (form.event ? String(form.event) : null); const message = buildSubmissionReplyEmail({ to, from: request.auth.credentials.user?.email || 'EventBuzz', subject, body, }); const result = await sendOrgMail({ organizationId: orgId, to, subject: message.subject, html: message.html, text: message.text, }); if (!result?.ok && !result?.skipped) { return h.response(error('MAIL_FAILED', result?.error || 'Could not send email')).code(502); } return h.response(success({ sent: true })); }; const getPublicForm = async (request, h) => { const form = await Form.findById(request.params.formId).lean(); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); if (!form.settings?.isActive) { return h.response(error('VALIDATION_ERROR', 'This form is not accepting responses.')).code(400); } const { ev: resolvedEvent, micrositeLogo } = await resolveEventForPublicForm(form); const eventPublic = resolvedEvent ? buildPublicEventPayload(resolvedEvent, micrositeLogo) : null; const safe = { _id: form._id, name: form.name, description: form.description || '', type: form.type, event: eventPublic, fields: publicRegistrationFields(form.fields, form.type), settings: { thankYouMessage: form.settings?.thankYouMessage, showThankYou: form.settings?.showThankYou !== false, collectEmail: !!form.settings?.collectEmail, allowAnonymous: !!form.settings?.allowAnonymous, }, }; return h.response(success(safe)); }; module.exports = { list, listByOrganization, create, createByOrganization, get, update, remove, submit, listSubmissions, replyToSubmission, getPublicForm, };