Add WhatsApp blast CTAs, presenter photo shapes, and related wall/attendance APIs.

Persist drip/blast button configs, expose presentPhotoShape, and extend photo wall and attendance sync support.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-08-19 09:37:16 +05:30
parent b86cb5e992
commit b9d0c32a6b
22 changed files with 1068 additions and 18 deletions

View File

@ -34,6 +34,7 @@ const visitorThemeRoutes = require('./routes/visitorTheme.routes');
const eventMicrositeRoutes = require('./routes/eventMicrosite.routes'); const eventMicrositeRoutes = require('./routes/eventMicrosite.routes');
const visitorRoutes = require('./routes/visitor.routes'); const visitorRoutes = require('./routes/visitor.routes');
const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes'); const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes');
const whatsappBlastRoutes = require('./routes/whatsappBlast.routes');
const badgeBatchRoutes = require('./routes/badgeBatch.routes'); const badgeBatchRoutes = require('./routes/badgeBatch.routes');
const eventTicketRoutes = require('./routes/eventTicket.routes'); const eventTicketRoutes = require('./routes/eventTicket.routes');
const publicEventTicketRoutes = require('./routes/publicEventTicket.routes'); const publicEventTicketRoutes = require('./routes/publicEventTicket.routes');
@ -334,6 +335,7 @@ const createServer = async () => {
server.route(eventMicrositeRoutes); server.route(eventMicrositeRoutes);
server.route(staticMicrositeRoutes); server.route(staticMicrositeRoutes);
server.route(whatsappTemplateRoutes); server.route(whatsappTemplateRoutes);
server.route(whatsappBlastRoutes);
server.route(badgeBatchRoutes); server.route(badgeBatchRoutes);
server.route(eventTicketRoutes); server.route(eventTicketRoutes);
server.route(ticketManualRoutes); server.route(ticketManualRoutes);

View File

@ -8,6 +8,8 @@ const { participantDisplayName } = require('../services/attendanceCheckIn.servic
const { success, error } = require('../utils/response'); const { success, error } = require('../utils/response');
const { const {
performCheckIn, performCheckIn,
performAssignBlank,
performUndoCheckIn,
performBulkCheckInSync, performBulkCheckInSync,
getAttendanceStats, getAttendanceStats,
searchEventParticipantsForAttendance, searchEventParticipantsForAttendance,
@ -70,6 +72,7 @@ const create = async (request, h) => {
description: description || '', description: description || '',
settings: { settings: {
allowDuplicateCheckIn: Boolean(settings?.allowDuplicateCheckIn), allowDuplicateCheckIn: Boolean(settings?.allowDuplicateCheckIn),
allowAssignBlank: Boolean(settings?.allowAssignBlank),
publicLinkEnabled: settings?.publicLinkEnabled !== false, publicLinkEnabled: settings?.publicLinkEnabled !== false,
audience: sanitizeAudienceForSave(settings), audience: sanitizeAudienceForSave(settings),
}, },
@ -110,6 +113,9 @@ const update = async (request, h) => {
if (settings.allowDuplicateCheckIn !== undefined) { if (settings.allowDuplicateCheckIn !== undefined) {
item.settings.allowDuplicateCheckIn = Boolean(settings.allowDuplicateCheckIn); item.settings.allowDuplicateCheckIn = Boolean(settings.allowDuplicateCheckIn);
} }
if (settings.allowAssignBlank !== undefined) {
item.settings.allowAssignBlank = Boolean(settings.allowAssignBlank);
}
if (settings.publicLinkEnabled !== undefined) { if (settings.publicLinkEnabled !== undefined) {
item.settings.publicLinkEnabled = Boolean(settings.publicLinkEnabled); item.settings.publicLinkEnabled = Boolean(settings.publicLinkEnabled);
} }
@ -283,6 +289,43 @@ const checkIn = async (request, h) => {
return h.response(success(outcome.result, 'Checked in')); return h.response(success(outcome.result, 'Checked in'));
}; };
const undoCheckIn = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const { participantId } = request.payload;
const outcome = await performUndoCheckIn({
attendanceId: id,
eventId,
participantId,
source: 'dashboard',
checkedInBy: user._id?.toString() || user.id,
});
if (outcome.err) return h.response(outcome.err).code(outcome.statusCode || 400);
return h.response(success(outcome.result, 'Check-in undone'));
};
const assignBlank = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const { participantId, firstName, lastName, email, phone } = request.payload;
const outcome = await performAssignBlank({
attendanceId: id,
eventId,
participantId,
profile: { firstName, lastName, email, phone },
source: 'dashboard',
checkedInBy: user._id?.toString() || user.id,
});
if (outcome.err) return h.response(outcome.err).code(outcome.statusCode || 400);
return h.response(success(outcome.result, 'Assigned and checked in'));
};
const syncCheckIns = async (request, h) => { const syncCheckIns = async (request, h) => {
const { eventId, id } = request.params; const { eventId, id } = request.params;
const user = request.auth.credentials.user; const user = request.auth.credentials.user;
@ -336,6 +379,8 @@ module.exports = {
searchParticipants, searchParticipants,
getParticipantPreview, getParticipantPreview,
checkIn, checkIn,
undoCheckIn,
assignBlank,
syncCheckIns, syncCheckIns,
exportCsv, exportCsv,
}; };

View File

@ -18,6 +18,7 @@ const {
ensureVenueFaqs, ensureVenueFaqs,
normalizeFaqsPayload, normalizeFaqsPayload,
} = require('../utils/conciergeVenueFaqs'); } = require('../utils/conciergeVenueFaqs');
const { normalizeWhatsAppCtas } = require('../utils/whatsappCtas');
function normalizeReceptionPayload(p) { function normalizeReceptionPayload(p) {
if (!p || typeof p !== 'object') return null; if (!p || typeof p !== 'object') return null;
@ -32,15 +33,32 @@ function normalizeReceptionPayload(p) {
templateId = d.templateId; templateId = d.templateId;
} }
const delayMinutes = Number(d.delayMinutes); const delayMinutes = Number(d.delayMinutes);
return { let agendaCount = Number(d.agendaCount);
if (!Number.isFinite(agendaCount)) agendaCount = 3;
agendaCount = Math.min(8, Math.max(1, Math.round(agendaCount)));
const contentKind = d.contentKind === 'upcoming_agenda' ? 'upcoming_agenda' : 'custom';
const ctas =
contentKind === 'upcoming_agenda' ? [] : normalizeWhatsAppCtas(d.ctas, { strict: true });
const body = String(d.body || '');
if (ctas.length && !body.trim()) {
throw new Error('Drip message text is required when buttons are set.');
}
const row = {
trigger, trigger,
delayMinutes: Number.isFinite(delayMinutes) ? Math.max(0, delayMinutes) : 0, delayMinutes: Number.isFinite(delayMinutes) ? Math.max(0, delayMinutes) : 0,
sendAt: d.sendAt ? new Date(d.sendAt) : null, sendAt: d.sendAt ? new Date(d.sendAt) : null,
body: String(d.body || ''), body,
mediaType, mediaType: contentKind === 'upcoming_agenda' ? 'none' : mediaType,
mediaUrl: String(d.mediaUrl || '').trim(), mediaUrl: contentKind === 'upcoming_agenda' ? '' : String(d.mediaUrl || '').trim(),
contentKind,
agendaCount,
templateId, templateId,
ctas,
}; };
if (d._id && mongoose.Types.ObjectId.isValid(String(d._id))) {
row._id = d._id;
}
return row;
}) })
: []; : [];
const audience = p.dripAudience === 'all_inbound' ? 'all_inbound' : 'matched'; const audience = p.dripAudience === 'all_inbound' ? 'all_inbound' : 'matched';
@ -406,11 +424,16 @@ const update = async (request, h) => {
} }
if (p.enabled != null) { if (p.enabled != null) {
bot.enabled = Boolean(p.enabled); bot.enabled = Boolean(p.enabled);
if (bot.enabled && bot.status !== 'ARCHIVED') bot.status = 'ACTIVE'; if (bot.enabled) bot.status = 'ACTIVE';
if (!bot.enabled && bot.status === 'ACTIVE') bot.status = 'DRAFT'; if (!bot.enabled && bot.status === 'ACTIVE') bot.status = 'DRAFT';
} }
if (p.reception != null) { if (p.reception != null) {
const next = normalizeReceptionPayload(p.reception); let next;
try {
next = normalizeReceptionPayload(p.reception);
} catch (e) {
return h.response(error('VALIDATION_ERROR', e.message || 'Invalid reception settings')).code(400);
}
if (next.enabled) { if (next.enabled) {
const clashMsg = await assertSingleReceptionBot(bot.organization, bot._id); const clashMsg = await assertSingleReceptionBot(bot.organization, bot._id);
if (clashMsg) { if (clashMsg) {
@ -418,6 +441,10 @@ const update = async (request, h) => {
} }
} }
bot.reception = { ...(bot.reception?.toObject?.() || bot.reception || {}), ...next }; bot.reception = { ...(bot.reception?.toObject?.() || bot.reception || {}), ...next };
bot.markModified('reception');
if (next.enabled && bot.status === 'ARCHIVED') {
bot.status = bot.enabled ? 'ACTIVE' : 'DRAFT';
}
} }
let eventRebound = false; let eventRebound = false;

View File

@ -26,6 +26,18 @@ function parseTagsFromPayload(payload) {
return []; return [];
} }
function mimeFromFilename(name, fallback) {
const n = String(name || '').toLowerCase();
if (n.endsWith('.jpg') || n.endsWith('.jpeg')) return 'image/jpeg';
if (n.endsWith('.png')) return 'image/png';
if (n.endsWith('.gif')) return 'image/gif';
if (n.endsWith('.webp')) return 'image/webp';
if (n.endsWith('.mp4')) return 'video/mp4';
if (n.endsWith('.mov')) return 'video/quicktime';
if (n.endsWith('.pdf')) return 'application/pdf';
return fallback || 'application/octet-stream';
}
function inferContentType(mimeType, payloadType) { function inferContentType(mimeType, payloadType) {
if (payloadType && ['image', 'pdf', 'video', 'document', 'other'].includes(payloadType)) return payloadType; if (payloadType && ['image', 'pdf', 'video', 'document', 'other'].includes(payloadType)) return payloadType;
const m = String(mimeType || '').toLowerCase(); const m = String(mimeType || '').toLowerCase();
@ -59,10 +71,11 @@ function extractUploadFile(payload) {
if (!file) return null; if (!file) return null;
if (Buffer.isBuffer(file)) { if (Buffer.isBuffer(file)) {
const filename = (typeof payload.name === 'string' && payload.name.trim()) || 'file';
return { return {
buffer: file, buffer: file,
filename: (typeof payload.name === 'string' && payload.name.trim()) || 'file', filename,
mimeType: 'application/octet-stream', mimeType: mimeFromFilename(filename, 'application/octet-stream'),
}; };
} }
@ -148,7 +161,8 @@ const upload = async (request, h) => {
if (extracted) { if (extracted) {
const { buffer, filename: origName, mimeType: ct } = extracted; const { buffer, filename: origName, mimeType: ct } = extracted;
const filename = `${Date.now()}-${(origName || 'file').replace(/[^a-zA-Z0-9.-]/g, '_')}`; const filename = `${Date.now()}-${(origName || 'file').replace(/[^a-zA-Z0-9.-]/g, '_')}`;
mimeType = ct || 'application/octet-stream'; mimeType =
ct && ct !== 'application/octet-stream' ? ct : mimeFromFilename(origName, ct || 'application/octet-stream');
const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({ const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({
buffer, buffer,

View File

@ -5,7 +5,7 @@ const Event = require('../models/Event');
const Form = require('../models/Form'); const Form = require('../models/Form');
const { success, error } = require('../utils/response'); const { success, error } = require('../utils/response');
const { deleteEventPhotoFromS3, resolvePhotoPublicUrl, resolvePhotoThumbUrl } = require('../utils/s3EventPhotos'); const { deleteEventPhotoFromS3, resolvePhotoPublicUrl, resolvePhotoThumbUrl } = require('../utils/s3EventPhotos');
const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers'); const { normalizeEventHeaderDisplay, normalizePresenterEventHeaderDisplay } = require('../utils/formPublicHelpers');
const { const {
normalizeFrameImageLayout, normalizeFrameImageLayout,
normalizeFrameLayout, normalizeFrameLayout,
@ -75,6 +75,12 @@ function serializeWall(wall, counts = {}) {
maxPhotoWidth: wall.maxPhotoWidth ?? 320, maxPhotoWidth: wall.maxPhotoWidth ?? 320,
maxPhotoHeight: wall.maxPhotoHeight ?? 320, maxPhotoHeight: wall.maxPhotoHeight ?? 320,
gapPx: wall.gapPx ?? 8, gapPx: wall.gapPx ?? 8,
presentMarginTopPx: wall.presentMarginTopPx ?? 0,
presentMarginLeftPx: wall.presentMarginLeftPx ?? 0,
presentMarginRightPx: wall.presentMarginRightPx ?? 0,
presentPhotoShape: ['rect', 'rounded', 'circle'].includes(wall.presentPhotoShape)
? wall.presentPhotoShape
: 'rounded',
showUploaderNameOnWall: Boolean(wall.showUploaderNameOnWall), showUploaderNameOnWall: Boolean(wall.showUploaderNameOnWall),
requireUploadCrop: Boolean(wall.requireUploadCrop), requireUploadCrop: Boolean(wall.requireUploadCrop),
frameEnabled: Boolean(wall.frameEnabled), frameEnabled: Boolean(wall.frameEnabled),
@ -99,6 +105,7 @@ function serializeWall(wall, counts = {}) {
whatsapp: Boolean(wall.sharePlatforms?.whatsapp), whatsapp: Boolean(wall.sharePlatforms?.whatsapp),
}, },
eventHeader: normalizeEventHeaderDisplay(wall.eventHeader), eventHeader: normalizeEventHeaderDisplay(wall.eventHeader),
presenterEventHeader: normalizePresenterEventHeaderDisplay(wall.presenterEventHeader),
uploadToken: wall.uploadToken || null, uploadToken: wall.uploadToken || null,
presentToken: wall.presentToken || null, presentToken: wall.presentToken || null,
photoCounts: { photoCounts: {
@ -220,6 +227,21 @@ const updatePhotoWall = async (request, h) => {
const n = Number(p.gapPx); const n = Number(p.gapPx);
if (Number.isFinite(n)) wall.gapPx = Math.min(64, Math.max(0, Math.round(n))); if (Number.isFinite(n)) wall.gapPx = Math.min(64, Math.max(0, Math.round(n)));
} }
if (p.presentMarginTopPx != null) {
const n = Number(p.presentMarginTopPx);
if (Number.isFinite(n)) wall.presentMarginTopPx = Math.min(2000, Math.max(0, Math.round(n)));
}
if (p.presentMarginLeftPx != null) {
const n = Number(p.presentMarginLeftPx);
if (Number.isFinite(n)) wall.presentMarginLeftPx = Math.min(2000, Math.max(0, Math.round(n)));
}
if (p.presentMarginRightPx != null) {
const n = Number(p.presentMarginRightPx);
if (Number.isFinite(n)) wall.presentMarginRightPx = Math.min(2000, Math.max(0, Math.round(n)));
}
if (p.presentPhotoShape === 'rect' || p.presentPhotoShape === 'rounded' || p.presentPhotoShape === 'circle') {
wall.presentPhotoShape = p.presentPhotoShape;
}
if (typeof p.showUploaderNameOnWall === 'boolean') { if (typeof p.showUploaderNameOnWall === 'boolean') {
wall.showUploaderNameOnWall = p.showUploaderNameOnWall; wall.showUploaderNameOnWall = p.showUploaderNameOnWall;
} }
@ -276,6 +298,13 @@ const updatePhotoWall = async (request, h) => {
}); });
} }
if (p.presenterEventHeader != null && typeof p.presenterEventHeader === 'object') {
wall.presenterEventHeader = normalizePresenterEventHeaderDisplay({
...(wall.presenterEventHeader?.toObject?.() || wall.presenterEventHeader || {}),
...p.presenterEventHeader,
});
}
if (p.formId === null || p.formId === '') { if (p.formId === null || p.formId === '') {
wall.formId = null; wall.formId = null;
} else if (p.formId != null) { } else if (p.formId != null) {

View File

@ -15,6 +15,7 @@ const {
EVENT_PUBLIC_SELECT, EVENT_PUBLIC_SELECT,
buildPublicEventPayload, buildPublicEventPayload,
normalizeEventHeaderDisplay, normalizeEventHeaderDisplay,
normalizePresenterEventHeaderDisplay,
} = require('../utils/formPublicHelpers'); } = require('../utils/formPublicHelpers');
const ALLOWED_MIME = new Set([ const ALLOWED_MIME = new Set([
@ -508,7 +509,11 @@ const getPresentWall = async (request, h) => {
return h.response(error('NOT_FOUND', 'Photo wall not available')).code(404); return h.response(error('NOT_FOUND', 'Photo wall not available')).code(404);
} }
const event = await Event.findById(wall.event).select('name').lean(); const event = await Event.findById(wall.event).select(EVENT_PUBLIC_SELECT).lean();
const ms = event
? await EventMicrosite.findOne({ event: wall.event }).select('logo').lean()
: null;
const eventPublic = event ? buildPublicEventPayload(event, ms?.logo) : null;
const showUploaderNameOnWall = Boolean(wall.showUploaderNameOnWall); const showUploaderNameOnWall = Boolean(wall.showUploaderNameOnWall);
const frameEnabled = Boolean(wall.frameEnabled); const frameEnabled = Boolean(wall.frameEnabled);
const photos = await PhotoWallPhoto.find({ const photos = await PhotoWallPhoto.find({
@ -528,11 +533,19 @@ const getPresentWall = async (request, h) => {
.response( .response(
success({ success({
eventId: wall.event, eventId: wall.event,
eventName: event?.name || 'Event', eventName: eventPublic?.name || event?.name || 'Event',
event: eventPublic,
eventHeader: normalizePresenterEventHeaderDisplay(wall.presenterEventHeader),
photosPerRow: wall.photosPerRow ?? 4, photosPerRow: wall.photosPerRow ?? 4,
maxPhotoWidth, maxPhotoWidth,
maxPhotoHeight, maxPhotoHeight,
gapPx: wall.gapPx ?? 8, gapPx: wall.gapPx ?? 8,
presentMarginTopPx: wall.presentMarginTopPx ?? 0,
presentMarginLeftPx: wall.presentMarginLeftPx ?? 0,
presentMarginRightPx: wall.presentMarginRightPx ?? 0,
presentPhotoShape: ['rect', 'rounded', 'circle'].includes(wall.presentPhotoShape)
? wall.presentPhotoShape
: 'rounded',
showUploaderNameOnWall, showUploaderNameOnWall,
frameEnabled, frameEnabled,
frameCanvasWidth, frameCanvasWidth,

View File

@ -0,0 +1,134 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const WhatsAppBlast = require('../models/WhatsAppBlast');
const WhatsAppChatSession = require('../models/WhatsAppChatSession');
const { success, error } = require('../utils/response');
const { assertModulePermission, userOrgId } = require('../services/acl.service');
const { eligibleSessionFilter } = require('../utils/whatsappBlastEligible');
const { normalizeWhatsAppCtas } = require('../utils/whatsappCtas');
const MODULE = 'custom_template_for_wa_messaging';
async function assertPerm(user, action) {
const orgId = userOrgId(user);
try {
await assertModulePermission(user, orgId, MODULE, action);
return null;
} catch (e) {
return { code: 'FORBIDDEN', message: e.message || 'Insufficient permissions', status: 403 };
}
}
async function getEventForUser(eventId, user) {
if (!mongoose.Types.ObjectId.isValid(String(eventId))) {
return { err: { code: 'VALIDATION_ERROR', message: 'Invalid event id', status: 400 } };
}
const event = await Event.findById(eventId).select('organization name').lean();
if (!event) return { err: { code: 'NOT_FOUND', message: 'Event not found', status: 404 } };
if (user.role !== 'super_admin') {
const oid = userOrgId(user);
if (!oid || String(event.organization) !== String(oid)) {
return { err: { code: 'FORBIDDEN', message: 'Access denied', status: 403 } };
}
}
return { event };
}
function serializeBlast(doc) {
const stats = doc.stats || {};
return {
_id: doc._id,
event: doc.event,
organization: doc.organization,
body: doc.body || '',
mediaType: doc.mediaType || 'none',
mediaUrl: doc.mediaUrl || '',
ctas: Array.isArray(doc.ctas) ? doc.ctas : [],
sendAt: doc.sendAt,
status: doc.status,
stats: {
eligible: Number(stats.eligible) || 0,
sent: Number(stats.sent) || 0,
skipped: Number(stats.skipped) || 0,
failed: Number(stats.failed) || 0,
},
lastError: doc.lastError || '',
sentAt: doc.sentAt || null,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
const list = async (request, h) => {
const user = request.auth.credentials.user;
const denied = await assertPerm(user, 'read');
if (denied) return h.response(error(denied.code, denied.message)).code(denied.status);
const { event, err } = await getEventForUser(request.params.eventId, user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const blasts = await WhatsAppBlast.find({ event: event._id }).sort({ createdAt: -1 }).limit(100).lean();
return h.response(success({ blasts: blasts.map(serializeBlast) }));
};
const eligibleCount = async (request, h) => {
const user = request.auth.credentials.user;
const denied = await assertPerm(user, 'read');
if (denied) return h.response(error(denied.code, denied.message)).code(denied.status);
const { event, err } = await getEventForUser(request.params.eventId, user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const count = await WhatsAppChatSession.countDocuments(eligibleSessionFilter(event._id));
return h.response(success({ count }));
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
const denied = await assertPerm(user, 'write');
if (denied) return h.response(error(denied.code, denied.message)).code(denied.status);
const { event, err } = await getEventForUser(request.params.eventId, user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const p = request.payload || {};
const body = String(p.body || '').trim();
const mediaType = ['image', 'video'].includes(p.mediaType) ? p.mediaType : 'none';
const mediaUrl = mediaType === 'none' ? '' : String(p.mediaUrl || '').trim();
let ctas = [];
try {
ctas = normalizeWhatsAppCtas(p.ctas, { strict: true });
} catch (e) {
return h.response(error('VALIDATION_ERROR', e.message || 'Invalid CTAs')).code(400);
}
if (ctas.length && !body) {
return h.response(error('VALIDATION_ERROR', 'Message text is required when buttons are set.')).code(400);
}
if (!body && !mediaUrl) {
return h.response(error('VALIDATION_ERROR', 'Enter a message or attach media.')).code(400);
}
if (mediaType !== 'none' && !mediaUrl) {
return h.response(error('VALIDATION_ERROR', 'Media URL is required for image or video.')).code(400);
}
let sendAt = new Date();
if (p.sendAt) {
const d = new Date(p.sendAt);
if (Number.isNaN(d.getTime())) {
return h.response(error('VALIDATION_ERROR', 'Invalid schedule time.')).code(400);
}
sendAt = d.getTime() < Date.now() - 5000 ? new Date() : d;
}
const blast = await WhatsAppBlast.create({
organization: event.organization,
event: event._id,
createdBy: user._id,
body,
mediaType,
mediaUrl,
ctas,
sendAt,
status: 'queued',
stats: { eligible: 0, sent: 0, skipped: 0, failed: 0 },
});
return h.response(success(serializeBlast(blast.toObject()), 'Notification queued', 201)).code(201);
};
module.exports = { list, eligibleCount, create };

View File

@ -19,6 +19,7 @@ const attendanceSchema = new mongoose.Schema(
description: { type: String, default: '', trim: true }, description: { type: String, default: '', trim: true },
settings: { settings: {
allowDuplicateCheckIn: { type: Boolean, default: false }, allowDuplicateCheckIn: { type: Boolean, default: false },
allowAssignBlank: { type: Boolean, default: false },
publicLinkEnabled: { type: Boolean, default: true }, publicLinkEnabled: { type: Boolean, default: true },
audience: { audience: {
mode: { mode: {

View File

@ -10,6 +10,15 @@ const checkInEntrySchema = new mongoose.Schema(
{ _id: false } { _id: false }
); );
const undoEntrySchema = new mongoose.Schema(
{
clientOpId: { type: String, trim: true, required: true },
undoneAt: { type: Date, required: true, default: Date.now },
undoneBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
},
{ _id: false }
);
const attendanceParticipantSchema = new mongoose.Schema( const attendanceParticipantSchema = new mongoose.Schema(
{ {
attendance: { attendance: {
@ -35,6 +44,7 @@ const attendanceParticipantSchema = new mongoose.Schema(
email: { type: String, trim: true, lowercase: true }, email: { type: String, trim: true, lowercase: true },
phone: { type: String, trim: true }, phone: { type: String, trim: true },
checkIns: { type: [checkInEntrySchema], default: [] }, checkIns: { type: [checkInEntrySchema], default: [] },
undos: { type: [undoEntrySchema], default: [] },
}, },
{ timestamps: true } { timestamps: true }
); );
@ -42,6 +52,7 @@ const attendanceParticipantSchema = new mongoose.Schema(
attendanceParticipantSchema.index({ attendance: 1, participant: 1 }, { unique: true }); attendanceParticipantSchema.index({ attendance: 1, participant: 1 }, { unique: true });
attendanceParticipantSchema.index({ attendance: 1, updatedAt: -1 }); attendanceParticipantSchema.index({ attendance: 1, updatedAt: -1 });
attendanceParticipantSchema.index({ attendance: 1, 'checkIns.clientOpId': 1 }, { sparse: true }); attendanceParticipantSchema.index({ attendance: 1, 'checkIns.clientOpId': 1 }, { sparse: true });
attendanceParticipantSchema.index({ attendance: 1, 'undos.clientOpId': 1 }, { sparse: true });
module.exports = mongoose.model( module.exports = mongoose.model(
'AttendanceParticipant', 'AttendanceParticipant',

View File

@ -134,11 +134,25 @@ const conciergeBotSchema = new mongoose.Schema(
default: 'none', default: 'none',
}, },
mediaUrl: { type: String, default: '', trim: true }, mediaUrl: { type: String, default: '', trim: true },
contentKind: {
type: String,
enum: ['custom', 'upcoming_agenda'],
default: 'custom',
},
agendaCount: { type: Number, default: 3, min: 1, max: 8 },
templateId: { templateId: {
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
ref: 'WhatsAppTemplate', ref: 'WhatsAppTemplate',
default: null, default: null,
}, },
/** Up to 3 session CTAs: all reply OR one url (Meta). */
ctas: [
{
type: { type: String, enum: ['reply', 'url'], required: true },
label: { type: String, default: '', trim: true, maxlength: 20 },
value: { type: String, default: '', trim: true, maxlength: 2000 },
},
],
}, },
], ],
}, },

View File

@ -34,6 +34,16 @@ const photoWallSchema = new mongoose.Schema(
maxPhotoWidth: { type: Number, default: 320, min: 80, max: 1200 }, maxPhotoWidth: { type: Number, default: 320, min: 80, max: 1200 },
maxPhotoHeight: { type: Number, default: 320, min: 80, max: 1200 }, maxPhotoHeight: { type: Number, default: 320, min: 80, max: 1200 },
gapPx: { type: Number, default: 8, min: 0, max: 64 }, gapPx: { type: Number, default: 8, min: 0, max: 64 },
/** Outer spacing around the presenter photo grid (px). */
presentMarginTopPx: { type: Number, default: 0, min: 0, max: 2000 },
presentMarginLeftPx: { type: Number, default: 0, min: 0, max: 2000 },
presentMarginRightPx: { type: Number, default: 0, min: 0, max: 2000 },
/** Presenter grid photo corners: sharp rect, soft rounded, or circle */
presentPhotoShape: {
type: String,
enum: ['rect', 'rounded', 'circle'],
default: 'rounded',
},
/** Show uploader name under photos on the presenter wall */ /** Show uploader name under photos on the presenter wall */
showUploaderNameOnWall: { type: Boolean, default: false }, showUploaderNameOnWall: { type: Boolean, default: false },
/** Require crop on upload when image aspect ratio ≠ maxWidth/maxHeight */ /** Require crop on upload when image aspect ratio ≠ maxWidth/maxHeight */
@ -71,6 +81,16 @@ const photoWallSchema = new mongoose.Schema(
showLocationType: { type: Boolean, default: true }, showLocationType: { type: Boolean, default: true },
showVenue: { type: Boolean, default: true }, showVenue: { type: Boolean, default: true },
}, },
/** Presenter wall event header visibility (defaults: title only). */
presenterEventHeader: {
showLogo: { type: Boolean, default: false },
showBanner: { type: Boolean, default: false },
showTitle: { type: Boolean, default: true },
showDateRange: { type: Boolean, default: false },
showEventType: { type: Boolean, default: false },
showLocationType: { type: Boolean, default: false },
showVenue: { type: Boolean, default: false },
},
uploadToken: { type: String, trim: true }, uploadToken: { type: String, trim: true },
presentToken: { type: String, trim: true }, presentToken: { type: String, trim: true },
}, },

View File

@ -0,0 +1,41 @@
const mongoose = require('mongoose');
const whatsAppBlastSchema = new mongoose.Schema(
{
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true },
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
body: { type: String, default: '' },
mediaType: { type: String, enum: ['none', 'image', 'video'], default: 'none' },
mediaUrl: { type: String, default: '', trim: true },
/** Up to 3 session CTAs: all reply OR one url (Meta). */
ctas: [
{
type: { type: String, enum: ['reply', 'url'], required: true },
label: { type: String, default: '', trim: true, maxlength: 20 },
value: { type: String, default: '', trim: true, maxlength: 2000 },
},
],
sendAt: { type: Date, required: true, index: true },
status: {
type: String,
enum: ['queued', 'sending', 'sent', 'failed'],
default: 'queued',
index: true,
},
stats: {
eligible: { type: Number, default: 0 },
sent: { type: Number, default: 0 },
skipped: { type: Number, default: 0 },
failed: { type: Number, default: 0 },
},
lastError: { type: String, default: '' },
sentAt: Date,
},
{ timestamps: true, collection: 'whatsappblasts' },
);
whatsAppBlastSchema.index({ event: 1, createdAt: -1 });
whatsAppBlastSchema.index({ status: 1, sendAt: 1 });
module.exports = mongoose.model('WhatsAppBlast', whatsAppBlastSchema);

View File

@ -0,0 +1,16 @@
const mongoose = require('mongoose');
/** Shared collection with concierge; used here for eligible-count queries. */
const whatsAppChatSessionSchema = new mongoose.Schema(
{
eventId: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', index: true },
phoneNumber: { type: String, trim: true },
endedAt: Date,
lastSeenAt: Date,
participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' },
},
{ timestamps: true, collection: 'whatsappchatsessions', strict: false },
);
module.exports =
mongoose.models.WhatsAppChatSession || mongoose.model('WhatsAppChatSession', whatsAppChatSessionSchema);

View File

@ -13,6 +13,7 @@ const audienceSchema = Joi.object({
const settingsSchema = Joi.object({ const settingsSchema = Joi.object({
allowDuplicateCheckIn: Joi.boolean(), allowDuplicateCheckIn: Joi.boolean(),
allowAssignBlank: Joi.boolean(),
publicLinkEnabled: Joi.boolean(), publicLinkEnabled: Joi.boolean(),
audience: audienceSchema.optional(), audience: audienceSchema.optional(),
}); });
@ -34,6 +35,21 @@ const checkInPayload = Joi.object({
participantId: Joi.string().required(), participantId: Joi.string().required(),
}); });
const assignBlankProfile = Joi.object({
firstName: Joi.string().trim().required(),
lastName: Joi.string().trim().allow('').optional(),
email: Joi.string().trim().allow('').optional(),
phone: Joi.string().trim().allow('').optional(),
});
const assignBlankPayload = Joi.object({
participantId: Joi.string().required(),
firstName: Joi.string().trim().required(),
lastName: Joi.string().trim().allow('').optional(),
email: Joi.string().trim().allow('').optional(),
phone: Joi.string().trim().allow('').optional(),
});
const syncCheckInsPayload = Joi.object({ const syncCheckInsPayload = Joi.object({
deviceId: Joi.string().trim().allow('', null).optional(), deviceId: Joi.string().trim().allow('', null).optional(),
operations: Joi.array() operations: Joi.array()
@ -42,6 +58,8 @@ const syncCheckInsPayload = Joi.object({
clientOpId: Joi.string().trim().required(), clientOpId: Joi.string().trim().required(),
participantId: Joi.string().required(), participantId: Joi.string().required(),
checkedInAt: Joi.alternatives().try(Joi.date().iso(), Joi.string().isoDate()).required(), checkedInAt: Joi.alternatives().try(Joi.date().iso(), Joi.string().isoDate()).required(),
action: Joi.string().valid('check_in', 'undo').default('check_in'),
profile: assignBlankProfile.optional(),
}) })
) )
.min(1) .min(1)
@ -112,6 +130,24 @@ module.exports = [
handler: controller.checkIn, handler: controller.checkIn,
}, },
}, },
{
method: 'POST',
path: '/api/events/{eventId}/attendances/{id}/undo-check-in',
options: {
validate: { payload: checkInPayload },
pre: [{ method: requirePermissionPre('attendance', 'write') }],
handler: controller.undoCheckIn,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/attendances/{id}/assign-blank',
options: {
validate: { payload: assignBlankPayload },
pre: [{ method: requirePermissionPre('attendance', 'write') }],
handler: controller.assignBlank,
},
},
{ {
method: 'POST', method: 'POST',
path: '/api/events/{eventId}/attendances/{id}/check-ins/sync', path: '/api/events/{eventId}/attendances/{id}/check-ins/sync',

View File

@ -79,6 +79,12 @@ const createPayload = Joi.object({
enabled: Joi.boolean(), enabled: Joi.boolean(),
}); });
const ctaItemSchema = Joi.object({
type: Joi.string().valid('reply', 'url').required(),
label: Joi.string().trim().max(20).required(),
value: Joi.string().trim().max(2000).allow(''),
});
const receptionDripSchema = Joi.object({ const receptionDripSchema = Joi.object({
trigger: Joi.string().valid('after_first_inbound', 'after_match', 'absolute'), trigger: Joi.string().valid('after_first_inbound', 'after_match', 'absolute'),
delayMinutes: Joi.number().min(0).allow(null), delayMinutes: Joi.number().min(0).allow(null),
@ -86,8 +92,11 @@ const receptionDripSchema = Joi.object({
body: Joi.string().allow(''), body: Joi.string().allow(''),
mediaType: Joi.string().valid('none', 'image', 'video'), mediaType: Joi.string().valid('none', 'image', 'video'),
mediaUrl: Joi.string().allow(''), mediaUrl: Joi.string().allow(''),
contentKind: Joi.string().valid('custom', 'upcoming_agenda'),
agendaCount: Joi.number().integer().min(1).max(8),
templateId: Joi.string().allow('', null), templateId: Joi.string().allow('', null),
}); ctas: Joi.array().items(ctaItemSchema).max(3),
}).unknown(true);
const receptionSchema = Joi.object({ const receptionSchema = Joi.object({
enabled: Joi.boolean(), enabled: Joi.boolean(),

View File

@ -56,6 +56,10 @@ const updatePayload = Joi.object({
maxPhotoWidth: Joi.number().integer().min(80).max(1200), maxPhotoWidth: Joi.number().integer().min(80).max(1200),
maxPhotoHeight: Joi.number().integer().min(80).max(1200), maxPhotoHeight: Joi.number().integer().min(80).max(1200),
gapPx: Joi.number().integer().min(0).max(64), gapPx: Joi.number().integer().min(0).max(64),
presentMarginTopPx: Joi.number().integer().min(0).max(2000),
presentMarginLeftPx: Joi.number().integer().min(0).max(2000),
presentMarginRightPx: Joi.number().integer().min(0).max(2000),
presentPhotoShape: Joi.string().valid('rect', 'rounded', 'circle'),
showUploaderNameOnWall: Joi.boolean(), showUploaderNameOnWall: Joi.boolean(),
requireUploadCrop: Joi.boolean(), requireUploadCrop: Joi.boolean(),
frameEnabled: Joi.boolean(), frameEnabled: Joi.boolean(),
@ -74,6 +78,7 @@ const updatePayload = Joi.object({
whatsapp: Joi.boolean(), whatsapp: Joi.boolean(),
}), }),
eventHeader: eventHeaderSchema, eventHeader: eventHeaderSchema,
presenterEventHeader: eventHeaderSchema,
regenerateTokens: Joi.boolean(), regenerateTokens: Joi.boolean(),
}).min(1); }).min(1);

View File

@ -0,0 +1,34 @@
const Joi = require('joi');
const controller = require('../controllers/whatsappBlast.controller');
const ctaItemSchema = Joi.object({
type: Joi.string().valid('reply', 'url').required(),
label: Joi.string().trim().max(20).required(),
value: Joi.string().trim().max(2000).allow(''),
});
const createPayload = Joi.object({
body: Joi.string().allow('').max(4000),
mediaType: Joi.string().valid('none', 'image', 'video'),
mediaUrl: Joi.string().allow('').max(2000),
sendAt: Joi.alternatives().try(Joi.date().iso(), Joi.valid(null, '')),
ctas: Joi.array().items(ctaItemSchema).max(3),
});
module.exports = [
{
method: 'GET',
path: '/api/events/{eventId}/whatsapp-blasts/eligible-count',
options: { handler: controller.eligibleCount },
},
{
method: 'GET',
path: '/api/events/{eventId}/whatsapp-blasts',
options: { handler: controller.list },
},
{
method: 'POST',
path: '/api/events/{eventId}/whatsapp-blasts',
options: { validate: { payload: createPayload }, handler: controller.create },
},
];

View File

@ -24,6 +24,57 @@ function snapshotFromParticipant(participant) {
}; };
} }
function normalizeAssignBlankProfile(raw) {
const firstName = String(raw?.firstName || '').trim();
if (!firstName) return { err: 'First name is required' };
return {
profile: {
firstName,
lastName: String(raw?.lastName || '').trim(),
email: String(raw?.email || '').trim().toLowerCase(),
phone: String(raw?.phone || '').trim(),
},
};
}
async function applyAssignBlankProfile(participant, profile) {
const email = profile.email || '';
if (email) {
const other = await Participant.findOne({
tenantOrganization: participant.tenantOrganization,
email,
deletedAt: null,
_id: { $ne: participant._id },
})
.select('_id')
.lean();
if (other) {
return {
err: error('CONFLICT', 'Another participant already uses this email'),
statusCode: 409,
};
}
}
participant.firstName = profile.firstName;
participant.lastName = profile.lastName;
participant.email = email || undefined;
participant.phone = profile.phone;
participant.displayName = [profile.firstName, profile.lastName].filter(Boolean).join(' ').trim();
try {
await participant.save();
} catch (e) {
if (e && e.code === 11000) {
return {
err: error('CONFLICT', 'Another participant already uses this email'),
statusCode: 409,
};
}
throw e;
}
return {};
}
async function loadAttendanceById(attendanceId, eventId) { async function loadAttendanceById(attendanceId, eventId) {
if (!mongoose.Types.ObjectId.isValid(String(attendanceId))) { if (!mongoose.Types.ObjectId.isValid(String(attendanceId))) {
return { err: error('VALIDATION_ERROR', 'Invalid attendance id') }; return { err: error('VALIDATION_ERROR', 'Invalid attendance id') };
@ -111,26 +162,36 @@ async function performBulkCheckInSync({
} }
const allowDuplicate = Boolean(attendance.settings?.allowDuplicateCheckIn); const allowDuplicate = Boolean(attendance.settings?.allowDuplicateCheckIn);
const allowAssignBlank = Boolean(attendance.settings?.allowAssignBlank);
const eventIdStr = String(attendance.event); const eventIdStr = String(attendance.event);
const results = []; const results = [];
const opIds = operations.map((o) => String(o?.clientOpId || '')).filter(Boolean);
// Preload existing clientOpIds for this attendance (idempotency) // Preload existing clientOpIds for this attendance (idempotency)
const existingOps = await AttendanceParticipant.find({ const existingOps = await AttendanceParticipant.find({
attendance: attendance._id, attendance: attendance._id,
'checkIns.clientOpId': { $in: operations.map((o) => String(o?.clientOpId || '')).filter(Boolean) }, $or: [{ 'checkIns.clientOpId': { $in: opIds } }, { 'undos.clientOpId': { $in: opIds } }],
}) })
.select('participant checkIns') .select('participant checkIns undos')
.lean(); .lean();
const opToAp = new Map(); const opToAp = new Map();
for (const ap of existingOps) { for (const ap of existingOps) {
const meta = lastCheckInFromAp(ap);
for (const ci of ap.checkIns || []) { for (const ci of ap.checkIns || []) {
if (ci.clientOpId) { if (ci.clientOpId) {
opToAp.set(String(ci.clientOpId), { opToAp.set(String(ci.clientOpId), {
participantId: String(ap.participant), participantId: String(ap.participant),
checkInCount: (ap.checkIns || []).length, ...meta,
lastCheckedInAt: });
ap.checkIns?.length > 0 ? ap.checkIns[ap.checkIns.length - 1].checkedInAt : null, }
}
for (const u of ap.undos || []) {
if (u.clientOpId) {
opToAp.set(String(u.clientOpId), {
participantId: String(ap.participant),
...meta,
}); });
} }
} }
@ -166,6 +227,76 @@ async function performBulkCheckInSync({
continue; continue;
} }
const action = String(op?.action || 'check_in') === 'undo' ? 'undo' : 'check_in';
if (action === 'undo') {
const ts = parseClientCheckedInAt(op?.checkedInAt);
if (ts.err) {
results.push({ clientOpId, status: 'rejected', message: ts.err });
continue;
}
try {
const undoOut = await applyUndoOperation({
attendance,
eventIdStr,
participantId,
clientOpId,
checkedInBy,
});
if (undoOut.rejected) {
results.push({ clientOpId, status: 'rejected', message: undoOut.message });
continue;
}
if (undoOut.conflict) {
results.push({
clientOpId,
status: 'conflict',
checkInCount: undoOut.checkInCount,
lastCheckedInAt: undoOut.lastCheckedInAt,
message: undoOut.message,
});
continue;
}
opToAp.set(clientOpId, {
participantId: undoOut.participantId,
checkInCount: undoOut.checkInCount,
lastCheckedInAt: undoOut.lastCheckedInAt,
});
results.push({
clientOpId,
status: undoOut.duplicate ? 'duplicate' : 'applied',
checkInCount: undoOut.checkInCount,
lastCheckedInAt: undoOut.lastCheckedInAt,
message: undoOut.duplicate ? 'Operation already applied' : 'Check-in undone',
});
} catch (e) {
results.push({
clientOpId,
status: 'rejected',
message: e?.message || 'Failed to undo check-in',
});
}
continue;
}
let assignProfile = null;
if (op?.profile) {
if (!allowAssignBlank) {
results.push({
clientOpId,
status: 'rejected',
message: 'Assign a Blank is not enabled for this attendance session',
});
continue;
}
const norm = normalizeAssignBlankProfile(op.profile);
if (norm.err) {
results.push({ clientOpId, status: 'rejected', message: norm.err });
continue;
}
assignProfile = norm.profile;
}
const ts = parseClientCheckedInAt(op?.checkedInAt); const ts = parseClientCheckedInAt(op?.checkedInAt);
if (ts.err) { if (ts.err) {
results.push({ clientOpId, status: 'rejected', message: ts.err }); results.push({ clientOpId, status: 'rejected', message: ts.err });
@ -219,6 +350,18 @@ async function performBulkCheckInSync({
continue; continue;
} }
if (assignProfile) {
const applied = await applyAssignBlankProfile(participant, assignProfile);
if (applied.err) {
results.push({
clientOpId,
status: 'rejected',
message: applied.err.error?.message || 'Failed to update participant',
});
continue;
}
}
// Race-safe re-check for clientOpId // Race-safe re-check for clientOpId
const race = await AttendanceParticipant.findOne({ const race = await AttendanceParticipant.findOne({
attendance: attendance._id, attendance: attendance._id,
@ -254,6 +397,7 @@ async function performBulkCheckInSync({
checkIns: [entry], checkIns: [entry],
}); });
} else { } else {
Object.assign(ap, snapshotFromParticipant(participant));
ap.checkIns.push(entry); ap.checkIns.push(entry);
await ap.save(); await ap.save();
} }
@ -288,6 +432,142 @@ async function performBulkCheckInSync({
return { results }; return { results };
} }
function lastCheckInFromAp(ap) {
const checkIns = ap?.checkIns || [];
const last = checkIns.length ? checkIns[checkIns.length - 1] : null;
return {
checkInCount: checkIns.length,
lastCheckedInAt: last?.checkedInAt || null,
};
}
function hasUndoOp(ap, clientOpId) {
if (!ap || !clientOpId) return false;
return (ap.undos || []).some((u) => String(u.clientOpId) === String(clientOpId));
}
async function popLastCheckIn(ap, { clientOpId, undoneBy }) {
ap.checkIns.pop();
if (!Array.isArray(ap.undos)) ap.undos = [];
ap.undos.push({
clientOpId,
undoneAt: new Date(),
...(undoneBy ? { undoneBy } : {}),
});
await ap.save();
}
async function applyUndoOperation({ attendance, eventIdStr, participantId, clientOpId, checkedInBy }) {
const participant = await Participant.findById(participantId);
if (!participant) return { rejected: true, message: 'Participant not found' };
if (!getMembership(participant, eventIdStr)) {
return { rejected: true, message: 'Participant is not registered for this event' };
}
const audienceCheck = await participantAllowedByAudience(
participant,
attendance,
eventIdStr,
attendance.organization
);
if (!audienceCheck.ok) {
return { rejected: true, message: audienceCheck.message || 'Participant not in attendance audience' };
}
const ap = await AttendanceParticipant.findOne({
attendance: attendance._id,
participant: participant._id,
});
const meta = lastCheckInFromAp(ap);
if (hasUndoOp(ap, clientOpId)) {
return {
duplicate: true,
participantId: String(participant._id),
...meta,
};
}
if (!ap || !ap.checkIns?.length) {
return {
conflict: true,
checkInCount: 0,
lastCheckedInAt: null,
message: 'Participant is not checked in',
};
}
await popLastCheckIn(ap, { clientOpId, undoneBy: checkedInBy });
return {
participantId: String(participant._id),
...lastCheckInFromAp(ap),
};
}
/**
* Undo the last check-in for a participant in an attendance session.
*/
async function performUndoCheckIn({
attendanceId,
eventId,
participantId,
source = 'dashboard',
checkedInBy,
clientOpId,
}) {
const loaded = await loadAttendanceById(attendanceId, eventId);
if (loaded.err) {
return { err: loaded.err, statusCode: loaded.err.code === 'VALIDATION_ERROR' ? 400 : 404 };
}
const attendance = loaded.attendance;
const activeCheck = validateAttendanceActive(attendance, { source });
if (activeCheck.err) {
return { err: activeCheck.err, statusCode: activeCheck.err.code === 'FORBIDDEN' ? 403 : 400 };
}
if (!mongoose.Types.ObjectId.isValid(String(participantId))) {
return { err: error('VALIDATION_ERROR', 'Invalid participant id'), statusCode: 400 };
}
const participant = await Participant.findById(participantId);
if (!participant) return { err: error('NOT_FOUND', 'Participant not found'), statusCode: 404 };
const eventIdStr = String(attendance.event);
if (!getMembership(participant, eventIdStr)) {
return { err: error('BAD_REQUEST', 'Participant is not registered for this event'), statusCode: 400 };
}
const audienceCheck = await participantAllowedByAudience(
participant,
attendance,
eventIdStr,
attendance.organization
);
if (!audienceCheck.ok) {
return { err: error('FORBIDDEN', audienceCheck.message), statusCode: 403 };
}
const ap = await AttendanceParticipant.findOne({
attendance: attendance._id,
participant: participant._id,
});
if (clientOpId && hasUndoOp(ap, clientOpId)) {
return { result: formatCheckInResult(attendance, ap, participant) };
}
if (!ap || !ap.checkIns?.length) {
return { err: error('CONFLICT', 'Participant is not checked in'), statusCode: 409 };
}
if (clientOpId) {
await popLastCheckIn(ap, { clientOpId, undoneBy: checkedInBy });
} else {
ap.checkIns.pop();
await ap.save();
}
return { result: formatCheckInResult(attendance, ap, participant) };
}
function formatCheckInResult(attendance, apDoc, participant) { function formatCheckInResult(attendance, apDoc, participant) {
const checkIns = apDoc.checkIns || []; const checkIns = apDoc.checkIns || [];
const last = checkIns.length ? checkIns[checkIns.length - 1] : null; const last = checkIns.length ? checkIns[checkIns.length - 1] : null;
@ -305,6 +585,108 @@ function formatCheckInResult(attendance, apDoc, participant) {
}; };
} }
/**
* Update a spare/blank badge participant then check them in.
*/
async function performAssignBlank({
attendanceId,
eventId,
participantId,
profile: rawProfile,
source = 'dashboard',
checkedInBy,
checkedInAt,
clientOpId,
}) {
const loaded = await loadAttendanceById(attendanceId, eventId);
if (loaded.err) {
return { err: loaded.err, statusCode: loaded.err.code === 'VALIDATION_ERROR' ? 400 : 404 };
}
const attendance = loaded.attendance;
const activeCheck = validateAttendanceActive(attendance, { source });
if (activeCheck.err) {
return { err: activeCheck.err, statusCode: activeCheck.err.code === 'FORBIDDEN' ? 403 : 400 };
}
if (!attendance.settings?.allowAssignBlank) {
return {
err: error('FORBIDDEN', 'Assign a Blank is not enabled for this attendance session'),
statusCode: 403,
};
}
const norm = normalizeAssignBlankProfile(rawProfile);
if (norm.err) {
return { err: error('VALIDATION_ERROR', norm.err), statusCode: 400 };
}
if (!mongoose.Types.ObjectId.isValid(String(participantId))) {
return { err: error('VALIDATION_ERROR', 'Invalid participant id'), statusCode: 400 };
}
const participant = await Participant.findById(participantId);
if (!participant) return { err: error('NOT_FOUND', 'Participant not found'), statusCode: 404 };
const eventIdStr = String(attendance.event);
if (!getMembership(participant, eventIdStr)) {
return { err: error('BAD_REQUEST', 'Participant is not registered for this event'), statusCode: 400 };
}
const audienceCheck = await participantAllowedByAudience(
participant,
attendance,
eventIdStr,
attendance.organization
);
if (!audienceCheck.ok) {
return { err: error('FORBIDDEN', audienceCheck.message), statusCode: 403 };
}
const allowDuplicate = Boolean(attendance.settings?.allowDuplicateCheckIn);
let ap = await AttendanceParticipant.findOne({
attendance: attendance._id,
participant: participant._id,
});
if (ap && ap.checkIns.length > 0 && !allowDuplicate) {
return { err: error('CONFLICT', 'Participant already checked in'), statusCode: 409 };
}
const applied = await applyAssignBlankProfile(participant, norm.profile);
if (applied.err) return applied;
const entry = {
checkedInAt: checkedInAt instanceof Date ? checkedInAt : new Date(),
source,
...(clientOpId ? { clientOpId } : {}),
...(checkedInBy && (source === 'dashboard' || source === 'offline_sync')
? { checkedInBy }
: {}),
};
const snap = snapshotFromParticipant(participant);
if (!ap) {
ap = await AttendanceParticipant.create({
attendance: attendance._id,
event: attendance.event,
participant: participant._id,
...snap,
checkIns: [entry],
});
} else {
Object.assign(ap, snap);
ap.checkIns.push(entry);
await ap.save();
}
await syncEventMembershipCheckIn(participant, eventIdStr, entry.checkedInAt);
return {
result: formatCheckInResult(attendance, ap, participant),
};
}
/** /**
* Record a check-in for an attendance session. * Record a check-in for an attendance session.
* @param {object} opts * @param {object} opts
@ -503,6 +885,8 @@ async function searchEventParticipantsForAttendance({
module.exports = { module.exports = {
performCheckIn, performCheckIn,
performAssignBlank,
performUndoCheckIn,
performBulkCheckInSync, performBulkCheckInSync,
getAttendanceStats, getAttendanceStats,
searchEventParticipantsForAttendance, searchEventParticipantsForAttendance,

View File

@ -202,4 +202,126 @@ describe('performBulkCheckInSync', () => {
expect(entry.clientOpId).toBe('op-new'); expect(entry.clientOpId).toBe('op-new');
expect(new Date(entry.checkedInAt).toISOString()).toBe('2026-08-01T11:00:00.000Z'); expect(new Date(entry.checkedInAt).toISOString()).toBe('2026-08-01T11:00:00.000Z');
}); });
test('rejects assign-blank profile when setting is off', async () => {
AttendanceParticipant.find.mockReturnValue({
select: () => ({ lean: () => Promise.resolve([]) }),
});
const out = await performBulkCheckInSync({
attendanceId,
eventId,
checkedInBy: 'user1',
operations: [
{
clientOpId: 'op-blank',
participantId,
checkedInAt: '2026-08-01T11:00:00.000Z',
profile: { firstName: 'Walk', lastName: 'In', email: '', phone: '' },
},
],
});
expect(out.results[0].status).toBe('rejected');
expect(out.results[0].message).toMatch(/Assign a Blank/i);
});
test('applies undo by popping last check-in', async () => {
AttendanceParticipant.find.mockReturnValue({
select: () => ({ lean: () => Promise.resolve([]) }),
});
getMembership.mockReturnValue({ checkedIn: true });
Participant.findById.mockResolvedValue({
_id: participantId,
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
phone: '',
});
const apDoc = {
checkIns: [
{ checkedInAt: new Date('2026-08-01T09:00:00.000Z'), clientOpId: 'op-in' },
],
undos: [],
save: jest.fn().mockResolvedValue(undefined),
};
AttendanceParticipant.findOne.mockResolvedValue(apDoc);
const out = await performBulkCheckInSync({
attendanceId,
eventId,
checkedInBy: 'user1',
operations: [
{
clientOpId: 'op-undo',
participantId,
checkedInAt: '2026-08-01T11:00:00.000Z',
action: 'undo',
},
],
});
expect(out.results[0].status).toBe('applied');
expect(out.results[0].checkInCount).toBe(0);
expect(apDoc.checkIns).toHaveLength(0);
expect(apDoc.undos[0].clientOpId).toBe('op-undo');
expect(apDoc.save).toHaveBeenCalled();
});
test('returns duplicate for existing undo clientOpId', async () => {
AttendanceParticipant.find.mockReturnValue({
select: () => ({
lean: () =>
Promise.resolve([
{
participant: participantId,
checkIns: [],
undos: [{ clientOpId: 'op-undo-dup', undoneAt: new Date() }],
},
]),
}),
});
const out = await performBulkCheckInSync({
attendanceId,
eventId,
checkedInBy: 'user1',
operations: [
{
clientOpId: 'op-undo-dup',
participantId,
checkedInAt: '2026-08-01T11:00:00.000Z',
action: 'undo',
},
],
});
expect(out.results[0].status).toBe('duplicate');
});
test('conflicts undo when participant is not checked in', async () => {
AttendanceParticipant.find.mockReturnValue({
select: () => ({ lean: () => Promise.resolve([]) }),
});
getMembership.mockReturnValue({ checkedIn: false });
Participant.findById.mockResolvedValue({
_id: participantId,
firstName: 'Ada',
lastName: 'Lovelace',
});
AttendanceParticipant.findOne.mockResolvedValue({ checkIns: [], undos: [] });
const out = await performBulkCheckInSync({
attendanceId,
eventId,
checkedInBy: 'user1',
operations: [
{
clientOpId: 'op-undo-empty',
participantId,
checkedInAt: '2026-08-01T11:00:00.000Z',
action: 'undo',
},
],
});
expect(out.results[0].status).toBe('conflict');
expect(out.results[0].message).toMatch(/not checked in/i);
});
}); });

View File

@ -66,6 +66,31 @@ function normalizeEventHeaderDisplay(raw) {
}; };
} }
/** Photo Wall presenter defaults: title on; logo/banner/date/type/location/venue off. */
function normalizePresenterEventHeaderDisplay(raw) {
if (!raw || typeof raw !== 'object') {
return {
showLogo: false,
showBanner: false,
showTitle: true,
showDateRange: false,
showEventType: false,
showLocationType: false,
showVenue: false,
};
}
const src = raw;
return {
showLogo: src.showLogo === true,
showBanner: src.showBanner === true,
showTitle: src.showTitle !== false,
showDateRange: src.showDateRange === true,
showEventType: src.showEventType === true,
showLocationType: src.showLocationType === true,
showVenue: src.showVenue === true,
};
}
/** Safe public form settings returned by unauthenticated form endpoints. */ /** Safe public form settings returned by unauthenticated form endpoints. */
function settingMessage(formOrSettings, key, fallback) { function settingMessage(formOrSettings, key, fallback) {
const s = formOrSettings?.settings || formOrSettings || {}; const s = formOrSettings?.settings || formOrSettings || {};
@ -133,6 +158,7 @@ module.exports = {
buildPublicEventPayload, buildPublicEventPayload,
buildPublicFormSettings, buildPublicFormSettings,
normalizeEventHeaderDisplay, normalizeEventHeaderDisplay,
normalizePresenterEventHeaderDisplay,
resolveEventForPublicForm, resolveEventForPublicForm,
settingMessage, settingMessage,
}; };

View File

@ -0,0 +1,14 @@
const WINDOW_MS = 24 * 60 * 60 * 1000;
function eligibleSessionFilter(eventId, now = new Date()) {
const since = new Date(now.getTime() - WINDOW_MS);
return {
eventId,
endedAt: null,
participant: { $exists: true, $nin: [null] },
lastSeenAt: { $gte: since },
phoneNumber: { $exists: true, $nin: [null, ''] },
};
}
module.exports = { WINDOW_MS, eligibleSessionFilter };

53
src/utils/whatsappCtas.js Normal file
View File

@ -0,0 +1,53 @@
/**
* Normalize WhatsApp session CTAs for drips / blasts.
* Meta: either up to 3 reply buttons OR exactly 1 URL CTA — not mixed.
*
* @param {unknown} raw
* @param {{ strict?: boolean }} [opts] — when strict, throws on mixed types / multi-URL
* @returns {{ type: 'reply'|'url', label: string, value: string }[]}
*/
function normalizeWhatsAppCtas(raw, opts = {}) {
const strict = Boolean(opts.strict);
if (!Array.isArray(raw) || raw.length === 0) return [];
const rows = [];
for (const item of raw.slice(0, 3)) {
if (!item || typeof item !== 'object') continue;
const type = item.type === 'url' ? 'url' : item.type === 'reply' ? 'reply' : null;
if (!type) continue;
const label = String(item.label || '')
.trim()
.slice(0, 20);
let value = String(item.value || '').trim();
if (!label) continue;
if (!value && type === 'reply') value = label;
if (!value) continue;
if (type === 'url') {
if (!/^https?:\/\//i.test(value)) {
if (strict) throw new Error('URL buttons must start with http:// or https://');
continue;
}
value = value.slice(0, 2000);
} else {
value = value.slice(0, 256);
}
rows.push({ type, label, value });
}
if (!rows.length) return [];
const types = new Set(rows.map((r) => r.type));
if (types.size > 1) {
if (strict) {
throw new Error('CTAs must all be Reply or all be URL (Meta limitation).');
}
return [];
}
if (rows[0].type === 'url' && rows.length > 1) {
if (strict) throw new Error('Only one URL button is allowed per message.');
return rows.slice(0, 1);
}
return rows;
}
module.exports = { normalizeWhatsAppCtas };