TenantAtlas/app/Filament/Resources/BackupScheduleResource.php
2026-01-18 14:44:16 +01:00

1057 lines
46 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Models\User;
use App\Rules\SupportedPolicyTypesRule;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\TenantRole;
use BackedEnum;
use Carbon\CarbonImmutable;
use DateTimeZone;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class BackupScheduleResource extends Resource
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
protected static function currentTenantRole(): ?TenantRole
{
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
return $user->tenantRole(Tenant::current());
}
public static function canViewAny(): bool
{
return static::currentTenantRole() !== null;
}
public static function canView(Model $record): bool
{
return static::currentTenantRole() !== null;
}
public static function canCreate(): bool
{
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
}
public static function canEdit(Model $record): bool
{
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
}
public static function canDelete(Model $record): bool
{
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
}
public static function canDeleteAny(): bool
{
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('name')
->label('Schedule Name')
->required()
->maxLength(255),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
Select::make('timezone')
->label('Timezone')
->options(static::timezoneOptions())
->searchable()
->default('UTC')
->required(),
Select::make('frequency')
->label('Frequency')
->options([
'daily' => 'Daily',
'weekly' => 'Weekly',
])
->default('daily')
->required()
->reactive(),
TextInput::make('time_of_day')
->label('Time of day')
->type('time')
->required()
->extraInputAttributes(['step' => 60]),
CheckboxList::make('days_of_week')
->label('Days of the week')
->options(static::dayOfWeekOptions())
->columns(2)
->visible(fn (Get $get): bool => $get('frequency') === 'weekly')
->required(fn (Get $get): bool => $get('frequency') === 'weekly')
->rules(['array', 'min:1']),
CheckboxList::make('policy_types')
->label('Policy types')
->options(static::policyTypeOptions())
->columns(2)
->required()
->helperText('Select the Microsoft Graph policy types that should be included in each run.')
->rules([
'array',
'min:1',
new SupportedPolicyTypesRule,
])
->columnSpanFull(),
Toggle::make('include_foundations')
->label('Include foundation types')
->default(true),
TextInput::make('retention_keep_last')
->label('Retention (keep last N Backup Sets)')
->type('number')
->default(30)
->minValue(1)
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('next_run_at', 'asc')
->columns([
IconColumn::make('is_enabled')
->label('Enabled')
->boolean()
->alignCenter(),
TextColumn::make('name')
->searchable()
->label('Schedule'),
TextColumn::make('frequency')
->label('Frequency')
->badge()
->formatStateUsing(fn (?string $state): string => match ($state) {
'daily' => 'Daily',
'weekly' => 'Weekly',
default => (string) $state,
})
->color(fn (?string $state): string => match ($state) {
'daily' => 'success',
'weekly' => 'warning',
default => 'gray',
}),
TextColumn::make('time_of_day')
->label('Time')
->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null),
TextColumn::make('timezone')
->label('Timezone'),
TextColumn::make('policy_types')
->label('Policy types')
->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record))
->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)),
TextColumn::make('retention_keep_last')
->label('Retention')
->suffix(' sets'),
TextColumn::make('last_run_status')
->label('Last run status')
->badge()
->formatStateUsing(fn (?string $state): string => match ($state) {
BackupScheduleRun::STATUS_RUNNING => 'Running',
BackupScheduleRun::STATUS_SUCCESS => 'Success',
BackupScheduleRun::STATUS_PARTIAL => 'Partial',
BackupScheduleRun::STATUS_FAILED => 'Failed',
BackupScheduleRun::STATUS_CANCELED => 'Canceled',
BackupScheduleRun::STATUS_SKIPPED => 'Skipped',
default => $state ? Str::headline($state) : '—',
})
->color(fn (?string $state): string => match ($state) {
BackupScheduleRun::STATUS_SUCCESS => 'success',
BackupScheduleRun::STATUS_PARTIAL => 'warning',
BackupScheduleRun::STATUS_RUNNING => 'primary',
BackupScheduleRun::STATUS_SKIPPED => 'gray',
BackupScheduleRun::STATUS_FAILED,
BackupScheduleRun::STATUS_CANCELED => 'danger',
default => 'gray',
}),
TextColumn::make('last_run_at')
->label('Last run')
->dateTime()
->sortable(),
TextColumn::make('next_run_at')
->label('Next run')
->getStateUsing(function (BackupSchedule $record): ?string {
$nextRun = $record->next_run_at;
if (! $nextRun) {
return null;
}
$timezone = $record->timezone ?: 'UTC';
try {
return $nextRun->setTimezone($timezone)->format('M j, Y H:i:s');
} catch (\Throwable) {
return $nextRun->format('M j, Y H:i:s');
}
})
->sortable(),
])
->filters([
SelectFilter::make('enabled_state')
->label('Enabled')
->options([
'enabled' => 'Enabled',
'disabled' => 'Disabled',
])
->query(function (Builder $query, array $data): void {
$value = $data['value'] ?? null;
if (blank($value)) {
return;
}
if ($value === 'enabled') {
$query->where('is_enabled', true);
return;
}
if ($value === 'disabled') {
$query->where('is_enabled', false);
}
}),
])
->actions([
ActionGroup::make([
Action::make('runNow')
->label('Run now')
->icon('heroicon-o-play')
->color('success')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (BackupSchedule $record, HasTable $livewire): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
$tenant = Tenant::current();
$user = auth()->user();
$userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: [
'backup_schedule_id' => (int) $record->getKey(),
],
initiator: $userModel
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Run already queued')
->body('This schedule already has a queued or running backup.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
return;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Run already queued')
->body('Please wait a moment and try again.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule run.',
],
],
);
return;
}
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'run_now',
],
],
);
$bulkRunId = null;
if ($userModel instanceof User) {
$bulkRunId = app(BulkOperationService::class)
->createRun(
tenant: $tenant,
user: $userModel,
resource: 'backup_schedule',
action: 'run',
itemIds: [(string) $record->id],
totalItems: 1,
)
->id;
}
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $operationRun->type)
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
}),
Action::make('retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (BackupSchedule $record, HasTable $livewire): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
$tenant = Tenant::current();
$user = auth()->user();
$userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.retry',
inputs: [
'backup_schedule_id' => (int) $record->getKey(),
],
initiator: $userModel
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Retry already queued')
->body('This schedule already has a queued or running retry.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
return;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Retry already queued')
->body('Please wait a moment and try again.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule retry run.',
],
],
);
return;
}
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'retry',
],
],
);
$bulkRunId = null;
if ($userModel instanceof User) {
$bulkRunId = app(BulkOperationService::class)
->createRun(
tenant: $tenant,
user: $userModel,
resource: 'backup_schedule',
action: 'retry',
itemIds: [(string) $record->id],
totalItems: 1,
)
->id;
}
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $operationRun->type)
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
}),
EditAction::make()
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
DeleteAction::make()
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_run_now')
->label('Run now')
->icon('heroicon-o-play')
->color('success')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (Collection $records, HasTable $livewire): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
if ($records->isEmpty()) {
return;
}
$tenant = Tenant::current();
$userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null;
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$bulkRun = null;
if ($user) {
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_schedule',
action: 'run',
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
totalItems: $records->count(),
);
}
$createdRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: [
'backup_schedule_id' => (int) $record->getKey(),
],
initiator: $user
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
continue;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule run.',
],
],
);
continue;
}
$createdRunIds[] = (int) $run->id;
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_run_now',
'bulk_run_id' => $bulkRun?->id,
],
],
);
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
});
}
$notification = Notification::make()
->title('Runs dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
if (count($createdRunIds) === 0) {
$notification->warning();
} else {
$notification->success();
}
if ($user instanceof User) {
$notification->actions([
Action::make('view_runs')
->label('View in Operations')
->url(OperationRunLinks::index($tenant)),
])->sendToDatabase($user);
}
$notification->send();
if (count($createdRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
}),
BulkAction::make('bulk_retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (Collection $records, HasTable $livewire): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
if ($records->isEmpty()) {
return;
}
$tenant = Tenant::current();
$userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null;
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$bulkRun = null;
if ($user) {
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_schedule',
action: 'retry',
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
totalItems: $records->count(),
);
}
$createdRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.retry',
inputs: [
'backup_schedule_id' => (int) $record->getKey(),
],
initiator: $user
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
continue;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule retry run.',
],
],
);
continue;
}
$createdRunIds[] = (int) $run->id;
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_retry',
'bulk_run_id' => $bulkRun?->id,
],
],
);
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
});
}
$notification = Notification::make()
->title('Retries dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
if (count($createdRunIds) === 0) {
$notification->warning();
} else {
$notification->success();
}
if ($user instanceof User) {
$notification->actions([
Action::make('view_runs')
->label('View in Operations')
->url(OperationRunLinks::index($tenant)),
])->sendToDatabase($user);
}
$notification->send();
if (count($createdRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
}),
DeleteBulkAction::make('bulk_delete')
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->where('tenant_id', $tenantId)
->orderByDesc('is_enabled')
->orderBy('next_run_at');
}
public static function getRelations(): array
{
return [
BackupScheduleRunsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBackupSchedules::route('/'),
'create' => Pages\CreateBackupSchedule::route('/create'),
'edit' => Pages\EditBackupSchedule::route('/{record}/edit'),
];
}
public static function policyTypesFullLabel(BackupSchedule $record): string
{
$labels = static::policyTypesLabels($record);
return $labels === [] ? 'None' : implode(', ', $labels);
}
public static function policyTypesPreviewLabel(BackupSchedule $record): string
{
$labels = static::policyTypesLabels($record);
if ($labels === []) {
return 'None';
}
$preview = array_slice($labels, 0, 2);
$remaining = count($labels) - count($preview);
$label = implode(', ', $preview);
if ($remaining > 0) {
$label .= sprintf(' +%d more', $remaining);
}
return $label;
}
/**
* @return array<int, string>
*/
private static function policyTypesLabels(BackupSchedule $record): array
{
$state = $record->policy_types;
if (is_string($state)) {
$decoded = json_decode($state, true);
if (is_array($decoded)) {
$state = $decoded;
}
}
if ($state instanceof \Illuminate\Contracts\Support\Arrayable) {
$state = $state->toArray();
}
if (blank($state) || (! is_array($state))) {
return [];
}
$types = array_is_list($state)
? $state
: array_keys(array_filter($state));
$types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''));
if ($types === []) {
return [];
}
$labelMap = collect(config('tenantpilot.supported_policy_types', []))
->mapWithKeys(fn (array $policy): array => [
(string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))),
])
->filter(fn (string $label, string $type): bool => $type !== '')
->all();
return array_map(
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
$types,
);
}
public static function ensurePolicyTypes(array $data): array
{
$types = array_values((array) ($data['policy_types'] ?? []));
try {
app(PolicyTypeResolver::class)->ensureSupported($types);
} catch (InvalidPolicyTypeException $exception) {
throw ValidationException::withMessages([
'policy_types' => [sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))],
]);
}
$data['policy_types'] = $types;
return $data;
}
public static function assignTenant(array $data): array
{
$data['tenant_id'] = Tenant::current()->getKey();
return $data;
}
public static function hydrateNextRun(array $data): array
{
if (! empty($data['time_of_day'])) {
$data['time_of_day'] = static::normalizeTimeOfDay($data['time_of_day']);
}
$schedule = new BackupSchedule;
$schedule->forceFill([
'frequency' => $data['frequency'] ?? 'daily',
'time_of_day' => $data['time_of_day'] ?? '00:00:00',
'timezone' => $data['timezone'] ?? 'UTC',
'days_of_week' => (array) ($data['days_of_week'] ?? []),
]);
$nextRun = app(ScheduleTimeService::class)->nextRunFor($schedule);
$data['next_run_at'] = $nextRun?->toDateTimeString();
return $data;
}
public static function normalizeTimeOfDay(string $time): string
{
if (preg_match('/^\d{2}:\d{2}$/', $time)) {
return $time.':00';
}
return $time;
}
protected static function timezoneOptions(): array
{
$zones = DateTimeZone::listIdentifiers();
sort($zones);
return array_combine($zones, $zones);
}
protected static function policyTypeOptions(): array
{
return static::policyTypeLabelMap();
}
protected static function policyTypeLabels(array $types): array
{
$map = static::policyTypeLabelMap();
return array_map(fn (string $type): string => $map[$type] ?? Str::headline($type), $types);
}
protected static function policyTypeLabelMap(): array
{
return collect(config('tenantpilot.supported_policy_types', []))
->mapWithKeys(fn (array $policy) => [
$policy['type'] => $policy['label'] ?? Str::headline($policy['type']),
])
->all();
}
protected static function dayOfWeekOptions(): array
{
return [
1 => 'Monday',
2 => 'Tuesday',
3 => 'Wednesday',
4 => 'Thursday',
5 => 'Friday',
6 => 'Saturday',
7 => 'Sunday',
];
}
}