From b9d0c32a6b60ce5f70140980dce5a3c4af32a5c1 Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Wed, 19 Aug 2026 09:37:16 +0530 Subject: [PATCH] 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 --- src/app.js | 2 + src/controllers/attendance.controller.js | 45 ++ src/controllers/conciergeBot.controller.js | 39 +- src/controllers/media.controller.js | 20 +- src/controllers/photoWall.controller.js | 31 +- src/controllers/publicPhotoWall.controller.js | 17 +- src/controllers/whatsappBlast.controller.js | 134 ++++++ src/models/Attendance.js | 1 + src/models/AttendanceParticipant.js | 11 + src/models/ConciergeBot.js | 14 + src/models/PhotoWall.js | 20 + src/models/WhatsAppBlast.js | 41 ++ src/models/WhatsAppChatSession.js | 16 + src/routes/attendance.routes.js | 36 ++ src/routes/conciergeBot.routes.js | 11 +- src/routes/photoWall.routes.js | 5 + src/routes/whatsappBlast.routes.js | 34 ++ src/services/attendanceCheckIn.service.js | 394 +++++++++++++++++- src/services/attendanceCheckIn.sync.test.js | 122 ++++++ src/utils/formPublicHelpers.js | 26 ++ src/utils/whatsappBlastEligible.js | 14 + src/utils/whatsappCtas.js | 53 +++ 22 files changed, 1068 insertions(+), 18 deletions(-) create mode 100644 src/controllers/whatsappBlast.controller.js create mode 100644 src/models/WhatsAppBlast.js create mode 100644 src/models/WhatsAppChatSession.js create mode 100644 src/routes/whatsappBlast.routes.js create mode 100644 src/utils/whatsappBlastEligible.js create mode 100644 src/utils/whatsappCtas.js diff --git a/src/app.js b/src/app.js index d0eb6bd..6f4ac12 100644 --- a/src/app.js +++ b/src/app.js @@ -34,6 +34,7 @@ const visitorThemeRoutes = require('./routes/visitorTheme.routes'); const eventMicrositeRoutes = require('./routes/eventMicrosite.routes'); const visitorRoutes = require('./routes/visitor.routes'); const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes'); +const whatsappBlastRoutes = require('./routes/whatsappBlast.routes'); const badgeBatchRoutes = require('./routes/badgeBatch.routes'); const eventTicketRoutes = require('./routes/eventTicket.routes'); const publicEventTicketRoutes = require('./routes/publicEventTicket.routes'); @@ -334,6 +335,7 @@ const createServer = async () => { server.route(eventMicrositeRoutes); server.route(staticMicrositeRoutes); server.route(whatsappTemplateRoutes); + server.route(whatsappBlastRoutes); server.route(badgeBatchRoutes); server.route(eventTicketRoutes); server.route(ticketManualRoutes); diff --git a/src/controllers/attendance.controller.js b/src/controllers/attendance.controller.js index 7b6713b..5850e40 100644 --- a/src/controllers/attendance.controller.js +++ b/src/controllers/attendance.controller.js @@ -8,6 +8,8 @@ const { participantDisplayName } = require('../services/attendanceCheckIn.servic const { success, error } = require('../utils/response'); const { performCheckIn, + performAssignBlank, + performUndoCheckIn, performBulkCheckInSync, getAttendanceStats, searchEventParticipantsForAttendance, @@ -70,6 +72,7 @@ const create = async (request, h) => { description: description || '', settings: { allowDuplicateCheckIn: Boolean(settings?.allowDuplicateCheckIn), + allowAssignBlank: Boolean(settings?.allowAssignBlank), publicLinkEnabled: settings?.publicLinkEnabled !== false, audience: sanitizeAudienceForSave(settings), }, @@ -110,6 +113,9 @@ const update = async (request, h) => { if (settings.allowDuplicateCheckIn !== undefined) { item.settings.allowDuplicateCheckIn = Boolean(settings.allowDuplicateCheckIn); } + if (settings.allowAssignBlank !== undefined) { + item.settings.allowAssignBlank = Boolean(settings.allowAssignBlank); + } if (settings.publicLinkEnabled !== undefined) { item.settings.publicLinkEnabled = Boolean(settings.publicLinkEnabled); } @@ -283,6 +289,43 @@ const checkIn = async (request, h) => { 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 { eventId, id } = request.params; const user = request.auth.credentials.user; @@ -336,6 +379,8 @@ module.exports = { searchParticipants, getParticipantPreview, checkIn, + undoCheckIn, + assignBlank, syncCheckIns, exportCsv, }; diff --git a/src/controllers/conciergeBot.controller.js b/src/controllers/conciergeBot.controller.js index 19acb40..17c1a19 100644 --- a/src/controllers/conciergeBot.controller.js +++ b/src/controllers/conciergeBot.controller.js @@ -18,6 +18,7 @@ const { ensureVenueFaqs, normalizeFaqsPayload, } = require('../utils/conciergeVenueFaqs'); +const { normalizeWhatsAppCtas } = require('../utils/whatsappCtas'); function normalizeReceptionPayload(p) { if (!p || typeof p !== 'object') return null; @@ -32,15 +33,32 @@ function normalizeReceptionPayload(p) { templateId = d.templateId; } 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, delayMinutes: Number.isFinite(delayMinutes) ? Math.max(0, delayMinutes) : 0, sendAt: d.sendAt ? new Date(d.sendAt) : null, - body: String(d.body || ''), - mediaType, - mediaUrl: String(d.mediaUrl || '').trim(), + body, + mediaType: contentKind === 'upcoming_agenda' ? 'none' : mediaType, + mediaUrl: contentKind === 'upcoming_agenda' ? '' : String(d.mediaUrl || '').trim(), + contentKind, + agendaCount, 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'; @@ -406,11 +424,16 @@ const update = async (request, h) => { } if (p.enabled != null) { 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 (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) { const clashMsg = await assertSingleReceptionBot(bot.organization, bot._id); if (clashMsg) { @@ -418,6 +441,10 @@ const update = async (request, h) => { } } 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; diff --git a/src/controllers/media.controller.js b/src/controllers/media.controller.js index 1363a77..b7a92c0 100644 --- a/src/controllers/media.controller.js +++ b/src/controllers/media.controller.js @@ -26,6 +26,18 @@ function parseTagsFromPayload(payload) { 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) { if (payloadType && ['image', 'pdf', 'video', 'document', 'other'].includes(payloadType)) return payloadType; const m = String(mimeType || '').toLowerCase(); @@ -59,10 +71,11 @@ function extractUploadFile(payload) { if (!file) return null; if (Buffer.isBuffer(file)) { + const filename = (typeof payload.name === 'string' && payload.name.trim()) || 'file'; return { buffer: file, - filename: (typeof payload.name === 'string' && payload.name.trim()) || 'file', - mimeType: 'application/octet-stream', + filename, + mimeType: mimeFromFilename(filename, 'application/octet-stream'), }; } @@ -148,7 +161,8 @@ const upload = async (request, h) => { if (extracted) { const { buffer, filename: origName, mimeType: ct } = extracted; 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({ buffer, diff --git a/src/controllers/photoWall.controller.js b/src/controllers/photoWall.controller.js index 6adebea..313d59b 100644 --- a/src/controllers/photoWall.controller.js +++ b/src/controllers/photoWall.controller.js @@ -5,7 +5,7 @@ const Event = require('../models/Event'); const Form = require('../models/Form'); const { success, error } = require('../utils/response'); const { deleteEventPhotoFromS3, resolvePhotoPublicUrl, resolvePhotoThumbUrl } = require('../utils/s3EventPhotos'); -const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers'); +const { normalizeEventHeaderDisplay, normalizePresenterEventHeaderDisplay } = require('../utils/formPublicHelpers'); const { normalizeFrameImageLayout, normalizeFrameLayout, @@ -75,6 +75,12 @@ function serializeWall(wall, counts = {}) { maxPhotoWidth: wall.maxPhotoWidth ?? 320, maxPhotoHeight: wall.maxPhotoHeight ?? 320, 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), requireUploadCrop: Boolean(wall.requireUploadCrop), frameEnabled: Boolean(wall.frameEnabled), @@ -99,6 +105,7 @@ function serializeWall(wall, counts = {}) { whatsapp: Boolean(wall.sharePlatforms?.whatsapp), }, eventHeader: normalizeEventHeaderDisplay(wall.eventHeader), + presenterEventHeader: normalizePresenterEventHeaderDisplay(wall.presenterEventHeader), uploadToken: wall.uploadToken || null, presentToken: wall.presentToken || null, photoCounts: { @@ -220,6 +227,21 @@ const updatePhotoWall = async (request, h) => { const n = Number(p.gapPx); 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') { 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 === '') { wall.formId = null; } else if (p.formId != null) { diff --git a/src/controllers/publicPhotoWall.controller.js b/src/controllers/publicPhotoWall.controller.js index 992e24d..d2a5d96 100644 --- a/src/controllers/publicPhotoWall.controller.js +++ b/src/controllers/publicPhotoWall.controller.js @@ -15,6 +15,7 @@ const { EVENT_PUBLIC_SELECT, buildPublicEventPayload, normalizeEventHeaderDisplay, + normalizePresenterEventHeaderDisplay, } = require('../utils/formPublicHelpers'); 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); } - 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 frameEnabled = Boolean(wall.frameEnabled); const photos = await PhotoWallPhoto.find({ @@ -528,11 +533,19 @@ const getPresentWall = async (request, h) => { .response( success({ eventId: wall.event, - eventName: event?.name || 'Event', + eventName: eventPublic?.name || event?.name || 'Event', + event: eventPublic, + eventHeader: normalizePresenterEventHeaderDisplay(wall.presenterEventHeader), photosPerRow: wall.photosPerRow ?? 4, maxPhotoWidth, maxPhotoHeight, 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, frameEnabled, frameCanvasWidth, diff --git a/src/controllers/whatsappBlast.controller.js b/src/controllers/whatsappBlast.controller.js new file mode 100644 index 0000000..f96d636 --- /dev/null +++ b/src/controllers/whatsappBlast.controller.js @@ -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 }; diff --git a/src/models/Attendance.js b/src/models/Attendance.js index e872018..0489cf8 100644 --- a/src/models/Attendance.js +++ b/src/models/Attendance.js @@ -19,6 +19,7 @@ const attendanceSchema = new mongoose.Schema( description: { type: String, default: '', trim: true }, settings: { allowDuplicateCheckIn: { type: Boolean, default: false }, + allowAssignBlank: { type: Boolean, default: false }, publicLinkEnabled: { type: Boolean, default: true }, audience: { mode: { diff --git a/src/models/AttendanceParticipant.js b/src/models/AttendanceParticipant.js index 2866f1e..68e3bbc 100644 --- a/src/models/AttendanceParticipant.js +++ b/src/models/AttendanceParticipant.js @@ -10,6 +10,15 @@ const checkInEntrySchema = new mongoose.Schema( { _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( { attendance: { @@ -35,6 +44,7 @@ const attendanceParticipantSchema = new mongoose.Schema( email: { type: String, trim: true, lowercase: true }, phone: { type: String, trim: true }, checkIns: { type: [checkInEntrySchema], default: [] }, + undos: { type: [undoEntrySchema], default: [] }, }, { timestamps: true } ); @@ -42,6 +52,7 @@ const attendanceParticipantSchema = new mongoose.Schema( attendanceParticipantSchema.index({ attendance: 1, participant: 1 }, { unique: true }); attendanceParticipantSchema.index({ attendance: 1, updatedAt: -1 }); attendanceParticipantSchema.index({ attendance: 1, 'checkIns.clientOpId': 1 }, { sparse: true }); +attendanceParticipantSchema.index({ attendance: 1, 'undos.clientOpId': 1 }, { sparse: true }); module.exports = mongoose.model( 'AttendanceParticipant', diff --git a/src/models/ConciergeBot.js b/src/models/ConciergeBot.js index 74f631f..06121e9 100644 --- a/src/models/ConciergeBot.js +++ b/src/models/ConciergeBot.js @@ -134,11 +134,25 @@ const conciergeBotSchema = new mongoose.Schema( default: 'none', }, 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: { type: mongoose.Schema.Types.ObjectId, ref: 'WhatsAppTemplate', 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 }, + }, + ], }, ], }, diff --git a/src/models/PhotoWall.js b/src/models/PhotoWall.js index d19b7f4..ad5392a 100644 --- a/src/models/PhotoWall.js +++ b/src/models/PhotoWall.js @@ -34,6 +34,16 @@ const photoWallSchema = new mongoose.Schema( maxPhotoWidth: { 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 }, + /** 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 */ showUploaderNameOnWall: { type: Boolean, default: false }, /** Require crop on upload when image aspect ratio ≠ maxWidth/maxHeight */ @@ -71,6 +81,16 @@ const photoWallSchema = new mongoose.Schema( showLocationType: { 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 }, presentToken: { type: String, trim: true }, }, diff --git a/src/models/WhatsAppBlast.js b/src/models/WhatsAppBlast.js new file mode 100644 index 0000000..ba5c75b --- /dev/null +++ b/src/models/WhatsAppBlast.js @@ -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); diff --git a/src/models/WhatsAppChatSession.js b/src/models/WhatsAppChatSession.js new file mode 100644 index 0000000..497e48d --- /dev/null +++ b/src/models/WhatsAppChatSession.js @@ -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); diff --git a/src/routes/attendance.routes.js b/src/routes/attendance.routes.js index 88a57b1..033fec7 100644 --- a/src/routes/attendance.routes.js +++ b/src/routes/attendance.routes.js @@ -13,6 +13,7 @@ const audienceSchema = Joi.object({ const settingsSchema = Joi.object({ allowDuplicateCheckIn: Joi.boolean(), + allowAssignBlank: Joi.boolean(), publicLinkEnabled: Joi.boolean(), audience: audienceSchema.optional(), }); @@ -34,6 +35,21 @@ const checkInPayload = Joi.object({ 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({ deviceId: Joi.string().trim().allow('', null).optional(), operations: Joi.array() @@ -42,6 +58,8 @@ const syncCheckInsPayload = Joi.object({ clientOpId: Joi.string().trim().required(), participantId: Joi.string().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) @@ -112,6 +130,24 @@ module.exports = [ 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', path: '/api/events/{eventId}/attendances/{id}/check-ins/sync', diff --git a/src/routes/conciergeBot.routes.js b/src/routes/conciergeBot.routes.js index d84c01d..c1d6866 100644 --- a/src/routes/conciergeBot.routes.js +++ b/src/routes/conciergeBot.routes.js @@ -79,6 +79,12 @@ const createPayload = Joi.object({ 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({ trigger: Joi.string().valid('after_first_inbound', 'after_match', 'absolute'), delayMinutes: Joi.number().min(0).allow(null), @@ -86,8 +92,11 @@ const receptionDripSchema = Joi.object({ body: Joi.string().allow(''), mediaType: Joi.string().valid('none', 'image', 'video'), mediaUrl: Joi.string().allow(''), + contentKind: Joi.string().valid('custom', 'upcoming_agenda'), + agendaCount: Joi.number().integer().min(1).max(8), templateId: Joi.string().allow('', null), -}); + ctas: Joi.array().items(ctaItemSchema).max(3), +}).unknown(true); const receptionSchema = Joi.object({ enabled: Joi.boolean(), diff --git a/src/routes/photoWall.routes.js b/src/routes/photoWall.routes.js index b993b29..6b53cfb 100644 --- a/src/routes/photoWall.routes.js +++ b/src/routes/photoWall.routes.js @@ -56,6 +56,10 @@ const updatePayload = Joi.object({ maxPhotoWidth: Joi.number().integer().min(80).max(1200), maxPhotoHeight: Joi.number().integer().min(80).max(1200), 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(), requireUploadCrop: Joi.boolean(), frameEnabled: Joi.boolean(), @@ -74,6 +78,7 @@ const updatePayload = Joi.object({ whatsapp: Joi.boolean(), }), eventHeader: eventHeaderSchema, + presenterEventHeader: eventHeaderSchema, regenerateTokens: Joi.boolean(), }).min(1); diff --git a/src/routes/whatsappBlast.routes.js b/src/routes/whatsappBlast.routes.js new file mode 100644 index 0000000..49285f6 --- /dev/null +++ b/src/routes/whatsappBlast.routes.js @@ -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 }, + }, +]; diff --git a/src/services/attendanceCheckIn.service.js b/src/services/attendanceCheckIn.service.js index 74d0f55..3b3555d 100644 --- a/src/services/attendanceCheckIn.service.js +++ b/src/services/attendanceCheckIn.service.js @@ -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) { if (!mongoose.Types.ObjectId.isValid(String(attendanceId))) { return { err: error('VALIDATION_ERROR', 'Invalid attendance id') }; @@ -111,26 +162,36 @@ async function performBulkCheckInSync({ } const allowDuplicate = Boolean(attendance.settings?.allowDuplicateCheckIn); + const allowAssignBlank = Boolean(attendance.settings?.allowAssignBlank); const eventIdStr = String(attendance.event); const results = []; + const opIds = operations.map((o) => String(o?.clientOpId || '')).filter(Boolean); + // Preload existing clientOpIds for this attendance (idempotency) const existingOps = await AttendanceParticipant.find({ 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(); const opToAp = new Map(); for (const ap of existingOps) { + const meta = lastCheckInFromAp(ap); for (const ci of ap.checkIns || []) { if (ci.clientOpId) { opToAp.set(String(ci.clientOpId), { participantId: String(ap.participant), - checkInCount: (ap.checkIns || []).length, - lastCheckedInAt: - ap.checkIns?.length > 0 ? ap.checkIns[ap.checkIns.length - 1].checkedInAt : null, + ...meta, + }); + } + } + 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; } + 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); if (ts.err) { results.push({ clientOpId, status: 'rejected', message: ts.err }); @@ -219,6 +350,18 @@ async function performBulkCheckInSync({ 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 const race = await AttendanceParticipant.findOne({ attendance: attendance._id, @@ -254,6 +397,7 @@ async function performBulkCheckInSync({ checkIns: [entry], }); } else { + Object.assign(ap, snapshotFromParticipant(participant)); ap.checkIns.push(entry); await ap.save(); } @@ -288,6 +432,142 @@ async function performBulkCheckInSync({ 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) { const checkIns = apDoc.checkIns || []; 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. * @param {object} opts @@ -503,6 +885,8 @@ async function searchEventParticipantsForAttendance({ module.exports = { performCheckIn, + performAssignBlank, + performUndoCheckIn, performBulkCheckInSync, getAttendanceStats, searchEventParticipantsForAttendance, diff --git a/src/services/attendanceCheckIn.sync.test.js b/src/services/attendanceCheckIn.sync.test.js index 66c9fde..d111e81 100644 --- a/src/services/attendanceCheckIn.sync.test.js +++ b/src/services/attendanceCheckIn.sync.test.js @@ -202,4 +202,126 @@ describe('performBulkCheckInSync', () => { expect(entry.clientOpId).toBe('op-new'); 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); + }); }); diff --git a/src/utils/formPublicHelpers.js b/src/utils/formPublicHelpers.js index 35d08d3..14392f5 100644 --- a/src/utils/formPublicHelpers.js +++ b/src/utils/formPublicHelpers.js @@ -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. */ function settingMessage(formOrSettings, key, fallback) { const s = formOrSettings?.settings || formOrSettings || {}; @@ -133,6 +158,7 @@ module.exports = { buildPublicEventPayload, buildPublicFormSettings, normalizeEventHeaderDisplay, + normalizePresenterEventHeaderDisplay, resolveEventForPublicForm, settingMessage, }; diff --git a/src/utils/whatsappBlastEligible.js b/src/utils/whatsappBlastEligible.js new file mode 100644 index 0000000..e9a5758 --- /dev/null +++ b/src/utils/whatsappBlastEligible.js @@ -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 }; diff --git a/src/utils/whatsappCtas.js b/src/utils/whatsappCtas.js new file mode 100644 index 0000000..50027a8 --- /dev/null +++ b/src/utils/whatsappCtas.js @@ -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 };