From da06db2d3bef52b5615428106df15607450b0f65 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 1 Jan 2026 19:36:51 +0100 Subject: [PATCH] Add tenant lifecycle view and limit controls --- .beads/issues.jsonl | 2 +- .beads/last-touched | 2 +- app/Filament/Resources/TenantResource.php | 238 ++++++++++- .../TenantResource/Pages/EditTenant.php | 5 + .../TenantResource/Pages/ViewTenant.php | 10 + .../Pages/ViewTenantLifecycle.php | 42 ++ .../TenantResource/Schemas/TenantInfolist.php | 118 ------ .../Schemas/TenantLifecycleInfolist.php | 391 ++++++++++++++++++ .../Api/Tenant/PhotoController.php | 35 +- app/Models/Tenant.php | 24 ++ app/Models/TenantLifecycleEvent.php | 29 ++ .../Packages/PackageLimitEvaluator.php | 77 +++- app/Services/Tenant/TenantLifecycleLogger.php | 27 ++ app/Services/Tenant/TenantUsageService.php | 59 +++ ...9_create_tenant_lifecycle_events_table.php | 34 ++ ..._grace_period_ends_at_to_tenants_table.php | 32 ++ resources/lang/de/admin.php | 45 ++ resources/lang/en/admin.php | 45 ++ tests/Feature/TenantLifecycleActionsTest.php | 29 ++ .../Feature/TenantLifecycleManagementTest.php | 121 ++++++ tests/Feature/TenantLifecycleViewTest.php | 2 +- tests/Feature/TenantLimitEnforcementTest.php | 93 +++++ 22 files changed, 1312 insertions(+), 148 deletions(-) create mode 100644 app/Filament/Resources/TenantResource/Pages/ViewTenantLifecycle.php create mode 100644 app/Filament/Resources/TenantResource/Schemas/TenantLifecycleInfolist.php create mode 100644 app/Models/TenantLifecycleEvent.php create mode 100644 app/Services/Tenant/TenantLifecycleLogger.php create mode 100644 app/Services/Tenant/TenantUsageService.php create mode 100644 database/migrations/2026_01_01_191109_create_tenant_lifecycle_events_table.php create mode 100644 database/migrations/2026_01_01_191128_add_grace_period_ends_at_to_tenants_table.php create mode 100644 tests/Feature/TenantLifecycleManagementTest.php create mode 100644 tests/Feature/TenantLimitEnforcementTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3278c0b..19e50b8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -106,7 +106,7 @@ {"id":"fotospiel-app-vk4","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:01.574904029+01:00","created_by":"soeren","updated_at":"2026-01-01T16:11:18.65499639+01:00","closed_at":"2026-01-01T16:11:18.65499639+01:00","close_reason":"Duplicate of fotospiel-app-l6a"} {"id":"fotospiel-app-w2x","title":"SEC-FE-03 Cookie banner UX + localisation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:26.182193434+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:31.84344419+01:00","closed_at":"2026-01-01T15:55:31.84344419+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-w7g","title":"Paddle catalog sync: document failure recovery playbook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:26.255623751+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:26.255623751+01:00"} -{"id":"fotospiel-app-wde","title":"Tenant lifecycle controls (status, limits, suspend/grace)","description":"Superadmin controls for tenant status, grace periods, and hard limits (uploads/storage/events). Includes UI, policy checks, and audit events.","notes":"Implemented superadmin tenant lifecycle controls: activate/deactivate, suspend/unsuspend, schedule/cancel deletion (optional warning email), anonymize now; added tenant view infolist with lifecycle + audit timeline (retention milestones + recent notification logs) and tests for lifecycle actions + view timeline. Remaining: grace-period logic + hard limits (uploads/storage/events) wiring, policy checks, and audit-event storage.","status":"in_progress","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:23.062036821+01:00","updated_at":"2026-01-01T15:17:07.752938631+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-wkl","title":"Paddle catalog sync: paddle:sync-packages command (dry-run/pull)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:58.753792575+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:04.39629062+01:00","closed_at":"2026-01-01T16:01:04.39629062+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"} {"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/.beads/last-touched b/.beads/last-touched index cd49248..fa4fd9c 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-hbt +fotospiel-app-wde diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index fe1c7c9..7d3ebda 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -10,6 +10,7 @@ use App\Filament\Resources\TenantResource\Schemas\TenantInfolist; use App\Jobs\AnonymizeAccount; use App\Models\Tenant; use App\Notifications\InactiveTenantDeletionWarning; +use App\Services\Tenant\TenantLifecycleLogger; use BackedEnum; use Carbon\Carbon; use Filament\Actions; @@ -20,6 +21,8 @@ use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; +use Filament\Pages\Enums\SubNavigationPosition; +use Filament\Resources\Pages\Page; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables; @@ -38,6 +41,8 @@ class TenantResource extends Resource protected static UnitEnum|string|null $navigationGroup = null; + protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top; + public static function getNavigationGroup(): UnitEnum|string|null { return __('admin.nav.tenants'); @@ -105,6 +110,15 @@ class TenantResource extends Resource return TenantInfolist::configure($schema); } + public static function getRecordSubNavigation(Page $page): array + { + return $page->generateNavigationItems([ + Pages\ViewTenant::class, + Pages\ViewTenantLifecycle::class, + Pages\EditTenant::class, + ]); + } + public static function table(Table $table): Table { @@ -220,6 +234,7 @@ class TenantResource extends Resource return [ 'index' => Pages\ListTenants::route('/'), 'view' => Pages\ViewTenant::route('/{record}'), + 'lifecycle' => Pages\ViewTenantLifecycle::route('/{record}/lifecycle'), 'edit' => Pages\EditTenant::route('/{record}/edit'), ]; } @@ -245,7 +260,17 @@ class TenantResource extends Resource ->color('success') ->visible(fn (Tenant $record): bool => ! $record->is_active && ! $record->anonymized_at) ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) - ->action(fn (Tenant $record): bool => $record->update(['is_active' => true])), + ->action(function (Tenant $record): bool { + $updated = $record->update(['is_active' => true]); + + app(TenantLifecycleLogger::class)->record( + $record, + 'activated', + actor: Filament::auth()->user() + ); + + return $updated; + }), Actions\Action::make('deactivate') ->label(__('admin.tenants.actions.deactivate')) ->icon('heroicon-o-no-symbol') @@ -253,7 +278,17 @@ class TenantResource extends Resource ->requiresConfirmation() ->visible(fn (Tenant $record): bool => (bool) $record->is_active && ! $record->anonymized_at) ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) - ->action(fn (Tenant $record): bool => $record->update(['is_active' => false])), + ->action(function (Tenant $record): bool { + $updated = $record->update(['is_active' => false]); + + app(TenantLifecycleLogger::class)->record( + $record, + 'deactivated', + actor: Filament::auth()->user() + ); + + return $updated; + }), Actions\Action::make('suspend') ->label(__('admin.tenants.actions.suspend')) ->icon('heroicon-o-pause-circle') @@ -261,14 +296,34 @@ class TenantResource extends Resource ->requiresConfirmation() ->visible(fn (Tenant $record): bool => ! $record->is_suspended && ! $record->anonymized_at) ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('suspend', $record) ?? false) - ->action(fn (Tenant $record): bool => $record->update(['is_suspended' => true])), + ->action(function (Tenant $record): bool { + $updated = $record->update(['is_suspended' => true]); + + app(TenantLifecycleLogger::class)->record( + $record, + 'suspended', + actor: Filament::auth()->user() + ); + + return $updated; + }), Actions\Action::make('unsuspend') ->label(__('admin.tenants.actions.unsuspend')) ->icon('heroicon-o-play-circle') ->color('success') ->visible(fn (Tenant $record): bool => (bool) $record->is_suspended && ! $record->anonymized_at) ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('suspend', $record) ?? false) - ->action(fn (Tenant $record): bool => $record->update(['is_suspended' => false])), + ->action(function (Tenant $record): bool { + $updated = $record->update(['is_suspended' => false]); + + app(TenantLifecycleLogger::class)->record( + $record, + 'unsuspended', + actor: Filament::auth()->user() + ); + + return $updated; + }), Actions\Action::make('schedule_deletion') ->label(__('admin.tenants.actions.schedule_deletion')) ->icon('heroicon-o-calendar-days') @@ -309,6 +364,16 @@ class TenantResource extends Resource } $record->forceFill($update)->save(); + + app(TenantLifecycleLogger::class)->record( + $record, + 'deletion_scheduled', + [ + 'pending_deletion_at' => $plannedDeletion->toDateTimeString(), + 'send_warning' => (bool) ($data['send_warning'] ?? false), + ], + Filament::auth()->user() + ); }) ->successNotificationTitle(__('admin.tenants.actions.schedule_deletion_success')), Actions\Action::make('cancel_deletion') @@ -318,10 +383,21 @@ class TenantResource extends Resource ->visible(fn (Tenant $record): bool => $record->pending_deletion_at !== null && ! $record->anonymized_at) ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) ->action(function (Tenant $record): void { + $previous = $record->pending_deletion_at?->toDateTimeString(); + $record->forceFill([ 'pending_deletion_at' => null, 'deletion_warning_sent_at' => null, ])->save(); + + app(TenantLifecycleLogger::class)->record( + $record, + 'deletion_cancelled', + [ + 'pending_deletion_at' => $previous, + ], + Filament::auth()->user() + ); }) ->successNotificationTitle(__('admin.tenants.actions.cancel_deletion_success')), Actions\Action::make('anonymize_now') @@ -331,8 +407,160 @@ class TenantResource extends Resource ->requiresConfirmation() ->visible(fn (Tenant $record): bool => ! $record->anonymized_at) ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) - ->action(fn (Tenant $record) => AnonymizeAccount::dispatch(null, $record->id)) + ->action(function (Tenant $record): void { + AnonymizeAccount::dispatch(null, $record->id); + + app(TenantLifecycleLogger::class)->record( + $record, + 'anonymize_requested', + actor: Filament::auth()->user() + ); + }) ->successNotificationTitle(__('admin.tenants.actions.anonymize_success')), ]; } + + /** + * @return array + */ + public static function lifecycleManagementActions(): array + { + return [ + Actions\Action::make('update_limits') + ->label(__('admin.tenants.actions.update_limits')) + ->icon('heroicon-o-adjustments-horizontal') + ->modalWidth('2xl') + ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) + ->form([ + TextInput::make('max_photos_per_event') + ->label(__('admin.tenants.fields.max_photos_per_event')) + ->numeric() + ->minValue(0) + ->default(fn (Tenant $record) => $record->max_photos_per_event), + TextInput::make('max_storage_mb') + ->label(__('admin.tenants.fields.max_storage_mb')) + ->numeric() + ->minValue(0) + ->default(fn (Tenant $record) => $record->max_storage_mb), + Forms\Components\Textarea::make('note') + ->label(__('admin.tenants.actions.note')) + ->rows(3), + ]) + ->action(function (Tenant $record, array $data): void { + $before = [ + 'max_photos_per_event' => $record->max_photos_per_event, + 'max_storage_mb' => $record->max_storage_mb, + ]; + + $record->forceFill([ + 'max_photos_per_event' => $data['max_photos_per_event'], + 'max_storage_mb' => $data['max_storage_mb'], + ])->save(); + + $after = [ + 'max_photos_per_event' => $record->max_photos_per_event, + 'max_storage_mb' => $record->max_storage_mb, + ]; + + app(TenantLifecycleLogger::class)->record( + $record, + 'limits_updated', + [ + 'before' => $before, + 'after' => $after, + 'note' => $data['note'] ?? null, + ], + Filament::auth()->user() + ); + }), + Actions\Action::make('update_subscription_expires_at') + ->label(__('admin.tenants.actions.update_subscription_expires_at')) + ->icon('heroicon-o-calendar') + ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) + ->form([ + Forms\Components\DateTimePicker::make('subscription_expires_at') + ->label(__('admin.tenants.fields.subscription_expires_at')) + ->default(fn (Tenant $record) => $record->subscription_expires_at), + Forms\Components\Textarea::make('note') + ->label(__('admin.tenants.actions.note')) + ->rows(3), + ]) + ->action(function (Tenant $record, array $data): void { + $before = [ + 'subscription_expires_at' => optional($record->subscription_expires_at)->toDateTimeString(), + ]; + + $record->forceFill([ + 'subscription_expires_at' => $data['subscription_expires_at'], + ])->save(); + + $after = [ + 'subscription_expires_at' => optional($record->subscription_expires_at)->toDateTimeString(), + ]; + + app(TenantLifecycleLogger::class)->record( + $record, + 'subscription_expires_at_updated', + [ + 'before' => $before, + 'after' => $after, + 'note' => $data['note'] ?? null, + ], + Filament::auth()->user() + ); + }), + Actions\Action::make('set_grace_period') + ->label(__('admin.tenants.actions.set_grace_period')) + ->icon('heroicon-o-clock') + ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) + ->form([ + Forms\Components\DateTimePicker::make('grace_period_ends_at') + ->label(__('admin.tenants.fields.grace_period_ends_at')) + ->required() + ->minDate(now()) + ->default(fn (Tenant $record) => $record->grace_period_ends_at), + Forms\Components\Textarea::make('note') + ->label(__('admin.tenants.actions.note')) + ->rows(3), + ]) + ->action(function (Tenant $record, array $data): void { + $record->forceFill([ + 'grace_period_ends_at' => $data['grace_period_ends_at'], + ])->save(); + + app(TenantLifecycleLogger::class)->record( + $record, + 'grace_period_set', + [ + 'grace_period_ends_at' => optional($record->grace_period_ends_at)->toDateTimeString(), + 'note' => $data['note'] ?? null, + ], + Filament::auth()->user() + ); + }), + Actions\Action::make('clear_grace_period') + ->label(__('admin.tenants.actions.clear_grace_period')) + ->icon('heroicon-o-x-circle') + ->color('gray') + ->requiresConfirmation() + ->visible(fn (Tenant $record): bool => $record->grace_period_ends_at !== null) + ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) + ->action(function (Tenant $record): void { + $previous = $record->grace_period_ends_at?->toDateTimeString(); + + $record->forceFill([ + 'grace_period_ends_at' => null, + ])->save(); + + app(TenantLifecycleLogger::class)->record( + $record, + 'grace_period_cleared', + [ + 'grace_period_ends_at' => $previous, + ], + Filament::auth()->user() + ); + }), + ]; + } } diff --git a/app/Filament/Resources/TenantResource/Pages/EditTenant.php b/app/Filament/Resources/TenantResource/Pages/EditTenant.php index 605a710..89244be 100644 --- a/app/Filament/Resources/TenantResource/Pages/EditTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/EditTenant.php @@ -8,4 +8,9 @@ use Filament\Resources\Pages\EditRecord; class EditTenant extends EditRecord { protected static string $resource = TenantResource::class; + + public static function getNavigationLabel(): string + { + return __('admin.tenants.pages.edit'); + } } diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index 776e231..76944fb 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -10,6 +10,16 @@ class ViewTenant extends ViewRecord { protected static string $resource = TenantResource::class; + public static function getNavigationLabel(): string + { + return __('admin.tenants.pages.overview'); + } + + public function getTitle(): string + { + return __('admin.tenants.pages.overview'); + } + protected function getHeaderActions(): array { return [ diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenantLifecycle.php b/app/Filament/Resources/TenantResource/Pages/ViewTenantLifecycle.php new file mode 100644 index 0000000..1856cc9 --- /dev/null +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenantLifecycle.php @@ -0,0 +1,42 @@ +label(__('admin.tenants.actions.lifecycle')) + ->icon('heroicon-o-shield-exclamation'), + Actions\ActionGroup::make(TenantResource::lifecycleManagementActions()) + ->label(__('admin.tenants.actions.lifecycle_controls')) + ->icon('heroicon-o-adjustments-horizontal'), + ]; + } + + public function infolist(Schema $schema): Schema + { + return TenantLifecycleInfolist::configure($schema); + } +} diff --git a/app/Filament/Resources/TenantResource/Schemas/TenantInfolist.php b/app/Filament/Resources/TenantResource/Schemas/TenantInfolist.php index 7b73a5f..9e7cfbf 100644 --- a/app/Filament/Resources/TenantResource/Schemas/TenantInfolist.php +++ b/app/Filament/Resources/TenantResource/Schemas/TenantInfolist.php @@ -4,11 +4,9 @@ namespace App\Filament\Resources\TenantResource\Schemas; use App\Models\Tenant; use Filament\Infolists\Components\IconEntry; -use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; -use Illuminate\Support\Str; class TenantInfolist { @@ -67,122 +65,6 @@ class TenantInfolist ->dateTime() ->placeholder('—'), ]), - Section::make(__('admin.tenants.sections.timeline')) - ->schema([ - RepeatableEntry::make('lifecycle_timeline') - ->label(__('admin.tenants.sections.timeline')) - ->state(fn (Tenant $record) => static::buildTimeline($record)) - ->schema([ - TextEntry::make('title') - ->label(__('admin.tenants.timeline.title')) - ->columnSpanFull(), - TextEntry::make('details') - ->label(__('admin.tenants.timeline.details')) - ->columnSpanFull() - ->placeholder('—'), - TextEntry::make('tone') - ->label(__('admin.tenants.timeline.status')) - ->badge() - ->color(fn (?string $state) => $state ?? 'gray') - ->formatStateUsing(fn (?string $state) => $state - ? __('admin.tenants.timeline.tone.'.$state) - : __('admin.tenants.timeline.tone.muted')), - TextEntry::make('occurred_at') - ->label(__('admin.tenants.timeline.occurred_at')) - ->dateTime(), - ]) - ->columns(2), - ]), ]); } - - private static function buildTimeline(Tenant $tenant): array - { - $events = [ - static::timelineEntry( - __('admin.tenants.timeline.created'), - __('admin.tenants.timeline.created_details'), - $tenant->created_at, - 'success' - ), - ]; - - if ($tenant->last_activity_at) { - $events[] = static::timelineEntry( - __('admin.tenants.timeline.last_activity'), - __('admin.tenants.timeline.last_activity_details'), - $tenant->last_activity_at, - 'info' - ); - } - - if ($tenant->deletion_warning_sent_at) { - $events[] = static::timelineEntry( - __('admin.tenants.timeline.deletion_warning'), - __('admin.tenants.timeline.deletion_warning_details'), - $tenant->deletion_warning_sent_at, - 'warning' - ); - } - - if ($tenant->pending_deletion_at) { - $events[] = static::timelineEntry( - __('admin.tenants.timeline.deletion_scheduled'), - __('admin.tenants.timeline.deletion_scheduled_details'), - $tenant->pending_deletion_at, - 'warning' - ); - } - - if ($tenant->anonymized_at) { - $events[] = static::timelineEntry( - __('admin.tenants.timeline.anonymized'), - __('admin.tenants.timeline.anonymized_details'), - $tenant->anonymized_at, - 'danger' - ); - } - - $logs = $tenant->notificationLogs() - ->latest('sent_at') - ->limit(10) - ->get(); - - foreach ($logs as $log) { - $status = $log->status === 'failed' ? 'danger' : 'info'; - $eventTitle = $log->status === 'failed' - ? __('admin.tenants.timeline.notification_failed') - : __('admin.tenants.timeline.notification_sent'); - - $details = collect([ - Str::headline($log->type), - $log->channel ? Str::upper($log->channel) : null, - $log->recipient, - $log->failure_reason ? 'reason: '.$log->failure_reason : null, - ])->filter()->implode(' - '); - - $events[] = static::timelineEntry( - $eventTitle, - $details, - $log->sent_at ?? $log->failed_at ?? $log->created_at, - $status - ); - } - - return collect($events) - ->filter(fn (array $event) => $event['occurred_at'] !== null) - ->sortByDesc('occurred_at') - ->values() - ->all(); - } - - private static function timelineEntry(string $title, ?string $details, $occurredAt, string $tone): array - { - return [ - 'title' => $title, - 'details' => $details, - 'occurred_at' => $occurredAt, - 'tone' => $tone, - ]; - } } diff --git a/app/Filament/Resources/TenantResource/Schemas/TenantLifecycleInfolist.php b/app/Filament/Resources/TenantResource/Schemas/TenantLifecycleInfolist.php new file mode 100644 index 0000000..6b587a8 --- /dev/null +++ b/app/Filament/Resources/TenantResource/Schemas/TenantLifecycleInfolist.php @@ -0,0 +1,391 @@ +> + */ + private static array $usageCache = []; + + public static function configure(Schema $schema): Schema + { + return $schema->components([ + Section::make(__('admin.tenants.sections.lifecycle')) + ->columns(3) + ->schema([ + TextEntry::make('access_status') + ->label(__('admin.tenants.fields.access_status')) + ->badge() + ->color(fn (Tenant $record): string => static::accessStatusTone($record)) + ->state(fn (Tenant $record): string => static::accessStatusLabel($record)), + IconEntry::make('is_active') + ->label(__('admin.tenants.fields.is_active')) + ->boolean(), + IconEntry::make('is_suspended') + ->label(__('admin.tenants.fields.is_suspended')) + ->boolean(), + TextEntry::make('subscription_expires_at') + ->label(__('admin.tenants.fields.subscription_expires_at')) + ->dateTime() + ->placeholder('—'), + TextEntry::make('grace_period_ends_at') + ->label(__('admin.tenants.fields.grace_period_ends_at')) + ->dateTime() + ->placeholder('—'), + TextEntry::make('pending_deletion_at') + ->label(__('admin.tenants.fields.pending_deletion_at')) + ->dateTime() + ->placeholder('—'), + TextEntry::make('deletion_warning_sent_at') + ->label(__('admin.tenants.fields.deletion_warning_sent_at')) + ->dateTime() + ->placeholder('—'), + TextEntry::make('anonymized_at') + ->label(__('admin.tenants.fields.anonymized_at')) + ->dateTime() + ->placeholder('—'), + ]), + Section::make(__('admin.tenants.sections.limits')) + ->columns(3) + ->schema([ + TextEntry::make('max_photos_per_event') + ->label(__('admin.tenants.fields.max_photos_per_event')) + ->state(fn (Tenant $record): string => static::formatLimitValue($record->max_photos_per_event)), + TextEntry::make('max_storage_mb') + ->label(__('admin.tenants.fields.max_storage_mb')) + ->state(fn (Tenant $record): string => static::formatLimitValue($record->max_storage_mb, 'MB')), + TextEntry::make('storage_used_mb') + ->label(__('admin.tenants.fields.storage_used_mb')) + ->state(fn (Tenant $record): string => static::formatStorageValue($record)['used']), + TextEntry::make('storage_remaining_mb') + ->label(__('admin.tenants.fields.storage_remaining_mb')) + ->state(fn (Tenant $record): string => static::formatStorageValue($record)['remaining']), + TextEntry::make('storage_usage_percent') + ->label(__('admin.tenants.fields.storage_usage_percent')) + ->badge() + ->color(fn (Tenant $record): string => static::storageUsageTone($record)) + ->state(fn (Tenant $record): string => static::formatStorageValue($record)['percentage']), + ]), + Section::make(__('admin.tenants.sections.timeline')) + ->schema([ + RepeatableEntry::make('lifecycle_timeline') + ->label(__('admin.tenants.sections.timeline')) + ->state(fn (Tenant $record) => static::buildTimeline($record)) + ->schema([ + TextEntry::make('title') + ->label(__('admin.tenants.timeline.title')) + ->columnSpanFull(), + TextEntry::make('details') + ->label(__('admin.tenants.timeline.details')) + ->columnSpanFull() + ->placeholder('—'), + TextEntry::make('tone') + ->label(__('admin.tenants.timeline.status')) + ->badge() + ->color(fn (?string $state) => $state ?? 'gray') + ->formatStateUsing(fn (?string $state) => $state + ? __('admin.tenants.timeline.tone.'.$state) + : __('admin.tenants.timeline.tone.muted')), + TextEntry::make('occurred_at') + ->label(__('admin.tenants.timeline.occurred_at')) + ->dateTime(), + ]) + ->columns(2), + ]), + ]); + } + + private static function accessStatusLabel(Tenant $tenant): string + { + if ($tenant->anonymized_at) { + return __('admin.tenants.status.anonymized'); + } + + if ($tenant->is_suspended) { + return __('admin.tenants.status.suspended'); + } + + if (! $tenant->is_active) { + return __('admin.tenants.status.inactive'); + } + + if ($tenant->subscription_expires_at && $tenant->subscription_expires_at->isPast()) { + return $tenant->isInGracePeriod() + ? __('admin.tenants.status.grace') + : __('admin.tenants.status.expired'); + } + + return __('admin.tenants.status.active'); + } + + private static function accessStatusTone(Tenant $tenant): string + { + if ($tenant->anonymized_at) { + return 'danger'; + } + + if ($tenant->is_suspended) { + return 'warning'; + } + + if (! $tenant->is_active) { + return 'danger'; + } + + if ($tenant->subscription_expires_at && $tenant->subscription_expires_at->isPast()) { + return $tenant->isInGracePeriod() ? 'warning' : 'danger'; + } + + return 'success'; + } + + private static function formatLimitValue(?int $value, ?string $suffix = null): string + { + if (! $value || $value <= 0) { + return __('admin.tenants.limits.unlimited'); + } + + return $suffix ? $value.' '.$suffix : (string) $value; + } + + /** + * @return array{used: string, remaining: string, percentage: string} + */ + private static function formatStorageValue(Tenant $tenant): array + { + $summary = static::storageSummary($tenant); + + $used = $summary['used_mb'] !== null + ? $summary['used_mb'].' MB' + : '—'; + + $remaining = $summary['remaining_mb'] !== null + ? $summary['remaining_mb'].' MB' + : __('admin.tenants.limits.unlimited'); + + $percentage = $summary['percentage'] !== null + ? $summary['percentage'].'%' + : __('admin.tenants.limits.unlimited'); + + return [ + 'used' => $used, + 'remaining' => $remaining, + 'percentage' => $percentage, + ]; + } + + private static function storageUsageTone(Tenant $tenant): string + { + $summary = static::storageSummary($tenant); + + if ($summary['percentage'] === null) { + return 'gray'; + } + + if ($summary['percentage'] >= 95) { + return 'danger'; + } + + if ($summary['percentage'] >= 80) { + return 'warning'; + } + + return 'success'; + } + + /** + * @return array + */ + private static function storageSummary(Tenant $tenant): array + { + if (isset(static::$usageCache[$tenant->getKey()])) { + return static::$usageCache[$tenant->getKey()]; + } + + $summary = app(TenantUsageService::class)->storageSummary($tenant); + + static::$usageCache[$tenant->getKey()] = $summary; + + return $summary; + } + + private static function buildTimeline(Tenant $tenant): array + { + $events = [ + static::timelineEntry( + __('admin.tenants.timeline.created'), + __('admin.tenants.timeline.created_details'), + $tenant->created_at, + 'success' + ), + ]; + + if ($tenant->last_activity_at) { + $events[] = static::timelineEntry( + __('admin.tenants.timeline.last_activity'), + __('admin.tenants.timeline.last_activity_details'), + $tenant->last_activity_at, + 'info' + ); + } + + if ($tenant->deletion_warning_sent_at) { + $events[] = static::timelineEntry( + __('admin.tenants.timeline.deletion_warning'), + __('admin.tenants.timeline.deletion_warning_details'), + $tenant->deletion_warning_sent_at, + 'warning' + ); + } + + if ($tenant->pending_deletion_at) { + $events[] = static::timelineEntry( + __('admin.tenants.timeline.deletion_scheduled'), + __('admin.tenants.timeline.deletion_scheduled_details'), + $tenant->pending_deletion_at, + 'warning' + ); + } + + if ($tenant->anonymized_at) { + $events[] = static::timelineEntry( + __('admin.tenants.timeline.anonymized'), + __('admin.tenants.timeline.anonymized_details'), + $tenant->anonymized_at, + 'danger' + ); + } + + $lifecycleEvents = $tenant->lifecycleEvents() + ->with('actor') + ->latest('occurred_at') + ->limit(25) + ->get(); + + foreach ($lifecycleEvents as $event) { + $events[] = static::timelineEntry( + static::lifecycleTitle($event), + static::lifecycleDetails($event), + $event->occurred_at ?? $event->created_at, + static::lifecycleTone($event) + ); + } + + $logs = $tenant->notificationLogs() + ->latest('sent_at') + ->limit(10) + ->get(); + + foreach ($logs as $log) { + $status = $log->status === 'failed' ? 'danger' : 'info'; + $eventTitle = $log->status === 'failed' + ? __('admin.tenants.timeline.notification_failed') + : __('admin.tenants.timeline.notification_sent'); + + $details = collect([ + Str::headline($log->type), + $log->channel ? Str::upper($log->channel) : null, + $log->recipient, + $log->failure_reason ? 'reason: '.$log->failure_reason : null, + ])->filter()->implode(' - '); + + $events[] = static::timelineEntry( + $eventTitle, + $details, + $log->sent_at ?? $log->failed_at ?? $log->created_at, + $status + ); + } + + return collect($events) + ->filter(fn (array $event) => $event['occurred_at'] !== null) + ->sortByDesc('occurred_at') + ->values() + ->all(); + } + + private static function lifecycleTitle(TenantLifecycleEvent $event): string + { + $key = 'admin.tenants.timeline.events.'.$event->type; + + return __($key) !== $key ? __($key) : Str::headline($event->type); + } + + private static function lifecycleTone(TenantLifecycleEvent $event): string + { + return match ($event->type) { + 'activated' => 'success', + 'unsuspended' => 'success', + 'deactivated' => 'danger', + 'suspended' => 'warning', + 'anonymize_requested' => 'danger', + 'deletion_scheduled' => 'warning', + 'deletion_cancelled' => 'info', + 'grace_period_set' => 'warning', + 'grace_period_cleared' => 'info', + 'limits_updated' => 'info', + 'subscription_expires_at_updated' => 'info', + default => 'info', + }; + } + + private static function lifecycleDetails(TenantLifecycleEvent $event): ?string + { + $payload = is_array($event->payload) ? $event->payload : []; + $details = []; + + if (! empty($payload['note'])) { + $details[] = (string) $payload['note']; + } + + if (isset($payload['grace_period_ends_at'])) { + $details[] = __('admin.tenants.timeline.grace_period_until', [ + 'date' => (string) $payload['grace_period_ends_at'], + ]); + } + + if (isset($payload['before'], $payload['after']) && is_array($payload['before']) && is_array($payload['after'])) { + foreach (['max_photos_per_event', 'max_storage_mb', 'subscription_expires_at'] as $field) { + if (! array_key_exists($field, $payload['before']) && ! array_key_exists($field, $payload['after'])) { + continue; + } + + $before = $payload['before'][$field] ?? '—'; + $after = $payload['after'][$field] ?? '—'; + $label = __('admin.tenants.fields.'.$field); + + $details[] = $label.': '.$before.' -> '.$after; + } + } + + if ($event->actor) { + $details[] = __('admin.tenants.timeline.by', [ + 'name' => $event->actor->getFilamentName(), + ]); + } + + return empty($details) ? null : implode(' - ', $details); + } + + private static function timelineEntry(string $title, ?string $details, $occurredAt, string $tone): array + { + return [ + 'title' => $title, + 'details' => $details, + 'occurred_at' => $occurredAt, + 'tone' => $tone, + ]; + } +} diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index fde946b..6cb3b4c 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -23,8 +23,8 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; -use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; class PhotoController extends Controller { @@ -265,20 +265,6 @@ class PhotoController extends Controller ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) : null; - if ($tenant) { - $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event); - - if ($violation !== null) { - return ApiError::response( - $violation['code'], - $violation['title'], - $violation['message'], - $violation['status'], - $violation['meta'] - ); - } - } - $previousUsedPhotos = $eventPackage?->used_photos ?? 0; $validated = $request->validated(); @@ -304,6 +290,25 @@ class PhotoController extends Controller ]); } + if ($tenant) { + $violation = $this->packageLimitEvaluator->assessPhotoUpload( + $tenant, + $event->id, + $event, + $file->getSize() + ); + + if ($violation !== null) { + return ApiError::response( + $violation['code'], + $violation['title'], + $violation['message'], + $violation['status'], + $violation['meta'] + ); + } + } + // Determine storage target $disk = $this->eventStorageManager->getHotDiskForEvent($event); diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index b3eb918..8b32603 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -31,8 +31,11 @@ class Tenant extends Model 'total_revenue' => 'decimal:2', 'settings_updated_at' => 'datetime', 'subscription_expires_at' => 'datetime', + 'grace_period_ends_at' => 'datetime', 'credit_warning_sent_at' => 'datetime', 'credit_warning_threshold' => 'integer', + 'max_photos_per_event' => 'integer', + 'max_storage_mb' => 'integer', ]; public function events(): HasMany @@ -84,6 +87,11 @@ class Tenant extends Model return $this->hasMany(TenantNotificationLog::class); } + public function lifecycleEvents(): HasMany + { + return $this->hasMany(TenantLifecycleEvent::class); + } + public function canCreateEvent(): bool { return $this->hasEventAllowance(); @@ -174,4 +182,20 @@ class Tenant extends Model { return $this->belongsTo(User::class); } + + public function getStorageQuotaAttribute(): int + { + $limitMb = (int) ($this->max_storage_mb ?? 0); + + return max(0, $limitMb) * 1024 * 1024; + } + + public function isInGracePeriod(): bool + { + if (! $this->grace_period_ends_at) { + return false; + } + + return now()->lessThanOrEqualTo($this->grace_period_ends_at); + } } diff --git a/app/Models/TenantLifecycleEvent.php b/app/Models/TenantLifecycleEvent.php new file mode 100644 index 0000000..56c5cb0 --- /dev/null +++ b/app/Models/TenantLifecycleEvent.php @@ -0,0 +1,29 @@ + 'array', + 'occurred_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } +} diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index b77a78a..64c449b 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -5,9 +5,12 @@ namespace App\Services\Packages; use App\Models\Event; use App\Models\EventPackage; use App\Models\Tenant; +use App\Services\Tenant\TenantUsageService; class PackageLimitEvaluator { + public function __construct(private readonly TenantUsageService $tenantUsageService) {} + public function assessEventCreation(Tenant $tenant): ?array { $hasEndcustomerPackage = $tenant->tenantPackages() @@ -60,8 +63,12 @@ class PackageLimitEvaluator ]; } - public function assessPhotoUpload(Tenant $tenant, int $eventId, ?Event $preloadedEvent = null): ?array - { + public function assessPhotoUpload( + Tenant $tenant, + int $eventId, + ?Event $preloadedEvent = null, + ?int $incomingBytes = null + ): ?array { [$event, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent); if (! $event) { @@ -92,11 +99,7 @@ class PackageLimitEvaluator $maxPhotos = $eventPackage->effectivePhotoLimit(); - if ($maxPhotos === null) { - return null; - } - - if ($eventPackage->used_photos >= $maxPhotos) { + if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) { return [ 'code' => 'photo_limit_exceeded', 'title' => 'Photo upload limit reached', @@ -113,6 +116,51 @@ class PackageLimitEvaluator ]; } + $tenantPhotoLimit = $this->normalizeTenantLimit($tenant->max_photos_per_event); + + if ($tenantPhotoLimit !== null && ($maxPhotos === null || $tenantPhotoLimit < $maxPhotos)) { + if ($eventPackage->used_photos >= $tenantPhotoLimit) { + return [ + 'code' => 'tenant_photo_limit_exceeded', + 'title' => 'Tenant photo limit reached', + 'message' => 'This tenant has reached its photo allowance for the event.', + 'status' => 402, + 'meta' => [ + 'scope' => 'photos', + 'used' => (int) $eventPackage->used_photos, + 'limit' => (int) $tenantPhotoLimit, + 'remaining' => max(0, (int) $tenantPhotoLimit - (int) $eventPackage->used_photos), + 'event_id' => $event->id, + 'limit_source' => 'tenant', + ], + ]; + } + } + + $storageLimitBytes = $this->tenantUsageService->storageLimitBytes($tenant); + + if ($storageLimitBytes !== null) { + $usedBytes = $this->tenantUsageService->storageUsedBytes($tenant); + $projectedBytes = $usedBytes + max(0, (int) ($incomingBytes ?? 0)); + + if ($projectedBytes >= $storageLimitBytes) { + return [ + 'code' => 'tenant_storage_limit_exceeded', + 'title' => 'Tenant storage limit reached', + 'message' => 'This tenant has reached its storage allowance.', + 'status' => 402, + 'meta' => [ + 'scope' => 'storage', + 'used_bytes' => $usedBytes, + 'limit_bytes' => $storageLimitBytes, + 'remaining_bytes' => max(0, $storageLimitBytes - $usedBytes), + 'event_id' => $event->id, + 'limit_source' => 'tenant', + ], + ]; + } + } + return null; } @@ -307,4 +355,19 @@ class PackageLimitEvaluator 'expired_notified_at' => $eventPackage->gallery_expired_notified_at?->toIso8601String(), ]; } + + private function normalizeTenantLimit(?int $value): ?int + { + if ($value === null) { + return null; + } + + $value = (int) $value; + + if ($value <= 0) { + return null; + } + + return $value; + } } diff --git a/app/Services/Tenant/TenantLifecycleLogger.php b/app/Services/Tenant/TenantLifecycleLogger.php new file mode 100644 index 0000000..17a9c1c --- /dev/null +++ b/app/Services/Tenant/TenantLifecycleLogger.php @@ -0,0 +1,27 @@ + $tenant->getKey(), + 'actor_id' => $actor?->getKey(), + 'type' => $type, + 'payload' => $payload, + 'occurred_at' => $occurredAt ?? now(), + ]); + } +} diff --git a/app/Services/Tenant/TenantUsageService.php b/app/Services/Tenant/TenantUsageService.php new file mode 100644 index 0000000..ac1342e --- /dev/null +++ b/app/Services/Tenant/TenantUsageService.php @@ -0,0 +1,59 @@ +storageLimitBytes($tenant); + $usedBytes = $this->storageUsedBytes($tenant); + $remainingBytes = $limitBytes !== null + ? max(0, $limitBytes - $usedBytes) + : null; + + return [ + 'used_bytes' => $usedBytes, + 'limit_bytes' => $limitBytes, + 'remaining_bytes' => $remainingBytes, + 'used_mb' => $this->bytesToMegabytes($usedBytes), + 'limit_mb' => $limitBytes !== null ? $this->bytesToMegabytes($limitBytes) : null, + 'remaining_mb' => $remainingBytes !== null ? $this->bytesToMegabytes($remainingBytes) : null, + 'percentage' => $limitBytes !== null && $limitBytes > 0 + ? round(min(1, $usedBytes / $limitBytes) * 100, 1) + : null, + ]; + } + + public function storageLimitBytes(Tenant $tenant): ?int + { + $limitMb = $tenant->max_storage_mb; + + if ($limitMb === null) { + return null; + } + + $limitMb = (int) $limitMb; + + if ($limitMb <= 0) { + return null; + } + + return $limitMb * 1024 * 1024; + } + + public function storageUsedBytes(Tenant $tenant): int + { + return (int) EventMediaAsset::query() + ->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->getKey())) + ->sum('size_bytes'); + } + + private function bytesToMegabytes(int $bytes): float + { + return round($bytes / (1024 * 1024), 2); + } +} diff --git a/database/migrations/2026_01_01_191109_create_tenant_lifecycle_events_table.php b/database/migrations/2026_01_01_191109_create_tenant_lifecycle_events_table.php new file mode 100644 index 0000000..b622cf5 --- /dev/null +++ b/database/migrations/2026_01_01_191109_create_tenant_lifecycle_events_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('type'); + $table->json('payload')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'occurred_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_lifecycle_events'); + } +}; diff --git a/database/migrations/2026_01_01_191128_add_grace_period_ends_at_to_tenants_table.php b/database/migrations/2026_01_01_191128_add_grace_period_ends_at_to_tenants_table.php new file mode 100644 index 0000000..fbba0e0 --- /dev/null +++ b/database/migrations/2026_01_01_191128_add_grace_period_ends_at_to_tenants_table.php @@ -0,0 +1,32 @@ +timestamp('grace_period_ends_at')->nullable(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + if (Schema::hasColumn('tenants', 'grace_period_ends_at')) { + $table->dropColumn('grace_period_ends_at'); + } + }); + } +}; diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index f0ea6fd..7aa6f6c 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -390,11 +390,19 @@ return [ 'deletion_warning_sent_at' => 'Loeschwarnung gesendet', 'anonymized_at' => 'Anonymisiert am', 'subscription_expires_at' => 'Ablaufdatum Abo', + 'grace_period_ends_at' => 'Grace‑Periode endet', + 'max_photos_per_event' => 'Max. Fotos pro Event', + 'max_storage_mb' => 'Max. Speicher (MB)', + 'storage_used_mb' => 'Speicher belegt', + 'storage_remaining_mb' => 'Speicher verbleibend', + 'storage_usage_percent' => 'Speicherauslastung', 'owner' => 'Eigentuemer', + 'access_status' => 'Zugriffsstatus', ], 'sections' => [ 'profile' => 'Profil', 'lifecycle' => 'Lebenszyklus', + 'limits' => 'Limits', 'timeline' => 'Audit Timeline', ], 'actions' => [ @@ -418,6 +426,28 @@ return [ 'send_warning' => 'Warnung per E-Mail senden', 'send_warning_missing_title' => 'Keine Kontakt-E-Mail', 'send_warning_missing_body' => 'Es ist keine E-Mail-Adresse hinterlegt, daher konnte keine Warnung gesendet werden.', + 'update_limits' => 'Limits aktualisieren', + 'update_subscription_expires_at' => 'Abo-Ablauf aktualisieren', + 'set_grace_period' => 'Grace-Periode setzen', + 'clear_grace_period' => 'Grace-Periode entfernen', + 'lifecycle_controls' => 'Lebenszyklus-Steuerung', + 'note' => 'Interne Notiz', + ], + 'pages' => [ + 'overview' => 'Uebersicht', + 'lifecycle' => 'Lebenszyklus', + 'edit' => 'Bearbeiten', + ], + 'status' => [ + 'active' => 'Aktiv', + 'inactive' => 'Inaktiv', + 'suspended' => 'Suspendiert', + 'expired' => 'Abgelaufen', + 'grace' => 'Grace-Periode', + 'anonymized' => 'Anonymisiert', + ], + 'limits' => [ + 'unlimited' => 'Unbegrenzt', ], 'timeline' => [ 'title' => 'Ereignis', @@ -436,6 +466,21 @@ return [ 'anonymized_details' => 'Mandantendaten wurden anonymisiert.', 'notification_sent' => 'Benachrichtigung gesendet', 'notification_failed' => 'Benachrichtigung fehlgeschlagen', + 'grace_period_until' => 'Grace bis :date', + 'by' => 'Von :name', + 'events' => [ + 'activated' => 'Mandant aktiviert', + 'deactivated' => 'Mandant deaktiviert', + 'suspended' => 'Mandant suspendiert', + 'unsuspended' => 'Suspendierung aufgehoben', + 'deletion_scheduled' => 'Loeschung geplant', + 'deletion_cancelled' => 'Loeschung aufgehoben', + 'anonymize_requested' => 'Anonymisierung angestoßen', + 'grace_period_set' => 'Grace-Periode gesetzt', + 'grace_period_cleared' => 'Grace-Periode entfernt', + 'limits_updated' => 'Limits aktualisiert', + 'subscription_expires_at_updated' => 'Abo-Ablauf aktualisiert', + ], 'tone' => [ 'success' => 'Erfolg', 'warning' => 'Warnung', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 4c959a3..30eb644 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -376,11 +376,19 @@ return [ 'deletion_warning_sent_at' => 'Deletion warning sent at', 'anonymized_at' => 'Anonymized at', 'subscription_expires_at' => 'Subscription expires at', + 'grace_period_ends_at' => 'Grace period ends at', + 'max_photos_per_event' => 'Max photos per event', + 'max_storage_mb' => 'Max storage (MB)', + 'storage_used_mb' => 'Storage used', + 'storage_remaining_mb' => 'Storage remaining', + 'storage_usage_percent' => 'Storage usage', 'owner' => 'Owner', + 'access_status' => 'Access status', ], 'sections' => [ 'profile' => 'Profile', 'lifecycle' => 'Lifecycle', + 'limits' => 'Limits', 'timeline' => 'Audit timeline', ], 'actions' => [ @@ -404,6 +412,28 @@ return [ 'send_warning' => 'Send warning email', 'send_warning_missing_title' => 'No contact email available', 'send_warning_missing_body' => 'No email address is available to send the warning.', + 'update_limits' => 'Update limits', + 'update_subscription_expires_at' => 'Update subscription expiry', + 'set_grace_period' => 'Set grace period', + 'clear_grace_period' => 'Clear grace period', + 'lifecycle_controls' => 'Lifecycle controls', + 'note' => 'Internal note', + ], + 'pages' => [ + 'overview' => 'Overview', + 'lifecycle' => 'Lifecycle', + 'edit' => 'Edit', + ], + 'status' => [ + 'active' => 'Active', + 'inactive' => 'Inactive', + 'suspended' => 'Suspended', + 'expired' => 'Expired', + 'grace' => 'Grace period', + 'anonymized' => 'Anonymized', + ], + 'limits' => [ + 'unlimited' => 'Unlimited', ], 'timeline' => [ 'title' => 'Event', @@ -422,6 +452,21 @@ return [ 'anonymized_details' => 'Tenant data anonymized.', 'notification_sent' => 'Notification sent', 'notification_failed' => 'Notification failed', + 'grace_period_until' => 'Grace until :date', + 'by' => 'By :name', + 'events' => [ + 'activated' => 'Tenant activated', + 'deactivated' => 'Tenant deactivated', + 'suspended' => 'Tenant suspended', + 'unsuspended' => 'Tenant unsuspended', + 'deletion_scheduled' => 'Deletion scheduled', + 'deletion_cancelled' => 'Deletion cancelled', + 'anonymize_requested' => 'Anonymization requested', + 'grace_period_set' => 'Grace period set', + 'grace_period_cleared' => 'Grace period cleared', + 'limits_updated' => 'Limits updated', + 'subscription_expires_at_updated' => 'Subscription expiry updated', + ], 'tone' => [ 'success' => 'Success', 'warning' => 'Warning', diff --git a/tests/Feature/TenantLifecycleActionsTest.php b/tests/Feature/TenantLifecycleActionsTest.php index 52b0e54..ba1808c 100644 --- a/tests/Feature/TenantLifecycleActionsTest.php +++ b/tests/Feature/TenantLifecycleActionsTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Jobs\AnonymizeAccount; use App\Models\Tenant; +use App\Models\TenantLifecycleEvent; use App\Models\User; use Filament\Actions\Testing\TestAction; use Filament\Facades\Filament; @@ -45,6 +46,10 @@ class TenantLifecycleActionsTest extends TestCase $plannedDeletion->toDateTimeString(), $tenant->pending_deletion_at?->toDateTimeString() ); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'deletion_scheduled') + ->exists()); Livewire::test(ListTenants::class) ->callAction(TestAction::make('cancel_deletion')->table($tenant)); @@ -53,6 +58,10 @@ class TenantLifecycleActionsTest extends TestCase $this->assertNull($tenant->pending_deletion_at); $this->assertNull($tenant->deletion_warning_sent_at); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'deletion_cancelled') + ->exists()); } public function test_superadmin_can_toggle_tenant_status_flags(): void @@ -70,24 +79,40 @@ class TenantLifecycleActionsTest extends TestCase $tenant->refresh(); $this->assertFalse((bool) $tenant->is_active); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'deactivated') + ->exists()); Livewire::test(ListTenants::class) ->callAction(TestAction::make('activate')->table($tenant)); $tenant->refresh(); $this->assertTrue((bool) $tenant->is_active); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'activated') + ->exists()); Livewire::test(ListTenants::class) ->callAction(TestAction::make('suspend')->table($tenant)); $tenant->refresh(); $this->assertTrue((bool) $tenant->is_suspended); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'suspended') + ->exists()); Livewire::test(ListTenants::class) ->callAction(TestAction::make('unsuspend')->table($tenant)); $tenant->refresh(); $this->assertFalse((bool) $tenant->is_suspended); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'unsuspended') + ->exists()); } public function test_superadmin_can_dispatch_tenant_anonymization(): void @@ -105,6 +130,10 @@ class TenantLifecycleActionsTest extends TestCase Queue::assertPushed(AnonymizeAccount::class, function (AnonymizeAccount $job) use ($tenant) { return $job->tenantId() === $tenant->id; }); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'anonymize_requested') + ->exists()); } private function bootSuperAdminPanel(User $user): void diff --git a/tests/Feature/TenantLifecycleManagementTest.php b/tests/Feature/TenantLifecycleManagementTest.php new file mode 100644 index 0000000..9550868 --- /dev/null +++ b/tests/Feature/TenantLifecycleManagementTest.php @@ -0,0 +1,121 @@ +create(['role' => 'super_admin']); + $tenant = Tenant::factory()->create([ + 'max_photos_per_event' => 500, + 'max_storage_mb' => 1024, + ]); + + $this->bootSuperAdminPanel($user); + + Livewire::test(ViewTenantLifecycle::class, ['record' => $tenant->id]) + ->callAction(TestAction::make('update_limits'), [ + 'max_photos_per_event' => 750, + 'max_storage_mb' => 2048, + 'note' => 'adjusted for onboarding', + ]); + + $tenant->refresh(); + + $this->assertSame(750, $tenant->max_photos_per_event); + $this->assertSame(2048, $tenant->max_storage_mb); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'limits_updated') + ->exists()); + } + + public function test_superadmin_can_set_and_clear_grace_period(): void + { + $user = User::factory()->create(['role' => 'super_admin']); + $tenant = Tenant::factory()->create(); + + $this->bootSuperAdminPanel($user); + + $graceUntil = now()->addDays(14)->startOfDay(); + + Livewire::test(ViewTenantLifecycle::class, ['record' => $tenant->id]) + ->callAction(TestAction::make('set_grace_period'), [ + 'grace_period_ends_at' => $graceUntil->toDateTimeString(), + 'note' => 'billing exception', + ]); + + $tenant->refresh(); + + $this->assertSame( + $graceUntil->toDateTimeString(), + $tenant->grace_period_ends_at?->toDateTimeString() + ); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'grace_period_set') + ->exists()); + + Livewire::test(ViewTenantLifecycle::class, ['record' => $tenant->id]) + ->callAction(TestAction::make('clear_grace_period')); + + $tenant->refresh(); + + $this->assertNull($tenant->grace_period_ends_at); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'grace_period_cleared') + ->exists()); + } + + public function test_superadmin_can_update_subscription_expiry(): void + { + $user = User::factory()->create(['role' => 'super_admin']); + $tenant = Tenant::factory()->create(); + + $this->bootSuperAdminPanel($user); + + $expiresAt = now()->addMonths(3)->startOfDay(); + + Livewire::test(ViewTenantLifecycle::class, ['record' => $tenant->id]) + ->callAction(TestAction::make('update_subscription_expires_at'), [ + 'subscription_expires_at' => $expiresAt->toDateTimeString(), + 'note' => 'manual extension', + ]); + + $tenant->refresh(); + + $this->assertSame( + $expiresAt->toDateTimeString(), + $tenant->subscription_expires_at?->toDateTimeString() + ); + $this->assertTrue(TenantLifecycleEvent::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'subscription_expires_at_updated') + ->exists()); + } + + private function bootSuperAdminPanel(User $user): void + { + $panel = Filament::getPanel('superadmin'); + + $this->assertNotNull($panel); + + Filament::setCurrentPanel($panel); + Filament::bootCurrentPanel(); + Filament::auth()->login($user); + } +} diff --git a/tests/Feature/TenantLifecycleViewTest.php b/tests/Feature/TenantLifecycleViewTest.php index 33bde60..1908501 100644 --- a/tests/Feature/TenantLifecycleViewTest.php +++ b/tests/Feature/TenantLifecycleViewTest.php @@ -34,7 +34,7 @@ class TenantLifecycleViewTest extends TestCase $this->actingAs($user, 'super_admin'); - $url = TenantResource::getUrl('view', ['record' => $tenant], panel: 'superadmin'); + $url = TenantResource::getUrl('lifecycle', ['record' => $tenant], panel: 'superadmin'); $this->get($url) ->assertOk() diff --git a/tests/Feature/TenantLimitEnforcementTest.php b/tests/Feature/TenantLimitEnforcementTest.php new file mode 100644 index 0000000..d09b1ff --- /dev/null +++ b/tests/Feature/TenantLimitEnforcementTest.php @@ -0,0 +1,93 @@ +create([ + 'max_photos_per_event' => 2, + 'max_storage_mb' => 0, + ]); + $event = Event::factory()->create(['tenant_id' => $tenant->id]); + $package = Package::factory()->create([ + 'max_photos' => 10, + 'type' => 'endcustomer', + ]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => 0, + 'purchased_at' => now(), + 'used_photos' => 2, + ]); + + $violation = app(PackageLimitEvaluator::class) + ->assessPhotoUpload($tenant, $event->id, $event); + + $this->assertNotNull($violation); + $this->assertSame('tenant_photo_limit_exceeded', $violation['code']); + } + + public function test_photo_upload_blocks_when_tenant_storage_limit_reached(): void + { + $tenant = Tenant::factory()->create([ + 'max_photos_per_event' => 0, + 'max_storage_mb' => 1, + ]); + $event = Event::factory()->create(['tenant_id' => $tenant->id]); + $package = Package::factory()->create([ + 'max_photos' => 10, + 'type' => 'endcustomer', + ]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => 0, + 'purchased_at' => now(), + 'used_photos' => 0, + ]); + + $storageTarget = MediaStorageTarget::create([ + 'key' => 'local', + 'name' => 'Local Storage', + 'driver' => 'local', + 'config' => [], + 'is_hot' => true, + 'is_default' => true, + 'is_active' => true, + 'priority' => 1, + ]); + + EventMediaAsset::create([ + 'event_id' => $event->id, + 'media_storage_target_id' => $storageTarget->id, + 'variant' => 'original', + 'disk' => 'local', + 'path' => 'events/'.$event->id.'/photos/test.jpg', + 'size_bytes' => 1024 * 1024, + 'status' => 'hot', + ]); + + $violation = app(PackageLimitEvaluator::class) + ->assessPhotoUpload($tenant, $event->id, $event, 0); + + $this->assertNotNull($violation); + $this->assertSame('tenant_storage_limit_exceeded', $violation['code']); + } +}