feat/032-backup-scheduling-mvp (#34)

What
Implements tenant-scoped backup scheduling end-to-end: schedules CRUD, minute-based dispatch, queued execution, run history, manual “Run now/Retry”, retention (keep last N), and auditability.

Key changes

Filament UI: Backup Schedules resource with tenant scoping + SEC-002 role gating.
Scheduler + queue: tenantpilot:schedules:dispatch command wired in scheduler (runs every minute), creates idempotent BackupScheduleRun records and dispatches jobs.
Execution: RunBackupScheduleJob syncs policies, creates immutable backup sets, updates run status, writes audit logs, applies retry/backoff mapping, and triggers retention.
Run history: Relation manager + “View” modal rendering run details.
UX polish: row actions grouped; bulk actions grouped (run now / retry / delete). Bulk dispatch writes DB notifications (shows in notifications panel).
Validation: policy type hard-validation on save; unknown policy types handled safely at runtime (skipped/partial).
Tests: comprehensive Pest coverage for CRUD/scoping/validation, idempotency, job outcomes, error mapping, retention, view modal, run-now/retry notifications, bulk delete (incl. operator forbidden).
Files / Areas

Filament: BackupScheduleResource.php and app/Filament/Resources/BackupScheduleResource/*
Scheduling/Jobs: app/Console/Commands/TenantpilotDispatchBackupSchedules.php, app/Jobs/RunBackupScheduleJob.php, app/Jobs/ApplyBackupScheduleRetentionJob.php, console.php
Models/Migrations: app/Models/BackupSchedule.php, app/Models/BackupScheduleRun.php, database/migrations/backup_schedules, backup_schedule_runs
Notifications: BackupScheduleRunDispatchedNotification.php
Specs: specs/032-backup-scheduling-mvp/* (tasks/checklist/quickstart updates)
How to test (Sail)

Run tests: ./vendor/bin/sail artisan test tests/Feature/BackupScheduling
Run formatter: ./vendor/bin/sail php ./vendor/bin/pint --dirty
Apply migrations: ./vendor/bin/sail artisan migrate
Manual dispatch: ./vendor/bin/sail artisan tenantpilot:schedules:dispatch
Notes

Uses DB notifications for queued UI actions to ensure they appear in the notifications panel even under queue fakes in tests.
Checklist gate for 032 is PASS; tasks updated accordingly.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #34
This commit is contained in:
ahmido 2026-01-05 04:22:13 +00:00
parent beffbfca4c
commit 4d3fcd28a9
45 changed files with 3530 additions and 106 deletions

View File

@ -5,6 +5,7 @@ # TenantAtlas Development Guidelines
## Active Technologies
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -24,6 +25,7 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
- feat/005-bulk-operations: Added PHP 8.4.15

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.)
- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.)

View File

@ -0,0 +1,204 @@
openapi: 3.0.3
info:
title: TenantPilot Backup Scheduling (Spec 032)
version: "0.1"
description: |
Conceptual contract for Backup Scheduling MVP. TenantPilot uses Filament/Livewire;
these endpoints describe behavior for review/testing and future API alignment.
servers:
- url: https://{host}
variables:
host:
default: example.local
paths:
/tenants/{tenantId}/backup-schedules:
get:
summary: List backup schedules for a tenant
parameters:
- $ref: '#/components/parameters/TenantId'
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/BackupSchedule'
post:
summary: Create a backup schedule
parameters:
- $ref: '#/components/parameters/TenantId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BackupScheduleCreate'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/BackupSchedule'
'422':
description: Validation error (e.g. unknown policy_types)
/tenants/{tenantId}/backup-schedules/{scheduleId}:
patch:
summary: Update a backup schedule
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ScheduleId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BackupScheduleUpdate'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/BackupSchedule'
'422':
description: Validation error
delete:
summary: Delete (or disable) a schedule
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ScheduleId'
responses:
'204':
description: Deleted
/tenants/{tenantId}/backup-schedules/{scheduleId}/run-now:
post:
summary: Trigger a run immediately
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ScheduleId'
responses:
'202':
description: Accepted (run created and job dispatched)
content:
application/json:
schema:
$ref: '#/components/schemas/BackupScheduleRun'
/tenants/{tenantId}/backup-schedules/{scheduleId}/retry:
post:
summary: Create a new run as retry
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ScheduleId'
responses:
'202':
description: Accepted
content:
application/json:
schema:
$ref: '#/components/schemas/BackupScheduleRun'
/tenants/{tenantId}/backup-schedules/{scheduleId}/runs:
get:
summary: List runs for a schedule
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ScheduleId'
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/BackupScheduleRun'
components:
parameters:
TenantId:
name: tenantId
in: path
required: true
schema:
type: integer
ScheduleId:
name: scheduleId
in: path
required: true
schema:
type: integer
schemas:
BackupSchedule:
type: object
required: [id, tenant_id, name, is_enabled, timezone, frequency, time_of_day, policy_types, retention_keep_last]
properties:
id: { type: integer }
tenant_id: { type: integer }
name: { type: string }
is_enabled: { type: boolean }
timezone: { type: string, example: "Europe/Berlin" }
frequency: { type: string, enum: [daily, weekly] }
time_of_day: { type: string, example: "02:00:00" }
days_of_week:
type: array
nullable: true
items: { type: integer, minimum: 1, maximum: 7 }
policy_types:
type: array
items: { type: string }
description: Must be keys from config('tenantpilot.supported_policy_types').
include_foundations: { type: boolean }
retention_keep_last: { type: integer, minimum: 1 }
last_run_at: { type: string, format: date-time, nullable: true }
last_run_status: { type: string, nullable: true }
next_run_at: { type: string, format: date-time, nullable: true }
BackupScheduleCreate:
allOf:
- $ref: '#/components/schemas/BackupScheduleUpdate'
- type: object
required: [name, timezone, frequency, time_of_day, policy_types]
BackupScheduleUpdate:
type: object
properties:
name: { type: string }
is_enabled: { type: boolean }
timezone: { type: string }
frequency: { type: string, enum: [daily, weekly] }
time_of_day: { type: string }
days_of_week:
type: array
nullable: true
items: { type: integer, minimum: 1, maximum: 7 }
policy_types:
type: array
items: { type: string }
include_foundations: { type: boolean }
retention_keep_last: { type: integer, minimum: 1 }
BackupScheduleRun:
type: object
required: [id, backup_schedule_id, tenant_id, scheduled_for, status]
properties:
id: { type: integer }
backup_schedule_id: { type: integer }
tenant_id: { type: integer }
scheduled_for: { type: string, format: date-time }
started_at: { type: string, format: date-time, nullable: true }
finished_at: { type: string, format: date-time, nullable: true }
status: { type: string, enum: [running, success, partial, failed, canceled, skipped] }
summary:
type: object
additionalProperties: true
error_code: { type: string, nullable: true }
error_message: { type: string, nullable: true }
backup_set_id: { type: integer, nullable: true }

View File

@ -0,0 +1,98 @@
# Data Model: Backup Scheduling MVP (032)
**Date**: 2026-01-05
This document describes the entities, relationships, validation rules, and state transitions derived from the feature spec.
## Entities
### 1) BackupSchedule (`backup_schedules`)
**Purpose**: Defines a tenant-scoped recurring backup plan.
**Fields**
- `id` (bigint, PK)
- `tenant_id` (FK → `tenants.id`, required)
- `name` (string, required)
- `is_enabled` (bool, default true)
- `timezone` (string, required; default `UTC`)
- `frequency` (enum: `daily|weekly`, required)
- `time_of_day` (time, required)
- `days_of_week` (json, nullable; required when `frequency=weekly`)
- array<int> in range 1..7 (Mon..Sun)
- `policy_types` (jsonb, required)
- array<string>; keys MUST exist in `config('tenantpilot.supported_policy_types')`
- `include_foundations` (bool, default true)
- `retention_keep_last` (int, default 30)
- `last_run_at` (datetime, nullable)
- `last_run_status` (string, nullable)
- `next_run_at` (datetime, nullable)
- timestamps
**Indexes**
- `(tenant_id, is_enabled)`
- optional `(next_run_at)`
**Validation Rules (MVP)**
- `tenant_id`: required, exists
- `name`: required, max length (e.g. 255)
- `timezone`: required, valid IANA tz
- `frequency`: required, in `[daily, weekly]`
- `time_of_day`: required
- `days_of_week`: required if weekly; values 1..7; unique values
- `policy_types`: required, array, min 1; all values in supported types config
- `retention_keep_last`: required, int, min 1
**State**
- Enabled/disabled (`is_enabled`)
---
### 2) BackupScheduleRun (`backup_schedule_runs`)
**Purpose**: Represents one execution attempt of a schedule.
**Fields**
- `id` (bigint, PK)
- `backup_schedule_id` (FK → `backup_schedules.id`, required)
- `tenant_id` (FK → `tenants.id`, required; denormalized)
- `scheduled_for` (datetime, required; UTC minute-slot)
- `started_at` (datetime, nullable)
- `finished_at` (datetime, nullable)
- `status` (enum: `running|success|partial|failed|canceled|skipped`, required)
- `summary` (jsonb, required)
- suggested keys:
- `policies_total` (int)
- `policies_backed_up` (int)
- `errors_count` (int)
- `type_breakdown` (object)
- `warnings` (array)
- `unknown_policy_types` (array<string>)
- `error_code` (string, nullable)
- `error_message` (text, nullable)
- `backup_set_id` (FK → `backup_sets.id`, nullable)
- timestamps
**Indexes**
- `(backup_schedule_id, scheduled_for)`
- `(tenant_id, created_at)`
- unique `(backup_schedule_id, scheduled_for)` (idempotency)
**State transitions**
- `running``success|partial|failed|skipped|canceled`
---
## Relationships
- Tenant `hasMany` BackupSchedule
- BackupSchedule `belongsTo` Tenant
- BackupSchedule `hasMany` BackupScheduleRun
- BackupScheduleRun `belongsTo` BackupSchedule
- BackupScheduleRun `belongsTo` Tenant
- BackupScheduleRun `belongsTo` BackupSet (nullable)
## Notes
- `BackupSet` and `BackupItem` already support soft deletes in this repo; retention can soft-delete old backup sets.
- Unknown policy types are prevented at save-time, but runs defensively re-check to handle legacy DB data.

View File

@ -1,67 +1,85 @@
# Plan: Backup Scheduling MVP (032)
# Implementation Plan: Backup Scheduling MVP (032)
**Date**: 2026-01-05
**Input**: spec.md
**Branch**: `feat/032-backup-scheduling-mvp` | **Date**: 2026-01-05 | **Spec**: specs/032-backup-scheduling-mvp/spec.md
**Input**: Feature specification from `specs/032-backup-scheduling-mvp/spec.md`
## Architecture / Reuse
- Reuse existing services:
- `PolicySyncService::syncPoliciesWithReport()` for selected policy types
- `BackupService::createBackupSet()` to create immutable snapshots + items (include_foundations supported)
- Store selection as `policy_types` (config keys), not free-form categories.
- Use tenant scoping (`tenant_id`) consistent with existing tables (`backup_sets`, `backup_items`).
## Summary
## Scheduling Mechanism
- Add Artisan command: `tenantpilot:schedules:dispatch`.
- Scheduler integration (Laravel 12): schedule the command every minute via `routes/console.php` + ops configuration (Dokploy cron `schedule:run` or long-running `schedule:work`).
- Dispatcher algorithm:
1) load enabled schedules
2) compute whether due for the current minute in schedule timezone
3) create run with `scheduled_for` slot (minute precision) using DB unique constraint
4) dispatch `RunBackupScheduleJob(schedule_id, run_id)`
- Concurrency:
- Cache lock per schedule (`lock:backup_schedule:{id}`) plus DB unique slot constraint for idempotency.
- If lock is held: mark run as `skipped` with a clear error_code (no parallel execution).
Implement tenant-scoped backup schedules that dispatch idempotent runs every minute via Laravel scheduler and queue workers. Each run syncs selected policy types from Graph into the local DB (via existing `PolicySyncService`) and creates an immutable `BackupSet` snapshot (via existing `BackupService`), with strict audit logging, fail-safe handling for unknown policy types, retention (keep last N), and Filament UI for managing schedules and viewing run history.
## Run Execution
- `RunBackupScheduleJob`:
1) load schedule + tenant
2) preflight: tenant active; Graph/auth errors mapped to error_code
3) sync policies for selected types (collect report)
4) select policy IDs from local DB for those types (exclude ignored)
5) create backup set:
- name: `{schedule_name} - {Y-m-d H:i}`
- includeFoundations: schedule flag
6) set run status:
- success if backup_set.status == completed
- partial if backup_set.status == partial OR sync had failures but backup succeeded
- failed if nothing backed up / hard error
7) update schedule last_run_* and compute/persist next_run_at
8) dispatch retention job
9) audit logs:
- log run start + completion (status, counts, error_code; no secrets)
## Technical Context
## Retry / Backoff
- Configure job retry behavior based on error classification:
- Throttling/transient (e.g. 429/503): backoff + retry
- Auth/permission (401/403): no retry
- Unknown: limited retries
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
**Storage**: PostgreSQL (Sail locally)
**Testing**: Pest v4
**Target Platform**: Containerized (Sail local), Dokploy deploy (staging/prod)
**Project Type**: Web application (Laravel monolith + Filament admin)
**Performance Goals**: Scheduler runs every minute; per-run work is queued; avoid long locks
**Constraints**: Idempotent dispatch (unique slot), per-schedule concurrency lock, no secrets/tokens in logs, “no catch-up” policy
**Scale/Scope**: Multi-tenant MSP use; schedules per tenant; runs stored for audit/history
## Retention
- `ApplyBackupScheduleRetentionJob(schedule_id)`:
- identify runs ordered newest→oldest
- keep last N runs that created a backup_set_id
- for older ones: soft-delete referenced BackupSets (and cascade soft-delete items)
- audit log: number of deleted BackupSets
## Constitution Check
## Filament UX
- Tenant-scoped resources:
- `BackupScheduleResource`
- Runs UI via RelationManager under schedule (or a dedicated resource if needed)
- Actions: enable/disable, run now, retry
- Notifications: persist via `->sendToDatabase($user)` for the DB info panel.
- MVP notification scope: only interactive actions notify the acting user; scheduled runs rely on Run history.
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
## Ops / Deployment Notes
- Requires queue worker.
- Requires scheduler running.
- Missed runs policy (MVP): no catch-up.
- Safety-First Restore: PASS (feature is backup-only; no restore scheduling)
- Auditability & Tenant Isolation: PASS (tenant_id everywhere; audit log entries for dispatch/run/retention)
- 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 per constitution and Pint)
## Project Structure
### Documentation (this feature)
```text
specs/032-backup-scheduling-mvp/
├── plan.md # This file (/speckit.plan output)
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (already present)
```
### Source Code (repository root)
```text
app/
├── Console/Commands/
├── Filament/Resources/
├── Jobs/
├── Models/
└── Services/
config/
database/migrations/
routes/console.php
tests/
```
Expected additions for this feature (at implementation time):
```text
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
app/Jobs/RunBackupScheduleJob.php
app/Jobs/ApplyBackupScheduleRetentionJob.php
app/Models/BackupSchedule.php
app/Models/BackupScheduleRun.php
app/Filament/Resources/BackupScheduleResource.php
database/migrations/*_create_backup_schedules_table.php
database/migrations/*_create_backup_schedule_runs_table.php
tests/Feature/BackupScheduling/*
tests/Unit/BackupScheduling/*
```
**Structure Decision**: Laravel monolith (Filament admin + queued jobs). No new top-level app folders.
## Phase Outputs
- Phase 0 (Outline & Research): `research.md`
- Phase 1 (Design & Contracts): `data-model.md`, `contracts/*`, `quickstart.md`
- Phase 2 (Tasks): `tasks.md` already exists; will be refined later via `/speckit.tasks` if needed
- Phase 1 (Design & Contracts): `data-model.md`, `contracts/*`, `quickstart.md`

View File

@ -0,0 +1,71 @@
# Quickstart: Backup Scheduling MVP (032)
This is a developer/operator quickstart for running the scheduling MVP locally with Sail.
## Prerequisites
- Laravel Sail running
- Database migrated
- Queue worker running
- Scheduler running (or run the dispatch command manually)
## Local setup (Sail)
1) Start Sail
- `./vendor/bin/sail up -d`
2) Run migrations
- `./vendor/bin/sail php artisan migrate`
3) Start a queue worker
- `./vendor/bin/sail php artisan queue:work`
## Run the dispatcher manually (MVP)
Once a schedule exists, you can dispatch due runs:
- `./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:
- Dev/local: run `schedule:work` in a separate terminal
- `./vendor/bin/sail php artisan schedule:work`
- Production/staging (Dokploy): cron every minute
- `* * * * * php artisan schedule:run`
## Create a schedule (Filament)
- Log into Filament admin
- Switch into a tenant context
- Create a Backup Schedule:
- frequency: daily/weekly
- time + timezone
- policy_types: pick from supported types
- retention_keep_last
- include_foundations
## Verify outcomes
- In the schedule list: check `Last Run` and `Next Run`
- 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

@ -0,0 +1,77 @@
# Research: Backup Scheduling MVP (032)
**Date**: 2026-01-05
This document resolves technical decisions and clarifies implementation approach for Feature 032.
## Decisions
### 1) Reuse existing sync + backup services
- **Decision**: Use `App\Services\Intune\PolicySyncService::syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array` and `App\Services\Intune\BackupService::createBackupSet(...)`.
- **Rationale**: These are already tenant-aware, use `GraphClientInterface` behind the scenes (via `PolicySyncService`), and `BackupService` already writes a `backup.created` audit log entry.
- **Alternatives considered**:
- Implement new Graph calls directly in the scheduler job → rejected (violates Graph abstraction gate; duplicates logic).
### 2) Policy type source of truth + validation
- **Decision**:
- Persist `backup_schedules.policy_types` as `array<string>` of **type keys** present in `config('tenantpilot.supported_policy_types')`.
- **Hard validation at save-time**: unknown keys are rejected.
- **Runtime defensive check** (legacy/DB): unknown keys are skipped.
- If ≥1 valid type remains → run becomes `partial` and `error_code=UNKNOWN_POLICY_TYPE`.
- If 0 valid types remain → run becomes `skipped` and `error_code=UNKNOWN_POLICY_TYPE` (no `BackupSet` created).
- **Rationale**: Prevent silent misconfiguration and enforce fail-safe behavior at entry points, while still handling legacy data safely.
- **Alternatives considered**:
- Save unknown keys and ignore silently → rejected (silent misconfiguration).
- Fail the run for any unknown type → rejected (too brittle for legacy).
### 3) Graph calls and contracts
- **Decision**: Do not hardcode Graph endpoints. All Graph access happens via `GraphClientInterface` (through `PolicySyncService` and `BackupService`).
- **Rationale**: Matches constitution requirements and existing code paths.
- **Alternatives considered**:
- Calling `deviceManagement/{type}` directly → rejected (explicitly forbidden by constitution; also unsafe for unknown types).
### 4) Scheduling mechanism
- **Decision**: Add an Artisan command `tenantpilot:schedules:dispatch` and register it with Laravel scheduler to run every minute.
- **Rationale**: Fits Laravel 12 structure (no Kernel), supports Dokploy operation models (`schedule:run` cron or `schedule:work`).
- **Alternatives considered**:
- Long-running daemon polling DB directly → rejected (less idiomatic; harder ops).
### 5) Due calculation + time semantics
- **Decision**:
- `scheduled_for` is minute-slot based and stored in UTC.
- Due calculation uses the schedule timezone.
- DST (MVP): invalid local time → skip; ambiguous local time → first occurrence.
- **Rationale**: Predictable and testable; avoids “surprise catch-up”.
- **Alternatives considered**:
- Catch-up missed slots → rejected by spec (MVP explicitly “no catch-up”).
### 6) Idempotency + concurrency
- **Decision**:
- DB unique constraint: `(backup_schedule_id, scheduled_for)`.
- Cache lock per schedule (`lock:backup_schedule:{id}`) to prevent parallel execution.
- If lock held, do not run in parallel: mark run `skipped` with a clear error_code.
- **Rationale**: Prevents double runs and provides deterministic behavior.
- **Alternatives considered**:
- Only cache lock (no DB constraint) → rejected (less robust under crashes/restarts).
### 7) Retry/backoff policy
- **Decision**:
- Transient/throttling failures (e.g. 429/503) → retries with backoff.
- Auth/permission failures (401/403) → no retry.
- Unknown failures → limited retries, then fail.
- **Rationale**: Avoid noisy retry loops for non-recoverable errors.
### 8) Audit logging
- **Decision**: Use `App\Services\Intune\AuditLogger` for:
- dispatch cycle (optional aggregated)
- run start + completion
- retention applied (count deletions)
- **Rationale**: Constitution requires audit log for every operation; existing `BackupService` already writes `backup.created`.
### 9) Notifications
- **Decision**: Only interactive actions (Run now / Retry) notify the acting user (database notifications). Scheduled runs rely on Run history.
- **Rationale**: Avoid undefined “who gets notified” without adding new ownership fields.
## Open Items
None blocking Phase 1 design.

View File

@ -16,6 +16,14 @@ ## Goals
- Retention löscht alte Backups nach Policy.
- Filament UI: Schedules verwalten, Run-History ansehen, “Run now”, “Retry”.
## 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).
## Non-Goals (MVP)
- Kein Kalender-UI als Pflicht (kann später ergänzt werden).
- Kein Cross-Tenant Bulk Scheduling (MSP-Templates später).
@ -37,10 +45,14 @@ ### Functional Requirements
- **FR-003**: Run nutzt bestehende Services:
- Sync Policies (nur selektierte policy types)
- Create BackupSet aus lokalen Policy-IDs (inkl. Foundations optional)
- **FR-003a**: `policy_types` sind ausschließlich Keys aus `config('tenantpilot.supported_policy_types')`.
- **FR-003b**: UI/Server-side Validation verhindert das Speichern unbekannter `policy_types`.
- **FR-003c**: Laufzeit-Validierung (defensiv): Unbekannte `policy_types` werden geskippt; wenn mindestens ein gültiger Type verarbeitet wurde, wird der Run als `partial` markiert und `error_code=UNKNOWN_POLICY_TYPE` gesetzt (inkl. Liste der betroffenen Types in `summary`).
- **FR-003d**: Wenn zur Laufzeit nach dem Skip **0 gültige Types** verbleiben, wird **kein BackupSet** erzeugt und der Run als `skipped` markiert (mit `error_code=UNKNOWN_POLICY_TYPE` und Liste der betroffenen Types in `summary`).
- **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)
@ -114,4 +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: 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 7: Verification
- [ ] T019 Run targeted tests (Pest).
- [ ] T020 Run Pint (`./vendor/bin/pint --dirty`).
## 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
Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish

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();
});