Updates web check-in
This commit is contained in:
parent
a963dffcd4
commit
55fc21e791
|
|
@ -27,12 +27,57 @@ const {
|
||||||
} = require('../services/acl.service');
|
} = require('../services/acl.service');
|
||||||
const { canAccessEvent } = require('../services/eventScope.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) {
|
function throwRegError(message, code) {
|
||||||
const err = new Error(message);
|
const err = new Error(message);
|
||||||
err.code = code;
|
err.code = code;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function eventRegistrationStatus(event) {
|
function eventRegistrationStatus(event) {
|
||||||
const settings = event.settings || {};
|
const settings = event.settings || {};
|
||||||
if (settings.requiresApproval === true) return 'pending';
|
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({
|
const submission = await FormSubmission.create({
|
||||||
form: formId,
|
form: formId,
|
||||||
tenantOrganization,
|
tenantOrganization,
|
||||||
event: submissionEventId || undefined,
|
event: submissionEventId || undefined,
|
||||||
user: user?._id,
|
user: user?._id,
|
||||||
participant: participantId,
|
participant: participantId,
|
||||||
data: processedData,
|
data: safeFormDataForMongo(processedData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (submissionEventId) {
|
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: `<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);
|
||||||
|
}
|
||||||
|
|
||||||
return h.response(success(submission, 'Submission received', 201)).code(201);
|
return h.response(success(submission, 'Submission received', 201)).code(201);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function dataMapToObject(data) {
|
function dataMapToObject(data) {
|
||||||
if (!data) return {};
|
if (!data) return {};
|
||||||
if (data instanceof Map) return Object.fromEntries(data);
|
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);
|
const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user);
|
||||||
if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
|
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);
|
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 orgId = form.tenantOrganization || (form.event ? String(form.event) : null);
|
||||||
const message = buildSubmissionReplyEmail({
|
const message = buildSubmissionReplyEmail({
|
||||||
to,
|
to,
|
||||||
|
|
@ -644,6 +839,15 @@ const replyToSubmission = async (request, h) => {
|
||||||
return h.response(error('MAIL_FAILED', result?.error || 'Could not send email')).code(502);
|
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 }));
|
return h.response(success({ sent: true }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ const formSubmissionSchema = new mongoose.Schema(
|
||||||
reviewedAt: Date,
|
reviewedAt: Date,
|
||||||
reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
||||||
notes: String,
|
notes: String,
|
||||||
|
|
||||||
|
replied: { type: Boolean, default: false },
|
||||||
|
repliedAt: Date,
|
||||||
|
repliedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ const submitPayload = Joi.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const replyPayload = Joi.object({
|
const replyPayload = Joi.object({
|
||||||
|
submissionId: Joi.string().required(),
|
||||||
to: Joi.string().email().required(),
|
to: Joi.string().email().required(),
|
||||||
subject: Joi.string().allow('', null),
|
subject: Joi.string().allow('', null),
|
||||||
body: Joi.string().allow('', null),
|
body: Joi.string().allow('', null),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue