Add integrations health monitoring
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Widgets\IntegrationsHealthWidget;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class IntegrationsHealthDashboard extends Page
|
||||
{
|
||||
protected string $view = 'filament.super-admin.pages.integrations-health-dashboard';
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-link';
|
||||
|
||||
protected static null|string|UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 15;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.infrastructure');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.integrations_health.navigation.label');
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
IntegrationsHealthWidget::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Filament/Widgets/IntegrationsHealthWidget.php
Normal file
22
app/Filament/Widgets/IntegrationsHealthWidget.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Services\Integrations\IntegrationHealthService;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class IntegrationsHealthWidget extends Widget
|
||||
{
|
||||
protected string $view = 'filament.widgets.integrations-health';
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$health = app(IntegrationHealthService::class);
|
||||
|
||||
return [
|
||||
'providers' => $health->providers(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Addons\EventAddonWebhookService;
|
||||
use App\Services\Checkout\CheckoutWebhookService;
|
||||
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -14,6 +15,7 @@ class PaddleWebhookController extends Controller
|
||||
public function __construct(
|
||||
private readonly CheckoutWebhookService $webhooks,
|
||||
private readonly EventAddonWebhookService $addonWebhooks,
|
||||
private readonly IntegrationWebhookRecorder $recorder,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): JsonResponse
|
||||
@@ -32,6 +34,12 @@ class PaddleWebhookController extends Controller
|
||||
}
|
||||
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
|
||||
$webhookEvent = $this->recorder->recordReceived(
|
||||
'paddle',
|
||||
$eventId ? (string) $eventId : null,
|
||||
$eventType ? (string) $eventType : null,
|
||||
);
|
||||
$handled = false;
|
||||
|
||||
$this->logDev('Paddle webhook received', [
|
||||
@@ -53,6 +61,9 @@ class PaddleWebhookController extends Controller
|
||||
]);
|
||||
|
||||
$statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED;
|
||||
$handled
|
||||
? $this->recorder->markProcessed($webhookEvent, ['handled' => true])
|
||||
: $this->recorder->markIgnored($webhookEvent, ['handled' => false]);
|
||||
|
||||
return response()->json([
|
||||
'status' => $handled ? 'processed' : 'ignored',
|
||||
@@ -68,6 +79,10 @@ class PaddleWebhookController extends Controller
|
||||
|
||||
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
|
||||
|
||||
if (isset($webhookEvent)) {
|
||||
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ProcessRevenueCatWebhook;
|
||||
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -11,6 +12,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RevenueCatWebhookController extends Controller
|
||||
{
|
||||
public function __construct(private readonly IntegrationWebhookRecorder $recorder) {}
|
||||
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
$secret = (string) config('services.revenuecat.webhook', '');
|
||||
@@ -61,9 +64,18 @@ class RevenueCatWebhookController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$eventId = (string) $request->header('X-Event-Id', '');
|
||||
$eventType = data_get($decoded, 'event.type');
|
||||
$webhookEvent = $this->recorder->recordReceived(
|
||||
'revenuecat',
|
||||
$eventId !== '' ? $eventId : null,
|
||||
is_string($eventType) && $eventType !== '' ? $eventType : null,
|
||||
);
|
||||
|
||||
ProcessRevenueCatWebhook::dispatch(
|
||||
$decoded,
|
||||
(string) $request->header('X-Event-Id', '')
|
||||
$eventId,
|
||||
$webhookEvent->id,
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'accepted'], 202);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EventPurchase;
|
||||
use App\Models\IntegrationWebhookEvent;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -25,24 +27,27 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
|
||||
private ?string $eventId;
|
||||
|
||||
private ?int $webhookEventId;
|
||||
|
||||
public int $tries = 5;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function __construct(array $payload, ?string $eventId = null)
|
||||
public function __construct(array $payload, ?string $eventId = null, ?int $webhookEventId = null)
|
||||
{
|
||||
$this->payload = $payload;
|
||||
$this->eventId = $eventId !== '' ? $eventId : null;
|
||||
$this->webhookEventId = $webhookEventId;
|
||||
$this->queue = config('services.revenuecat.queue', 'webhooks');
|
||||
$this->onQueue($this->queue);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$webhookEvent = $this->resolveWebhookEvent();
|
||||
$appUserId = $this->value('event.app_user_id')
|
||||
?? $this->value('subscriber.app_user_id');
|
||||
|
||||
@@ -50,6 +55,8 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
Log::warning('RevenueCat webhook missing app_user_id', [
|
||||
'event_id' => $this->eventId,
|
||||
]);
|
||||
$this->markFailed($webhookEvent, 'Missing app_user_id');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,6 +66,8 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
'event_id' => $this->eventId,
|
||||
'app_user_id' => $appUserId,
|
||||
]);
|
||||
$this->markFailed($webhookEvent, 'Tenant not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,13 +83,15 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
if (EventPurchase::where('provider', 'revenuecat')
|
||||
->where('external_receipt_id', $transactionId)
|
||||
->exists()) {
|
||||
$this->markIgnored($webhookEvent, 'Duplicate transaction');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$amount = (float) ($this->value('event.price') ?? 0);
|
||||
$currency = strtoupper((string) ($this->value('event.currency') ?? 'EUR'));
|
||||
|
||||
DB::transaction(function () use ($tenant, $credits, $transactionId, $productId, $amount, $currency) {
|
||||
DB::transaction(function () use ($tenant, $credits, $transactionId, $amount, $currency) {
|
||||
$tenant->refresh();
|
||||
|
||||
$purchase = EventPurchase::create([
|
||||
@@ -104,6 +115,14 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
'product_id' => $productId,
|
||||
'credits' => $credits,
|
||||
]);
|
||||
|
||||
$this->markProcessed($webhookEvent);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$webhookEvent = $this->resolveWebhookEvent();
|
||||
$this->markFailed($webhookEvent, $exception->getMessage());
|
||||
}
|
||||
|
||||
private function updateSubscriptionStatus(Tenant $tenant): void
|
||||
@@ -149,7 +168,7 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
}
|
||||
|
||||
foreach ([':', '-', '_'] as $delimiter) {
|
||||
$needle = strtolower($prefix) . $delimiter;
|
||||
$needle = strtolower($prefix).$delimiter;
|
||||
if (str_starts_with($lower, $needle)) {
|
||||
$candidate = substr($appUserId, strlen($needle));
|
||||
if (is_numeric($candidate)) {
|
||||
@@ -226,7 +245,7 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
return $mappings;
|
||||
}
|
||||
|
||||
private function value(string $path, $default = null)
|
||||
private function value(string $path, $default = null): mixed
|
||||
{
|
||||
$segments = explode('.', $path);
|
||||
$value = $this->payload;
|
||||
@@ -241,4 +260,40 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function resolveWebhookEvent(): ?IntegrationWebhookEvent
|
||||
{
|
||||
if (! $this->webhookEventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return IntegrationWebhookEvent::find($this->webhookEventId);
|
||||
}
|
||||
|
||||
private function markProcessed(?IntegrationWebhookEvent $event): void
|
||||
{
|
||||
if (! $event) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(IntegrationWebhookRecorder::class)->markProcessed($event);
|
||||
}
|
||||
|
||||
private function markIgnored(?IntegrationWebhookEvent $event, string $reason): void
|
||||
{
|
||||
if (! $event) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(IntegrationWebhookRecorder::class)->markIgnored($event, ['reason' => $reason]);
|
||||
}
|
||||
|
||||
private function markFailed(?IntegrationWebhookEvent $event, string $reason): void
|
||||
{
|
||||
if (! $event) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(IntegrationWebhookRecorder::class)->markFailed($event, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Models/IntegrationWebhookEvent.php
Normal file
42
app/Models/IntegrationWebhookEvent.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class IntegrationWebhookEvent extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\IntegrationWebhookEventFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_RECEIVED = 'received';
|
||||
|
||||
public const STATUS_PROCESSED = 'processed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_IGNORED = 'ignored';
|
||||
|
||||
protected $fillable = [
|
||||
'provider',
|
||||
'event_id',
|
||||
'event_type',
|
||||
'status',
|
||||
'received_at',
|
||||
'processed_at',
|
||||
'failed_at',
|
||||
'error_message',
|
||||
'context',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'received_at' => 'datetime',
|
||||
'processed_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'context' => 'array',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
\App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class,
|
||||
\App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage::class,
|
||||
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class,
|
||||
\App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class,
|
||||
])
|
||||
->authGuard('super_admin');
|
||||
|
||||
|
||||
180
app/Services/Integrations/IntegrationHealthService.php
Normal file
180
app/Services/Integrations/IntegrationHealthService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integrations;
|
||||
|
||||
use App\Jobs\ProcessRevenueCatWebhook;
|
||||
use App\Models\IntegrationWebhookEvent;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IntegrationHealthService
|
||||
{
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function providers(): array
|
||||
{
|
||||
return [
|
||||
$this->buildProvider('paddle', 'Paddle', [
|
||||
'is_configured' => filled(config('paddle.webhook_secret')),
|
||||
'label' => 'Webhook secret',
|
||||
]),
|
||||
$this->buildProvider('revenuecat', 'RevenueCat', [
|
||||
'is_configured' => filled(config('services.revenuecat.webhook')),
|
||||
'label' => 'Webhook secret',
|
||||
'queue' => config('services.revenuecat.queue', 'webhooks'),
|
||||
'job_class' => ProcessRevenueCatWebhook::class,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildProvider(string $provider, string $label, array $config): array
|
||||
{
|
||||
$query = IntegrationWebhookEvent::query()->where('provider', $provider);
|
||||
$lastEvent = (clone $query)->orderByDesc('received_at')->first();
|
||||
$lastProcessed = (clone $query)
|
||||
->where('status', IntegrationWebhookEvent::STATUS_PROCESSED)
|
||||
->orderByDesc('processed_at')
|
||||
->first();
|
||||
$lastFailed = (clone $query)
|
||||
->where('status', IntegrationWebhookEvent::STATUS_FAILED)
|
||||
->orderByDesc('failed_at')
|
||||
->first();
|
||||
|
||||
$pendingCount = (clone $query)
|
||||
->where('status', IntegrationWebhookEvent::STATUS_RECEIVED)
|
||||
->count();
|
||||
|
||||
$recentFailures = (clone $query)
|
||||
->where('status', IntegrationWebhookEvent::STATUS_FAILED)
|
||||
->where('failed_at', '>=', now()->subDay())
|
||||
->count();
|
||||
|
||||
$queueName = $config['queue'] ?? null;
|
||||
$jobClass = $config['job_class'] ?? null;
|
||||
|
||||
$queueBacklog = $this->countJobs($queueName, $jobClass);
|
||||
$failedJobs = $this->countFailedJobs($queueName, $jobClass);
|
||||
|
||||
$status = $this->resolveStatus(
|
||||
(bool) ($config['is_configured'] ?? false),
|
||||
$pendingCount,
|
||||
$recentFailures,
|
||||
$failedJobs,
|
||||
$lastEvent
|
||||
);
|
||||
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'label' => $label,
|
||||
'config_label' => (string) ($config['label'] ?? 'Config'),
|
||||
'is_configured' => (bool) ($config['is_configured'] ?? false),
|
||||
'status' => $status,
|
||||
'status_label' => $this->statusLabel($status),
|
||||
'pending_count' => $pendingCount,
|
||||
'recent_failures' => $recentFailures,
|
||||
'queue_backlog' => $queueBacklog,
|
||||
'failed_jobs' => $failedJobs,
|
||||
'last_event' => $this->formatEvent($lastEvent),
|
||||
'last_processed' => $this->formatEvent($lastProcessed),
|
||||
'last_failed' => $this->formatEvent($lastFailed),
|
||||
'processing_lag' => $this->formatLag($lastEvent),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveStatus(
|
||||
bool $isConfigured,
|
||||
int $pendingCount,
|
||||
int $recentFailures,
|
||||
int $failedJobs,
|
||||
?IntegrationWebhookEvent $lastEvent,
|
||||
): string {
|
||||
if (! $isConfigured) {
|
||||
return 'unconfigured';
|
||||
}
|
||||
|
||||
if ($pendingCount > 0) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
if ($recentFailures > 0 || $failedJobs > 0) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
if (! $lastEvent) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
private function statusLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'healthy' => __('admin.integrations_health.status.healthy'),
|
||||
'pending' => __('admin.integrations_health.status.pending'),
|
||||
'degraded' => __('admin.integrations_health.status.degraded'),
|
||||
'unconfigured' => __('admin.integrations_health.status.unconfigured'),
|
||||
default => __('admin.integrations_health.status.unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
private function formatEvent(?IntegrationWebhookEvent $event): ?array
|
||||
{
|
||||
if (! $event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $event->status,
|
||||
'event_type' => $event->event_type,
|
||||
'received_at' => $event->received_at,
|
||||
'processed_at' => $event->processed_at,
|
||||
'failed_at' => $event->failed_at,
|
||||
'error_message' => $event->error_message,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatLag(?IntegrationWebhookEvent $event): ?array
|
||||
{
|
||||
if (! $event || ! $event->received_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$end = $event->processed_at ?? now();
|
||||
$seconds = $end->diffInSeconds($event->received_at);
|
||||
|
||||
return [
|
||||
'seconds' => $seconds,
|
||||
'label' => Carbon::now()->subSeconds($seconds)->diffForHumans(null, true),
|
||||
];
|
||||
}
|
||||
|
||||
private function countJobs(?string $queueName, ?string $jobClass): int
|
||||
{
|
||||
if (! $queueName || ! $jobClass) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table('jobs')
|
||||
->where('queue', $queueName)
|
||||
->where('payload', 'like', '%'.$jobClass.'%')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function countFailedJobs(?string $queueName, ?string $jobClass): int
|
||||
{
|
||||
if (! $queueName || ! $jobClass) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table('failed_jobs')
|
||||
->where('queue', $queueName)
|
||||
->where('payload', 'like', '%'.$jobClass.'%')
|
||||
->count();
|
||||
}
|
||||
}
|
||||
82
app/Services/Integrations/IntegrationWebhookRecorder.php
Normal file
82
app/Services/Integrations/IntegrationWebhookRecorder.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integrations;
|
||||
|
||||
use App\Models\IntegrationWebhookEvent;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class IntegrationWebhookRecorder
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function recordReceived(string $provider, ?string $eventId, ?string $eventType, array $context = []): IntegrationWebhookEvent
|
||||
{
|
||||
return IntegrationWebhookEvent::create([
|
||||
'provider' => $provider,
|
||||
'event_id' => $eventId,
|
||||
'event_type' => $eventType,
|
||||
'status' => IntegrationWebhookEvent::STATUS_RECEIVED,
|
||||
'received_at' => now(),
|
||||
'context' => $context,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function markProcessed(IntegrationWebhookEvent $event, array $context = []): IntegrationWebhookEvent
|
||||
{
|
||||
$event->forceFill([
|
||||
'status' => IntegrationWebhookEvent::STATUS_PROCESSED,
|
||||
'processed_at' => now(),
|
||||
'context' => $this->mergeContext($event, $context),
|
||||
])->save();
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function markIgnored(IntegrationWebhookEvent $event, array $context = []): IntegrationWebhookEvent
|
||||
{
|
||||
$event->forceFill([
|
||||
'status' => IntegrationWebhookEvent::STATUS_IGNORED,
|
||||
'processed_at' => now(),
|
||||
'context' => $this->mergeContext($event, $context),
|
||||
])->save();
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function markFailed(IntegrationWebhookEvent $event, string $message, array $context = []): IntegrationWebhookEvent
|
||||
{
|
||||
$event->forceFill([
|
||||
'status' => IntegrationWebhookEvent::STATUS_FAILED,
|
||||
'failed_at' => now(),
|
||||
'error_message' => Str::limit($message, 500, ''),
|
||||
'context' => $this->mergeContext($event, $context),
|
||||
])->save();
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergeContext(IntegrationWebhookEvent $event, array $context): array
|
||||
{
|
||||
$existing = $event->context ?? [];
|
||||
|
||||
if (! is_array($existing)) {
|
||||
$existing = [];
|
||||
}
|
||||
|
||||
return array_merge($existing, $context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user