feat/032-backup-scheduling-mvp #36

Merged
ahmido merged 14 commits from feat/032-backup-scheduling-mvp into dev 2026-01-07 01:12:13 +00:00
41 changed files with 3001 additions and 50 deletions
Showing only changes of commit 0b77c99975 - Show all commits

7
.gitignore vendored
View File

@ -13,6 +13,9 @@
/.zed
/auth.json
/node_modules
dist/
build/
coverage/
/public/build
/public/hot
/public/storage
@ -22,4 +25,6 @@
Homestead.json
Homestead.yaml
Thumbs.db
/references
/references
*.tmp
*.swp

View File

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Services\BackupScheduling\BackupScheduleDispatcher;
use Illuminate\Console\Command;
class TenantpilotDispatchBackupSchedules extends Command
{
protected $signature = 'tenantpilot:schedules:dispatch {--tenant=* : Limit to tenant_id/external_id}';
protected $description = 'Dispatch due backup schedules (idempotent per schedule minute-slot).';
public function handle(BackupScheduleDispatcher $dispatcher): int
{
$tenantIdentifiers = (array) $this->option('tenant');
$result = $dispatcher->dispatchDue($tenantIdentifiers);
$this->info(sprintf(
'Scanned %d schedule(s), created %d run(s), skipped %d duplicate run(s).',
$result['scanned_schedules'],
$result['created_runs'],
$result['skipped_runs'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class InvalidPolicyTypeException extends RuntimeException
{
public array $unknownPolicyTypes;
public function __construct(array $unknownPolicyTypes)
{
$this->unknownPolicyTypes = array_values($unknownPolicyTypes);
parent::__construct('Unknown policy types: '.implode(', ', $this->unknownPolicyTypes));
}
}

View File

@ -0,0 +1,737 @@
<?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\Notifications\BackupScheduleRunDispatchedNotification;
use App\Rules\SupportedPolicyTypesRule;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\Intune\AuditLogger;
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\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
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')
->wrap()
->getStateUsing(function (BackupSchedule $record): string {
$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 (! is_array($state)) {
return 'None';
}
$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 'None';
}
$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();
$labels = array_map(
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
$types,
);
return implode(', ', $labels);
}),
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')
->dateTime()
->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): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
$tenant = Tenant::current();
$user = auth()->user();
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
for ($i = 0; $i < 5; $i++) {
$exists = BackupScheduleRun::query()
->where('backup_schedule_id', $record->id)
->where('scheduled_for', $scheduledFor)
->exists();
if (! $exists) {
break;
}
$scheduledFor = $scheduledFor->addMinute();
}
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
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',
],
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id));
if ($user instanceof User) {
$user->notify(new BackupScheduleRunDispatchedNotification([
'tenant_id' => (int) $tenant->getKey(),
'backup_schedule_id' => (int) $record->id,
'backup_schedule_run_id' => (int) $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'run_now',
]));
}
Notification::make()
->title('Run dispatched')
->body('The backup run has been queued.')
->success()
->send();
}),
Action::make('retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (BackupSchedule $record): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
$tenant = Tenant::current();
$user = auth()->user();
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
for ($i = 0; $i < 5; $i++) {
$exists = BackupScheduleRun::query()
->where('backup_schedule_id', $record->id)
->where('scheduled_for', $scheduledFor)
->exists();
if (! $exists) {
break;
}
$scheduledFor = $scheduledFor->addMinute();
}
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
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',
],
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id));
if ($user instanceof User) {
$user->notify(new BackupScheduleRunDispatchedNotification([
'tenant_id' => (int) $tenant->getKey(),
'backup_schedule_id' => (int) $record->id,
'backup_schedule_run_id' => (int) $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'retry',
]));
}
Notification::make()
->title('Retry dispatched')
->body('A new backup run has been queued.')
->success()
->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): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
if ($records->isEmpty()) {
return;
}
$tenant = Tenant::current();
$user = auth()->user();
$createdRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
for ($i = 0; $i < 5; $i++) {
$exists = BackupScheduleRun::query()
->where('backup_schedule_id', $record->id)
->where('scheduled_for', $scheduledFor)
->exists();
if (! $exists) {
break;
}
$scheduledFor = $scheduledFor->addMinute();
}
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
$createdRunIds[] = (int) $run->id;
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',
],
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id));
}
if ($user instanceof User) {
$user->notify(new BackupScheduleRunDispatchedNotification([
'tenant_id' => (int) $tenant->getKey(),
'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
'backup_schedule_run_ids' => $createdRunIds,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
'trigger' => 'bulk_run_now',
]));
}
Notification::make()
->title('Runs dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
->success()
->send();
}),
BulkAction::make('bulk_retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (Collection $records): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
if ($records->isEmpty()) {
return;
}
$tenant = Tenant::current();
$user = auth()->user();
$createdRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
for ($i = 0; $i < 5; $i++) {
$exists = BackupScheduleRun::query()
->where('backup_schedule_id', $record->id)
->where('scheduled_for', $scheduledFor)
->exists();
if (! $exists) {
break;
}
$scheduledFor = $scheduledFor->addMinute();
}
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
$createdRunIds[] = (int) $run->id;
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',
],
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id));
}
if ($user instanceof User) {
$user->notify(new BackupScheduleRunDispatchedNotification([
'tenant_id' => (int) $tenant->getKey(),
'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
'backup_schedule_run_ids' => $createdRunIds,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
'trigger' => 'bulk_retry',
]));
}
Notification::make()
->title('Retries dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
->success()
->send();
}),
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 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',
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBackupSchedule extends CreateRecord
{
protected static string $resource = BackupScheduleResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data = BackupScheduleResource::ensurePolicyTypes($data);
$data = BackupScheduleResource::assignTenant($data);
return BackupScheduleResource::hydrateNextRun($data);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\EditRecord;
class EditBackupSchedule extends EditRecord
{
protected static string $resource = BackupScheduleResource::class;
protected function mutateFormDataBeforeSave(array $data): array
{
$data = BackupScheduleResource::ensurePolicyTypes($data);
return BackupScheduleResource::hydrateNextRun($data);
}
}

View File

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

View File

@ -0,0 +1,110 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
class BackupScheduleRunsRelationManager extends RelationManager
{
protected static string $relationship = 'runs';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')
->label('Scheduled for')
->dateTime(),
Tables\Columns\TextColumn::make('status')
->badge()
->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',
}),
Tables\Columns\TextColumn::make('duration')
->label('Duration')
->getStateUsing(function (BackupScheduleRun $record): string {
if (! $record->started_at || ! $record->finished_at) {
return '—';
}
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
if ($seconds < 60) {
return $seconds.'s';
}
$minutes = intdiv($seconds, 60);
$rem = $seconds % 60;
return sprintf('%dm %ds', $minutes, $rem);
}),
Tables\Columns\TextColumn::make('counts')
->label('Counts')
->getStateUsing(function (BackupScheduleRun $record): string {
$summary = is_array($record->summary) ? $record->summary : [];
$total = (int) ($summary['policies_total'] ?? 0);
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
$errors = (int) ($summary['errors_count'] ?? 0);
if ($total === 0 && $backedUp === 0 && $errors === 0) {
return '—';
}
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
}),
Tables\Columns\TextColumn::make('error_code')
->label('Error')
->badge()
->default('—'),
Tables\Columns\TextColumn::make('error_message')
->label('Message')
->default('—')
->limit(80)
->wrap(),
Tables\Columns\TextColumn::make('backup_set_id')
->label('Backup set')
->default('—')
->url(function (BackupScheduleRun $record): ?string {
if (! $record->backup_set_id) {
return null;
}
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
})
->openUrlInNewTab(true),
])
->filters([])
->headerActions([])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->modalHeading('View backup schedule run')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (BackupScheduleRun $record): View {
return view('filament.modals.backup-schedule-run-view', [
'run' => $record,
]);
}),
])
->bulkActions([]);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Jobs;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Services\Intune\AuditLogger;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
class ApplyBackupScheduleRetentionJob implements ShouldQueue
{
use Queueable;
public function __construct(public int $backupScheduleId) {}
public function handle(AuditLogger $auditLogger): void
{
$schedule = BackupSchedule::query()
->with('tenant')
->find($this->backupScheduleId);
if (! $schedule || ! $schedule->tenant) {
return;
}
$keepLast = (int) ($schedule->retention_keep_last ?? 30);
if ($keepLast < 1) {
$keepLast = 1;
}
/** @var Collection<int, int> $keepBackupSetIds */
$keepBackupSetIds = BackupScheduleRun::query()
->where('backup_schedule_id', $schedule->id)
->whereNotNull('backup_set_id')
->orderByDesc('scheduled_for')
->limit($keepLast)
->pluck('backup_set_id')
->filter()
->values();
/** @var Collection<int, int> $deleteBackupSetIds */
$deleteBackupSetIds = BackupScheduleRun::query()
->where('backup_schedule_id', $schedule->id)
->whereNotNull('backup_set_id')
->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all()))
->pluck('backup_set_id')
->filter()
->unique()
->values();
if ($deleteBackupSetIds->isEmpty()) {
$auditLogger->log(
tenant: $schedule->tenant,
action: 'backup_schedule.retention_applied',
resourceType: 'backup_schedule',
resourceId: (string) $schedule->id,
status: 'success',
context: [
'metadata' => [
'keep_last' => $keepLast,
'deleted_backup_sets' => 0,
],
],
);
return;
}
$deletedCount = 0;
BackupSet::query()
->where('tenant_id', $schedule->tenant_id)
->whereIn('id', $deleteBackupSetIds->all())
->whereNull('deleted_at')
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
foreach ($sets as $set) {
$set->delete();
$deletedCount++;
}
});
$auditLogger->log(
tenant: $schedule->tenant,
action: 'backup_schedule.retention_applied',
resourceType: 'backup_schedule',
resourceId: (string) $schedule->id,
status: 'success',
context: [
'metadata' => [
'keep_last' => $keepLast,
'deleted_backup_sets' => $deletedCount,
],
],
);
}
}

View File

@ -0,0 +1,275 @@
<?php
namespace App\Jobs;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
class RunBackupScheduleJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function __construct(public int $backupScheduleRunId) {}
public function handle(
PolicySyncService $policySyncService,
BackupService $backupService,
PolicyTypeResolver $policyTypeResolver,
ScheduleTimeService $scheduleTimeService,
AuditLogger $auditLogger,
RunErrorMapper $errorMapper,
): void {
$run = BackupScheduleRun::query()
->with(['schedule', 'tenant'])
->find($this->backupScheduleRunId);
if (! $run) {
return;
}
$schedule = $run->schedule;
if (! $schedule instanceof BackupSchedule) {
$run->update([
'status' => BackupScheduleRun::STATUS_FAILED,
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
'error_message' => 'Schedule not found.',
'finished_at' => CarbonImmutable::now('UTC'),
]);
return;
}
$tenant = $run->tenant;
if (! $tenant) {
$run->update([
'status' => BackupScheduleRun::STATUS_FAILED,
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
'error_message' => 'Tenant not found.',
'finished_at' => CarbonImmutable::now('UTC'),
]);
return;
}
$lock = Cache::lock("backup_schedule:{$schedule->id}", 900);
if (! $lock->get()) {
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
errorCode: 'CONCURRENT_RUN',
errorMessage: 'Another run is already in progress for this schedule.',
summary: ['reason' => 'concurrent_run'],
scheduleTimeService: $scheduleTimeService,
);
return;
}
try {
$nowUtc = CarbonImmutable::now('UTC');
$run->forceFill([
'started_at' => $run->started_at ?? $nowUtc,
'status' => BackupScheduleRun::STATUS_RUNNING,
])->save();
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_started',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $run->scheduled_for?->toDateTimeString(),
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success'
);
$runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? []));
$validTypes = $runtime['valid'];
$unknownTypes = $runtime['unknown'];
if (empty($validTypes)) {
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
errorCode: 'UNKNOWN_POLICY_TYPE',
errorMessage: 'All configured policy types are unknown.',
summary: [
'unknown_policy_types' => $unknownTypes,
],
scheduleTimeService: $scheduleTimeService,
);
return;
}
$supported = array_values(array_filter(
config('tenantpilot.supported_policy_types', []),
fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true),
));
$syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported);
$policyIds = $syncReport['synced'] ?? [];
$syncFailures = $syncReport['failures'] ?? [];
$backupSet = $backupService->createBackupSet(
tenant: $tenant,
policyIds: $policyIds,
actorEmail: null,
actorName: null,
name: 'Scheduled backup: '.$schedule->name,
includeAssignments: false,
includeScopeTags: false,
includeFoundations: (bool) ($schedule->include_foundations ?? false),
);
$status = match ($backupSet->status) {
'completed' => BackupScheduleRun::STATUS_SUCCESS,
'partial' => BackupScheduleRun::STATUS_PARTIAL,
'failed' => BackupScheduleRun::STATUS_FAILED,
default => BackupScheduleRun::STATUS_SUCCESS,
};
$errorCode = null;
$errorMessage = null;
$summary = [
'policies_total' => count($policyIds),
'policies_backed_up' => (int) ($backupSet->item_count ?? 0),
'sync_failures' => $syncFailures,
];
if (! empty($unknownTypes)) {
$status = BackupScheduleRun::STATUS_PARTIAL;
$errorCode = 'UNKNOWN_POLICY_TYPE';
$errorMessage = 'Some configured policy types are unknown and were skipped.';
$summary['unknown_policy_types'] = $unknownTypes;
}
$this->finishRun(
run: $run,
schedule: $schedule,
status: $status,
errorCode: $errorCode,
errorMessage: $errorMessage,
summary: $summary,
scheduleTimeService: $scheduleTimeService,
backupSetId: (string) $backupSet->id,
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_finished',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'status' => $status,
'error_code' => $errorCode,
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial'
);
} catch (\Throwable $throwable) {
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
if ($mapped['shouldRetry']) {
$this->release($mapped['delay']);
return;
}
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED,
errorCode: $mapped['error_code'],
errorMessage: $mapped['error_message'],
summary: [
'exception' => get_class($throwable),
'attempt' => $attempt,
],
scheduleTimeService: $scheduleTimeService,
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_failed',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'error_code' => $mapped['error_code'],
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'failed'
);
} finally {
optional($lock)->release();
}
}
private function finishRun(
BackupScheduleRun $run,
BackupSchedule $schedule,
string $status,
?string $errorCode,
?string $errorMessage,
array $summary,
ScheduleTimeService $scheduleTimeService,
?string $backupSetId = null,
): void {
$nowUtc = CarbonImmutable::now('UTC');
$run->forceFill([
'status' => $status,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'summary' => Arr::wrap($summary),
'finished_at' => $nowUtc,
'backup_set_id' => $backupSetId,
])->save();
$schedule->forceFill([
'last_run_at' => $nowUtc,
'last_run_status' => $status,
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
}
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BackupSchedule extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'is_enabled' => 'boolean',
'include_foundations' => 'boolean',
'days_of_week' => 'array',
'policy_types' => 'array',
'last_run_at' => 'datetime',
'next_run_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function runs(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BackupScheduleRun extends Model
{
use HasFactory;
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCESS = 'success';
public const STATUS_PARTIAL = 'partial';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELED = 'canceled';
public const STATUS_SKIPPED = 'skipped';
protected $guarded = [];
protected $casts = [
'scheduled_for' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'summary' => 'array',
];
public function schedule(): BelongsTo
{
return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id');
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function backupSet(): BelongsTo
{
return $this->belongsTo(BackupSet::class);
}
}

View File

@ -175,6 +175,16 @@ public function backupSets(): HasMany
return $this->hasMany(BackupSet::class);
}
public function backupSchedules(): HasMany
{
return $this->hasMany(BackupSchedule::class);
}
public function backupScheduleRuns(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
public function policyVersions(): HasMany
{
return $this->hasMany(PolicyVersion::class);

View File

@ -0,0 +1,55 @@
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
class BackupScheduleRunDispatchedNotification extends Notification
{
/**
* @param array{
* tenant_id:int,
* trigger:string,
* scheduled_for:string,
* backup_schedule_id?:int,
* backup_schedule_run_id?:int,
* schedule_ids?:array<int, int>,
* backup_schedule_run_ids?:array<int, int>
* } $metadata
*/
public function __construct(public array $metadata) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
public function toDatabase(object $notifiable): array
{
$trigger = (string) ($this->metadata['trigger'] ?? 'run_now');
$title = match ($trigger) {
'retry' => 'Retry dispatched',
'bulk_retry' => 'Retries dispatched',
'bulk_run_now' => 'Runs dispatched',
default => 'Run dispatched',
};
$body = match ($trigger) {
'bulk_retry', 'bulk_run_now' => 'Backup runs have been queued.',
default => 'A backup run has been queued.',
};
return [
'title' => $title,
'body' => $body,
'metadata' => $this->metadata,
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Policies;
use App\Models\BackupSchedule;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantRole;
use Illuminate\Auth\Access\HandlesAuthorization;
class BackupSchedulePolicy
{
use HandlesAuthorization;
protected function resolveRole(User $user): ?TenantRole
{
$tenant = Tenant::current();
return $user->tenantRole($tenant);
}
public function viewAny(User $user): bool
{
return $this->resolveRole($user) !== null;
}
public function view(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user) !== null;
}
public function create(User $user): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
}
public function update(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
}
public function delete(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
}
}

View File

@ -2,9 +2,11 @@
namespace App\Providers;
use App\Models\BackupSchedule;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Policies\BackupSchedulePolicy;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient;
@ -23,6 +25,7 @@
use App\Services\Intune\WindowsUpdateRingNormalizer;
use Filament\Events\TenantSet;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
@ -102,5 +105,7 @@ public function boot(): void
],
);
});
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Rules;
use App\Exceptions\InvalidPolicyTypeException;
use App\Services\BackupScheduling\PolicyTypeResolver;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class SupportedPolicyTypesRule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$types = array_values((array) $value);
try {
app(PolicyTypeResolver::class)->ensureSupported($types);
} catch (InvalidPolicyTypeException $exception) {
$fail(sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes)));
}
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Services\BackupScheduling;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Bus;
class BackupScheduleDispatcher
{
public function __construct(
private readonly ScheduleTimeService $scheduleTimeService,
private readonly AuditLogger $auditLogger,
) {}
/**
* Dispatch due schedules.
*
* No catch-up policy: we only dispatch if the current minute-slot is due.
*
* @return array{created_runs:int, skipped_runs:int, scanned_schedules:int}
*/
public function dispatchDue(?array $tenantIdentifiers = null): array
{
$nowUtc = CarbonImmutable::now('UTC');
$schedulesQuery = BackupSchedule::query()
->where('is_enabled', true)
->whereHas('tenant', fn ($query) => $query->where('status', 'active'))
->with('tenant');
if (is_array($tenantIdentifiers) && ! empty($tenantIdentifiers)) {
$schedulesQuery->whereIn('tenant_id', $this->resolveTenantIds($tenantIdentifiers));
}
$createdRuns = 0;
$skippedRuns = 0;
$scannedSchedules = 0;
foreach ($schedulesQuery->cursor() as $schedule) {
$scannedSchedules++;
$slot = $this->scheduleTimeService->nextRunFor($schedule, $nowUtc->subMinute());
if ($slot === null) {
$schedule->forceFill(['next_run_at' => null])->saveQuietly();
continue;
}
if ($slot->greaterThan($nowUtc)) {
if (! $schedule->next_run_at || ! $schedule->next_run_at->equalTo($slot)) {
$schedule->forceFill(['next_run_at' => $slot])->saveQuietly();
}
continue;
}
$run = null;
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $schedule->tenant_id,
'scheduled_for' => $slot->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
} catch (QueryException $exception) {
// Idempotency: unique (backup_schedule_id, scheduled_for)
$skippedRuns++;
continue;
}
$createdRuns++;
$this->auditLogger->log(
tenant: $schedule->tenant,
action: 'backup_schedule.run_dispatched',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $slot->toDateTimeString(),
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success'
);
$schedule->forceFill([
'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
Bus::dispatch(new RunBackupScheduleJob($run->id));
}
return [
'created_runs' => $createdRuns,
'skipped_runs' => $skippedRuns,
'scanned_schedules' => $scannedSchedules,
];
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()
->where('status', 'active')
->forTenant($identifier)
->first();
if ($tenant) {
$tenantIds[] = $tenant->id;
}
}
return array_values(array_unique($tenantIds));
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Services\BackupScheduling;
use App\Exceptions\InvalidPolicyTypeException;
use Illuminate\Support\Arr;
class PolicyTypeResolver
{
public function supportedPolicyTypes(): array
{
return Arr::pluck(config('tenantpilot.supported_policy_types', []), 'type');
}
public function ensureSupported(array $types): void
{
$unknown = $this->findUnknown($types);
if (! empty($unknown)) {
throw new InvalidPolicyTypeException($unknown);
}
}
public function filterRuntime(array $types): array
{
$valid = $this->filter($types);
return array_values($valid);
}
public function resolveRuntime(array $types): array
{
$valid = $this->filter($types);
$unknown = $this->findUnknown($types);
return [
'valid' => array_values($valid),
'unknown' => array_values($unknown),
];
}
protected function filter(array $types): array
{
$supported = $this->supportedPolicyTypes();
return array_values(array_intersect($types, $supported));
}
protected function findUnknown(array $types): array
{
$supported = $this->supportedPolicyTypes();
return array_values(array_diff($types, $supported));
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Services\BackupScheduling;
use App\Services\Graph\GraphException;
use Throwable;
class RunErrorMapper
{
public const ERROR_TOKEN_EXPIRED = 'TOKEN_EXPIRED';
public const ERROR_PERMISSION_MISSING = 'PERMISSION_MISSING';
public const ERROR_GRAPH_THROTTLE = 'GRAPH_THROTTLE';
public const ERROR_GRAPH_UNAVAILABLE = 'GRAPH_UNAVAILABLE';
public const ERROR_UNKNOWN = 'UNKNOWN';
/**
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
*/
public function map(Throwable $throwable, int $attempt, int $maxAttempts = 3): array
{
$attempt = max(1, $attempt);
if ($throwable instanceof GraphException) {
$status = $throwable->status;
if ($status === 401) {
return $this->final(self::ERROR_TOKEN_EXPIRED, $throwable->getMessage());
}
if ($status === 403) {
return $this->final(self::ERROR_PERMISSION_MISSING, $throwable->getMessage());
}
if ($status === 429) {
return $this->retry(self::ERROR_GRAPH_THROTTLE, $throwable->getMessage(), $attempt, $maxAttempts);
}
if ($status === 503) {
return $this->retry(self::ERROR_GRAPH_UNAVAILABLE, $throwable->getMessage(), $attempt, $maxAttempts);
}
return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts);
}
return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts);
}
/**
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
*/
private function retry(string $code, string $message, int $attempt, int $maxAttempts): array
{
if ($attempt >= $maxAttempts) {
return $this->final($code, $message);
}
$delays = [60, 300, 900];
$delay = $delays[min($attempt - 1, count($delays) - 1)];
return [
'shouldRetry' => true,
'delay' => $delay,
'error_code' => $code,
'error_message' => $message,
'final_status' => 'failed',
];
}
/**
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
*/
private function final(string $code, string $message): array
{
return [
'shouldRetry' => false,
'delay' => 0,
'error_code' => $code,
'error_message' => $message,
'final_status' => 'failed',
];
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Services\BackupScheduling;
use App\Models\BackupSchedule;
use Carbon\CarbonImmutable;
class ScheduleTimeService
{
public function nextRunFor(BackupSchedule $schedule, ?CarbonImmutable $after = null): ?CarbonImmutable
{
$timezone = $schedule->timezone;
$cursor = $after?->copy()->timezone($timezone) ?? CarbonImmutable::now($timezone);
if ($schedule->frequency === 'weekly') {
return $this->nextWeeklyRun($schedule, $cursor);
}
return $this->nextDailyRun($schedule, $cursor);
}
protected function nextDailyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable
{
$time = $schedule->time_of_day;
$attempts = 0;
if ($cursor->format('H:i:s') >= $time) {
$cursor = $cursor->addDay();
}
while ($attempts++ < 14) {
$candidate = $this->buildLocalSlot($schedule, $cursor);
if ($candidate) {
return $candidate;
}
$cursor = $cursor->addDay();
}
return null;
}
protected function nextWeeklyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable
{
$allowed = $schedule->days_of_week ?? [];
$allowed = array_filter($allowed, fn ($day) => is_numeric($day) && $day >= 1 && $day <= 7);
$allowed = array_values($allowed);
if (empty($allowed)) {
return null;
}
$attempts = 0;
while ($attempts++ < 21) {
$dayOfWeek = $cursor->dayOfWeekIso;
if (in_array($dayOfWeek, $allowed, true)) {
$candidate = $this->buildLocalSlot($schedule, $cursor);
$cursorUtc = $cursor->copy()->timezone('UTC');
if ($candidate && $candidate->greaterThan($cursorUtc)) {
return $candidate;
}
}
$cursor = $cursor->addDay()->startOfDay();
}
return null;
}
protected function buildLocalSlot(BackupSchedule $schedule, CarbonImmutable $date): ?CarbonImmutable
{
$timezone = $schedule->timezone;
$time = $schedule->time_of_day;
$datePart = $date->format('Y-m-d');
$candidate = CarbonImmutable::createFromFormat('Y-m-d H:i:s', "{$datePart} {$time}", $timezone);
if (! $candidate || $candidate->format('H:i:s') !== $time) {
return null;
}
return $candidate->startOfMinute()->timezone('UTC');
}
}

View File

@ -18,4 +18,23 @@ public function canSync(): bool
self::Readonly => false,
};
}
public function canManageBackupSchedules(): bool
{
return match ($this) {
self::Owner,
self::Manager => true,
default => false,
};
}
public function canRunBackupSchedules(): bool
{
return match ($this) {
self::Owner,
self::Manager,
self::Operator => true,
self::Readonly => false,
};
}
}

View File

@ -0,0 +1,43 @@
<?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('backup_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('name');
$table->boolean('is_enabled')->default(true);
$table->string('timezone')->default('UTC');
$table->enum('frequency', ['daily', 'weekly']);
$table->time('time_of_day');
$table->json('days_of_week')->nullable();
$table->json('policy_types');
$table->boolean('include_foundations')->default(true);
$table->integer('retention_keep_last')->default(30);
$table->dateTime('last_run_at')->nullable();
$table->string('last_run_status')->nullable();
$table->dateTime('next_run_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'is_enabled']);
$table->index('next_run_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backup_schedules');
}
};

View File

@ -0,0 +1,41 @@
<?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('backup_schedule_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->dateTime('scheduled_for');
$table->dateTime('started_at')->nullable();
$table->dateTime('finished_at')->nullable();
$table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']);
$table->json('summary')->nullable();
$table->string('error_code')->nullable();
$table->text('error_message')->nullable();
$table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete();
$table->timestamps();
$table->unique(['backup_schedule_id', 'scheduled_for']);
$table->index(['backup_schedule_id', 'scheduled_for']);
$table->index(['tenant_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backup_schedule_runs');
}
};

View File

@ -0,0 +1,48 @@
<x-filament::section>
<div class="grid gap-3">
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-sm font-medium">Scheduled for</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->scheduled_for)->toDateTimeString() ?? '—' }}</div>
</div>
<div>
<div class="text-sm font-medium">Status</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->status ?? '—' }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-sm font-medium">Started at</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->started_at)->toDateTimeString() ?? '—' }}</div>
</div>
<div>
<div class="text-sm font-medium">Finished at</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->finished_at)->toDateTimeString() ?? '—' }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-sm font-medium">Error code</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->error_code ?: '—' }}</div>
</div>
<div>
<div class="text-sm font-medium">Backup set</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->backup_set_id ?: '—' }}</div>
</div>
</div>
<div>
<div class="text-sm font-medium">Error message</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->error_message ?: '—' }}</div>
</div>
<div>
<div class="text-sm font-medium">Summary</div>
<div class="rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
<pre class="whitespace-pre-wrap">{{ json_encode($run->summary ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
</div>
</div>
</x-filament::section>

View File

@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();

View File

@ -1,13 +1,13 @@
# Requirements Checklist (032)
- [ ] Tenant-scoped tables use `tenant_id` consistently.
- [ ] 1 Run = 1 BackupSet (no rolling reuse in MVP).
- [ ] Dispatcher is idempotent (unique schedule_id + scheduled_for).
- [ ] Concurrency lock prevents parallel runs per schedule.
- [ ] Run stores status + summary + error_code/error_message.
- [ ] UI shows schedule list + run history + link to backup set.
- [ ] Run now + Retry are permission-gated and write DB notifications.
- [ ] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets).
- [ ] Retry/backoff policy implemented (no retry for 401/403).
- [ ] Retention keeps last N and soft-deletes older backup sets.
- [ ] Tests cover due-calculation, idempotency, job success/failure, retention.
- [X] Tenant-scoped tables use `tenant_id` consistently. (Data model section in spec.md documents tenant_id on `backup_schedules` and `backup_schedule_runs`.)
- [X] 1 Run = 1 BackupSet (no rolling reuse in MVP). (Definitions + Goals in spec.md state the MVP semantics explicitly.)
- [X] Dispatcher is idempotent (unique schedule_id + scheduled_for). (Requirements FR-002 + FR-007 + plan's idempotent dispatch constraint specify unique slots.)
- [X] Concurrency lock prevents parallel runs per schedule. (FR-008 and plan note per-schedule concurrency lock; tasks T024/Run job mention locking.)
- [X] Run stores status + summary + error_code/error_message. (FR-004 and data model show these fields exist in `backup_schedule_runs`.)
- [X] UI shows schedule list + run history + link to backup set. (UX-001/UX-002 in spec, tasks T014 / relation managers + UI doc.)
- [X] Run now + Retry are permission-gated and write DB notifications. (SEC-002 + tasks T031-T034 describe Filament actions + notifications.)
- [X] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets). (SEC-003 plus tasks T026/T033/T034 mention audit logging.)
- [X] Retry/backoff policy implemented (no retry for 401/403). (NFR-003 and tasks T025 mention retry/backoff rules.)
- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.)
- [X] Tests cover due-calculation, idempotency, job success/failure, retention. (Tasks T011-T037 include Pest tests for due calculation, idempotency, job outcomes, and retention.)

View File

@ -29,7 +29,7 @@ ## Constitution Check
- Graph Abstraction & Contracts: PASS (sync uses `GraphClientInterface` via `PolicySyncService`; unknown policy types fail-safe; no hardcoded endpoints)
- Least Privilege: PASS (authorization via TenantRole matrix; no new scopes required beyond existing backup/sync)
- Spec-First Workflow: PASS (spec/plan/tasks/checklist in `specs/032-backup-scheduling-mvp/`)
- Quality Gates: PASS (tasks include Pest coverage and Pint)
- Quality Gates: PASS (tasks include Pest coverage per constitution and Pint)
## Project Structure

View File

@ -29,6 +29,10 @@ ## Run the dispatcher manually (MVP)
- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch`
Optional: limit dispatching to specific tenants:
- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch --tenant=<tenant_id_or_external_id>`
## Run the Laravel scheduler
Recommended operations model:
@ -56,7 +60,12 @@ ## Verify outcomes
- In run history: verify status, duration, error_code/message
- For successful/partial runs: verify a linked `BackupSet` exists
Retention
- After a successful/partial run creates a `BackupSet`, the retention job runs asynchronously and soft-deletes older `BackupSet`s so only the last N (per schedule) remain.
## Notes
- Unknown `policy_types` cannot be saved; legacy DB values are handled fail-safe at runtime.
- Scheduled runs do not notify a user; interactive actions (Run now / Retry) should persist a DB notification for the acting user.
- Run now / Retry actions are available for `operator`+ roles.

View File

@ -19,8 +19,10 @@ ## Goals
## Clarifications
### Session 2026-01-05
- Q: Wie sollen wir mit `policy_types` umgehen, die nicht in `config('tenantpilot.supported_policy_types')` enthalten sind? → A: Beim Speichern hart validieren und ablehnen; zur Laufzeit defensiv re-checken (Legacy/DB), unknown types skippen und Run als `partial` markieren mit `error_code=UNKNOWN_POLICY_TYPE` und Liste betroffener Types.
- Q: Wenn zur Laufzeit alle `policy_types` unbekannt sind (0 valid types nach Skip) welcher Status? → A: `skipped` (fail-safe).
- Q: Wie sollen wir mit `policy_types` umgehen, die nicht in `config('tenantpilot.supported_policy_types')` enthalten sind?
→ A: Beim Speichern hart validieren und ablehnen; zur Laufzeit defensiv re-checken (Legacy/DB), unknown types skippen und Run als `partial` markieren mit `error_code=UNKNOWN_POLICY_TYPE` und Liste betroffener Types.
- Q: Wenn zur Laufzeit alle `policy_types` unbekannt sind (0 valid types nach Skip) welcher Status?
→ A: `skipped` (fail-safe).
## Non-Goals (MVP)
- Kein Kalender-UI als Pflicht (kann später ergänzt werden).
@ -50,7 +52,7 @@ ### Functional Requirements
- **FR-004**: Run schreibt `backup_schedule_runs` mit Status + Summary + Error-Codes.
- **FR-005**: “Run now” erzeugt sofort einen Run (scheduled_for=now) und dispatcht Job.
- **FR-006**: “Retry” erzeugt einen neuen Run für denselben Schedule.
- **FR-007**: Retention hält nur die letzten N Runs/BackupSets pro Schedule (soft delete BackupSets).
- **FR-007**: Retention hält nur die letzten N BackupSets pro Schedule (soft delete BackupSets).
- **FR-008**: Concurrency: Pro Schedule darf nur ein Run gleichzeitig laufen. Wenn bereits ein Run läuft, wird ein neuer Run nicht parallel gestartet und stattdessen als `skipped` markiert (mit Fehlercode).
### UX Requirements (Filament)
@ -124,6 +126,5 @@ ## Acceptance Criteria
- UI zeigt Last Run + Next Run + Run-History.
- Run now startet sofort.
- Fehlerfälle (Token/Permission/Throttle) werden als failed/partial markiert mit error_code.
- Unbekannte `policy_types` können nicht gespeichert werden; falls Legacy-Daten vorkommen, werden sie zur Laufzeit geskippt und der Run wird als `partial` markiert mit `error_code=UNKNOWN_POLICY_TYPE`.
- Unbekannte `policy_types` können nicht gespeichert werden; falls Legacy-Daten vorkommen, werden sie zur Laufzeit geskippt: mit valid types → `partial`, ohne valid types → `skipped` (jeweils `error_code=UNKNOWN_POLICY_TYPE`).
- Retention hält nur die letzten N BackupSets pro Schedule.

View File

@ -1,42 +1,113 @@
---
description: "Task list for feature implementation"
---
# Tasks: Backup Scheduling MVP (032)
**Date**: 2026-01-05
**Input**: spec.md, plan.md
**Input**: Design documents from `specs/032-backup-scheduling-mvp/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
## Phase 1: Spec & Setup
- [ ] T001 Create specs/032-backup-scheduling-mvp (spec/plan/tasks + checklist).
**Tests**: Required by constitution quality gate (Pest) even for MVP.
## Phase 2: Data Model
- [ ] T002 Add migrations: backup_schedules + backup_schedule_runs (tenant-scoped, indexes, unique slot).
- [ ] T003 Add models + relationships (Tenant->schedules, Schedule->runs, Run->backupSet).
## Format: `[ID] [P?] [Story] Description`
## Phase 3: Scheduling + Dispatch
- [ ] T004 Add command `tenantpilot:schedules:dispatch`.
- [ ] T005 Register scheduler to run every minute.
- [ ] T006 Implement due-calculation (timezone, daily/weekly) + next_run_at computation.
- [ ] T007 Implement idempotent run creation (unique slot) + cache lock.
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (US1/US2/US3)
- Every task description includes at least one explicit file path
## Phase 4: Jobs
- [ ] T008 Implement `RunBackupScheduleJob` (sync -> select policy IDs -> create backup set -> update run + schedule).
- [ ] T009 Implement `ApplyBackupScheduleRetentionJob` (keep last N, soft-delete backup sets).
- [ ] T010 Add error mapping to `error_code` (TOKEN_EXPIRED, PERMISSION_MISSING, GRAPH_THROTTLE, UNKNOWN).
- [ ] T021 Add audit logging for dispatcher/run/retention (tenant-scoped; no secrets).
- [ ] T022 Implement retry/backoff strategy for `RunBackupScheduleJob` (no retry on 401/403).
## User Stories
## Phase 5: Filament UI
- [ ] T011 Add `BackupScheduleResource` (tenant-scoped): CRUD + enable/disable.
- [ ] T012 Add Runs UI (relation manager or resource) with details + link to BackupSet.
- [ ] T013 Add actions: Run now + Retry (permission-gated); notifications persisted to DB.
- [ ] T023 Wire authorization to TenantRole (readonly/operator/manager/owner) for schedule CRUD and run actions.
- **US1 (P1)**: Manage tenant-scoped backup schedules (CRUD, validation).
- **US2 (P1)**: Dispatch + execute runs idempotently (queue/scheduler), write runs + audit logs.
- **US3 (P2)**: View run history, run-now/retry actions, retention keep-last-N.
## Phase 6: Tests
- [ ] T014 Unit: due-calculation + next_run_at.
- [ ] T015 Feature: dispatcher idempotency (unique slot); lock behavior.
- [ ] T016 Job-level: successful run creates backup set, updates run/schedule (Graph mocked).
- [ ] T017 Job-level: token/permission/throttle errors map to error_code and status.
- [ ] T018 Retention: keeps last N and deletes older backup sets.
- [ ] T024 Tests: audit logs written (run success + retention delete) and retry policy behavior.
---
## Phase 1: Setup (Shared Infrastructure)
- [X] T001 [P] [US2] Review existing sync + backup APIs in app/Services/Intune/PolicySyncService.php and app/Services/Intune/BackupService.php
- [X] T002 [P] [US1] Confirm supported policy types config key/shape in config/tenantpilot.php (source of truth for `policy_types`)
- [X] T003 [P] [US2] Confirm audit logging primitives in app/Services/Intune/AuditLogger.php and app/Models/AuditLog.php
---
## Phase 2: Foundational (Blocking Prerequisites)
- [X] T004 [US1] Add migrations for schedules/runs in database/migrations/*_create_backup_schedules_table.php and database/migrations/*_create_backup_schedule_runs_table.php (tenant FKs, jsonb, indexes, unique (backup_schedule_id, scheduled_for))
- [X] T005 [P] [US1] Add model app/Models/BackupSchedule.php (casts, relationships)
- [X] T006 [P] [US2] Add model app/Models/BackupScheduleRun.php (casts, relationships, status + error fields)
- [X] T007 [P] [US1] Add tenant relationships in app/Models/Tenant.php (backupSchedules(), backupScheduleRuns())
- [X] T008 [P] [US1] Add TenantRole helpers in app/Support/TenantRole.php for SEC-002 (canManageBackupSchedules(), canRunBackupSchedules())
- [X] T009 [US2] Implement next-run + slot calculation in app/Services/BackupScheduling/ScheduleTimeService.php (UTC minute-slot, DST rules, no catch-up)
- [X] T010 [US2] Implement policy type validation/filtering in app/Services/BackupScheduling/PolicyTypeResolver.php (validate vs config/tenantpilot.php; runtime filtering for legacy DB)
---
## Phase 3: User Story 1 - Manage Schedules (Priority: P1)
### Tests (Pest)
- [X] T011 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleCrudTest.php (tenant scoping + manager/owner CRUD)
- [X] T012 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleValidationTest.php (weekly days_of_week rules; `policy_types` hard validation)
- [X] T013 [P] [US1] Add unit test tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php (next_run_at + DST invalid/ambiguous behavior)
### Implementation
- [X] T014 [US1] Implement resource app/Filament/Resources/BackupScheduleResource.php (list columns per UX-001; create/edit form fields)
- [X] T015 [US1] Enforce authorization in app/Filament/Resources/BackupScheduleResource.php using app/Support/TenantRole.php (SEC-002)
- [X] T016 [US1] Persist next_run_at updates via app/Services/BackupScheduling/ScheduleTimeService.php (on create/update)
- [X] T017 [US1] Validate `policy_types` via app/Services/BackupScheduling/PolicyTypeResolver.php (reject unknown keys at save-time)
---
## Phase 4: User Story 2 - Dispatch & Execute Runs (Priority: P1)
### Tests (Pest)
- [X] T018 [P] [US2] Add feature test tests/Feature/BackupScheduling/DispatchIdempotencyTest.php (same slot dispatch twice → one run)
- [X] T019 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php (success/partial/skipped outcomes + backup_set linkage)
- [X] T020 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunErrorMappingTest.php (retry/backoff vs no-retry mapping)
### Implementation
- [X] T021 [P] [US2] Add command app/Console/Commands/TenantpilotDispatchBackupSchedules.php (tenantpilot:schedules:dispatch)
- [X] T022 [US2] Register scheduler entry in routes/console.php (every minute)
- [X] T023 [US2] Implement dispatcher service app/Services/BackupScheduling/BackupScheduleDispatcher.php (find due schedules, create run, dispatch job)
- [X] T024 [US2] Implement job app/Jobs/RunBackupScheduleJob.php (lock per schedule, sync via app/Services/Intune/PolicySyncService.php, create backup set via app/Services/Intune/BackupService.php, update run + schedule fields)
- [X] T025 [US2] Implement retry/backoff in app/Jobs/RunBackupScheduleJob.php (429/503 backoff; 401/403 no retry; unknown limited retries)
- [X] T026 [US2] Write audit logs (dispatch/run start/run end) using app/Services/Intune/AuditLogger.php from app/Services/BackupScheduling/BackupScheduleDispatcher.php and app/Jobs/RunBackupScheduleJob.php
- [X] T027 [US2] Implement unknown policy types runtime behavior in app/Jobs/RunBackupScheduleJob.php using app/Services/BackupScheduling/PolicyTypeResolver.php (≥1 valid → partial; 0 valid → skipped; error_code UNKNOWN_POLICY_TYPE + list in run summary)
---
## Phase 5: User Story 3 - Run History, Actions, Retention (Priority: P2)
### Tests (Pest)
- [X] T028 [P] [US3] Add feature test tests/Feature/BackupScheduling/RunNowRetryActionsTest.php (operator+ allowed; DB notification persisted)
- [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older)
- [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression)
- [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete)
### Implementation
- [X] T030 [US3] Add runs RelationManager app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php (UX-002 fields + tenant scoping)
- [X] T031 [US3] Add Run now / Retry actions in app/Filament/Resources/BackupScheduleResource.php (SEC-002 gating via app/Support/TenantRole.php)
- [X] T032 [US3] Persist database notifications for interactive actions in app/Filament/Resources/BackupScheduleResource.php
- [X] T033 [US3] Implement retention job app/Jobs/ApplyBackupScheduleRetentionJob.php (soft-delete old backup sets; write audit log)
- [X] T034 [US3] Dispatch retention job from app/Jobs/RunBackupScheduleJob.php after completion
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T035 [P] [US2] Validate operational steps in specs/032-backup-scheduling-mvp/quickstart.md (queue + scheduler + manual dispatch)
- [X] T036 [P] [US2] Run formatter on touched files using vendor/bin/pint --dirty
- [X] T037 [P] [US2] Run targeted tests using vendor/bin/sail php artisan test tests/Feature/BackupScheduling tests/Unit/BackupScheduling
---
## Dependencies & Execution Order
Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish
## Phase 7: Verification
- [ ] T019 Run targeted tests (Pest).
- [ ] T020 Run Pint (`./vendor/bin/pint --dirty`).

View File

@ -0,0 +1,67 @@
<?php
use App\Jobs\ApplyBackupScheduleRetentionJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use Filament\Facades\Filament;
test('retention keeps last N backup sets per schedule', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 2,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$sets = collect(range(1, 5))->map(function (int $i) use ($tenant): BackupSet {
return BackupSet::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Set '.$i,
'status' => 'completed',
'item_count' => 0,
'completed_at' => now()->subMinutes(10 - $i),
]);
});
// Oldest → newest
$scheduledFor = now('UTC')->startOfMinute()->subMinutes(10);
foreach ($sets as $set) {
BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => $scheduledFor,
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0],
'backup_set_id' => $set->id,
]);
$scheduledFor = $scheduledFor->addMinute();
}
ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id);
$kept = $sets->take(-2);
$deleted = $sets->take(3);
foreach ($kept as $set) {
$this->assertDatabaseHas('backup_sets', [
'id' => $set->id,
'deleted_at' => null,
]);
}
foreach ($deleted as $set) {
$this->assertSoftDeleted('backup_sets', ['id' => $set->id]);
}
});

View File

@ -0,0 +1,89 @@
<?php
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Models\BackupSchedule;
use Filament\Facades\Filament;
use Livewire\Livewire;
test('owner can bulk delete backup schedules', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$scheduleA = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Delete A',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$scheduleB = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Delete B',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '02:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB]))
->assertHasNoTableBulkActionErrors();
expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count())
->toBe(0);
});
test('operator cannot bulk delete backup schedules', function () {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$scheduleA = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Keep A',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$scheduleB = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Keep B',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '02:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
try {
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB]));
} catch (\Throwable) {
// Action should be hidden/blocked for operator users.
}
expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count())
->toBe(2);
});

View File

@ -0,0 +1,93 @@
<?php
use App\Filament\Resources\BackupScheduleResource\Pages\CreateBackupSchedule;
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
use App\Models\BackupSchedule;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Livewire\Livewire;
test('backup schedules listing is tenant scoped', function () {
[$user, $tenantA] = createUserWithTenant(role: 'manager');
$tenantB = Tenant::factory()->create();
createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager');
BackupSchedule::query()->create([
'tenant_id' => $tenantA->id,
'name' => 'Tenant A schedule',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
BackupSchedule::query()->create([
'tenant_id' => $tenantB->id,
'name' => 'Tenant B schedule',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '02:00:00',
'days_of_week' => null,
'policy_types' => ['deviceCompliancePolicy'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
$this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA)))
->assertOk()
->assertSee('Tenant A schedule')
->assertSee('Device Configuration')
->assertDontSee('Tenant B schedule');
});
test('backup schedules pages return 404 for unauthorized tenant', function () {
[$user] = createUserWithTenant(role: 'manager');
$unauthorizedTenant = Tenant::factory()->create();
$this->actingAs($user)
->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($unauthorizedTenant)))
->assertNotFound();
});
test('manager can create and edit backup schedules via filament', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(CreateBackupSchedule::class)
->fillForm([
'name' => 'Daily at 10',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00',
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
])
->call('create')
->assertHasNoFormErrors();
$schedule = BackupSchedule::query()->where('tenant_id', $tenant->id)->first();
expect($schedule)->not->toBeNull();
expect($schedule->next_run_at)->not->toBeNull();
Livewire::test(EditBackupSchedule::class, ['record' => $schedule->getRouteKey()])
->fillForm([
'name' => 'Daily at 11',
])
->call('save')
->assertHasNoFormErrors();
$schedule->refresh();
expect($schedule->name)->toBe('Daily at 11');
});

View File

@ -0,0 +1,53 @@
<?php
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
test('backup schedule run view modal renders run details', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$backupSet = BackupSet::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Set 174',
'status' => 'completed',
'item_count' => 0,
]);
$run = BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => now('UTC')->startOfMinute()->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => [
'policies_total' => 7,
'policies_backed_up' => 7,
'errors_count' => 0,
],
'error_code' => null,
'error_message' => null,
'backup_set_id' => $backupSet->id,
]);
$this->actingAs($user);
$html = view('filament.modals.backup-schedule-run-view', ['run' => $run])->render();
expect($html)->toContain('Scheduled for');
expect($html)->toContain('Status');
expect($html)->toContain('Summary');
expect($html)->toContain((string) $backupSet->id);
});

View File

@ -0,0 +1,48 @@
<?php
use App\Filament\Resources\BackupScheduleResource\Pages\CreateBackupSchedule;
use Filament\Facades\Filament;
use Livewire\Livewire;
test('weekly schedules require at least one day of week', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(CreateBackupSchedule::class)
->fillForm([
'name' => 'Weekly schedule',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'weekly',
'time_of_day' => '10:00',
'days_of_week' => [],
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
])
->call('create')
->assertHasFormErrors(['days_of_week']);
});
test('unknown policy types are rejected at save time', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(CreateBackupSchedule::class)
->fillForm([
'name' => 'Invalid policy type schedule',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00',
'policy_types' => ['definitelyNotARealPolicyType'],
'include_foundations' => true,
'retention_keep_last' => 30,
])
->call('create')
->assertHasFormErrors(['policy_types']);
});

View File

@ -0,0 +1,40 @@
<?php
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Services\BackupScheduling\BackupScheduleDispatcher;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Bus;
it('dispatching the same slot twice creates only one run', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Daily 10:00',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => null,
]);
Bus::fake();
$dispatcher = app(BackupScheduleDispatcher::class);
$dispatcher->dispatchDue([$tenant->external_id]);
$dispatcher->dispatchDue([$tenant->external_id]);
expect(BackupScheduleRun::query()->count())->toBe(1);
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
});

View File

@ -0,0 +1,123 @@
<?php
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
it('creates a backup set and marks the run successful', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Daily 10:00',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => null,
]);
$run = BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
'status' => BackupScheduleRun::STATUS_RUNNING,
]);
app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
{
public function __construct() {}
public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array
{
return ['synced' => [], 'failures' => []];
}
});
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'status' => 'completed',
'item_count' => 0,
]);
app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService
{
public function __construct(private readonly BackupSet $backupSet) {}
public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet
{
return $this->backupSet;
}
});
Cache::flush();
(new RunBackupScheduleJob($run->id))->handle(
app(PolicySyncService::class),
app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class),
);
$run->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
expect($run->backup_set_id)->toBe($backupSet->id);
});
it('skips runs when all policy types are unknown', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Daily 10:00',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00:00',
'days_of_week' => null,
'policy_types' => ['definitelyNotARealPolicyType'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => null,
]);
$run = BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
'status' => BackupScheduleRun::STATUS_RUNNING,
]);
Cache::flush();
(new RunBackupScheduleJob($run->id))->handle(
app(PolicySyncService::class),
app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class),
);
$run->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
expect($run->backup_set_id)->toBeNull();
});

View File

@ -0,0 +1,42 @@
<?php
use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\Graph\GraphException;
it('marks 401 as token expired without retry', function () {
$mapper = app(RunErrorMapper::class);
$mapped = $mapper->map(new GraphException('auth failed', 401), attempt: 1, maxAttempts: 3);
expect($mapped['shouldRetry'])->toBeFalse();
expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_TOKEN_EXPIRED);
});
it('marks 403 as permission missing without retry', function () {
$mapper = app(RunErrorMapper::class);
$mapped = $mapper->map(new GraphException('forbidden', 403), attempt: 1, maxAttempts: 3);
expect($mapped['shouldRetry'])->toBeFalse();
expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_PERMISSION_MISSING);
});
it('retries throttling with backoff', function () {
$mapper = app(RunErrorMapper::class);
$mapped = $mapper->map(new GraphException('throttled', 429), attempt: 1, maxAttempts: 3);
expect($mapped['shouldRetry'])->toBeTrue();
expect($mapped['delay'])->toBe(60);
expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_THROTTLE);
});
it('retries service unavailable with backoff', function () {
$mapper = app(RunErrorMapper::class);
$mapped = $mapper->map(new GraphException('unavailable', 503), attempt: 2, maxAttempts: 3);
expect($mapped['shouldRetry'])->toBeTrue();
expect($mapped['delay'])->toBe(300);
expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_UNAVAILABLE);
});

View File

@ -0,0 +1,205 @@
<?php
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
test('operator can run now and it persists a database notification', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSchedules::class)
->callTableAction('runNow', $schedule);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(1);
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
expect($run)->not->toBeNull();
Queue::assertPushed(RunBackupScheduleJob::class);
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'notifiable_type' => User::class,
]);
});
test('operator can retry and it persists a database notification', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSchedules::class)
->callTableAction('retry', $schedule);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class);
$this->assertDatabaseCount('notifications', 1);
});
test('readonly cannot dispatch run now or retry', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
try {
Livewire::test(ListBackupSchedules::class)
->callTableAction('runNow', $schedule);
} catch (\Throwable) {
// Action should be hidden/blocked for readonly users.
}
try {
Livewire::test(ListBackupSchedules::class)
->callTableAction('retry', $schedule);
} catch (\Throwable) {
// Action should be hidden/blocked for readonly users.
}
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
});
test('operator can bulk run now and it persists a database notification', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$scheduleA = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly A',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$scheduleB = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly B',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '02:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB]));
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(2);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1);
});
test('operator can bulk retry and it persists a database notification', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$scheduleA = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly A',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$scheduleB = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly B',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '02:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(2);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1);
});

View File

@ -0,0 +1,45 @@
<?php
use App\Models\BackupSchedule;
use App\Services\BackupScheduling\ScheduleTimeService;
use Carbon\CarbonImmutable;
use Tests\TestCase;
uses(TestCase::class);
it('skips nonexistent DST local time slots for daily schedules', function () {
$schedule = new BackupSchedule;
$schedule->forceFill([
'frequency' => 'daily',
'timezone' => 'Europe/Berlin',
'time_of_day' => '02:30:00',
'days_of_week' => [],
]);
$service = app(ScheduleTimeService::class);
// On 2026-03-29 in Europe/Berlin, the clock jumps from 02:00 to 03:00 (02:30 is nonexistent).
// Using an "after" cursor later than 02:30 on the previous day forces the candidate day to be 2026-03-29.
$after = CarbonImmutable::create(2026, 3, 28, 3, 0, 0, 'Europe/Berlin');
$next = $service->nextRunFor($schedule, $after);
expect($next)->not->toBeNull();
expect($next->timezone('UTC')->format('Y-m-d H:i:s'))->toBe('2026-03-30 00:30:00');
});
it('returns null for weekly schedules without allowed days', function () {
$schedule = new BackupSchedule;
$schedule->forceFill([
'frequency' => 'weekly',
'timezone' => 'UTC',
'time_of_day' => '10:00:00',
'days_of_week' => [],
]);
$service = app(ScheduleTimeService::class);
$next = $service->nextRunFor($schedule, CarbonImmutable::create(2026, 1, 5, 0, 0, 0, 'UTC'));
expect($next)->toBeNull();
});