From 1e69d5ff71ac2d1d133b0e40637b437d7cc8bc4f Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 29 Apr 2026 20:32:20 +0200 Subject: [PATCH] feat(webhooks): enrich event.* payloads with customer contact + share_token (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event.published webhook reporter wired into n8n to send WhatsApp gallery links was missing the data needed to actually message the customer — only event_name + share_url were in the payload, no customer_name / customer_email / customer_phone, and no bare share token to construct alternate URLs. Adds a single canonical event subject helper (webhookService.buildEventSubject) so every event.* webhook returns the same shape: { id, slug, event_name, event_type, event_date, share_url, share_token, customer_name, customer_email, customer_phone } Fields the caller does not have in scope come back as null — keys are always present so receivers do not have to distinguish "field missing" from "field null". Pure addition: existing receivers continue to work, existing templates ${data.event.event_name} keep working, and new templates can now reference ${data.event.customer_phone} etc. Wired into all five firing sites: - routes/events.js — public event create (created + published) - routes/adminEvents.js — admin create + draft→publish - routes/v1/events.js — public v1 API (created + published) - services/expirationChecker.js — event.expired (extra: expires_at) - services/archiveService.js — event.archived (extra: archive_path) PII surface area widens (customer email/phone now flow to webhook receivers), so: - Settings → Webhooks UI gets an amber Callout above the create form warning admins to only point webhooks at receivers they trust. - Docs page updated with the new payload sample, the always-present null contract, and a Callout warning. Verified end-to-end against the local dev webhook receiver — delivered payload contains all 10 fields. webhookDelivery integration suite remains 8/8 green. --- backend/src/routes/adminEvents.js | 45 +++++++++++++++++-- backend/src/routes/events.js | 21 ++++++--- backend/src/routes/v1/events.js | 21 ++++++--- backend/src/services/archiveService.js | 18 +++++++- backend/src/services/expirationChecker.js | 19 +++++++- backend/src/services/webhookService.js | 24 ++++++++++ .../features/settings/tabs/WebhooksTab.tsx | 14 ++++++ 7 files changed, 144 insertions(+), 18 deletions(-) diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index c1a6d0e7..fbf929fd 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -610,10 +610,26 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // Fire event.created webhook (#327). If the event is being published // immediately (not a draft), event.published also fires below. + // Payload uses canonical event subject (#341) so receivers always see + // the same shape (id/slug/event_name + customer contact + share_*). try { const webhookService = require('../services/webhookService'); await webhookService.fire('event.created', { - event: { id: eventId, slug, event_name, event_type, event_date, is_draft: parseBooleanInput(is_draft, true) }, + event: { + ...webhookService.buildEventSubject({ + id: eventId, + slug, + event_name, + event_type, + event_date, + share_url: shareUrl, + share_token: shareToken, + customer_name: customerName, + customer_email: customerEmail, + customer_phone: customerPhone, + }), + is_draft: parseBooleanInput(is_draft, true), + }, }); } catch (e) { /* webhookService.fire never throws but be defensive */ } @@ -661,7 +677,18 @@ router.post('/', adminAuth, requirePermission('events.create'), [ try { const webhookService = require('../services/webhookService'); await webhookService.fire('event.published', { - event: { id: eventId, slug, event_name, share_url: shareUrl }, + event: webhookService.buildEventSubject({ + id: eventId, + slug, + event_name, + event_type, + event_date, + share_url: shareUrl, + share_token: shareToken, + customer_name: customerName, + customer_email: customerEmail, + customer_phone: customerPhone, + }), }); } catch (e) { /* non-fatal */ } } @@ -902,11 +929,23 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require ); // Fire event.published webhook (#327) — draft → live transition. + // Canonical payload (#341): includes customer contact + share_token. try { const webhookService = require('../services/webhookService'); const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); await webhookService.fire('event.published', { - event: { id: parseInt(id, 10), slug: event.slug, event_name: event.event_name, share_url: shareUrl }, + event: webhookService.buildEventSubject({ + id: parseInt(id, 10), + slug: event.slug, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + share_url: shareUrl, + share_token: event.share_token, + customer_name: event.customer_name || event.host_name, + customer_email: event.customer_email || event.host_email, + customer_phone: event.customer_phone, + }), }); } catch (e) { /* non-fatal */ } diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 6da5820e..a42942a9 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -191,15 +191,24 @@ router.post('/', adminAuth, [ }); // Webhook lifecycle (#327). Legacy public endpoint — events go live - // immediately so created + published fire together. + // immediately so created + published fire together. Payload uses the + // canonical event subject (#341) — every event.* webhook now includes + // customer contact + share_token. try { const webhookService = require('../services/webhookService'); - await webhookService.fire('event.created', { - event: { id: eventId, slug, event_name, event_type, event_date, share_url: shareUrl }, - }); - await webhookService.fire('event.published', { - event: { id: eventId, slug, event_name, share_url: shareUrl }, + const eventSubject = webhookService.buildEventSubject({ + id: eventId, + slug, + event_name, + event_type, + event_date, + share_url: shareUrl, + share_token: shareToken, + customer_name: customerName, + customer_email: customerEmail, }); + await webhookService.fire('event.created', { event: eventSubject }); + await webhookService.fire('event.published', { event: eventSubject }); } catch (e) { /* non-fatal */ } res.json({ diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 8a760af0..c8aab9d7 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -183,15 +183,24 @@ router.post( }); // Webhook lifecycle (#327). v1 events are not draft-aware, so they're - // both created AND published in the same call. + // both created AND published in the same call. Canonical event + // subject (#341) — customer contact + share_token always included. try { const webhookService = require('../../services/webhookService'); - await webhookService.fire('event.created', { - event: { id, slug, event_name, event_type, event_date, share_url: shareUrl }, - }); - await webhookService.fire('event.published', { - event: { id, slug, event_name, share_url: shareUrl }, + const eventSubject = webhookService.buildEventSubject({ + id, + slug, + event_name, + event_type, + event_date, + share_url: shareUrl, + share_token: shareToken, + customer_name, + customer_email, + customer_phone, }); + await webhookService.fire('event.created', { event: eventSubject }); + await webhookService.fire('event.published', { event: eventSubject }); } catch (e) { /* non-fatal */ } res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken }); diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index 8d17c1ab..fae51523 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -99,10 +99,26 @@ async function archiveEvent(event) { // Fire event.archived webhook (#327). Receivers infer per-photo loss // from this event — we deliberately do NOT fire photo.deleted for each // archived photo to avoid flooding subscribers on bulk archives. + // Canonical event subject (#341) so the shape matches event.created / + // event.published / event.expired; archive_path is an event.archived- + // specific extra. try { const webhookService = require('./webhookService'); await webhookService.fire('event.archived', { - event: { id: event.id, slug: event.slug, event_name: event.event_name, archive_path: archiveRelKey }, + event: { + ...webhookService.buildEventSubject({ + id: event.id, + slug: event.slug, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + share_token: event.share_token, + customer_name: event.customer_name || event.host_name, + customer_email: event.customer_email || event.host_email, + customer_phone: event.customer_phone, + }), + archive_path: archiveRelKey, + }, }); } catch (e) { /* non-fatal */ } diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index 0e6ab41a..7103730c 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -86,11 +86,26 @@ async function handleExpiredEvent(event) { await db('events').where('id', event.id).update({ is_active: formatBoolean(false) }); // Fire event.expired BEFORE the cascading archive call so receivers - // get the lifecycle in order (expired → archived). + // get the lifecycle in order (expired → archived). Canonical event + // subject (#341) so receivers see the same shape across all event.* + // types; expires_at retained as an event.expired-specific extra. try { const webhookService = require('./webhookService'); await webhookService.fire('event.expired', { - event: { id: event.id, slug: event.slug, event_name: event.event_name, expires_at: event.expires_at }, + event: { + ...webhookService.buildEventSubject({ + id: event.id, + slug: event.slug, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + share_token: event.share_token, + customer_name: event.customer_name || event.host_name, + customer_email: event.customer_email || event.host_email, + customer_phone: event.customer_phone, + }), + expires_at: event.expires_at, + }, }); } catch (e) { /* non-fatal */ } diff --git a/backend/src/services/webhookService.js b/backend/src/services/webhookService.js index 3b3df20c..3dca6b02 100644 --- a/backend/src/services/webhookService.js +++ b/backend/src/services/webhookService.js @@ -210,6 +210,29 @@ function parseJsonField(value, fallback) { try { return JSON.parse(value) ?? fallback; } catch { return fallback; } } +/** + * Canonical event sub-object for outbound webhooks (#341). Always returns + * the full key set so receivers don't have to handle "field missing vs + * field null" — pass whatever the caller has in scope, missing fields + * become null. Adding a new field here propagates to every event.* type + * payload at once. + */ +function buildEventSubject(input = {}) { + const e = input || {}; + return { + id: e.id ?? null, + slug: e.slug ?? null, + event_name: e.event_name ?? null, + event_type: e.event_type ?? null, + event_date: e.event_date ?? null, + share_url: e.share_url ?? null, + share_token: e.share_token ?? null, + customer_name: e.customer_name ?? null, + customer_email: e.customer_email ?? null, + customer_phone: e.customer_phone ?? null, + }; +} + module.exports = { fire, generateSecret, @@ -219,6 +242,7 @@ module.exports = { renderTemplate, validateTemplate, getByPath, + buildEventSubject, EVENT_TYPES, SECRET_PREFIX, }; diff --git a/frontend/src/features/settings/tabs/WebhooksTab.tsx b/frontend/src/features/settings/tabs/WebhooksTab.tsx index 8ba8a3b0..4ce27d58 100644 --- a/frontend/src/features/settings/tabs/WebhooksTab.tsx +++ b/frontend/src/features/settings/tabs/WebhooksTab.tsx @@ -130,6 +130,20 @@ export const WebhooksTab: React.FC = () => { {t('settings.webhooks.subtitle', 'POST event notifications to your URL the moment something happens — gallery published, photo uploaded, event archived, etc. Signed with HMAC-SHA256 in the X-PicPeak-Signature header.')}

+ {/* PII notice (#341). event.* payloads include customer contact + fields (name / email / phone) plus the share token. Make sure + admins know what flows to a webhook receiver before they wire + one up to a third-party automation tool. */} +
+ +

+ {t( + 'settings.webhooks.piiNotice', + 'event.* payloads include customer contact info (name, email, phone) and the gallery share token if you have stored them. Only point webhooks at receivers you trust — they have everything needed to message the customer or open the gallery.' + )} +

+
+ {justCreatedSecret && (