Merge remote main; combine web check-in QR flow with registration confirmation email
This commit is contained in:
commit
ae0c9c7a4f
Binary file not shown.
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 560 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -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 }));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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