Add integrations health monitoring
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-02 18:35:12 +01:00
parent 9057a4cd15
commit fc3e6715db
21 changed files with 715 additions and 13 deletions

View File

@@ -36,7 +36,7 @@
{"id":"fotospiel-app-9mj","title":"Ops mail default + withdrawal legal routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:02.167387589+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:07.783737036+01:00","closed_at":"2026-01-01T16:10:07.783737036+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-a1n","title":"Paddle migration: define mobile/native billing strategy (RevenueCat vs Paddle)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:40.030226023+01:00","created_by":"soeren","updated_at":"2026-01-01T15:56:40.030226023+01:00"}
{"id":"fotospiel-app-agz","title":"Tenant admin onboarding: update PRP/docs + handoff notes","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:51.748367378+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:51.748367378+01:00"}
{"id":"fotospiel-app-arp","title":"Guest policy settings (toggles, rate limits, retention defaults)","description":"Global guest feature toggles, rate limits, and retention defaults. Settings page + persistence.","notes":"Fix: WatermarkSettingsPage now defines form(Schema ) to register . Ran vendor/bin/pint --dirty and php artisan test tests/Unit/SuperAdminNavigationGroupsTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:18:52.931017783+01:00","updated_at":"2026-01-01T20:41:44.791851648+01:00","closed_at":"2026-01-01T20:41:44.791851648+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-arp","title":"Guest policy settings (toggles, rate limits, retention defaults)","description":"Global guest feature toggles, rate limits, and retention defaults. Settings page + persistence.","notes":"Fix: WatermarkSettingsPage now defines form(Schema ) to register . Ran vendor/bin/pint --dirty and php artisan test tests/Unit/SuperAdminNavigationGroupsTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:18:52.931017783+01:00","updated_at":"2026-01-02T17:33:44.707289021+01:00","closed_at":"2026-01-02T17:33:44.707289021+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-auq","title":"Security review checklist: Media pipeline/storage dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:57.616770583+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:57.616770583+01:00"}
{"id":"fotospiel-app-b0h","title":"Security review: trust boundaries/entrypoints mapped","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:43.175087637+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:48.799343248+01:00","closed_at":"2026-01-01T16:03:48.799343248+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-bep","title":"SEC-IO-01 Document PAT revocation/rotation playbook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:44.568780967+01:00","created_by":"soeren","updated_at":"2026-01-01T15:51:44.568780967+01:00"}
@@ -46,6 +46,7 @@
{"id":"fotospiel-app-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:20.543083527+01:00"}
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
@@ -57,8 +58,8 @@
{"id":"fotospiel-app-g5o","title":"SEC-MS-04 Storage health widget in Super Admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:15.088501536+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:20.739996548+01:00","closed_at":"2026-01-01T15:53:20.739996548+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-g74","title":"Paddle migration: automated tests for checkout/webhooks/sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:34.795423009+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:40.467997776+01:00","closed_at":"2026-01-01T15:58:40.467997776+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-gsv","title":"Localized SEO: validate hreflang via Search Console/Lighthouse","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:36.4821072+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:36.4821072+01:00"}
{"id":"fotospiel-app-hbt","title":"Moderation queue for guest content","description":"Queue for flagged guest content (photos, feedback). Bulk actions to hide/delete/resolve with audit.","notes":"Land the plane: tests run (FilamentPanelNavigationTest, PhotoModerationQueueTest, TenantFeedbackModerationQueueTest, TenantLifecycle*), migrations added for photo + feedback moderation, no follow-up blockers.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:37.777772819+01:00","updated_at":"2026-01-01T18:50:57.274743566+01:00","closed_at":"2026-01-01T18:46:09.677538603+01:00"}
{"id":"fotospiel-app-ihd","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","notes":"Added superadmin control surface + access matrix to docs/ops/operations-manual.md (Section 1.1), including non-goals and role scope.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:18:10.789147344+01:00","updated_at":"2026-01-01T19:52:54.391624328+01:00","closed_at":"2026-01-01T19:52:54.391628452+01:00"}
{"id":"fotospiel-app-hbt","title":"Moderation queue for guest content","description":"Queue for flagged guest content (photos, feedback). Bulk actions to hide/delete/resolve with audit.","notes":"Land the plane: tests run (FilamentPanelNavigationTest, PhotoModerationQueueTest, TenantFeedbackModerationQueueTest, TenantLifecycle*), migrations added for photo + feedback moderation, no follow-up blockers.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:37.777772819+01:00","updated_at":"2026-01-02T17:33:22.599440896+01:00","closed_at":"2026-01-02T17:33:22.599440896+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-ihd","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","notes":"Added superadmin control surface + access matrix to docs/ops/operations-manual.md (Section 1.1), including non-goals and role scope.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:18:10.789147344+01:00","updated_at":"2026-01-02T17:33:57.71777777+01:00","closed_at":"2026-01-02T17:33:57.71777777+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-iyc","title":"Superadmin audit log for admin actions","description":"Audit trail for superadmin actions without PII payloads.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:19.043695952+01:00","updated_at":"2026-01-02T11:57:23.328889123+01:00","closed_at":"2026-01-02T11:57:23.328889123+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"}
{"id":"fotospiel-app-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"}
@@ -95,7 +96,7 @@
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"}
{"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-01T21:07:06.982356199+01:00","closed_at":"2026-01-01T21:07:06.982356199+01:00","close_reason":"completed"}
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-umr","title":"Paddle migration: build Paddle API service layer","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:38.529187953+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:44.178675076+01:00","closed_at":"2026-01-01T15:57:44.178675076+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-v31","title":"SEC-MS-01 AV + EXIF scrubber worker integration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:52.476048623+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:58.118336529+01:00","closed_at":"2026-01-01T15:52:58.118336529+01:00","close_reason":"Completed in codebase (verified)"}
@@ -106,10 +107,11 @@
{"id":"fotospiel-app-vk4","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:01.574904029+01:00","created_by":"soeren","updated_at":"2026-01-01T16:11:18.65499639+01:00","closed_at":"2026-01-01T16:11:18.65499639+01:00","close_reason":"Duplicate of fotospiel-app-l6a"}
{"id":"fotospiel-app-w2x","title":"SEC-FE-03 Cookie banner UX + localisation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:26.182193434+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:31.84344419+01:00","closed_at":"2026-01-01T15:55:31.84344419+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-w7g","title":"Paddle catalog sync: document failure recovery playbook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:26.255623751+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:26.255623751+01:00"}
{"id":"fotospiel-app-wde","title":"Tenant lifecycle controls (status, limits, suspend/grace)","description":"Superadmin controls for tenant status, grace periods, and hard limits (uploads/storage/events). Includes UI, policy checks, and audit events.","notes":"Delivered dedicated tenant lifecycle view with limits + audit timeline, added grace_period_ends_at field and tenant_lifecycle_events logging, wired lifecycle actions (activate/suspend/deletion/anonymize) + management actions (limits, grace, subscription expiry), enforced tenant photo/storage limits in PackageLimitEvaluator, added lifecycle/limits tests, ran Pint + targeted tests.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:23.062036821+01:00","updated_at":"2026-01-01T19:36:09.3227431+01:00","closed_at":"2026-01-01T19:36:09.322746908+01:00"}
{"id":"fotospiel-app-wde","title":"Tenant lifecycle controls (status, limits, suspend/grace)","description":"Superadmin controls for tenant status, grace periods, and hard limits (uploads/storage/events). Includes UI, policy checks, and audit events.","notes":"Delivered dedicated tenant lifecycle view with limits + audit timeline, added grace_period_ends_at field and tenant_lifecycle_events logging, wired lifecycle actions (activate/suspend/deletion/anonymize) + management actions (limits, grace, subscription expiry), enforced tenant photo/storage limits in PackageLimitEvaluator, added lifecycle/limits tests, ran Pint + targeted tests.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:23.062036821+01:00","updated_at":"2026-01-02T17:33:35.031605632+01:00","closed_at":"2026-01-02T17:33:35.031605632+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-wkl","title":"Paddle catalog sync: paddle:sync-packages command (dry-run/pull)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:58.753792575+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:04.39629062+01:00","closed_at":"2026-01-01T16:01:04.39629062+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"}
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T17:39:14.808842908+01:00"}
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
{"id":"fotospiel-app-z5g","title":"Tenant admin onboarding: PWA/Capacitor/TWA packaging prep","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:46.126417696+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:46.126417696+01:00"}
{"id":"fotospiel-app-zli","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:03.625388684+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:09.286391766+01:00","closed_at":"2026-01-01T15:55:09.286391766+01:00","close_reason":"Completed in codebase (verified)"}

View File

@@ -1 +1 @@
fotospiel-app-kxe
fotospiel-app-y1f

View File

@@ -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,
];
}
}

View 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(),
];
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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
*/
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
@@ -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);
}
}

View 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',
];
}
}

View File

@@ -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');

View 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();
}
}

View 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);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\IntegrationWebhookEvent;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\IntegrationWebhookEvent>
*/
class IntegrationWebhookEventFactory extends Factory
{
protected $model = IntegrationWebhookEvent::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$receivedAt = $this->faker->dateTimeBetween('-2 days', 'now');
$processedAt = (clone $receivedAt)->modify('+2 minutes');
return [
'provider' => $this->faker->randomElement(['paddle', 'revenuecat']),
'event_id' => $this->faker->uuid(),
'event_type' => $this->faker->word(),
'status' => IntegrationWebhookEvent::STATUS_PROCESSED,
'received_at' => $receivedAt,
'processed_at' => $processedAt,
'failed_at' => null,
'error_message' => null,
'context' => [],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('integration_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider', 40);
$table->string('event_id', 191)->nullable();
$table->string('event_type', 120)->nullable();
$table->string('status', 30)->default('received');
$table->timestamp('received_at');
$table->timestamp('processed_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->string('error_message', 500)->nullable();
$table->json('context')->nullable();
$table->timestamps();
$table->index(['provider', 'status']);
$table->index(['provider', 'received_at']);
$table->index(['provider', 'event_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('integration_webhook_events');
}
};

View File

@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class IntegrationWebhookEventSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@@ -188,6 +188,31 @@ return [
'label' => 'Paddle-Status',
],
],
'integrations_health' => [
'navigation' => [
'label' => 'Integrationen-Status',
],
'status' => [
'healthy' => 'OK',
'pending' => 'Ausstehend',
'degraded' => 'Beeinträchtigt',
'unconfigured' => 'Nicht konfiguriert',
'unknown' => 'Unbekannt',
],
'heading' => 'Integrationen-Status',
'help' => 'Operativer Überblick über Paddle/RevenueCat-Webhooks, Queue-Backlog und jüngste Fehler.',
'configured' => 'Konfiguriert',
'unconfigured' => 'Nicht konfiguriert',
'last_received' => 'Zuletzt empfangen',
'last_processed' => 'Zuletzt verarbeitet',
'processing_lag' => 'Verarbeitungsdauer',
'pending_events' => 'Offene Events',
'recent_failures' => 'Fehler (24h)',
'queue_backlog' => 'Queue-Backlog',
'failed_jobs' => 'Fehlgeschlagene Jobs',
'last_error' => 'Letzter Fehler',
'empty' => 'Noch keine Integrationsdaten verfügbar.',
],
'guest_policy' => [
'navigation' => [
'label' => 'Gast-Richtlinien',

View File

@@ -188,6 +188,31 @@ return [
'label' => 'Paddle health',
],
],
'integrations_health' => [
'navigation' => [
'label' => 'Integrations health',
],
'status' => [
'healthy' => 'Healthy',
'pending' => 'Pending',
'degraded' => 'Degraded',
'unconfigured' => 'Unconfigured',
'unknown' => 'Unknown',
],
'heading' => 'Integrations health',
'help' => 'Operational snapshot of Paddle/RevenueCat webhooks, queue backlog, and recent failures.',
'configured' => 'Configured',
'unconfigured' => 'Unconfigured',
'last_received' => 'Last received',
'last_processed' => 'Last processed',
'processing_lag' => 'Processing lag',
'pending_events' => 'Pending events',
'recent_failures' => 'Failures (24h)',
'queue_backlog' => 'Queue backlog',
'failed_jobs' => 'Failed jobs',
'last_error' => 'Last error',
'empty' => 'No integration health data available yet.',
],
'guest_policy' => [
'navigation' => [
'label' => 'Guest policy',

View File

@@ -0,0 +1,9 @@
@php
use Filament\Support\Icons\Heroicon;
@endphp
<x-filament-panels::page>
<x-filament::section :icon="Heroicon::InformationCircle">
{{ __('admin.integrations_health.help') }}
</x-filament::section>
</x-filament-panels::page>

View File

@@ -0,0 +1,77 @@
<x-filament-widgets::widget>
<x-filament::section heading="{{ __('admin.integrations_health.heading') }}">
<div class="grid gap-4 md:grid-cols-2">
@forelse($providers as $provider)
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-100">{{ $provider['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ $provider['config_label'] }}:
<span class="font-semibold {{ $provider['is_configured'] ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400' }}">
{{ $provider['is_configured'] ? __('admin.integrations_health.configured') : __('admin.integrations_health.unconfigured') }}
</span>
</p>
</div>
<span @class([
'rounded-full px-3 py-1 text-xs font-semibold',
'bg-emerald-100 text-emerald-800' => $provider['status'] === 'healthy',
'bg-amber-100 text-amber-800' => $provider['status'] === 'pending',
'bg-rose-100 text-rose-800' => in_array($provider['status'], ['degraded', 'unconfigured']),
'bg-slate-100 text-slate-600' => $provider['status'] === 'unknown',
])>
{{ $provider['status_label'] }}
</span>
</div>
<div class="mt-4 grid gap-2 text-xs text-slate-600 dark:text-slate-300">
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.last_received') }}</span>
<span class="font-semibold">
{{ optional(data_get($provider, 'last_event.received_at'))->diffForHumans() ?? '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.last_processed') }}</span>
<span class="font-semibold">
{{ optional(data_get($provider, 'last_processed.processed_at'))->diffForHumans() ?? '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.processing_lag') }}</span>
<span class="font-semibold">
{{ $provider['processing_lag']['label'] ?? '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.pending_events') }}</span>
<span class="font-semibold">{{ number_format($provider['pending_count']) }}</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.recent_failures') }}</span>
<span class="font-semibold">{{ number_format($provider['recent_failures']) }}</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.queue_backlog') }}</span>
<span class="font-semibold">{{ number_format($provider['queue_backlog']) }}</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('admin.integrations_health.failed_jobs') }}</span>
<span class="font-semibold">{{ number_format($provider['failed_jobs']) }}</span>
</div>
</div>
@if(! empty(data_get($provider, 'last_failed.error_message')))
<div class="mt-3 rounded-lg bg-rose-50 px-3 py-2 text-xs text-rose-700 dark:bg-rose-900/30 dark:text-rose-200">
{{ __('admin.integrations_health.last_error') }}: {{ data_get($provider, 'last_failed.error_message') }}
</div>
@endif
</div>
@empty
<p class="text-sm text-slate-500 dark:text-slate-300">
{{ __('admin.integrations_health.empty') }}
</p>
@endforelse
</div>
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Models\CheckoutSession;
use App\Models\IntegrationWebhookEvent;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
@@ -25,6 +26,7 @@ class PaddleWebhookControllerTest extends TestCase
[$tenant, $package, $session] = $this->prepareSession();
$payload = [
'event_id' => 'evt_123',
'event_type' => 'transaction.completed',
'data' => [
'id' => 'txn_123',
@@ -56,6 +58,13 @@ class PaddleWebhookControllerTest extends TestCase
$response->assertOk()->assertJson(['status' => 'processed']);
$this->assertDatabaseHas('integration_webhook_events', [
'provider' => 'paddle',
'event_id' => 'evt_123',
'event_type' => 'transaction.completed',
'status' => IntegrationWebhookEvent::STATUS_PROCESSED,
]);
$session->refresh();
$this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status);

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Jobs\ProcessRevenueCatWebhook;
use App\Models\IntegrationWebhookEvent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
@@ -17,6 +18,7 @@ class RevenueCatWebhookTest extends TestCase
$payload = [
'event' => [
'type' => 'INITIAL_PURCHASE',
'app_user_id' => 'tenant-123',
'product_id' => 'pro_month',
],
@@ -28,12 +30,20 @@ class RevenueCatWebhookTest extends TestCase
Bus::fake();
$response = $this->postJson('/api/v1/webhooks/revenuecat', $payload, [
'X-Event-Id' => 'evt_123',
'X-Signature' => $signature,
]);
$response->assertStatus(202)
->assertJson(['status' => 'accepted']);
$this->assertDatabaseHas('integration_webhook_events', [
'provider' => 'revenuecat',
'event_id' => 'evt_123',
'event_type' => 'INITIAL_PURCHASE',
'status' => IntegrationWebhookEvent::STATUS_RECEIVED,
]);
Bus::assertDispatched(ProcessRevenueCatWebhook::class);
}
@@ -53,6 +63,8 @@ class RevenueCatWebhookTest extends TestCase
->assertJsonPath('error.code', 'signature_invalid')
->assertJsonPath('error.title', 'Invalid Signature');
$this->assertDatabaseCount('integration_webhook_events', 0);
Bus::assertNotDispatched(ProcessRevenueCatWebhook::class);
}
}

View File

@@ -43,6 +43,7 @@ class SuperAdminNavigationGroupsTest extends TestCase
\App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class => 'admin.nav.branding',
\App\Filament\SuperAdmin\Pages\DokployDeployments::class => 'admin.nav.infrastructure',
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => 'admin.nav.infrastructure',
\App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class => 'admin.nav.infrastructure',
];
foreach ($expectations as $resourceClass => $key) {
@@ -57,6 +58,7 @@ class SuperAdminNavigationGroupsTest extends TestCase
\App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource::class => DailyOpsCluster::class,
\App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class,
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => DailyOpsCluster::class,
\App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class => DailyOpsCluster::class,
\App\Filament\Resources\TaskResource::class => WeeklyOpsCluster::class,
\App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class,
\App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class,