659 lines
24 KiB
JavaScript
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,
|
|
};
|