# Feature 004: Assignments & Scope Tags - Research Notes

## Overview
This document captures key technology decisions, API patterns, and implementation strategies for the Assignments & Scope Tags feature.

---

## Research Questions & Answers

### 1. How should we store assignments data?

**Question**: Should assignments be stored as JSONB in `backup_items` table or as separate relational tables?

**Options Evaluated**:

| Approach | Pros | Cons |
|----------|------|------|
| **JSONB Column** | Simple schema, flexible structure, fast writes, matches Graph API response format | Complex queries, no foreign key constraints, larger row size |
| **Separate Tables** (`assignment` table) | Normalized, relational integrity, easier reporting | Complex migrations, slower backups, graph-to-relational mapping overhead |

**Decision**: **JSONB Column** (`backup_items.assignments`)

**Rationale**:
1. Assignments are **immutable snapshots** - we're not querying/filtering them frequently
2. Graph API returns assignments as nested JSON - direct storage avoids mapping
3. Backup/restore operations are write-heavy, not read-heavy (JSONB excels here)
4. Simplifies restore logic (no need to reconstruct JSON from relations)
5. Matches existing pattern for policy payloads in `backup_items.payload` (JSONB)

**Implementation**:
```php
// Migration
Schema::table('backup_items', function (Blueprint $table) {
    $table->json('assignments')->nullable()->after('metadata');
});

// Model Cast
protected $casts = [
    'assignments' => 'array',
    // ...
];

// Accessor for assignment count
public function getAssignmentCountAttribute(): int
{
    return count($this->assignments ?? []);
}
```

**Trade-off**: If we need advanced assignment analytics (e.g., "show me all backups targeting group X"), we'll need to:
- Use PostgreSQL JSONB query operators (`@>`, `?`)
- Or add a read model (separate `assignment_analytics` table populated async)

---

### 2. What's the best Graph API fallback strategy for assignments?

**Question**: The spec mentions a fallback strategy for fetching assignments. What's the production-tested approach?

**Problem Context**:
- Graph API behavior varies by policy template family
- Some policies return empty `/assignments` endpoint but have assignments via `$expand`
- Known issue with certain Settings Catalog template types

**Strategy** (from spec FR-004.2):

```php
class AssignmentFetcher
{
    public function fetch(string $tenantId, string $policyId): array
    {
        try {
            // Primary: Direct assignments endpoint
            $response = $this->graph->get("/deviceManagement/configurationPolicies/{$policyId}/assignments");
            
            if (!empty($response['value'])) {
                return $response['value'];
            }
            
            // Fallback: Use $expand (slower but more reliable)
            $response = $this->graph->get(
                "/deviceManagement/configurationPolicies",
                [
                    '$filter' => "id eq '{$policyId}'",
                    '$expand' => 'assignments'
                ]
            );
            
            return $response['value'][0]['assignments'] ?? [];
            
        } catch (GraphException $e) {
            // Log warning, return empty (fail-soft)
            Log::warning("Failed to fetch assignments for policy {$policyId}", [
                'tenant_id' => $tenantId,
                'error' => $e->getMessage(),
                'request_id' => $e->getRequestId(),
            ]);
            
            return [];
        }
    }
}
```

**Testing Strategy**:
- Mock both successful and failed responses
- Test with empty `value` array (triggers fallback)
- Test complete failure (returns empty, logs warning)

**Edge Cases**:
- **Rate Limiting**: If primary call hits rate limit, fallback may also fail → log and continue
- **Timeout**: 30-second timeout on both calls → fail-soft, mark `assignments_fetch_failed: true`

---

### 3. How to resolve group IDs to names efficiently?

**Question**: We need to display group names in UI, but assignments only have group IDs. What's the best resolution strategy?

**Graph API Options**:

| Method | Endpoint | Pros | Cons |
|--------|----------|------|------|
| **Batch Resolution** | POST `/directoryObjects/getByIds` | Single request for 100+ IDs, stable API | Requires POST, batch size limit (1000) |
| **Filter Query** | GET `/groups?$filter=id in (...)` | Standard GET | Requires advanced query (not all tenants enabled), URL length limits |
| **Individual GET** | GET `/groups/{id}` per group | Simple | N+1 queries, slow for 50+ groups |

**Decision**: **POST `/directoryObjects/getByIds` with Caching**

**Rationale**:
1. Most reliable for large group counts (tested with 500+ groups)
2. Single request vs N requests
3. Works without advanced query requirements
4. Supports multiple object types (groups, users, devices)

**Implementation**:
```php
class GroupResolver
{
    public function resolveGroupIds(array $groupIds, string $tenantId): array
    {
        // Check cache first (5 min TTL)
        $cacheKey = "groups:{$tenantId}:" . md5(implode(',', $groupIds));
        
        if ($cached = Cache::get($cacheKey)) {
            return $cached;
        }
        
        // Batch resolve
        $response = $this->graph->post('/directoryObjects/getByIds', [
            'ids' => $groupIds,
            'types' => ['group'],
        ]);
        
        $resolved = collect($response['value'])->keyBy('id')->toArray();
        
        // Handle orphaned IDs
        $result = [];
        foreach ($groupIds as $id) {
            $result[$id] = $resolved[$id] ?? [
                'id' => $id,
                'displayName' => null, // Will render as "Unknown Group (ID: {id})"
                'orphaned' => true,
            ];
        }
        
        Cache::put($cacheKey, $result, now()->addMinutes(5));
        
        return $result;
    }
}
```

**Optimization**: Pre-warm cache when entering Group Mapping wizard (fetch all target tenant groups once).

---

### 4. What's the UX pattern for Group Mapping with 500+ groups?

**Question**: How do we make group mapping usable for large tenants?

**UX Requirements** (from NFR-004.2):
- Must support 500+ groups
- Must be searchable
- Should feel responsive

**Filament Solution**: **Searchable Select with Server-Side Filtering**

```php
use Filament\Forms\Components\Select;

Select::make('target_group_id')
    ->label('Target Group')
    ->searchable()
    ->getSearchResultsUsing(fn (string $search) => 
        Group::query()
            ->where('tenant_id', $this->targetTenantId)
            ->where('display_name', 'ilike', "%{$search}%")
            ->limit(50)
            ->pluck('display_name', 'graph_id')
    )
    ->getOptionLabelUsing(fn ($value): ?string => 
        Group::where('graph_id', $value)->first()?->display_name
    )
    ->lazy() // Load options only when dropdown opens
    ->debounce(500) // Wait 500ms after typing before searching
```

**Caching Strategy**:
```php
// Pre-warm cache when wizard opens
public function mount()
{
    // Cache all target tenant groups for 5 minutes
    Cache::remember("groups:{$this->targetTenantId}", 300, function () {
        return $this->graph->get('/groups?$select=id,displayName')['value'];
    });
}
```

**Alternative** (if Filament Select is slow): Use Livewire component with Alpine.js dropdown + AJAX search.

---

### 5. How to handle assignment restore failures gracefully?

**Question**: What if some assignments succeed and others fail during restore?

**Requirements** (from FR-004.12, FR-004.13):
- Continue with remaining assignments (fail-soft)
- Log per-assignment outcome
- Report final status: "3 of 5 assignments restored"

**DELETE-then-CREATE Pattern**:
```php
class AssignmentRestoreService
{
    public function restore(string $policyId, array $assignments, array $groupMapping): array
    {
        $outcomes = [];
        
        // Step 1: DELETE existing assignments (clean slate)
        $existing = $this->graph->get("/deviceManagement/configurationPolicies/{$policyId}/assignments");
        
        foreach ($existing['value'] as $assignment) {
            try {
                $this->graph->delete("/deviceManagement/configurationPolicies/{$policyId}/assignments/{$assignment['id']}");
                // 204 No Content = success
            } catch (GraphException $e) {
                Log::warning("Failed to delete assignment {$assignment['id']}", [
                    'error' => $e->getMessage(),
                    'request_id' => $e->getRequestId(),
                ]);
            }
        }
        
        // Step 2: CREATE new assignments (with mapped IDs)
        foreach ($assignments as $assignment) {
            // Apply group mapping
            if (isset($assignment['target']['groupId'])) {
                $sourceGroupId = $assignment['target']['groupId'];
                
                // Skip if marked in group mapping
                if (isset($groupMapping[$sourceGroupId]) && $groupMapping[$sourceGroupId] === 'SKIP') {
                    $outcomes[] = ['status' => 'skipped', 'assignment' => $assignment];
                    continue;
                }
                
                // Replace with target group ID
                $assignment['target']['groupId'] = $groupMapping[$sourceGroupId] ?? $sourceGroupId;
            }
            
            try {
                $response = $this->graph->post(
                    "/deviceManagement/configurationPolicies/{$policyId}/assignments",
                    $assignment
                );
                // 201 Created = success
                
                $outcomes[] = ['status' => 'success', 'assignment' => $assignment];
                
                // Audit log
                AuditLog::create([
                    'action' => 'restore.assignment.created',
                    'resource_type' => 'assignment',
                    'resource_id' => $response['id'],
                    'metadata' => $assignment,
                ]);
                
            } catch (GraphException $e) {
                $outcomes[] = [
                    'status' => 'failed',
                    'assignment' => $assignment,
                    'error' => $e->getMessage(),
                    'request_id' => $e->getRequestId(),
                ];
                
                Log::error("Failed to restore assignment", [
                    'policy_id' => $policyId,
                    'assignment' => $assignment,
                    'error' => $e->getMessage(),
                ]);
            }
            
            // Rate limit protection: 100ms delay between POSTs
            usleep(100000);
        }
        
        return $outcomes;
    }
}
```

**Outcome Reporting**:
```php
$successCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'success'));
$failedCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'failed'));
$skippedCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'skipped'));

Notification::make()
    ->title("Assignment Restore Complete")
    ->body("{$successCount} of {$total} assignments restored. {$failedCount} failed, {$skippedCount} skipped.")
    ->success()
    ->send();
```

**Why DELETE-then-CREATE vs PATCH**?
- PATCH requires knowing existing assignment IDs (not available from backup)
- DELETE-then-CREATE is idempotent (can rerun safely)
- Graph API doesn't support "upsert" for assignments

---

### 6. How to handle Scope Tags?

**Question**: Scope Tags are simpler than assignments (just an array of IDs in policy payload). How should we handle them?

**Requirements**:
- Extract `roleScopeTagIds` array from policy payload during backup
- Resolve Scope Tag IDs to names for display
- Preserve IDs during restore (if they exist in target tenant)
- Warn if Scope Tag doesn't exist in target (don't block restore)

**Implementation**:

```php
// During Backup
class AssignmentBackupService
{
    public function backupScopeTags(array $policyPayload): array
    {
        $scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0']; // Default Scope Tag
        
        // Resolve names (cached for 1 hour)
        $scopeTags = $this->scopeTagResolver->resolve($scopeTagIds);
        
        return [
            'scope_tag_ids' => $scopeTagIds,
            'scope_tag_names' => array_column($scopeTags, 'displayName'),
        ];
    }
}

// During Restore
class AssignmentRestoreService
{
    public function validateScopeTags(array $scopeTagIds, string $targetTenantId): array
    {
        $targetScopeTags = $this->scopeTagResolver->getAllForTenant($targetTenantId);
        $targetScopeTagIds = array_column($targetScopeTags, 'id');
        
        $matched = array_intersect($scopeTagIds, $targetScopeTagIds);
        $missing = array_diff($scopeTagIds, $targetScopeTagIds);
        
        if (!empty($missing)) {
            Log::warning("Some Scope Tags not found in target tenant", [
                'missing' => $missing,
                'matched' => $matched,
            ]);
        }
        
        return [
            'matched' => $matched,
            'missing' => $missing,
            'can_proceed' => true, // Always allow restore
        ];
    }
}
```

**Caching**:
```php
class ScopeTagResolver
{
    public function resolve(array $scopeTagIds): array
    {
        return Cache::remember("scope_tags:all", 3600, function () {
            return $this->graph->get('/deviceManagement/roleScopeTags?$select=id,displayName')['value'];
        });
    }
}
```

**Display in UI**:
```php
// Restore Preview
Scope Tags:
  ✅ 2 matched (Default, HR-Admins)
  ⚠️ 1 not found in target (Finance-Admins)
  
Note: Restore will proceed. Missing Scope Tags will be created automatically by Graph API or ignored.
```

---

### 7. What's the testing strategy?

**Question**: How do we ensure reliability across all scenarios?

**Test Pyramid**:

```
        /\
       /  \
      / UI \           Browser Tests (5)
     /------\          - Group Mapping wizard flow
    / Feature \        Feature Tests (10)
   /----------\        - Backup with assignments
  /   Unit     \       - Policy view rendering
 /--------------\      - Restore with mapping
                        - Assignment fetch failures

                       Unit Tests (15)
                       - AssignmentFetcher
                       - GroupResolver
                       - ScopeTagResolver
                       - Model accessors
                       - Service methods
```

**Key Test Scenarios**:

1. **Unit Tests** (Fast, isolated):
   ```php
   it('fetches assignments with fallback on empty response', function () {
       $fetcher = new AssignmentFetcher($this->mockGraph);
       
       // Mock primary call returning empty
       $this->mockGraph
           ->shouldReceive('get')
           ->with('/deviceManagement/configurationPolicies/abc-123/assignments')
           ->andReturn(['value' => []]);
       
       // Mock fallback call returning assignments
       $this->mockGraph
           ->shouldReceive('get')
           ->with('/deviceManagement/configurationPolicies', [...])
           ->andReturn(['value' => [['assignments' => [...]]]]);
       
       $result = $fetcher->fetch('tenant-1', 'abc-123');
       
       expect($result)->toHaveCount(3);
   });
   ```

2. **Feature Tests** (Integration):
   ```php
   it('backs up policy with assignments when checkbox enabled', function () {
       $tenant = Tenant::factory()->create();
       $policy = Policy::factory()->for($tenant)->create();
       
       // Mock Graph API
       Http::fake([
           '*/assignments' => Http::response(['value' => [...]]),
           '*/roleScopeTags' => Http::response(['value' => [...]]),
       ]);
       
       $backupItem = (new AssignmentBackupService)->backup(
           tenantId: $tenant->id,
           policyId: $policy->graph_id,
           includeAssignments: true
       );
       
       expect($backupItem->assignments)->toHaveCount(3);
       expect($backupItem->metadata['assignment_count'])->toBe(3);
   });
   ```

3. **Browser Tests** (E2E):
   ```php
   it('allows group mapping during restore wizard', function () {
       $page = visit('/restore/wizard');
       
       $page->assertSee('Group Mapping Required')
           ->fill('source-group-abc-123', 'target-group-xyz-789')
           ->click('Continue')
           ->assertSee('Restore Preview')
           ->click('Restore')
           ->assertSee('Restore Complete');
       
       $restoreRun = RestoreRun::latest()->first();
       expect($restoreRun->group_mapping)->toHaveKey('source-group-abc-123');
   });
   ```

**Manual QA Checklist**:
- [ ] Create backup with assignments (same tenant)
- [ ] Create backup without assignments
- [ ] View policy with assignments tab
- [ ] Restore to same tenant (auto-match groups)
- [ ] Restore to different tenant (group mapping wizard)
- [ ] Handle orphaned group IDs gracefully
- [ ] Skip assignments during group mapping
- [ ] Handle Graph API failures (assignments fetch, group resolution)

---

## Technology Stack Summary

| Component | Technology | Justification |
|-----------|-----------|---------------|
| **Assignment Storage** | PostgreSQL JSONB | Immutable snapshots, matches Graph API format |
| **Graph API Fallback** | Primary + Fallback pattern | Production-tested reliability |
| **Group Resolution** | POST `/directoryObjects/getByIds` + Cache | Batch efficiency, 5min TTL |
| **Group Mapping UX** | Filament Searchable Select + Debounce | Supports 500+ groups, responsive |
| **Restore Pattern** | DELETE-then-CREATE | Idempotent, no ID tracking needed |
| **Scope Tag Handling** | Extract from payload + Cache | Simple, 1hr TTL |
| **Error Handling** | Fail-soft + Per-item logging | Graceful degradation |
| **Caching** | Laravel Cache (Redis/File) | 5min groups, 1hr scope tags |

---

## Performance Benchmarks

**Target Metrics**:
- Assignment fetch: < 2 seconds per policy (with fallback)
- Group resolution: < 1 second for 100 groups (batched)
- Group mapping UI: < 500ms search response with 500+ groups (debounced)
- Assignment restore: < 30 seconds for 20 policies (sequential with 100ms delay)

**Optimization Opportunities**:
1. Pre-warm group cache when wizard opens
2. Batch assignment POSTs (if Graph supports batch endpoint - check docs)
3. Use queues for large restores (20+ policies)
4. Add progress polling for long-running restores

---

## Open Questions (For Implementation)

1. **Smart Matching**: Should we auto-suggest group mappings based on name similarity?
   - **Defer**: Manual mapping MVP, consider for Phase 2

2. **Dynamic Groups**: How to handle membership rules?
   - **Defer**: Skip initially, document as limitation

3. **Assignment Filters**: Do we preserve device/user filters?
   - **Yes**: Store entire assignment object in JSONB

4. **Batch API**: Does Graph support batch assignment POST?
   - **Research**: Check Graph API docs for `/batch` endpoint support

---

**Status**: Research Complete  
**Next Document**: `data-model.md`
