Add integrations health monitoring
This commit is contained in:
@@ -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-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-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-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-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-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"}
|
{"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-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-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-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-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-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)"}
|
{"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-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-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-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-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-01T19:52:54.391624328+01:00","closed_at":"2026-01-01T19:52:54.391628452+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-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-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-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)"}
|
{"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-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-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-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-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-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)"}
|
{"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-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-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-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-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-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-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-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-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)"}
|
{"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)"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-kxe
|
fotospiel-app-y1f
|
||||||
|
|||||||
@@ -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\Addons\EventAddonWebhookService;
|
||||||
use App\Services\Checkout\CheckoutWebhookService;
|
use App\Services\Checkout\CheckoutWebhookService;
|
||||||
|
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -14,6 +15,7 @@ class PaddleWebhookController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutWebhookService $webhooks,
|
private readonly CheckoutWebhookService $webhooks,
|
||||||
private readonly EventAddonWebhookService $addonWebhooks,
|
private readonly EventAddonWebhookService $addonWebhooks,
|
||||||
|
private readonly IntegrationWebhookRecorder $recorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(Request $request): JsonResponse
|
public function handle(Request $request): JsonResponse
|
||||||
@@ -32,6 +34,12 @@ class PaddleWebhookController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$eventType = $payload['event_type'] ?? null;
|
$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;
|
$handled = false;
|
||||||
|
|
||||||
$this->logDev('Paddle webhook received', [
|
$this->logDev('Paddle webhook received', [
|
||||||
@@ -53,6 +61,9 @@ class PaddleWebhookController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED;
|
$statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED;
|
||||||
|
$handled
|
||||||
|
? $this->recorder->markProcessed($webhookEvent, ['handled' => true])
|
||||||
|
: $this->recorder->markIgnored($webhookEvent, ['handled' => false]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => $handled ? 'processed' : 'ignored',
|
'status' => $handled ? 'processed' : 'ignored',
|
||||||
@@ -68,6 +79,10 @@ class PaddleWebhookController extends Controller
|
|||||||
|
|
||||||
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
|
$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);
|
return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Jobs\ProcessRevenueCatWebhook;
|
use App\Jobs\ProcessRevenueCatWebhook;
|
||||||
|
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -11,6 +12,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
|
|
||||||
class RevenueCatWebhookController extends Controller
|
class RevenueCatWebhookController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly IntegrationWebhookRecorder $recorder) {}
|
||||||
|
|
||||||
public function handle(Request $request): JsonResponse
|
public function handle(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$secret = (string) config('services.revenuecat.webhook', '');
|
$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(
|
ProcessRevenueCatWebhook::dispatch(
|
||||||
$decoded,
|
$decoded,
|
||||||
(string) $request->header('X-Event-Id', '')
|
$eventId,
|
||||||
|
$webhookEvent->id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json(['status' => 'accepted'], 202);
|
return response()->json(['status' => 'accepted'], 202);
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\EventPurchase;
|
use App\Models\EventPurchase;
|
||||||
|
use App\Models\IntegrationWebhookEvent;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@@ -25,24 +27,27 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
|||||||
|
|
||||||
private ?string $eventId;
|
private ?string $eventId;
|
||||||
|
|
||||||
|
private ?int $webhookEventId;
|
||||||
|
|
||||||
public int $tries = 5;
|
public int $tries = 5;
|
||||||
|
|
||||||
public int $backoff = 60;
|
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->payload = $payload;
|
||||||
$this->eventId = $eventId !== '' ? $eventId : null;
|
$this->eventId = $eventId !== '' ? $eventId : null;
|
||||||
|
$this->webhookEventId = $webhookEventId;
|
||||||
$this->queue = config('services.revenuecat.queue', 'webhooks');
|
$this->queue = config('services.revenuecat.queue', 'webhooks');
|
||||||
$this->onQueue($this->queue);
|
$this->onQueue($this->queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
|
$webhookEvent = $this->resolveWebhookEvent();
|
||||||
$appUserId = $this->value('event.app_user_id')
|
$appUserId = $this->value('event.app_user_id')
|
||||||
?? $this->value('subscriber.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', [
|
Log::warning('RevenueCat webhook missing app_user_id', [
|
||||||
'event_id' => $this->eventId,
|
'event_id' => $this->eventId,
|
||||||
]);
|
]);
|
||||||
|
$this->markFailed($webhookEvent, 'Missing app_user_id');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +66,8 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
|||||||
'event_id' => $this->eventId,
|
'event_id' => $this->eventId,
|
||||||
'app_user_id' => $appUserId,
|
'app_user_id' => $appUserId,
|
||||||
]);
|
]);
|
||||||
|
$this->markFailed($webhookEvent, 'Tenant not found');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +83,15 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
|||||||
if (EventPurchase::where('provider', 'revenuecat')
|
if (EventPurchase::where('provider', 'revenuecat')
|
||||||
->where('external_receipt_id', $transactionId)
|
->where('external_receipt_id', $transactionId)
|
||||||
->exists()) {
|
->exists()) {
|
||||||
|
$this->markIgnored($webhookEvent, 'Duplicate transaction');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$amount = (float) ($this->value('event.price') ?? 0);
|
$amount = (float) ($this->value('event.price') ?? 0);
|
||||||
$currency = strtoupper((string) ($this->value('event.currency') ?? 'EUR'));
|
$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();
|
$tenant->refresh();
|
||||||
|
|
||||||
$purchase = EventPurchase::create([
|
$purchase = EventPurchase::create([
|
||||||
@@ -104,6 +115,14 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
|||||||
'product_id' => $productId,
|
'product_id' => $productId,
|
||||||
'credits' => $credits,
|
'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
|
private function updateSubscriptionStatus(Tenant $tenant): void
|
||||||
@@ -226,7 +245,7 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
|||||||
return $mappings;
|
return $mappings;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function value(string $path, $default = null)
|
private function value(string $path, $default = null): mixed
|
||||||
{
|
{
|
||||||
$segments = explode('.', $path);
|
$segments = explode('.', $path);
|
||||||
$value = $this->payload;
|
$value = $this->payload;
|
||||||
@@ -241,4 +260,40 @@ class ProcessRevenueCatWebhook implements ShouldQueue
|
|||||||
|
|
||||||
return $value;
|
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\WatermarkSettingsPage::class,
|
||||||
\App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage::class,
|
\App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage::class,
|
||||||
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class,
|
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class,
|
||||||
|
\App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class,
|
||||||
])
|
])
|
||||||
->authGuard('super_admin');
|
->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
database/factories/IntegrationWebhookEventFactory.php
Normal file
37
database/factories/IntegrationWebhookEventFactory.php
Normal 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' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
16
database/seeders/IntegrationWebhookEventSeeder.php
Normal file
16
database/seeders/IntegrationWebhookEventSeeder.php
Normal 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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,6 +188,31 @@ return [
|
|||||||
'label' => 'Paddle-Status',
|
'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' => [
|
'guest_policy' => [
|
||||||
'navigation' => [
|
'navigation' => [
|
||||||
'label' => 'Gast-Richtlinien',
|
'label' => 'Gast-Richtlinien',
|
||||||
|
|||||||
@@ -188,6 +188,31 @@ return [
|
|||||||
'label' => 'Paddle health',
|
'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' => [
|
'guest_policy' => [
|
||||||
'navigation' => [
|
'navigation' => [
|
||||||
'label' => 'Guest policy',
|
'label' => 'Guest policy',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
|
use App\Models\IntegrationWebhookEvent;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@@ -25,6 +26,7 @@ class PaddleWebhookControllerTest extends TestCase
|
|||||||
[$tenant, $package, $session] = $this->prepareSession();
|
[$tenant, $package, $session] = $this->prepareSession();
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
|
'event_id' => 'evt_123',
|
||||||
'event_type' => 'transaction.completed',
|
'event_type' => 'transaction.completed',
|
||||||
'data' => [
|
'data' => [
|
||||||
'id' => 'txn_123',
|
'id' => 'txn_123',
|
||||||
@@ -56,6 +58,13 @@ class PaddleWebhookControllerTest extends TestCase
|
|||||||
|
|
||||||
$response->assertOk()->assertJson(['status' => 'processed']);
|
$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();
|
$session->refresh();
|
||||||
|
|
||||||
$this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status);
|
$this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Jobs\ProcessRevenueCatWebhook;
|
use App\Jobs\ProcessRevenueCatWebhook;
|
||||||
|
use App\Models\IntegrationWebhookEvent;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -17,6 +18,7 @@ class RevenueCatWebhookTest extends TestCase
|
|||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'event' => [
|
'event' => [
|
||||||
|
'type' => 'INITIAL_PURCHASE',
|
||||||
'app_user_id' => 'tenant-123',
|
'app_user_id' => 'tenant-123',
|
||||||
'product_id' => 'pro_month',
|
'product_id' => 'pro_month',
|
||||||
],
|
],
|
||||||
@@ -28,12 +30,20 @@ class RevenueCatWebhookTest extends TestCase
|
|||||||
Bus::fake();
|
Bus::fake();
|
||||||
|
|
||||||
$response = $this->postJson('/api/v1/webhooks/revenuecat', $payload, [
|
$response = $this->postJson('/api/v1/webhooks/revenuecat', $payload, [
|
||||||
|
'X-Event-Id' => 'evt_123',
|
||||||
'X-Signature' => $signature,
|
'X-Signature' => $signature,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(202)
|
$response->assertStatus(202)
|
||||||
->assertJson(['status' => 'accepted']);
|
->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);
|
Bus::assertDispatched(ProcessRevenueCatWebhook::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +63,8 @@ class RevenueCatWebhookTest extends TestCase
|
|||||||
->assertJsonPath('error.code', 'signature_invalid')
|
->assertJsonPath('error.code', 'signature_invalid')
|
||||||
->assertJsonPath('error.title', 'Invalid Signature');
|
->assertJsonPath('error.title', 'Invalid Signature');
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('integration_webhook_events', 0);
|
||||||
|
|
||||||
Bus::assertNotDispatched(ProcessRevenueCatWebhook::class);
|
Bus::assertNotDispatched(ProcessRevenueCatWebhook::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class SuperAdminNavigationGroupsTest extends TestCase
|
|||||||
\App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class => 'admin.nav.branding',
|
\App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class => 'admin.nav.branding',
|
||||||
\App\Filament\SuperAdmin\Pages\DokployDeployments::class => 'admin.nav.infrastructure',
|
\App\Filament\SuperAdmin\Pages\DokployDeployments::class => 'admin.nav.infrastructure',
|
||||||
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::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) {
|
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\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource::class => DailyOpsCluster::class,
|
||||||
\App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class,
|
\App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class,
|
||||||
\App\Filament\SuperAdmin\Pages\OpsHealthDashboard::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\TaskResource::class => WeeklyOpsCluster::class,
|
||||||
\App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class,
|
\App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class,
|
||||||
\App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class,
|
\App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class,
|
||||||
|
|||||||
Reference in New Issue
Block a user