Merge pull request #342 from the-luap/feat/webhook-payload-enrichment

feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
This commit is contained in:
Paul Nothaft
2026-04-29 20:34:46 +02:00
committed by GitHub
7 changed files with 144 additions and 18 deletions
+42 -3
View File
@@ -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 */ }
+15 -6
View File
@@ -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({
+15 -6
View File
@@ -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 });
+17 -1
View File
@@ -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 */ }
+17 -2
View File
@@ -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 */ }
+24
View File
@@ -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,
};
@@ -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.')}
</p>
{/* 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. */}
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700/50 dark:bg-amber-900/20 p-3 mb-4 flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-900 dark:text-amber-200 leading-relaxed">
{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.'
)}
</p>
</div>
{justCreatedSecret && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4 mb-4">
<div className="flex items-start gap-3">