Add tenant lifecycle view and limit controls
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-01 19:36:51 +01:00
parent 117250879b
commit da06db2d3b
22 changed files with 1312 additions and 148 deletions

View File

@@ -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-vk4","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:01.574904029+01:00","created_by":"soeren","updated_at":"2026-01-01T16:11:18.65499639+01:00","closed_at":"2026-01-01T16:11:18.65499639+01:00","close_reason":"Duplicate of fotospiel-app-l6a"}
{"id":"fotospiel-app-w2x","title":"SEC-FE-03 Cookie banner UX + localisation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:26.182193434+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:31.84344419+01:00","closed_at":"2026-01-01T15:55:31.84344419+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-w2x","title":"SEC-FE-03 Cookie banner UX + localisation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:26.182193434+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:31.84344419+01:00","closed_at":"2026-01-01T15:55:31.84344419+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-w7g","title":"Paddle catalog sync: document failure recovery playbook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:26.255623751+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:26.255623751+01:00"} {"id":"fotospiel-app-w7g","title":"Paddle catalog sync: document failure recovery playbook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:26.255623751+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:26.255623751+01:00"}
{"id":"fotospiel-app-wde","title":"Tenant lifecycle controls (status, limits, suspend/grace)","description":"Superadmin controls for tenant status, grace periods, and hard limits (uploads/storage/events). Includes UI, policy checks, and audit events.","notes":"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-wkl","title":"Paddle catalog sync: paddle:sync-packages command (dry-run/pull)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:58.753792575+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:04.39629062+01:00","closed_at":"2026-01-01T16:01:04.39629062+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"} {"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"}
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}

View File

@@ -1 +1 @@
fotospiel-app-hbt fotospiel-app-wde

View File

@@ -10,6 +10,7 @@ use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
use App\Jobs\AnonymizeAccount; use App\Jobs\AnonymizeAccount;
use App\Models\Tenant; use App\Models\Tenant;
use App\Notifications\InactiveTenantDeletionWarning; use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Tenant\TenantLifecycleLogger;
use BackedEnum; use BackedEnum;
use Carbon\Carbon; use Carbon\Carbon;
use Filament\Actions; use Filament\Actions;
@@ -20,6 +21,8 @@ use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Enums\SubNavigationPosition;
use Filament\Resources\Pages\Page;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
@@ -38,6 +41,8 @@ class TenantResource extends Resource
protected static UnitEnum|string|null $navigationGroup = null; protected static UnitEnum|string|null $navigationGroup = null;
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top;
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.tenants'); return __('admin.nav.tenants');
@@ -105,6 +110,15 @@ class TenantResource extends Resource
return TenantInfolist::configure($schema); 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 public static function table(Table $table): Table
{ {
@@ -220,6 +234,7 @@ class TenantResource extends Resource
return [ return [
'index' => Pages\ListTenants::route('/'), 'index' => Pages\ListTenants::route('/'),
'view' => Pages\ViewTenant::route('/{record}'), 'view' => Pages\ViewTenant::route('/{record}'),
'lifecycle' => Pages\ViewTenantLifecycle::route('/{record}/lifecycle'),
'edit' => Pages\EditTenant::route('/{record}/edit'), 'edit' => Pages\EditTenant::route('/{record}/edit'),
]; ];
} }
@@ -245,7 +260,17 @@ class TenantResource extends Resource
->color('success') ->color('success')
->visible(fn (Tenant $record): bool => ! $record->is_active && ! $record->anonymized_at) ->visible(fn (Tenant $record): bool => ! $record->is_active && ! $record->anonymized_at)
->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) ->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') Actions\Action::make('deactivate')
->label(__('admin.tenants.actions.deactivate')) ->label(__('admin.tenants.actions.deactivate'))
->icon('heroicon-o-no-symbol') ->icon('heroicon-o-no-symbol')
@@ -253,7 +278,17 @@ class TenantResource extends Resource
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Tenant $record): bool => (bool) $record->is_active && ! $record->anonymized_at) ->visible(fn (Tenant $record): bool => (bool) $record->is_active && ! $record->anonymized_at)
->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) ->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') Actions\Action::make('suspend')
->label(__('admin.tenants.actions.suspend')) ->label(__('admin.tenants.actions.suspend'))
->icon('heroicon-o-pause-circle') ->icon('heroicon-o-pause-circle')
@@ -261,14 +296,34 @@ class TenantResource extends Resource
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->is_suspended && ! $record->anonymized_at) ->visible(fn (Tenant $record): bool => ! $record->is_suspended && ! $record->anonymized_at)
->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('suspend', $record) ?? false) ->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') Actions\Action::make('unsuspend')
->label(__('admin.tenants.actions.unsuspend')) ->label(__('admin.tenants.actions.unsuspend'))
->icon('heroicon-o-play-circle') ->icon('heroicon-o-play-circle')
->color('success') ->color('success')
->visible(fn (Tenant $record): bool => (bool) $record->is_suspended && ! $record->anonymized_at) ->visible(fn (Tenant $record): bool => (bool) $record->is_suspended && ! $record->anonymized_at)
->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('suspend', $record) ?? false) ->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') Actions\Action::make('schedule_deletion')
->label(__('admin.tenants.actions.schedule_deletion')) ->label(__('admin.tenants.actions.schedule_deletion'))
->icon('heroicon-o-calendar-days') ->icon('heroicon-o-calendar-days')
@@ -309,6 +364,16 @@ class TenantResource extends Resource
} }
$record->forceFill($update)->save(); $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')), ->successNotificationTitle(__('admin.tenants.actions.schedule_deletion_success')),
Actions\Action::make('cancel_deletion') 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) ->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) ->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false)
->action(function (Tenant $record): void { ->action(function (Tenant $record): void {
$previous = $record->pending_deletion_at?->toDateTimeString();
$record->forceFill([ $record->forceFill([
'pending_deletion_at' => null, 'pending_deletion_at' => null,
'deletion_warning_sent_at' => null, 'deletion_warning_sent_at' => null,
])->save(); ])->save();
app(TenantLifecycleLogger::class)->record(
$record,
'deletion_cancelled',
[
'pending_deletion_at' => $previous,
],
Filament::auth()->user()
);
}) })
->successNotificationTitle(__('admin.tenants.actions.cancel_deletion_success')), ->successNotificationTitle(__('admin.tenants.actions.cancel_deletion_success')),
Actions\Action::make('anonymize_now') Actions\Action::make('anonymize_now')
@@ -331,8 +407,160 @@ class TenantResource extends Resource
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->anonymized_at) ->visible(fn (Tenant $record): bool => ! $record->anonymized_at)
->authorize(fn (Tenant $record): bool => Filament::auth()->user()?->can('update', $record) ?? false) ->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')), ->successNotificationTitle(__('admin.tenants.actions.anonymize_success')),
]; ];
} }
/**
* @return array<int, Actions\Action>
*/
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()
);
}),
];
}
} }

View File

@@ -8,4 +8,9 @@ use Filament\Resources\Pages\EditRecord;
class EditTenant extends EditRecord class EditTenant extends EditRecord
{ {
protected static string $resource = TenantResource::class; protected static string $resource = TenantResource::class;
public static function getNavigationLabel(): string
{
return __('admin.tenants.pages.edit');
}
} }

View File

@@ -10,6 +10,16 @@ class ViewTenant extends ViewRecord
{ {
protected static string $resource = TenantResource::class; 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 protected function getHeaderActions(): array
{ {
return [ return [

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantResource\Schemas\TenantLifecycleInfolist;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Schema;
class ViewTenantLifecycle extends ViewRecord
{
protected static string $resource = TenantResource::class;
public static function getNavigationLabel(): string
{
return __('admin.tenants.pages.lifecycle');
}
public function getTitle(): string
{
return __('admin.tenants.pages.lifecycle');
}
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
Actions\ActionGroup::make(TenantResource::lifecycleActions())
->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);
}
}

View File

@@ -4,11 +4,9 @@ namespace App\Filament\Resources\TenantResource\Schemas;
use App\Models\Tenant; use App\Models\Tenant;
use Filament\Infolists\Components\IconEntry; use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class TenantInfolist class TenantInfolist
{ {
@@ -67,122 +65,6 @@ class TenantInfolist
->dateTime() ->dateTime()
->placeholder('—'), ->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,
];
}
} }

View File

@@ -0,0 +1,391 @@
<?php
namespace App\Filament\Resources\TenantResource\Schemas;
use App\Models\Tenant;
use App\Models\TenantLifecycleEvent;
use App\Services\Tenant\TenantUsageService;
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 TenantLifecycleInfolist
{
/**
* @var array<int, array<string, mixed>>
*/
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<string, float|int|null>
*/
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,
];
}
}

View File

@@ -23,8 +23,8 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PhotoController extends Controller class PhotoController extends Controller
{ {
@@ -265,20 +265,6 @@ class PhotoController extends Controller
? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event)
: null; : 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; $previousUsedPhotos = $eventPackage?->used_photos ?? 0;
$validated = $request->validated(); $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 // Determine storage target
$disk = $this->eventStorageManager->getHotDiskForEvent($event); $disk = $this->eventStorageManager->getHotDiskForEvent($event);

View File

@@ -31,8 +31,11 @@ class Tenant extends Model
'total_revenue' => 'decimal:2', 'total_revenue' => 'decimal:2',
'settings_updated_at' => 'datetime', 'settings_updated_at' => 'datetime',
'subscription_expires_at' => 'datetime', 'subscription_expires_at' => 'datetime',
'grace_period_ends_at' => 'datetime',
'credit_warning_sent_at' => 'datetime', 'credit_warning_sent_at' => 'datetime',
'credit_warning_threshold' => 'integer', 'credit_warning_threshold' => 'integer',
'max_photos_per_event' => 'integer',
'max_storage_mb' => 'integer',
]; ];
public function events(): HasMany public function events(): HasMany
@@ -84,6 +87,11 @@ class Tenant extends Model
return $this->hasMany(TenantNotificationLog::class); return $this->hasMany(TenantNotificationLog::class);
} }
public function lifecycleEvents(): HasMany
{
return $this->hasMany(TenantLifecycleEvent::class);
}
public function canCreateEvent(): bool public function canCreateEvent(): bool
{ {
return $this->hasEventAllowance(); return $this->hasEventAllowance();
@@ -174,4 +182,20 @@ class Tenant extends Model
{ {
return $this->belongsTo(User::class); 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);
}
} }

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantLifecycleEvent extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'payload' => 'array',
'occurred_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
}

View File

@@ -5,9 +5,12 @@ namespace App\Services\Packages;
use App\Models\Event; use App\Models\Event;
use App\Models\EventPackage; use App\Models\EventPackage;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Tenant\TenantUsageService;
class PackageLimitEvaluator class PackageLimitEvaluator
{ {
public function __construct(private readonly TenantUsageService $tenantUsageService) {}
public function assessEventCreation(Tenant $tenant): ?array public function assessEventCreation(Tenant $tenant): ?array
{ {
$hasEndcustomerPackage = $tenant->tenantPackages() $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); [$event, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent);
if (! $event) { if (! $event) {
@@ -92,11 +99,7 @@ class PackageLimitEvaluator
$maxPhotos = $eventPackage->effectivePhotoLimit(); $maxPhotos = $eventPackage->effectivePhotoLimit();
if ($maxPhotos === null) { if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) {
return null;
}
if ($eventPackage->used_photos >= $maxPhotos) {
return [ return [
'code' => 'photo_limit_exceeded', 'code' => 'photo_limit_exceeded',
'title' => 'Photo upload limit reached', '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; return null;
} }
@@ -307,4 +355,19 @@ class PackageLimitEvaluator
'expired_notified_at' => $eventPackage->gallery_expired_notified_at?->toIso8601String(), '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;
}
} }

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services\Tenant;
use App\Models\Tenant;
use App\Models\TenantLifecycleEvent;
use App\Models\User;
use Carbon\CarbonInterface;
class TenantLifecycleLogger
{
public function record(
Tenant $tenant,
string $type,
array $payload = [],
?User $actor = null,
?CarbonInterface $occurredAt = null
): TenantLifecycleEvent {
return TenantLifecycleEvent::create([
'tenant_id' => $tenant->getKey(),
'actor_id' => $actor?->getKey(),
'type' => $type,
'payload' => $payload,
'occurred_at' => $occurredAt ?? now(),
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Services\Tenant;
use App\Models\EventMediaAsset;
use App\Models\Tenant;
class TenantUsageService
{
public function storageSummary(Tenant $tenant): array
{
$limitBytes = $this->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);
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_lifecycle_events', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
if (! Schema::hasColumn('tenants', 'grace_period_ends_at')) {
$table->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');
}
});
}
};

View File

@@ -390,11 +390,19 @@ return [
'deletion_warning_sent_at' => 'Loeschwarnung gesendet', 'deletion_warning_sent_at' => 'Loeschwarnung gesendet',
'anonymized_at' => 'Anonymisiert am', 'anonymized_at' => 'Anonymisiert am',
'subscription_expires_at' => 'Ablaufdatum Abo', 'subscription_expires_at' => 'Ablaufdatum Abo',
'grace_period_ends_at' => 'GracePeriode 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', 'owner' => 'Eigentuemer',
'access_status' => 'Zugriffsstatus',
], ],
'sections' => [ 'sections' => [
'profile' => 'Profil', 'profile' => 'Profil',
'lifecycle' => 'Lebenszyklus', 'lifecycle' => 'Lebenszyklus',
'limits' => 'Limits',
'timeline' => 'Audit Timeline', 'timeline' => 'Audit Timeline',
], ],
'actions' => [ 'actions' => [
@@ -418,6 +426,28 @@ return [
'send_warning' => 'Warnung per E-Mail senden', 'send_warning' => 'Warnung per E-Mail senden',
'send_warning_missing_title' => 'Keine Kontakt-E-Mail', '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.', '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' => [ 'timeline' => [
'title' => 'Ereignis', 'title' => 'Ereignis',
@@ -436,6 +466,21 @@ return [
'anonymized_details' => 'Mandantendaten wurden anonymisiert.', 'anonymized_details' => 'Mandantendaten wurden anonymisiert.',
'notification_sent' => 'Benachrichtigung gesendet', 'notification_sent' => 'Benachrichtigung gesendet',
'notification_failed' => 'Benachrichtigung fehlgeschlagen', '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' => [ 'tone' => [
'success' => 'Erfolg', 'success' => 'Erfolg',
'warning' => 'Warnung', 'warning' => 'Warnung',

View File

@@ -376,11 +376,19 @@ return [
'deletion_warning_sent_at' => 'Deletion warning sent at', 'deletion_warning_sent_at' => 'Deletion warning sent at',
'anonymized_at' => 'Anonymized at', 'anonymized_at' => 'Anonymized at',
'subscription_expires_at' => 'Subscription expires 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', 'owner' => 'Owner',
'access_status' => 'Access status',
], ],
'sections' => [ 'sections' => [
'profile' => 'Profile', 'profile' => 'Profile',
'lifecycle' => 'Lifecycle', 'lifecycle' => 'Lifecycle',
'limits' => 'Limits',
'timeline' => 'Audit timeline', 'timeline' => 'Audit timeline',
], ],
'actions' => [ 'actions' => [
@@ -404,6 +412,28 @@ return [
'send_warning' => 'Send warning email', 'send_warning' => 'Send warning email',
'send_warning_missing_title' => 'No contact email available', 'send_warning_missing_title' => 'No contact email available',
'send_warning_missing_body' => 'No email address is available to send the warning.', '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' => [ 'timeline' => [
'title' => 'Event', 'title' => 'Event',
@@ -422,6 +452,21 @@ return [
'anonymized_details' => 'Tenant data anonymized.', 'anonymized_details' => 'Tenant data anonymized.',
'notification_sent' => 'Notification sent', 'notification_sent' => 'Notification sent',
'notification_failed' => 'Notification failed', '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' => [ 'tone' => [
'success' => 'Success', 'success' => 'Success',
'warning' => 'Warning', 'warning' => 'Warning',

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Jobs\AnonymizeAccount; use App\Jobs\AnonymizeAccount;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantLifecycleEvent;
use App\Models\User; use App\Models\User;
use Filament\Actions\Testing\TestAction; use Filament\Actions\Testing\TestAction;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@@ -45,6 +46,10 @@ class TenantLifecycleActionsTest extends TestCase
$plannedDeletion->toDateTimeString(), $plannedDeletion->toDateTimeString(),
$tenant->pending_deletion_at?->toDateTimeString() $tenant->pending_deletion_at?->toDateTimeString()
); );
$this->assertTrue(TenantLifecycleEvent::query()
->where('tenant_id', $tenant->id)
->where('type', 'deletion_scheduled')
->exists());
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
->callAction(TestAction::make('cancel_deletion')->table($tenant)); ->callAction(TestAction::make('cancel_deletion')->table($tenant));
@@ -53,6 +58,10 @@ class TenantLifecycleActionsTest extends TestCase
$this->assertNull($tenant->pending_deletion_at); $this->assertNull($tenant->pending_deletion_at);
$this->assertNull($tenant->deletion_warning_sent_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 public function test_superadmin_can_toggle_tenant_status_flags(): void
@@ -70,24 +79,40 @@ class TenantLifecycleActionsTest extends TestCase
$tenant->refresh(); $tenant->refresh();
$this->assertFalse((bool) $tenant->is_active); $this->assertFalse((bool) $tenant->is_active);
$this->assertTrue(TenantLifecycleEvent::query()
->where('tenant_id', $tenant->id)
->where('type', 'deactivated')
->exists());
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
->callAction(TestAction::make('activate')->table($tenant)); ->callAction(TestAction::make('activate')->table($tenant));
$tenant->refresh(); $tenant->refresh();
$this->assertTrue((bool) $tenant->is_active); $this->assertTrue((bool) $tenant->is_active);
$this->assertTrue(TenantLifecycleEvent::query()
->where('tenant_id', $tenant->id)
->where('type', 'activated')
->exists());
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
->callAction(TestAction::make('suspend')->table($tenant)); ->callAction(TestAction::make('suspend')->table($tenant));
$tenant->refresh(); $tenant->refresh();
$this->assertTrue((bool) $tenant->is_suspended); $this->assertTrue((bool) $tenant->is_suspended);
$this->assertTrue(TenantLifecycleEvent::query()
->where('tenant_id', $tenant->id)
->where('type', 'suspended')
->exists());
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
->callAction(TestAction::make('unsuspend')->table($tenant)); ->callAction(TestAction::make('unsuspend')->table($tenant));
$tenant->refresh(); $tenant->refresh();
$this->assertFalse((bool) $tenant->is_suspended); $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 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) { Queue::assertPushed(AnonymizeAccount::class, function (AnonymizeAccount $job) use ($tenant) {
return $job->tenantId() === $tenant->id; 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 private function bootSuperAdminPanel(User $user): void

View File

@@ -0,0 +1,121 @@
<?php
namespace Tests\Feature;
use App\Filament\Resources\TenantResource\Pages\ViewTenantLifecycle;
use App\Models\Tenant;
use App\Models\TenantLifecycleEvent;
use App\Models\User;
use Filament\Actions\Testing\TestAction;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TenantLifecycleManagementTest extends TestCase
{
use RefreshDatabase;
public function test_superadmin_can_update_limits(): void
{
$user = User::factory()->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);
}
}

View File

@@ -34,7 +34,7 @@ class TenantLifecycleViewTest extends TestCase
$this->actingAs($user, 'super_admin'); $this->actingAs($user, 'super_admin');
$url = TenantResource::getUrl('view', ['record' => $tenant], panel: 'superadmin'); $url = TenantResource::getUrl('lifecycle', ['record' => $tenant], panel: 'superadmin');
$this->get($url) $this->get($url)
->assertOk() ->assertOk()

View File

@@ -0,0 +1,93 @@
<?php
namespace Tests\Feature;
use App\Models\Event;
use App\Models\EventMediaAsset;
use App\Models\EventPackage;
use App\Models\MediaStorageTarget;
use App\Models\Package;
use App\Models\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantLimitEnforcementTest extends TestCase
{
use RefreshDatabase;
public function test_photo_upload_blocks_when_tenant_photo_limit_reached(): void
{
$tenant = Tenant::factory()->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']);
}
}