From 511d647eec656cd223c029a9455420fc0621cc80 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 27 Jun 2026 21:28:56 +0200 Subject: [PATCH] fix(events): wire customer notifications into both public API entry points (#647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two entry points for event creation were missing customer notifications, both discovered while triaging @Rekoo-PS's report that "API created events" don't send WhatsApp after #649/#650 landed. POST /api/v1/events (the OpenAPI-spec'd bearer-token API at v1/events.js): - gallery_created email was NEVER queued — only the webhook fired. - WhatsApp was NEVER queued either. POST /api/events (legacy admin-auth route at routes/events.js): - gallery_created email was queued, but WhatsApp was not. - customer_phone wasn't read from the body at all. Both routes now mirror the adminEvents.js create-and-publish path: best-effort queues that never block the API response, gated on customer_email / customer_phone presence and the global event_phone_field_enabled toggle for the phone field. The webhook subject from POST /api/events now also includes customer_phone, so downstream integrations get the same shape as the v1 API. No schema change. No migration. customer_phone column already exists on events (migration 080). WhatsApp config + template_language + template_params resolve through the existing queue processor. --- backend/src/routes/events.js | 48 +++++++++++++++++++++++++++++++++ backend/src/routes/v1/events.js | 42 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 209dfb68..39b6e800 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -18,6 +18,24 @@ const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormaliza // Use parseStringInput from shared parsers for customer data extraction const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); +const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone); + +// Whether the global "phone field" toggle (#322) is enabled. Same shape as +// the helper in adminEvents.js — kept local so this route doesn't import +// from a sibling route file. +const isPhoneFieldEnabled = async () => { + try { + const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); + if (!row) return false; + let value = row.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return value === true; + } catch { + return false; + } +}; const mapEventForApi = (event) => { if (!event || typeof event !== 'object') { @@ -69,6 +87,9 @@ router.post('/', adminAuth, [ body('event_date').isDate(), body('customer_name').notEmpty().trim(), body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), body('admin_email').isEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { @@ -109,6 +130,8 @@ router.post('/', adminAuth, [ } const customerColumnsAvailable = await hasCustomerContactColumns(); + const phoneEnabled = await isPhoneFieldEnabled(); + const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null; const requirePassword = parseBooleanInput(requirePasswordInput, true); @@ -163,6 +186,7 @@ router.post('/', adminAuth, [ event_name, event_date, ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + ...(customerPhone ? { customer_phone: customerPhone } : {}), host_name: customerName, host_email: customerEmail, admin_email, @@ -192,6 +216,29 @@ router.post('/', adminAuth, [ welcome_message: welcome_message || '' }); + // WhatsApp gallery_ready notification (#647 follow-up). Mirrors the + // adminEvents.js path: fires when the customer supplied a phone, the + // feature is enabled, and a config exists. Non-fatal — a queue failure + // must never block gallery creation. + if (customerPhone) { + try { + const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor'); + const waConfig = await getWhatsAppConfig(); + if (waConfig && waConfig.enabled) { + await queueWhatsapp(eventId, customerPhone, 'gallery_created', { + customer_name: customerName || '', + event_name, + gallery_link: shareUrl, + gallery_password: requirePassword ? password : '', + expiry_date: expires_at ? expires_at.toISOString() : null, + language: null, + }); + } + } catch (waError) { + console.warn('Failed to queue WhatsApp notification on create', waError.message); + } + } + // Webhook lifecycle (#327). Legacy public endpoint — events go live // immediately so created + published fire together. Payload uses the // canonical event subject (#341) — every event.* webhook now includes @@ -208,6 +255,7 @@ router.post('/', adminAuth, [ share_token: shareToken, customer_name: customerName, customer_email: customerEmail, + customer_phone: customerPhone, }); await webhookService.fire('event.created', { event: eventSubject }); await webhookService.fire('event.published', { event: eventSubject }); diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 6684a6ea..22a19b25 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -308,6 +308,48 @@ router.post( type: 'admin', id: req.admin.id, name: req.admin.username }); + // Customer notifications (#647 follow-up). v1 events go live in the + // same call (not draft-aware), so the gallery_created email + WhatsApp + // fire here — mirroring the adminEvents.js create-and-publish path. + // Both are best-effort: a queue failure must not block the API response. + const expiryIso = expires_at ? new Date(expires_at).toISOString() : null; + if (customer_email) { + try { + const { queueEmail } = require('../../services/emailProcessor'); + await queueEmail(id, customer_email, 'gallery_created', { + customer_name: customer_name || '', + customer_email, + host_name: customer_name || '', + event_name, + event_date: event_date || null, + gallery_link: shareUrl, + gallery_password: require_password ? password : 'No password required', + expiry_date: expiryIso, + welcome_message: '' + }); + } catch (emailError) { + logger.warn('v1 POST /events: failed to queue gallery_created email', { error: emailError.message }); + } + } + if (persistPhone) { + try { + const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); + const waConfig = await getWhatsAppConfig(); + if (waConfig && waConfig.enabled) { + await queueWhatsapp(id, persistPhone, 'gallery_created', { + customer_name: customer_name || '', + event_name, + gallery_link: shareUrl, + gallery_password: require_password ? password : '', + expiry_date: expiryIso, + language: null, + }); + } + } catch (waError) { + logger.warn('v1 POST /events: failed to queue WhatsApp notification', { error: waError.message }); + } + } + // Webhook lifecycle (#327). v1 events are not draft-aware, so they're // both created AND published in the same call. Canonical event // subject (#341) — customer contact + share_token always included.