Implement tenant announcements and audit log fixes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-02 14:19:46 +01:00
parent 412ecbe691
commit 8f13465415
33 changed files with 1400 additions and 117 deletions

View File

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

View File

@@ -0,0 +1,255 @@
<?php
namespace App\Filament\Resources;
use App\Enums\TenantAnnouncementAudience;
use App\Enums\TenantAnnouncementSegment;
use App\Enums\TenantAnnouncementStatus;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Models\TenantAnnouncement;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class TenantAnnouncementResource extends Resource
{
protected static ?string $model = TenantAnnouncement::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-megaphone';
protected static ?string $recordTitleAttribute = 'title';
protected static ?int $navigationSort = 70;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function form(Schema $schema): Schema
{
$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();
$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;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Filament\Resources\TenantAnnouncementResource;
class CreateTenantAnnouncement extends AuditedCreateRecord
{
protected static string $resource = TenantAnnouncementResource::class;
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Filament\Resources\TenantAnnouncementResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
class EditTenantAnnouncement extends AuditedEditRecord
{
protected static string $resource = TenantAnnouncementResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Filament\Resources\TenantAnnouncementResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTenantAnnouncements extends ListRecords
{
protected static string $resource = TenantAnnouncementResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -164,25 +164,16 @@ class GuestPolicySettingsPage extends Page
$settings->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'))

View File

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