- users:manage/roles:manage auf allen User-/Rollen-Masken erzwungen; is_super_admin aus $fillable entfernt und nur per autorisiertem forceFill setzbar; zentraler Gate::before-Super-Admin-Kurzschluss (K1/M8). - Nicht-Super-Admins können Super-Admin-Konten nicht mehr bearbeiten (Fix#2); Impersonation mit Rangprüfung, keine Token-Erstellung während Impersonation (H1). - Destruktive/verwaltende Admin-Aktionen (CRM/Billing/Compliance/Struktur) hängen an neuen admin-only Permissions; Editor behält reine Content-Rechte (H2). - Kanonischer Permission-Katalog (App\Authorization\Permission) als Single Source of Truth für Seeder und Monitoring. Deployment: `php artisan db:seed --class=RolesAndPermissionsSeeder` auf allen Umgebungen ausführen (neue Permissions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
173 lines
7.3 KiB
PHP
173 lines
7.3 KiB
PHP
<?php
|
||
|
||
use App\Models\NewsletterSubscription;
|
||
use App\Services\Admin\AdminPerformanceCache;
|
||
use App\Services\Newsletter\NewsletterSyncService;
|
||
use Livewire\Attributes\Layout;
|
||
use Livewire\Attributes\Title;
|
||
use Livewire\Volt\Component;
|
||
|
||
new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends Component
|
||
{
|
||
public ?string $syncMessage = null;
|
||
|
||
public ?string $dryRunMessage = null;
|
||
|
||
public function triggerDryRun(): void
|
||
{
|
||
$this->authorize('billing:manage');
|
||
|
||
$subscription = NewsletterSubscription::query()->latest('id')->first();
|
||
|
||
if ($subscription === null) {
|
||
$this->dryRunMessage = 'Dry-Run: Kein Datensatz fuer Vorschau vorhanden.';
|
||
|
||
return;
|
||
}
|
||
|
||
$action = ($subscription->is_confirmed && $subscription->unsubscribed_at === null)
|
||
? 'subscribe'
|
||
: 'unsubscribe';
|
||
|
||
$this->dryRunMessage = "Dry-Run: Es wuerde {$action} fuer Subscription #{$subscription->id} ({$subscription->email}) ausgefuehrt.";
|
||
}
|
||
|
||
public function triggerTestSync(): void
|
||
{
|
||
$this->authorize('billing:manage');
|
||
|
||
$subscription = NewsletterSubscription::query()->latest('id')->first();
|
||
|
||
if ($subscription === null) {
|
||
$this->syncMessage = 'Kein Datensatz fuer Test-Sync vorhanden.';
|
||
|
||
return;
|
||
}
|
||
|
||
app(NewsletterSyncService::class)->syncSubscription($subscription);
|
||
|
||
$action = ($subscription->is_confirmed && $subscription->unsubscribed_at === null)
|
||
? 'subscribe'
|
||
: 'unsubscribe';
|
||
|
||
$this->syncMessage = "Test-Sync ausgefuehrt ({$action}) fuer Subscription #{$subscription->id}.";
|
||
|
||
if ((string) config('newsletter.sync.provider') === 'placeholder') {
|
||
$this->syncMessage .= ' Hinweis: Platzhalter-Provider – es wurde kein externes System aufgerufen.';
|
||
}
|
||
}
|
||
|
||
public function with(): array
|
||
{
|
||
return [
|
||
'stats' => $this->stats(),
|
||
'syncConfig' => [
|
||
'enabled' => (bool) config('newsletter.sync.enabled'),
|
||
'provider' => (string) config('newsletter.sync.provider'),
|
||
'endpoint' => (string) (config('newsletter.sync.endpoint') ?? '-'),
|
||
'timeout' => (int) config('newsletter.sync.timeout', 10),
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array{total: int, confirmed: int, pending: int, unsubscribed: int}
|
||
*/
|
||
private function stats(): array
|
||
{
|
||
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::NewsletterStats, AdminPerformanceCache::StatsTtl, function (): array {
|
||
$stats = NewsletterSubscription::query()
|
||
->toBase()
|
||
->selectRaw('COUNT(*) as total')
|
||
->selectRaw('SUM(CASE WHEN is_confirmed = ? THEN 1 ELSE 0 END) as confirmed', [true])
|
||
->selectRaw('SUM(CASE WHEN is_confirmed = ? THEN 1 ELSE 0 END) as pending', [false])
|
||
->selectRaw('SUM(CASE WHEN unsubscribed_at IS NOT NULL THEN 1 ELSE 0 END) as unsubscribed')
|
||
->first();
|
||
|
||
return [
|
||
'total' => (int) ($stats->total ?? 0),
|
||
'confirmed' => (int) ($stats->confirmed ?? 0),
|
||
'pending' => (int) ($stats->pending ?? 0),
|
||
'unsubscribed' => (int) ($stats->unsubscribed ?? 0),
|
||
];
|
||
});
|
||
}
|
||
}; ?>
|
||
|
||
<div class="space-y-8">
|
||
{{-- ============== PAGE HEADER ============== --}}
|
||
<x-portal.page-header
|
||
:badge="__('Admin Backend')"
|
||
:eyebrow="__('Administration · Newsletter')"
|
||
:title="__('Newsletter Synchronisierung')"
|
||
:description="__('Vorbereitung fuer die kuenftige externe API-Anbindung. Aktuell ist nur das technische Grundgeruest aktiv.')"
|
||
>
|
||
<x-slot:meta>
|
||
@if ($syncConfig['enabled'])
|
||
<span class="badge ok dot">{{ __('Sync aktiv') }}</span>
|
||
@else
|
||
<span class="badge dot">{{ __('Sync deaktiviert') }}</span>
|
||
@endif
|
||
</x-slot:meta>
|
||
<x-slot:actions>
|
||
<flux:button size="sm" variant="filled" icon="eye" wire:click="triggerDryRun">
|
||
{{ __('Dry Run') }}
|
||
</flux:button>
|
||
<flux:button size="sm" variant="primary" icon="play" wire:click="triggerTestSync">
|
||
{{ __('Test-Sync ausfuehren') }}
|
||
</flux:button>
|
||
</x-slot:actions>
|
||
</x-portal.page-header>
|
||
|
||
@if ($dryRunMessage)
|
||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
|
||
<flux:icon.eye class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||
<div class="flex-1">{{ $dryRunMessage }}</div>
|
||
</div>
|
||
@endif
|
||
|
||
@if ($syncMessage)
|
||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
|
||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
|
||
{{ $syncMessage }}
|
||
</div>
|
||
@endif
|
||
|
||
{{-- ============== KPI-Reihe ============== --}}
|
||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||
<x-portal.stat-card variant="primary" :label="__('Gesamt')" :value="number_format($stats['total'])">
|
||
<x-slot:meta>{{ __('Subscriptions') }}</x-slot:meta>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="ok" :label="__('Bestaetigt')" :value="number_format($stats['confirmed'])">
|
||
<x-slot:meta>{{ __('Double Opt-in') }}</x-slot:meta>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="warn" :label="__('Unbestaetigt')" :value="number_format($stats['pending'])">
|
||
<x-slot:meta>{{ __('warten auf Opt-in') }}</x-slot:meta>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="muted" :label="__('Abgemeldet')" :value="number_format($stats['unsubscribed'])">
|
||
<x-slot:meta>{{ __('Unsubscribed') }}</x-slot:meta>
|
||
</x-portal.stat-card>
|
||
</section>
|
||
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Konfiguration') }}</span>
|
||
</div>
|
||
<dl class="p-5 grid grid-cols-1 gap-4 sm:grid-cols-2 text-[12.5px]">
|
||
<div>
|
||
<dt class="text-[10.5px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Provider') }}</dt>
|
||
<dd class="text-[color:var(--color-ink)] font-mono">{{ $syncConfig['provider'] }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-[10.5px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Timeout') }}</dt>
|
||
<dd class="text-[color:var(--color-ink)] tabular-nums">{{ $syncConfig['timeout'] }}s</dd>
|
||
</div>
|
||
<div class="sm:col-span-2">
|
||
<dt class="text-[10.5px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Endpoint') }}</dt>
|
||
<dd class="text-[color:var(--color-ink)] font-mono break-all">{{ $syncConfig['endpoint'] }}</dd>
|
||
</div>
|
||
</dl>
|
||
</article>
|
||
</div>
|