Files
landing-api/app/Support/Webhooks/Webhooks.php
Josep Piguillem 9bdbf834f6 Send signed outgoing webhooks
Fed by the records themselves, delivered only to public HTTPS addresses
checked at every attempt, retried, and switched off after repeated failures.
A submitter's details travel only to webhooks that opted into them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:42:39 +02:00

128 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Webhooks;
use App\Jobs\DeliverWebhook;
use App\Models\Application;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\Webhook;
use App\Support\AdminContent;
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();
}
}
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,
]);
}
/**
* 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,
];
}
}