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:
parent
b86cb5e992
commit
b9d0c32a6b
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
Loading…
Reference in New Issue