TenantAtlas/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php
Ahmed Darrazi c3bdcf4d2d feat(004): implement PolicyCaptureOrchestrator for assignment consistency
BREAKING CHANGE: Assignment capture flow completely refactored

Core Changes:
- Created PolicyCaptureOrchestrator service for centralized capture coordination
- Refactored BackupService to use orchestrator (version-first approach)
- Fixed domain model bug: PolicyVersion now stores assignments (source of truth)
- BackupItem references PolicyVersion and copies assignments for restore

Database:
- Added assignments, scope_tags, assignments_hash, scope_tags_hash to policy_versions
- Added policy_version_id foreign key to backup_items
- Migrations: 2025_12_22_171525, 2025_12_22_171545

Services:
- PolicyCaptureOrchestrator: Intelligent version reuse, idempotent backfilling
- VersionService: Enhanced to capture assignments during version creation
- BackupService: Uses orchestrator, version-first capture flow

UI:
- Moved assignments widget from Policy to PolicyVersion view
- Created PolicyVersionAssignmentsWidget Livewire component
- Updated BackupItemsRelationManager columns for new assignment fields

Tests:
- Deleted BackupWithAssignmentsTest (old behavior)
- Created BackupWithAssignmentsConsistencyTest (4 tests, all passing)
- Fixed AssignmentFetcherTest and GroupResolverTest for GraphResponse
- All 162 tests passing

Issue: Assignments/scope tags not displaying in BackupSet items table (UI only)
Status: Database contains correct data, UI column definitions need adjustment
2025-12-22 20:19:10 +01:00

185 lines
8.3 KiB
PHP

<?php
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
use App\Filament\Resources\PolicyResource;
use App\Models\BackupItem;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BackupItemsRelationManager extends RelationManager
{
protected static string $relationship = 'items';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->columns([
Tables\Columns\TextColumn::make('policy.display_name')
->label('Policy')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state) => static::typeMeta($state)['label'] ?? $state),
Tables\Columns\TextColumn::make('restore_mode')
->label('Restore')
->badge()
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
->color(fn (?string $state) => $state === 'preview-only' ? 'warning' : 'success'),
Tables\Columns\TextColumn::make('risk')
->label('Risk')
->badge()
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['risk'] ?? 'n/a')
->color(fn (?string $state) => str_contains((string) $state, 'high') ? 'danger' : 'gray'),
Tables\Columns\TextColumn::make('policy_identifier')
->label('Policy ID')
->copyable(),
Tables\Columns\TextColumn::make('platform')->badge(),
Tables\Columns\TextColumn::make('assignments')
->label('Assignments')
->badge()
->color('info')
->formatStateUsing(fn ($state) => is_array($state) ? count($state) : 0),
Tables\Columns\TextColumn::make('scope_tags')
->label('Scope Tags')
->badge()
->separator(',')
->default('—')
->formatStateUsing(function ($state, BackupItem $record) {
// Get scope tags from PolicyVersion if available
if ($record->policyVersion && !empty($record->policyVersion->scope_tags)) {
$tags = $record->policyVersion->scope_tags;
if (is_array($tags) && isset($tags['names'])) {
return implode(', ', $tags['names']);
}
}
return '—';
}),
Tables\Columns\TextColumn::make('captured_at')->dateTime(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->headerActions([
Actions\Action::make('addPolicies')
->label('Add Policies')
->icon('heroicon-o-plus')
->form([
Forms\Components\Select::make('policy_ids')
->label('Policies')
->multiple()
->required()
->searchable()
->options(function (RelationManager $livewire) {
$backupSet = $livewire->getOwnerRecord();
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
$existing = $backupSet
? $backupSet->items()->pluck('policy_id')->filter()->all()
: [];
return Policy::query()
->where('tenant_id', $tenantId)
->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround)
->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing))
->orderBy('display_name')
->pluck('display_name', 'id');
}),
Forms\Components\Checkbox::make('include_assignments')
->label('Include Assignments')
->default(true)
->helperText('Capture policy assignments and scope tags'),
])
->action(function (array $data, BackupService $service) {
if (empty($data['policy_ids'])) {
Notification::make()
->title('No policies selected')
->warning()
->send();
return;
}
$backupSet = $this->getOwnerRecord();
$tenant = $backupSet?->tenant ?? Tenant::current();
$service->addPoliciesToSet(
tenant: $tenant,
backupSet: $backupSet,
policyIds: $data['policy_ids'],
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
includeAssignments: $data['include_assignments'] ?? false,
);
Notification::make()
->title('Policies added to backup')
->success()
->send();
}),
])
->actions([
Actions\ViewAction::make()
->label('View policy')
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null)
->hidden(fn ($record) => ! $record->policy_id)
->openUrlInNewTab(true),
Actions\Action::make('remove')
->label('Remove')
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (BackupItem $record, AuditLogger $auditLogger) {
$record->delete();
if ($record->backupSet) {
$record->backupSet->update([
'item_count' => $record->backupSet->items()->count(),
]);
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.item_removed',
resourceType: 'backup_set',
resourceId: (string) $record->backup_set_id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id]]
);
}
Notification::make()
->title('Policy removed from backup')
->success()
->send();
}),
])
->bulkActions([]);
}
/**
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,mixed>
*/
private static function typeMeta(?string $type): array
{
if ($type === null) {
return [];
}
return collect(config('tenantpilot.supported_policy_types', []))
->firstWhere('type', $type) ?? [];
}
}