Add tenant lifecycle view and limit controls
This commit is contained in:
@@ -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)"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-hbt
|
fotospiel-app-wde
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/Models/TenantLifecycleEvent.php
Normal file
29
app/Models/TenantLifecycleEvent.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/Services/Tenant/TenantLifecycleLogger.php
Normal file
27
app/Services/Tenant/TenantLifecycleLogger.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Services/Tenant/TenantUsageService.php
Normal file
59
app/Services/Tenant/TenantUsageService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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' => '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',
|
'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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
121
tests/Feature/TenantLifecycleManagementTest.php
Normal file
121
tests/Feature/TenantLifecycleManagementTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
93
tests/Feature/TenantLimitEnforcementTest.php
Normal file
93
tests/Feature/TenantLimitEnforcementTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user