EventBuzz_Backend/src/controllers/form.controller.js

659 lines
24 KiB
JavaScript

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