Merge remote main; combine web check-in QR flow with registration confirmation email

This commit is contained in:
Ravi Gulhane 2026-07-22 09:36:26 +05:30
commit ae0c9c7a4f
10 changed files with 323 additions and 494 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 560 KiB

View File

@ -30,12 +30,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';
@ -610,13 +655,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) {
@ -629,6 +724,66 @@ const submit = async (request, h) => {
});
}
if (form.type === 'web-check-in') {
const gateData = {
webCheckIn: {
status: 'ACCEPTED',
},
};
await FormSubmission.findByIdAndUpdate(submission._id, {
$set: {
data: safeMergeSubmissionData(submission, gateData),
},
});
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: `<p>Hi,</p><p>Your web check-in QR code is ready.</p><p><img src="${qrUrl}" alt="Web check-in QR" style="width:220px;height:220px;"/></p><p style="color:#6b7280;font-size:13px;">If the QR doesn’t scan, save and rescan, or contact support.</p>`,
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);
}
if (form.type === 'registration' && participantId) {
void sendRegistrationConfirmationEmail({
form,
@ -642,6 +797,7 @@ const submit = async (request, h) => {
return h.response(success(submission, 'Submission received', 201)).code(201);
};
function dataMapToObject(data) {
if (!data) return {};
if (data instanceof Map) return Object.fromEntries(data);
@ -782,9 +938,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,
@ -805,6 +966,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 }));
};

View File

@ -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 }
);

View File

@ -98,6 +98,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),

View File

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

View File

@ -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');
});
});

16
src/utils/webCheckInQr.js Normal file
View File

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