diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 931ba18..9491e2a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"id":"--stealth-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":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T17:23:28.230936323+01:00","close_reason":"Duplicate of fotospiel-app-ihd after beads re-init","deleted_at":"2026-01-01T17:23:28.230936323+01:00","deleted_by":"soeren","delete_reason":"Remove stray stealth issue id","original_type":"task"} -{"id":"fotospiel-app-097","title":"Tenant announcements / release notes","description":"Broadcast announcements to tenants/admins with targeting and scheduling.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:21.68206312+01:00","updated_at":"2026-01-01T14:20:21.68206312+01:00"} +{"id":"fotospiel-app-097","title":"Tenant announcements / release notes","description":"Broadcast announcements to tenants/admins with targeting and scheduling.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:21.68206312+01:00","updated_at":"2026-01-02T14:18:31.676816348+01:00","closed_at":"2026-01-02T14:18:31.676816348+01:00","close_reason":"Closed"} {"id":"fotospiel-app-0h0","title":"SEC-BILL-02 Signature freshness + retry policies for Paddle webhooks","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:37.618780852+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:37.618780852+01:00"} {"id":"fotospiel-app-0rb","title":"Tenant admin onboarding: inline checkout integration in welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:22.434997456+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:28.026795975+01:00","closed_at":"2026-01-01T16:08:28.026795975+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-0u0","title":"Paddle migration: design Paddle data mappings for packages/products/prices","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:15.991704177+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:21.629616074+01:00","closed_at":"2026-01-01T15:57:21.629616074+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/.beads/last-touched b/.beads/last-touched index b4222b2..2e7b9de 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-iyc +fotospiel-app-097 diff --git a/app/Console/Commands/DispatchTenantAnnouncements.php b/app/Console/Commands/DispatchTenantAnnouncements.php new file mode 100644 index 0000000..7ec00f2 --- /dev/null +++ b/app/Console/Commands/DispatchTenantAnnouncements.php @@ -0,0 +1,40 @@ +process(); + + $this->info(sprintf( + 'Announcements: %d activated, %d archived, %d emails queued.', + $result['activated'], + $result['archived'], + $result['queued'], + )); + + return Command::SUCCESS; + } +} diff --git a/app/Enums/TenantAnnouncementAudience.php b/app/Enums/TenantAnnouncementAudience.php new file mode 100644 index 0000000..665c998 --- /dev/null +++ b/app/Enums/TenantAnnouncementAudience.php @@ -0,0 +1,19 @@ + __('Alle Tenants'), + self::TENANTS => __('Ausgewählte Tenants'), + self::SEGMENTS => __('Segmente'), + }; + } +} diff --git a/app/Enums/TenantAnnouncementDeliveryStatus.php b/app/Enums/TenantAnnouncementDeliveryStatus.php new file mode 100644 index 0000000..93b232c --- /dev/null +++ b/app/Enums/TenantAnnouncementDeliveryStatus.php @@ -0,0 +1,19 @@ + __('Warteschlange'), + self::FAILED => __('Fehlgeschlagen'), + self::SKIPPED => __('Übersprungen'), + }; + } +} diff --git a/app/Enums/TenantAnnouncementSegment.php b/app/Enums/TenantAnnouncementSegment.php new file mode 100644 index 0000000..ebd4013 --- /dev/null +++ b/app/Enums/TenantAnnouncementSegment.php @@ -0,0 +1,17 @@ + __('Aktives Paket'), + self::ACTIVE_STATUS => __('Aktiv (nicht gesperrt/gelöscht)'), + }; + } +} diff --git a/app/Enums/TenantAnnouncementStatus.php b/app/Enums/TenantAnnouncementStatus.php new file mode 100644 index 0000000..e40db2f --- /dev/null +++ b/app/Enums/TenantAnnouncementStatus.php @@ -0,0 +1,21 @@ + __('Entwurf'), + self::SCHEDULED => __('Geplant'), + self::ACTIVE => __('Aktiv'), + self::ARCHIVED => __('Archiviert'), + }; + } +} diff --git a/app/Filament/Resources/EventResource/Pages/ManageWatermark.php b/app/Filament/Resources/EventResource/Pages/ManageWatermark.php index b6a9280..0e1cb7d 100644 --- a/app/Filament/Resources/EventResource/Pages/ManageWatermark.php +++ b/app/Filament/Resources/EventResource/Pages/ManageWatermark.php @@ -3,27 +3,41 @@ namespace App\Filament\Resources\EventResource\Pages; use App\Filament\Resources\EventResource; +use App\Services\Audit\SuperAdminAuditLogger; use Filament\Forms; use Filament\Notifications\Notification; +use Filament\Resources\Pages\Concerns\InteractsWithRecord; use Filament\Resources\Pages\Page; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Schema; use Illuminate\Support\Arr; class ManageWatermark extends Page { + use InteractsWithRecord; + protected static string $resource = EventResource::class; protected string $view = 'filament.resources.event-resource.pages.manage-watermark'; public ?string $watermark_mode = 'base'; + public ?string $watermark_asset = null; + public string $watermark_position = 'bottom-right'; + public float $watermark_opacity = 0.25; + public float $watermark_scale = 0.2; + public int $watermark_padding = 16; + public bool $serve_originals = false; - public function mount(): void + public function mount(int|string $record): void { + $this->record = $this->resolveRecord($record); + $event = $this->record; $settings = $event->settings ?? []; $watermark = Arr::get($settings, 'watermark', []); @@ -37,67 +51,62 @@ class ManageWatermark extends Page $this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false); } - protected function getForms(): array + public function form(Schema $schema): Schema { - return [ - 'form' => $this->form( - $this->makeForm() - ->schema([ - Forms\Components\Fieldset::make(__('filament-watermark.heading')) - ->schema([ - Forms\Components\Select::make('watermark_mode') - ->label(__('filament-watermark.mode.label')) - ->options([ - 'base' => __('filament-watermark.mode.base'), - 'custom' => __('filament-watermark.mode.custom'), - 'off' => __('filament-watermark.mode.off'), - ]) - ->required(), - Forms\Components\FileUpload::make('watermark_asset') - ->label(__('filament-watermark.asset')) - ->disk('public') - ->directory('branding') - ->preserveFilenames() - ->image() - ->visible(fn (callable $get) => $get('watermark_mode') === 'custom'), - Forms\Components\Select::make('watermark_position') - ->label(__('filament-watermark.position')) - ->options([ - 'top-left' => 'Top Left', - 'top-right' => 'Top Right', - 'bottom-left' => 'Bottom Left', - 'bottom-right' => 'Bottom Right', - 'center' => 'Center', - ]) - ->required(), - Forms\Components\TextInput::make('watermark_opacity') - ->label(__('filament-watermark.opacity')) - ->numeric() - ->minValue(0) - ->maxValue(1) - ->step(0.05) - ->required(), - Forms\Components\TextInput::make('watermark_scale') - ->label(__('filament-watermark.scale')) - ->numeric() - ->minValue(0.05) - ->maxValue(1) - ->step(0.05) - ->required(), - Forms\Components\TextInput::make('watermark_padding') - ->label(__('filament-watermark.padding')) - ->numeric() - ->minValue(0) - ->required(), - Forms\Components\Toggle::make('serve_originals') - ->label(__('filament-watermark.serve_originals')) - ->helperText('Nur Admin/Owner: falls aktiviert, werden Originale statt watermarked ausgeliefert.') - ->default(false), - ]) - ->columns(2), - ]) - ), - ]; + return $schema->schema([ + Section::make(__('filament-watermark.heading')) + ->schema([ + Forms\Components\Select::make('watermark_mode') + ->label(__('filament-watermark.mode.label')) + ->options([ + 'base' => __('filament-watermark.mode.base'), + 'custom' => __('filament-watermark.mode.custom'), + 'off' => __('filament-watermark.mode.off'), + ]) + ->required(), + Forms\Components\FileUpload::make('watermark_asset') + ->label(__('filament-watermark.asset')) + ->disk('public') + ->directory('branding') + ->preserveFilenames() + ->image() + ->visible(fn (callable $get) => $get('watermark_mode') === 'custom'), + Forms\Components\Select::make('watermark_position') + ->label(__('filament-watermark.position')) + ->options([ + 'top-left' => 'Top Left', + 'top-right' => 'Top Right', + 'bottom-left' => 'Bottom Left', + 'bottom-right' => 'Bottom Right', + 'center' => 'Center', + ]) + ->required(), + Forms\Components\TextInput::make('watermark_opacity') + ->label(__('filament-watermark.opacity')) + ->numeric() + ->minValue(0) + ->maxValue(1) + ->step(0.05) + ->required(), + Forms\Components\TextInput::make('watermark_scale') + ->label(__('filament-watermark.scale')) + ->numeric() + ->minValue(0.05) + ->maxValue(1) + ->step(0.05) + ->required(), + Forms\Components\TextInput::make('watermark_padding') + ->label(__('filament-watermark.padding')) + ->numeric() + ->minValue(0) + ->required(), + Forms\Components\Toggle::make('serve_originals') + ->label(__('filament-watermark.serve_originals')) + ->helperText('Nur Admin/Owner: falls aktiviert, werden Originale statt watermarked ausgeliefert.') + ->default(false), + ]) + ->columns(2), + ]); } public function save(): void @@ -133,6 +142,17 @@ class ManageWatermark extends Page $event->forceFill(['settings' => $settings])->save(); + $changed = array_diff(array_keys($event->getChanges()), ['updated_at']); + + if ($changed !== []) { + app(SuperAdminAuditLogger::class)->record( + 'event.watermark_updated', + $event, + SuperAdminAuditLogger::fieldsMetadata($changed), + source: static::class + ); + } + Notification::make() ->title(__('filament-watermark.saved')) ->success() diff --git a/app/Filament/Resources/TenantAnnouncementResource.php b/app/Filament/Resources/TenantAnnouncementResource.php new file mode 100644 index 0000000..c05bd86 --- /dev/null +++ b/app/Filament/Resources/TenantAnnouncementResource.php @@ -0,0 +1,255 @@ +mapWithKeys(fn (TenantAnnouncementStatus $status) => [$status->value => $status->label()]) + ->all(); + + $audienceOptions = collect(TenantAnnouncementAudience::cases()) + ->mapWithKeys(fn (TenantAnnouncementAudience $audience) => [$audience->value => $audience->label()]) + ->all(); + + $segmentOptions = collect(TenantAnnouncementSegment::cases()) + ->mapWithKeys(fn (TenantAnnouncementSegment $segment) => [$segment->value => $segment->label()]) + ->all(); + + return $schema + ->schema([ + Section::make('Inhalt') + ->schema([ + TextInput::make('title') + ->label('Titel') + ->required() + ->maxLength(160), + Textarea::make('body') + ->label('Text') + ->rows(6) + ->required() + ->columnSpanFull(), + TextInput::make('cta_label') + ->label('CTA-Label') + ->maxLength(160), + TextInput::make('cta_url') + ->label('CTA-Link') + ->maxLength(255) + ->url() + ->nullable(), + ]) + ->columns(2), + Section::make('Zielgruppe') + ->schema([ + Select::make('audience') + ->label('Zielgruppe') + ->options($audienceOptions) + ->default(TenantAnnouncementAudience::ALL->value) + ->live() + ->required(), + Select::make('tenants') + ->label('Mandanten') + ->relationship('tenants', 'name') + ->multiple() + ->preload() + ->searchable() + ->visible(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value) + ->dehydrated(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value) + ->required(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value) + ->columnSpanFull(), + CheckboxList::make('segments') + ->label('Segmente') + ->options($segmentOptions) + ->columns(2) + ->default([]) + ->visible(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value) + ->dehydrated(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value) + ->required(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value) + ->columnSpanFull(), + Toggle::make('email_enabled') + ->label('E-Mail versenden') + ->default(true), + ]) + ->columns(2), + Section::make('Zeitplan') + ->schema([ + Select::make('status') + ->label('Status') + ->options($statusOptions) + ->default(TenantAnnouncementStatus::DRAFT->value) + ->live() + ->required(), + DateTimePicker::make('starts_at') + ->label('Startet am') + ->seconds(false) + ->nullable() + ->required(fn (Get $get): bool => $get('status') === TenantAnnouncementStatus::SCHEDULED->value), + DateTimePicker::make('ends_at') + ->label('Endet am') + ->seconds(false) + ->nullable(), + ]) + ->columns(2), + ]) + ->columns(1); + } + + public static function table(Table $table): Table + { + $statusOptions = collect(TenantAnnouncementStatus::cases()) + ->mapWithKeys(fn (TenantAnnouncementStatus $status) => [$status->value => $status->label()]) + ->all(); + + $audienceOptions = collect(TenantAnnouncementAudience::cases()) + ->mapWithKeys(fn (TenantAnnouncementAudience $audience) => [$audience->value => $audience->label()]) + ->all(); + + return $table + ->columns([ + Tables\Columns\TextColumn::make('title') + ->label('Titel') + ->searchable() + ->sortable() + ->limit(50), + Tables\Columns\TextColumn::make('status') + ->label('Status') + ->badge() + ->formatStateUsing(function ($state): string { + if ($state instanceof TenantAnnouncementStatus) { + return $state->label(); + } + + return TenantAnnouncementStatus::tryFrom((string) $state)?->label() ?? (string) $state; + }) + ->sortable(), + Tables\Columns\TextColumn::make('audience') + ->label('Zielgruppe') + ->badge() + ->formatStateUsing(function ($state): string { + if ($state instanceof TenantAnnouncementAudience) { + return $state->label(); + } + + return TenantAnnouncementAudience::tryFrom((string) $state)?->label() ?? (string) $state; + }) + ->sortable(), + Tables\Columns\IconColumn::make('email_enabled') + ->label('E-Mail') + ->boolean(), + Tables\Columns\TextColumn::make('starts_at') + ->label('Start') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('ends_at') + ->label('Ende') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('updated_at') + ->label('Aktualisiert') + ->since() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->label('Status') + ->options($statusOptions), + Tables\Filters\SelectFilter::make('audience') + ->label('Zielgruppe') + ->options($audienceOptions), + ]) + ->actions([ + Tables\Actions\EditAction::make() + ->after(fn (array $data, TenantAnnouncement $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( + 'updated', + $record, + SuperAdminAuditLogger::fieldsMetadata($data), + static::class + )), + ]) + ->bulkActions([ + Tables\Actions\DeleteBulkAction::make() + ->after(function (Collection $records): void { + $logger = app(SuperAdminAuditLogger::class); + + foreach ($records as $record) { + $logger->recordModelMutation( + 'deleted', + $record, + source: static::class + ); + } + }), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTenantAnnouncements::route('/'), + 'create' => Pages\CreateTenantAnnouncement::route('/create'), + 'edit' => Pages\EditTenantAnnouncement::route('/{record}/edit'), + ]; + } + + public static function mutateFormDataBeforeCreate(array $data): array + { + if ($userId = Auth::id()) { + $data['created_by'] = $userId; + $data['updated_by'] = $userId; + } + + return $data; + } + + public static function mutateFormDataBeforeSave(array $data): array + { + if ($userId = Auth::id()) { + $data['updated_by'] = $userId; + } + + return $data; + } +} diff --git a/app/Filament/Resources/TenantAnnouncementResource/Pages/CreateTenantAnnouncement.php b/app/Filament/Resources/TenantAnnouncementResource/Pages/CreateTenantAnnouncement.php new file mode 100644 index 0000000..8fad7b9 --- /dev/null +++ b/app/Filament/Resources/TenantAnnouncementResource/Pages/CreateTenantAnnouncement.php @@ -0,0 +1,11 @@ +after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation( + 'deleted', + $record, + source: static::class + )), + ]; + } +} diff --git a/app/Filament/Resources/TenantAnnouncementResource/Pages/ListTenantAnnouncements.php b/app/Filament/Resources/TenantAnnouncementResource/Pages/ListTenantAnnouncements.php new file mode 100644 index 0000000..94dee88 --- /dev/null +++ b/app/Filament/Resources/TenantAnnouncementResource/Pages/ListTenantAnnouncements.php @@ -0,0 +1,19 @@ +guest_notification_ttl_hours = $this->guest_notification_ttl_hours; $settings->save(); - app(SuperAdminAuditLogger::class)->record( - 'guest_policy.updated', - $settings, - SuperAdminAuditLogger::fieldsMetadata([ - 'guest_downloads_enabled', - 'guest_sharing_enabled', - 'guest_upload_visibility', - 'per_device_upload_limit', - 'join_token_failure_limit', - 'join_token_failure_decay_minutes', - 'join_token_access_limit', - 'join_token_access_decay_minutes', - 'join_token_download_limit', - 'join_token_download_decay_minutes', - 'share_link_ttl_hours', - 'guest_notification_ttl_hours', - ]), - source: static::class - ); + $changed = $settings->getChanges(); + + if ($changed !== []) { + app(SuperAdminAuditLogger::class)->record( + 'guest_policy.updated', + $settings, + SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)), + source: static::class + ); + } Notification::make() ->title(__('admin.guest_policy.notifications.saved')) diff --git a/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php b/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php index 4fee93f..313b4f0 100644 --- a/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php +++ b/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php @@ -129,20 +129,16 @@ class WatermarkSettingsPage extends Page $settings->offset_y = $this->offset_y; $settings->save(); - app(SuperAdminAuditLogger::class)->record( - 'watermark_settings.updated', - $settings, - SuperAdminAuditLogger::fieldsMetadata([ - 'asset', - 'position', - 'opacity', - 'scale', - 'padding', - 'offset_x', - 'offset_y', - ]), - source: static::class - ); + $changed = $settings->getChanges(); + + if ($changed !== []) { + app(SuperAdminAuditLogger::class)->record( + 'watermark_settings.updated', + $settings, + SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)), + source: static::class + ); + } Notification::make() ->title('Wasserzeichen aktualisiert') diff --git a/app/Http/Controllers/Api/Tenant/TenantAnnouncementController.php b/app/Http/Controllers/Api/Tenant/TenantAnnouncementController.php new file mode 100644 index 0000000..d915dd4 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/TenantAnnouncementController.php @@ -0,0 +1,41 @@ +attributes->get('tenant'); + + if (! $tenant instanceof Tenant) { + $decoded = $request->attributes->get('decoded_token', []); + $tenantId = Arr::get($decoded, 'tenant_id'); + + if ($tenantId) { + $tenant = Tenant::query()->find($tenantId); + } + } + + if (! $tenant instanceof Tenant) { + return response()->json([ + 'message' => 'Tenant context missing.', + ], 401); + } + + $announcements = $service->visibleAnnouncementsForTenant($tenant); + + return TenantAnnouncementResource::collection($announcements); + } +} diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 59e3211..ea64ed7 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -311,32 +311,18 @@ class MarketingController extends Controller public function blogIndex(Request $request, string $locale) { $locale = $locale ?: app()->getLocale(); - Log::info('Blog Index Debug - Initial', [ - 'locale' => $locale, - 'full_url' => $request->fullUrl(), - ]); - $query = BlogPost::query() ->with('author') ->whereHas('category', function ($query) { $query->where('slug', 'blog'); }); - $totalWithCategory = $query->count(); - Log::info('Blog Index Debug - With Category', ['count' => $totalWithCategory]); - $query->where('is_published', true) ->whereNotNull('published_at') ->where('published_at', '<=', now()); - $totalPublished = $query->count(); - Log::info('Blog Index Debug - Published', ['count' => $totalPublished]); - // Removed translation filter for now - $totalWithTranslation = $query->count(); - Log::info('Blog Index Debug - With Translation', ['count' => $totalWithTranslation, 'locale' => $locale]); - $posts = $query->orderBy('published_at', 'desc') ->paginate(4) ->through(function (BlogPost $post) use ($locale) { @@ -354,13 +340,6 @@ class MarketingController extends Controller ]; }); - Log::info('Blog Index Debug - Final Posts', [ - 'count' => $posts->count(), - 'total' => $posts->total(), - 'posts_data' => $posts->toArray(), - 'first_post_title' => $posts->count() > 0 ? ($posts->first()['title'] ?? 'No title') : 'No posts', - ]); - $postsArray = $posts->toArray(); $postsArray['links'] = array_map(function (array $link) use ($locale) { return [ diff --git a/app/Http/Resources/Tenant/TenantAnnouncementResource.php b/app/Http/Resources/Tenant/TenantAnnouncementResource.php new file mode 100644 index 0000000..6b867d5 --- /dev/null +++ b/app/Http/Resources/Tenant/TenantAnnouncementResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'body' => $this->body, + 'cta_label' => $this->cta_label, + 'cta_url' => $this->cta_url, + 'status' => $this->status?->value ?? $this->status, + 'starts_at' => $this->starts_at?->toISOString(), + 'ends_at' => $this->ends_at?->toISOString(), + 'created_at' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 8b32603..adb74e3 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -70,6 +70,17 @@ class Tenant extends Model return $this->hasMany(TenantPackage::class); } + public function announcements(): BelongsToMany + { + return $this->belongsToMany(TenantAnnouncement::class, 'tenant_announcement_targets') + ->withTimestamps(); + } + + public function announcementDeliveries(): HasMany + { + return $this->hasMany(TenantAnnouncementDelivery::class); + } + public function packages(): BelongsToMany { return $this->belongsToMany(Package::class, 'tenant_packages') diff --git a/app/Models/TenantAnnouncement.php b/app/Models/TenantAnnouncement.php new file mode 100644 index 0000000..dd4bd4f --- /dev/null +++ b/app/Models/TenantAnnouncement.php @@ -0,0 +1,121 @@ +|null $segments + */ +class TenantAnnouncement extends Model +{ + /** @use HasFactory<\Database\Factories\TenantAnnouncementFactory> */ + use HasFactory; + + protected $fillable = [ + 'title', + 'body', + 'cta_label', + 'cta_url', + 'status', + 'audience', + 'segments', + 'starts_at', + 'ends_at', + 'email_enabled', + 'created_by', + 'updated_by', + ]; + + protected function casts(): array + { + return [ + 'status' => TenantAnnouncementStatus::class, + 'audience' => TenantAnnouncementAudience::class, + 'segments' => 'array', + 'email_enabled' => 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } + + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class, 'tenant_announcement_targets') + ->withTimestamps(); + } + + public function deliveries(): HasMany + { + return $this->hasMany(TenantAnnouncementDelivery::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + public function scopeActive(Builder $query): void + { + $query->where('status', TenantAnnouncementStatus::ACTIVE->value) + ->where(function (Builder $builder) { + $builder->whereNull('starts_at') + ->orWhere('starts_at', '<=', Carbon::now()); + }) + ->where(function (Builder $builder) { + $builder->whereNull('ends_at') + ->orWhere('ends_at', '>', Carbon::now()); + }); + } + + /** + * @param array $segments + */ + public function matchesSegments(Tenant $tenant, array $segments): bool + { + if ($segments === []) { + return true; + } + + foreach ($segments as $segment) { + $normalized = $segment instanceof TenantAnnouncementSegment + ? $segment + : TenantAnnouncementSegment::tryFrom((string) $segment); + + if (! $normalized) { + continue; + } + + if (! $this->matchesSegment($tenant, $normalized)) { + return false; + } + } + + return true; + } + + private function matchesSegment(Tenant $tenant, TenantAnnouncementSegment $segment): bool + { + return match ($segment) { + TenantAnnouncementSegment::ACTIVE_PACKAGE => $tenant->activeResellerPackage()->exists(), + TenantAnnouncementSegment::ACTIVE_STATUS => (bool) $tenant->is_active + && ! (bool) $tenant->is_suspended + && $tenant->pending_deletion_at === null + && $tenant->anonymized_at === null, + }; + } +} diff --git a/app/Models/TenantAnnouncementDelivery.php b/app/Models/TenantAnnouncementDelivery.php new file mode 100644 index 0000000..615f8fc --- /dev/null +++ b/app/Models/TenantAnnouncementDelivery.php @@ -0,0 +1,39 @@ + TenantAnnouncementDeliveryStatus::class, + 'sent_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + } + + public function announcement(): BelongsTo + { + return $this->belongsTo(TenantAnnouncement::class, 'tenant_announcement_id'); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Notifications/TenantAnnouncementNotification.php b/app/Notifications/TenantAnnouncementNotification.php new file mode 100644 index 0000000..f966371 --- /dev/null +++ b/app/Notifications/TenantAnnouncementNotification.php @@ -0,0 +1,78 @@ +afterCommit(); + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $lines = collect(preg_split('/\r\n|\r|\n/', $this->announcement->body ?? '')) + ->map(fn ($line) => trim($line ?? '')) + ->filter() + ->values() + ->all(); + + $ctaLabel = $this->announcement->cta_label ?: 'Event-Admin öffnen'; + $ctaUrl = $this->announcement->cta_url ?: url('/event-admin'); + + return (new MailMessage) + ->subject($this->announcement->title) + ->view('emails.notifications.basic', [ + 'title' => $this->announcement->title, + 'preheader' => $this->announcement->title, + 'heroTitle' => $this->announcement->title, + 'lines' => $lines, + 'cta' => [ + [ + 'label' => $ctaLabel, + 'url' => $ctaUrl, + ], + ], + ]); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'announcement_id' => $this->announcement->getKey(), + 'tenant_id' => $this->tenant->getKey(), + ]; + } +} diff --git a/app/Services/Audit/SuperAdminAuditLogger.php b/app/Services/Audit/SuperAdminAuditLogger.php index 2a70a57..bfb54d0 100644 --- a/app/Services/Audit/SuperAdminAuditLogger.php +++ b/app/Services/Audit/SuperAdminAuditLogger.php @@ -49,6 +49,16 @@ class SuperAdminAuditLogger ?string $source = null, ?User $actor = null ): ?SuperAdminActionLog { + if ($operation === 'updated') { + $changed = array_keys($record->getChanges()); + + if ($changed === []) { + return null; + } + + $metadata = self::fieldsMetadata($changed); + } + $action = $this->formatAction($record, $operation); return $this->record( diff --git a/app/Services/TenantAnnouncements/TenantAnnouncementService.php b/app/Services/TenantAnnouncements/TenantAnnouncementService.php new file mode 100644 index 0000000..fb546b1 --- /dev/null +++ b/app/Services/TenantAnnouncements/TenantAnnouncementService.php @@ -0,0 +1,234 @@ +activateScheduled(); + $archived = $this->archiveExpired(); + $queued = $this->dispatchActiveEmails(); + + return [ + 'activated' => $activated, + 'archived' => $archived, + 'queued' => $queued, + ]; + } + + public function visibleAnnouncementsForTenant(Tenant $tenant): Collection + { + $baseQuery = TenantAnnouncement::query()->active(); + + $all = (clone $baseQuery) + ->where('audience', TenantAnnouncementAudience::ALL->value) + ->get(); + + $tenants = (clone $baseQuery) + ->where('audience', TenantAnnouncementAudience::TENANTS->value) + ->whereHas('tenants', fn (Builder $query) => $query->where('tenant_id', $tenant->id)) + ->get(); + + $segments = (clone $baseQuery) + ->where('audience', TenantAnnouncementAudience::SEGMENTS->value) + ->get() + ->filter(function (TenantAnnouncement $announcement) use ($tenant): bool { + $segments = $this->normalizeSegments($announcement->segments ?? []); + + return $announcement->matchesSegments($tenant, $segments); + }); + + return $all + ->merge($tenants) + ->merge($segments) + ->sortByDesc(fn (TenantAnnouncement $announcement) => $announcement->starts_at ?? $announcement->created_at) + ->values(); + } + + private function activateScheduled(): int + { + return TenantAnnouncement::query() + ->where('status', TenantAnnouncementStatus::SCHEDULED->value) + ->where(function (Builder $builder): void { + $builder->whereNull('starts_at') + ->orWhere('starts_at', '<=', Carbon::now()); + }) + ->update([ + 'status' => TenantAnnouncementStatus::ACTIVE->value, + 'updated_at' => now(), + ]); + } + + private function archiveExpired(): int + { + return TenantAnnouncement::query() + ->where('status', TenantAnnouncementStatus::ACTIVE->value) + ->whereNotNull('ends_at') + ->where('ends_at', '<', Carbon::now()) + ->update([ + 'status' => TenantAnnouncementStatus::ARCHIVED->value, + 'updated_at' => now(), + ]); + } + + private function dispatchActiveEmails(): int + { + $announcements = TenantAnnouncement::query() + ->active() + ->where('email_enabled', true) + ->get(); + + $queued = 0; + + foreach ($announcements as $announcement) { + $queued += $this->dispatchEmailsForAnnouncement($announcement); + } + + return $queued; + } + + private function dispatchEmailsForAnnouncement(TenantAnnouncement $announcement): int + { + $tenants = $this->targetTenants($announcement); + + if ($tenants->isEmpty()) { + return 0; + } + + $deliveredTenantIds = TenantAnnouncementDelivery::query() + ->where('tenant_announcement_id', $announcement->id) + ->where('channel', 'mail') + ->pluck('tenant_id') + ->all(); + + $queued = 0; + + foreach ($tenants as $tenant) { + if (in_array($tenant->id, $deliveredTenantIds, true)) { + continue; + } + + $queued += $this->queueDelivery($announcement, $tenant); + } + + return $queued; + } + + private function queueDelivery(TenantAnnouncement $announcement, Tenant $tenant): int + { + $recipient = $this->resolveRecipient($tenant); + + if (! $recipient) { + TenantAnnouncementDelivery::query()->create([ + 'tenant_announcement_id' => $announcement->id, + 'tenant_id' => $tenant->id, + 'channel' => 'mail', + 'status' => TenantAnnouncementDeliveryStatus::SKIPPED->value, + 'failed_at' => now(), + 'failure_reason' => 'missing_recipient', + ]); + + return 0; + } + + try { + Notification::route('mail', $recipient) + ->notify(new TenantAnnouncementNotification($announcement, $tenant)); + + TenantAnnouncementDelivery::query()->create([ + 'tenant_announcement_id' => $announcement->id, + 'tenant_id' => $tenant->id, + 'channel' => 'mail', + 'status' => TenantAnnouncementDeliveryStatus::QUEUED->value, + 'sent_at' => now(), + ]); + + return 1; + } catch (Throwable $exception) { + TenantAnnouncementDelivery::query()->create([ + 'tenant_announcement_id' => $announcement->id, + 'tenant_id' => $tenant->id, + 'channel' => 'mail', + 'status' => TenantAnnouncementDeliveryStatus::FAILED->value, + 'failed_at' => now(), + 'failure_reason' => class_basename($exception), + ]); + } + + return 0; + } + + private function resolveRecipient(Tenant $tenant): ?string + { + $email = $tenant->contact_email ?: $tenant->user?->email; + + return $email ?: null; + } + + private function targetTenants(TenantAnnouncement $announcement): Collection + { + $query = Tenant::query()->with('user'); + + if ($announcement->audience === TenantAnnouncementAudience::TENANTS) { + $query->whereHas('announcements', fn (Builder $builder) => $builder->where('tenant_announcements.id', $announcement->id)); + } + + if ($announcement->audience === TenantAnnouncementAudience::SEGMENTS) { + $this->applySegmentFilters($query, $this->normalizeSegments($announcement->segments ?? [])); + } + + return $query->get(); + } + + /** + * @param array $segments + */ + private function applySegmentFilters(Builder $query, array $segments): void + { + foreach ($segments as $segment) { + $normalized = $segment instanceof TenantAnnouncementSegment + ? $segment + : TenantAnnouncementSegment::tryFrom((string) $segment); + + if (! $normalized) { + continue; + } + + match ($normalized) { + TenantAnnouncementSegment::ACTIVE_PACKAGE => $query->whereHas('activeResellerPackage'), + TenantAnnouncementSegment::ACTIVE_STATUS => $query + ->where('is_active', true) + ->where('is_suspended', false) + ->whereNull('pending_deletion_at') + ->whereNull('anonymized_at'), + }; + } + } + + /** + * @param array $segments + * @return array + */ + private function normalizeSegments(array $segments): array + { + return array_values(array_filter($segments, fn ($segment) => $segment !== null && $segment !== '')); + } +} diff --git a/database/factories/TenantAnnouncementFactory.php b/database/factories/TenantAnnouncementFactory.php new file mode 100644 index 0000000..4bced6a --- /dev/null +++ b/database/factories/TenantAnnouncementFactory.php @@ -0,0 +1,34 @@ + + */ +class TenantAnnouncementFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'title' => $this->faker->sentence(4), + 'body' => $this->faker->paragraph(3), + 'cta_label' => null, + 'cta_url' => null, + 'status' => TenantAnnouncementStatus::DRAFT, + 'audience' => TenantAnnouncementAudience::ALL, + 'segments' => [], + 'email_enabled' => true, + 'starts_at' => null, + 'ends_at' => null, + ]; + } +} diff --git a/database/migrations/2026_01_02_122155_create_tenant_announcements_table.php b/database/migrations/2026_01_02_122155_create_tenant_announcements_table.php new file mode 100644 index 0000000..de78fdc --- /dev/null +++ b/database/migrations/2026_01_02_122155_create_tenant_announcements_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('title', 160); + $table->text('body'); + $table->string('cta_label', 160)->nullable(); + $table->string('cta_url', 255)->nullable(); + $table->string('status', 24)->default('draft'); + $table->string('audience', 24)->default('all'); + $table->json('segments')->nullable(); + $table->boolean('email_enabled')->default(true); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->foreignIdFor(\App\Models\User::class, 'created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignIdFor(\App\Models\User::class, 'updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['status', 'starts_at']); + $table->index(['audience', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_announcements'); + } +}; diff --git a/database/migrations/2026_01_02_122257_create_tenant_announcement_deliveries_table.php b/database/migrations/2026_01_02_122257_create_tenant_announcement_deliveries_table.php new file mode 100644 index 0000000..b070005 --- /dev/null +++ b/database/migrations/2026_01_02_122257_create_tenant_announcement_deliveries_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('tenant_announcement_id')->constrained('tenant_announcements')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('channel', 24)->default('mail'); + $table->string('status', 24)->default('queued'); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->string('failure_reason', 255)->nullable(); + $table->timestamps(); + + $table->unique(['tenant_announcement_id', 'tenant_id', 'channel'], 'tenant_announcement_delivery_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_announcement_deliveries'); + } +}; diff --git a/database/migrations/2026_01_02_122330_create_tenant_announcement_targets_table.php b/database/migrations/2026_01_02_122330_create_tenant_announcement_targets_table.php new file mode 100644 index 0000000..59f372c --- /dev/null +++ b/database/migrations/2026_01_02_122330_create_tenant_announcement_targets_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_announcement_id')->constrained('tenant_announcements')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['tenant_announcement_id', 'tenant_id'], 'tenant_announcement_target_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_announcement_targets'); + } +}; diff --git a/database/seeders/TenantAnnouncementSeeder.php b/database/seeders/TenantAnnouncementSeeder.php new file mode 100644 index 0000000..61d9a15 --- /dev/null +++ b/database/seeders/TenantAnnouncementSeeder.php @@ -0,0 +1,16 @@ +name('api.v1.')->group(function () { Route::get('dashboard', DashboardController::class) ->middleware('tenant.admin') ->name('tenant.dashboard'); + Route::get('announcements', [TenantAnnouncementController::class, 'index']) + ->middleware('tenant.admin') + ->name('tenant.announcements.index'); Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index'); Route::get('events', [EventController::class, 'index']) diff --git a/routes/console.php b/routes/console.php index 2220532..a5265be 100644 --- a/routes/console.php +++ b/routes/console.php @@ -27,3 +27,5 @@ Artisan::command('metrics:package-limits {--reset}', function () { Schedule::command('model:prune', [ '--model' => [SuperAdminActionLog::class], ])->daily(); + +Schedule::command('tenant-announcements:dispatch')->everyFiveMinutes(); diff --git a/tests/Feature/Api/Tenant/TenantAnnouncementsTest.php b/tests/Feature/Api/Tenant/TenantAnnouncementsTest.php new file mode 100644 index 0000000..9859331 --- /dev/null +++ b/tests/Feature/Api/Tenant/TenantAnnouncementsTest.php @@ -0,0 +1,68 @@ +create(); + + $activeAll = TenantAnnouncement::factory()->create([ + 'status' => TenantAnnouncementStatus::ACTIVE, + 'audience' => TenantAnnouncementAudience::ALL, + ]); + + $targeted = TenantAnnouncement::factory()->create([ + 'status' => TenantAnnouncementStatus::ACTIVE, + 'audience' => TenantAnnouncementAudience::TENANTS, + ]); + $targeted->tenants()->attach($this->tenant->id); + + $segmentActive = TenantAnnouncement::factory()->create([ + 'status' => TenantAnnouncementStatus::ACTIVE, + 'audience' => TenantAnnouncementAudience::SEGMENTS, + 'segments' => [TenantAnnouncementSegment::ACTIVE_STATUS->value], + ]); + + $segmentPackage = TenantAnnouncement::factory()->create([ + 'status' => TenantAnnouncementStatus::ACTIVE, + 'audience' => TenantAnnouncementAudience::SEGMENTS, + 'segments' => [TenantAnnouncementSegment::ACTIVE_PACKAGE->value], + ]); + + TenantPackage::factory()->for($this->tenant)->create(['active' => true]); + + $otherTargeted = TenantAnnouncement::factory()->create([ + 'status' => TenantAnnouncementStatus::ACTIVE, + 'audience' => TenantAnnouncementAudience::TENANTS, + ]); + $otherTargeted->tenants()->attach($otherTenant->id); + + $draft = TenantAnnouncement::factory()->create([ + 'status' => TenantAnnouncementStatus::DRAFT, + 'audience' => TenantAnnouncementAudience::ALL, + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/announcements'); + + $response->assertOk(); + + $ids = collect($response->json('data'))->pluck('id'); + + $this->assertTrue($ids->contains($activeAll->id)); + $this->assertTrue($ids->contains($targeted->id)); + $this->assertTrue($ids->contains($segmentActive->id)); + $this->assertTrue($ids->contains($segmentPackage->id)); + $this->assertFalse($ids->contains($otherTargeted->id)); + $this->assertFalse($ids->contains($draft->id)); + } +} diff --git a/tests/Feature/Console/DispatchTenantAnnouncementsCommandTest.php b/tests/Feature/Console/DispatchTenantAnnouncementsCommandTest.php new file mode 100644 index 0000000..7e3a011 --- /dev/null +++ b/tests/Feature/Console/DispatchTenantAnnouncementsCommandTest.php @@ -0,0 +1,55 @@ +create(['contact_email' => 'owner@example.com']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $tenant->user()->associate($user)->save(); + + $announcement = TenantAnnouncement::factory()->create([ + 'status' => TenantAnnouncementStatus::ACTIVE, + 'audience' => TenantAnnouncementAudience::ALL, + 'email_enabled' => true, + ]); + + $this->artisan('tenant-announcements:dispatch')->assertExitCode(0); + + Notification::assertSentOnDemand( + TenantAnnouncementNotification::class, + function (TenantAnnouncementNotification $notification, array $channels, $notifiable) use ($announcement, $tenant): bool { + $payload = $notification->toArray($notifiable); + + return in_array('mail', $channels, true) + && $payload['announcement_id'] === $announcement->id + && $payload['tenant_id'] === $tenant->id; + } + ); + + $this->assertTrue(TenantAnnouncementDelivery::query() + ->where('tenant_announcement_id', $announcement->id) + ->where('tenant_id', $tenant->id) + ->where('channel', 'mail') + ->where('status', TenantAnnouncementDeliveryStatus::QUEUED) + ->exists()); + } +} diff --git a/tests/Feature/SuperAdminAuditLogSettingsTest.php b/tests/Feature/SuperAdminAuditLogSettingsTest.php index e3c8be7..5aa1bfc 100644 --- a/tests/Feature/SuperAdminAuditLogSettingsTest.php +++ b/tests/Feature/SuperAdminAuditLogSettingsTest.php @@ -2,7 +2,9 @@ namespace Tests\Feature; +use App\Filament\Resources\EventResource\Pages\ManageWatermark; use App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage; +use App\Models\Event; use App\Models\SuperAdminActionLog; use App\Models\User; use Filament\Facades\Filament; @@ -29,6 +31,24 @@ class SuperAdminAuditLogSettingsTest extends TestCase ->exists()); } + public function test_event_watermark_save_creates_audit_log(): void + { + $user = User::factory()->create(['role' => 'super_admin']); + $event = Event::factory()->create(); + + $this->bootSuperAdminPanel($user); + + Livewire::test(ManageWatermark::class, ['record' => $event->getKey()]) + ->set('watermark_mode', 'base') + ->set('watermark_position', 'top-left') + ->call('save'); + + $this->assertTrue(SuperAdminActionLog::query() + ->where('action', 'event.watermark_updated') + ->where('subject_id', $event->id) + ->exists()); + } + private function bootSuperAdminPanel(User $user): void { $panel = Filament::getPanel('superadmin');