feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
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.
This commit is contained in:
@@ -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 */ }
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user