Files
landing-api/app/Support/Webhooks/Webhooks.php
Josep Piguillem 9390db18c2
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Send a webhook for every change a person makes to a record
Events are {resource}.created, .updated, .deleted and .restored for content,
the inbox, redirects, repositories and media, taken from the audit log so the
two agree on what happened. Subscriptions take wildcards. Users, tokens,
webhooks and settings are never sent, nor what the application does by
itself; the inbox's own words only with personal data enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:32:54 +02:00

248 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Webhooks;
use App\Jobs\DeliverWebhook;
use App\Models\Application;
use App\Models\AuditLog;
use App\Models\Member;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Support\AdminContent;
use App\Support\Audit\Auditor;
use App\Support\ContentTypes;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
/**
* Turning what happens here into deliveries for whoever listens.
*
* Fed by model events rather than by the controllers, so a page published from
* the panel, the API or a batch is announced the same way.
*/
final class Webhooks
{
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $personal Sent only to webhooks that opted into personal data.
*/
public static function dispatch(string $event, array $data, array $personal = []): void
{
$webhooks = Webhook::query()->active()->get()->filter(fn (Webhook $w) => $w->listensTo($event));
foreach ($webhooks as $webhook) {
$delivery = $webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => $event,
'payload' => $webhook->include_personal_data ? [...$data, ...$personal] : $data,
]);
DeliverWebhook::dispatch($delivery)->afterCommit();
}
}
/**
* A `ping`, to check the receiving end before anything real happens.
*/
public static function ping(Webhook $webhook): WebhookDelivery
{
$delivery = $webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => 'ping',
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
]);
DeliverWebhook::dispatch($delivery);
return $delivery->refresh();
}
/**
* One sent again, as a new delivery of the same event and payload.
*/
public static function redeliver(WebhookDelivery $delivery): WebhookDelivery
{
$again = $delivery->webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => $delivery->event,
'payload' => $delivery->payload,
]);
DeliverWebhook::dispatch($again);
return $again->refresh();
}
public static function applicationReceived(Application $application): void
{
self::dispatch('application.received', [
'id' => $application->id,
'kind' => $application->kind->value,
'call_id' => $application->call_id,
'subject' => $application->subject(),
'locale' => $application->locale,
'created_at' => $application->created_at?->toIso8601String(),
], [
'name' => $application->name,
'email' => $application->email,
'organisation' => $application->organisation,
'message' => $application->message,
]);
}
/**
* `projects.created`, `pages.updated`, `media.deleted`…: whatever a person
* did to a record, from the audit log — which already leaves out what the
* application does by itself, and already masks secrets, so the two
* cannot disagree about what happened.
*/
public static function auditRecorded(AuditLog $entry): void
{
[$resource, $action] = array_pad(explode('.', $entry->action, 2), 2, null);
if (! in_array($resource, Webhook::RESOURCES, true) || ! in_array($action, Webhook::ACTIONS, true)) {
return;
}
$record = self::find($resource, $entry->subject_id);
$changes = $entry->changes;
$data = [
'type' => $resource,
'id' => is_numeric($entry->subject_id) ? (int) $entry->subject_id : $entry->subject_id,
'title' => $entry->subject_label,
'action' => $action,
'by' => ['id' => $entry->user_id, 'name' => $entry->user_name],
'via' => $entry->via,
'changes' => $changes,
'url' => $action === 'deleted' || $record === null ? null : self::publicUrl($record),
];
$personal = [];
// A submission is somebody else's words: what is sent about it by
// default is how it is being handled, nothing they wrote.
if ($resource === 'applications') {
$data['title'] = $record instanceof Application ? $record->subject() : null;
$data['changes'] = $changes === null ? null : array_intersect_key($changes, array_flip(['status', 'handled_by', 'handled_at']));
$personal = [
'name' => $record?->name,
'email' => $record?->email,
'changes' => $changes,
];
}
self::dispatch($entry->action, $data, $personal);
}
protected static function find(string $resource, ?string $id): ?Model
{
$model = array_search($resource, Auditor::AUDITED, true);
if ($model === false || $id === null) {
return null;
}
$query = $model::query();
if (method_exists($model, 'bootSoftDeletes')) {
$query->withTrashed();
}
return $query->find($id);
}
/**
* Where it can be seen on the site, when it can be.
*/
protected static function publicUrl(Model $record): ?string
{
if (method_exists($record, 'isPublishedNow') && ! $record->isPublishedNow()) {
return null;
}
if ($record instanceof Member) {
return $record->is_public
? rtrim((string) config('site.url'), '/').SiteUrls::path('member', (string) config('locales.default'), $record->slug)
: null;
}
$siteType = AdminContent::PREVIEWABLE[AdminContent::nameOf($record) ?? ''] ?? null;
return $siteType
? (SiteUrls::absoluteAlternates($record, $siteType)[(string) config('locales.default')] ?? null)
: null;
}
/**
* Publishing and unpublishing are already told apart by the history, for
* every kind of record and from wherever it happened; this listens there.
*/
public static function revisionRecorded(Revision $revision): void
{
$model = Relation::getMorphedModel($revision->revisionable_type) ?? $revision->revisionable_type;
$record = $model::query()->find($revision->revisionable_id);
if ($record === null) {
return;
}
$event = match (true) {
$revision->event === 'published' => 'content.published',
$revision->event === 'unpublished' => 'content.unpublished',
$revision->event === 'created' && method_exists($record, 'isPublishedNow') && $record->isPublishedNow() => 'content.published',
default => null,
};
if ($event !== null) {
self::dispatch($event, self::describe($record));
}
}
public static function reviewRecorded(ReviewComment $comment): void
{
$event = match ($comment->event->value) {
'requested' => 'review.requested',
'returned' => 'review.returned',
default => null,
};
$record = $comment->reviewable;
if ($event === null || $record === null) {
return;
}
self::dispatch($event, [
...self::describe($record),
'by' => $comment->user_name,
'note' => $comment->body,
]);
}
/**
* @return array<string, mixed>
*/
protected static function describe(Model $record): array
{
$type = AdminContent::nameOf($record);
$siteType = AdminContent::PREVIEWABLE[$type] ?? null;
$locale = (string) config('locales.default');
return [
'type' => $type,
'id' => $record->getKey(),
'title' => ContentTypes::title($record),
'url' => $siteType && method_exists($record, 'getTranslation')
? (SiteUrls::absoluteAlternates($record, $siteType)[$locale] ?? null)
: null,
];
}
}