$data * @param array $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 */ 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, ]; } }