diff --git a/src/controllers/form.controller.js b/src/controllers/form.controller.js index 954c28d..abc0ad8 100644 --- a/src/controllers/form.controller.js +++ b/src/controllers/form.controller.js @@ -27,12 +27,57 @@ const { } = require('../services/acl.service'); const { canAccessEvent } = require('../services/eventScope.service'); +const { + normalizeEmail, + parseAccommodationValue, + buildGateStatus, + resolveSubmittedValue, +} = require('../utils/webCheckInGating'); + +const { buildParticipantQr } = require('../utils/webCheckInQr'); + + +function safeFormDataForMongo(val) { + // Ensure we never pass objects with "$..." keys into a Mongoose Map. + // Some upstream code can accidentally include mongoose internals like "$__deferred". + const strip = (x) => { + if (x == null) return x; + if (x instanceof Map) { + const obj = Object.fromEntries(x.entries()); + return strip(obj); + } + if (Array.isArray(x)) return x.map(strip); + if (typeof x !== 'object') return x; + + const out = {}; + for (const [k, v] of Object.entries(x)) { + if (k.startsWith('$')) continue; + out[k] = strip(v); + } + return out; + }; + + return strip(val); +} + +function mapToPlain(obj) { + if (!obj) return {}; + if (obj instanceof Map) return Object.fromEntries(obj.entries()); + return obj; +} + +function safeMergeSubmissionData(submissionDoc, extra) { + const current = mapToPlain(submissionDoc?.data); + return { ...current, ...extra }; +} + 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'; @@ -459,13 +504,63 @@ const submit = async (request, h) => { } } + let gateStatus = null; + if (form.type === 'web-check-in') { + const email = normalizeEmail( + resolveSubmittedValue({ form, dataObj: processedData, candidateKeys: ['email', 'Email'] }) + ); + const accommodationAssigned = parseAccommodationValue( + resolveSubmittedValue({ form, dataObj: processedData, candidateKeys: ['accommodation', 'Accommodation'] }) + ); + + + + let hasParticipant = false; + + // Validate membership using the event -> participant relationship. + // Use getMembership(participantDoc, eventId) as the established system pattern. + let participantDoc = null; + if (submissionEventId && email) { + participantDoc = await Participant.findOne({ email }).lean(); + if (participantDoc) { + hasParticipant = !!getMembership(participantDoc, String(submissionEventId)); + } + if (participantDoc) { + participantId = participantDoc._id; + } + } + + // Accommodation must be validated from stored participant data, + // because the current web-check-in form may not include an accommodation field. + const accommodationAssignedFromParticipant = !!participantDoc?.accommodation; + + gateStatus = buildGateStatus({ + hasParticipant, + accommodationAssigned: accommodationAssignedFromParticipant, + }); + + } + + // console.log("Matched Participant:", participantList); + + // For web check-in, do not create a submission / do not issue QR if gating fails. + if (form.type === 'web-check-in' && gateStatus && gateStatus !== 'ACCEPTED') { + // Requirement: if participant email not present OR accommodation not YES => do not submit + return h + .response(error('WEB_CHECKIN_GATED', gateStatus)) + .code(403); + } + + + + const submission = await FormSubmission.create({ form: formId, tenantOrganization, event: submissionEventId || undefined, user: user?._id, participant: participantId, - data: processedData, + data: safeFormDataForMongo(processedData), }); if (submissionEventId) { @@ -478,9 +573,104 @@ const submit = async (request, h) => { }); } + // Accepted: generate QR and email it. + if (form.type === 'web-check-in') { + const gateData = { + webCheckIn: { + status: 'ACCEPTED', + }, + }; + + await FormSubmission.findByIdAndUpdate(submission._id, { + $set: { + data: safeMergeSubmissionData(submission, gateData), + }, + }); + + const participantDoc = submission.participant ? await Participant.findById(submission.participant).lean() : null; + // const qrPayload = { + // type: 'web-check-in', + // submissionId: String(submission._id), + // eventId: submissionEventId ? String(submissionEventId) : null, + // formId: String(form._id), + // createdAt: new Date().toISOString(), + // participant: participantDoc + // ? { + // _id: String(participantDoc._id), + // email: participantDoc.email || null, + // firstName: participantDoc.firstName || null, + // lastName: participantDoc.lastName || null, + // accommodation: participantDoc.accommodation || false, + // } + // : { + // email: normalizeEmail(resolveSubmittedValue({ form, dataObj: processedData, candidateKeys: ['email', 'Email'] })) || null, + // firstName: resolveSubmittedValue({ form, dataObj: processedData, candidateKeys: ['firstName', 'First Name'] }) || null, + // lastName: resolveSubmittedValue({ form, dataObj: processedData, candidateKeys: ['lastName', 'Last Name'] }) || null, + // accommodation: parseAccommodationValue(resolveSubmittedValue({ form, dataObj: processedData, candidateKeys: ['accommodation', 'Accommodation'] })), + // }, + // fields: processedData, + // }; + + // Build QR as an image buffer, upload to S3, and store a public URL. + // const qrJson = JSON.stringify(qrPayload); + // const qrPngBuffer = await require('qrcode').toBuffer(qrJson, { + // errorCorrectionLevel: 'M', + // margin: 1, + // scale: 6, + // type: 'png', + // }); + const qrPngBuffer = await buildParticipantQr( + submissionEventId, + submission.participant +); + + const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media'); + const qrUpload = await uploadBufferToS3AndGetCloudFrontUrl({ + buffer: qrPngBuffer, + contentType: 'image/png', + orgId: tenantOrganization ? String(tenantOrganization) : null, + eventId: submissionEventId ? String(submissionEventId) : 'event', + module: 'web-checkin', + filename: `webcheckin-qr-${submission._id}.png`, + }); + + const qrUrl = qrUpload.url; + + await FormSubmission.findByIdAndUpdate(submission._id, { + $set: { + data: safeMergeSubmissionData(submission, { + webCheckIn: { + ...gateData.webCheckIn, + qrUrl, + }, + }), + }, + }); + + + const toEmail = normalizeEmail( + resolveSubmittedValue({ form, dataObj: processedData, candidateKeys: ['email', 'Email'] }) + ) || null; + if (toEmail) { + const orgId = tenantOrganization ? String(tenantOrganization) : null; + await sendOrgMail({ + organizationId: orgId, + to: toEmail, + subject: 'Your web check-in QR code', + html: `
Hi,
Your web check-in QR code is ready.
If the QR doesn’t scan, save and rescan, or contact support.
`, + text: `Your web check-in QR code is ready. Submission: ${submission._id}`, + }); + + } + + const updated = await FormSubmission.findById(submission._id).lean(); + return h.response(success(updated, 'Web check-in accepted', 201)).code(201); + } + return h.response(success(submission, 'Submission received', 201)).code(201); }; + function dataMapToObject(data) { if (!data) return {}; if (data instanceof Map) return Object.fromEntries(data); @@ -621,9 +811,14 @@ const replyToSubmission = async (request, h) => { 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 || {}; + const { submissionId, to, subject, body } = request.payload || {}; + if (!submissionId) return h.response(error('VALIDATION_ERROR', 'Submission id is required')).code(400); + if (!mongoose.Types.ObjectId.isValid(String(submissionId))) { + return h.response(error('VALIDATION_ERROR', 'Invalid submission id')).code(400); + } 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, @@ -644,6 +839,15 @@ const replyToSubmission = async (request, h) => { return h.response(error('MAIL_FAILED', result?.error || 'Could not send email')).code(502); } + await FormSubmission.findByIdAndUpdate(String(submissionId), { + $set: { + replied: true, + repliedAt: new Date(), + repliedBy: request.auth.credentials.user?._id, + }, + }); + + return h.response(success({ sent: true })); }; diff --git a/src/models/FormSubmission.js b/src/models/FormSubmission.js index 4a09542..da50580 100644 --- a/src/models/FormSubmission.js +++ b/src/models/FormSubmission.js @@ -29,6 +29,10 @@ const formSubmissionSchema = new mongoose.Schema( reviewedAt: Date, reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, notes: String, + + replied: { type: Boolean, default: false }, + repliedAt: Date, + repliedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, }, { timestamps: true } ); diff --git a/src/routes/form.routes.js b/src/routes/form.routes.js index 34d8bf5..a421cea 100644 --- a/src/routes/form.routes.js +++ b/src/routes/form.routes.js @@ -85,6 +85,7 @@ const submitPayload = Joi.object({ }); const replyPayload = Joi.object({ + submissionId: Joi.string().required(), to: Joi.string().email().required(), subject: Joi.string().allow('', null), body: Joi.string().allow('', null), diff --git a/src/utils/webCheckInGating.js b/src/utils/webCheckInGating.js new file mode 100644 index 0000000..85c37f9 --- /dev/null +++ b/src/utils/webCheckInGating.js @@ -0,0 +1,100 @@ +const mongoose = require('mongoose'); +const ParticipantList = require('../models/ParticipantList'); +const Participant = require('../models/Participant'); + +function normalizeEmail(email) { + return email != null ? String(email).trim().toLowerCase() : ''; +} + +function parseAccommodationValue(v) { + if (v === true) return true; + if (v === false) return false; + const s = v == null ? '' : String(v).trim().toLowerCase(); + if (!s) return false; + return ['yes', 'true', '1', 'y'].includes(s); +} + +function buildGateStatus({ hasParticipant, accommodationAssigned }) { + if (!hasParticipant) return 'NO_PARTICIPANT_ID_PRESENT'; + if (!accommodationAssigned) return 'ACCOMMODATION_NOT_ASSIGNED'; + return 'ACCEPTED'; +} + +function resolveSubmittedValue({ form, dataObj, candidateKeys }) { + const entries = Array.isArray(candidateKeys) ? candidateKeys : [candidateKeys]; + const source = dataObj && typeof dataObj === 'object' ? dataObj : {}; + const fields = Array.isArray(form?.fields) ? form.fields : []; + + for (const key of entries) { + if (source[key] !== undefined && source[key] !== null && String(source[key]).trim() !== '') { + return source[key]; + } + } + + const fieldMap = new Map(); + for (const field of fields) { + if (!field?.mapsTo) continue; + fieldMap.set(String(field.mapsTo).toLowerCase(), field); + } + + for (const key of entries) { + const field = fieldMap.get(String(key).toLowerCase()); + if (!field) continue; + const fieldKey = field.name || field.label; + const value = fieldKey ? source[fieldKey] : undefined; + if (value !== undefined && value !== null && String(value).trim() !== '') { + return value; + } + } + + return undefined; +} + +async function resolveEventFromForm(form) { + const directId = form?.event; + if (!directId) return null; + if (typeof directId === 'object' && directId._id) return String(directId._id); + if (mongoose.Types.ObjectId.isValid(String(directId))) return String(directId); + return null; +} + +async function getParticipantListForEvent({ eventId, tenantOrganizationId }) { + if (!eventId || !tenantOrganizationId) return null; + return ParticipantList.findOne({ + tenantOrganization: tenantOrganizationId, + event: eventId, + }).lean(); +} + +async function isEmailInParticipantList({ list, tenantOrganizationId, email }) { + if (!list) return false; + const emailNorm = normalizeEmail(email); + if (!emailNorm) return false; + + if (list.type === 'fixed' && Array.isArray(list.participantIds) && list.participantIds.length) { + const p = await Participant.findOne({ + tenantOrganization: tenantOrganizationId, + email: emailNorm, + _id: { $in: list.participantIds }, + }).select('_id').lean(); + return !!p; + } + + return false; +} + +function collectUserEnteredFields({ dataObj }) { + return dataObj && typeof dataObj === 'object' ? dataObj : {}; +} + +module.exports = { + normalizeEmail, + parseAccommodationValue, + buildGateStatus, + resolveSubmittedValue, + resolveEventFromForm, + getParticipantListForEvent, + isEmailInParticipantList, + collectUserEnteredFields, +}; + diff --git a/src/utils/webCheckInGating.test.js b/src/utils/webCheckInGating.test.js new file mode 100644 index 0000000..ef92cb8 --- /dev/null +++ b/src/utils/webCheckInGating.test.js @@ -0,0 +1,30 @@ +const { + buildGateStatus, + parseAccommodationValue, + resolveSubmittedValue, +} = require('./webCheckInGating'); + +describe('web check-in gating helpers', () => { + it('resolves email and accommodation values from form field mappings', () => { + const form = { + fields: [ + { name: 'emailField', label: 'Email', mapsTo: 'email' }, + { name: 'accommodationField', label: 'Accommodation', mapsTo: 'accommodation' }, + ], + }; + + const dataObj = { + emailField: 'Guest@example.com', + accommodationField: 'Yes', + }; + + expect(resolveSubmittedValue({ form, dataObj, candidateKeys: ['email'] })).toBe('Guest@example.com'); + expect(parseAccommodationValue(resolveSubmittedValue({ form, dataObj, candidateKeys: ['accommodation'] }))).toBe(true); + }); + + it('returns the requested gate signs for the validation states', () => { + expect(buildGateStatus({ hasParticipant: false, accommodationAssigned: true })).toBe('NO_PARTICIPANT_ID_PRESENT'); + expect(buildGateStatus({ hasParticipant: true, accommodationAssigned: false })).toBe('ACCOMMODATION_NOT_ASSIGNED'); + expect(buildGateStatus({ hasParticipant: true, accommodationAssigned: true })).toBe('ACCEPTED'); + }); +}); diff --git a/src/utils/webCheckInQr.js b/src/utils/webCheckInQr.js new file mode 100644 index 0000000..51b6ee7 --- /dev/null +++ b/src/utils/webCheckInQr.js @@ -0,0 +1,16 @@ +const QRCode = require("qrcode"); + +async function buildParticipantQr(eventId, participantId) { + const qrValue = `eventbuzz:event:${eventId}:participant:${participantId}`; + + return QRCode.toBuffer(qrValue, { + errorCorrectionLevel: "M", + margin: 1, + scale: 6, + type: "png", + }); +} + +module.exports = { + buildParticipantQr, +}; \ No newline at end of file