# Feature 004: Assignments & Scope Tags - Data Model

## Overview
This document defines the database schema changes, model relationships, and data structures for the Assignments & Scope Tags feature.

---

## Database Schema Changes

### Migration 1: Add `assignments` column to `backup_items`

**File**: `database/migrations/xxxx_add_assignments_to_backup_items.php`

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('backup_items', function (Blueprint $table) {
            $table->json('assignments')->nullable()->after('metadata');
        });
    }

    public function down(): void
    {
        Schema::table('backup_items', function (Blueprint $table) {
            $table->dropColumn('assignments');
        });
    }
};
```

**JSONB Structure** (`backup_items.assignments`):
```json
[
  {
    "id": "abc-123-def",
    "target": {
      "@odata.type": "#microsoft.graph.groupAssignmentTarget",
      "groupId": "group-abc-123",
      "deviceAndAppManagementAssignmentFilterId": null,
      "deviceAndAppManagementAssignmentFilterType": "none"
    },
    "intent": "apply",
    "settings": null,
    "source": "direct"
  },
  {
    "id": "def-456-ghi",
    "target": {
      "@odata.type": "#microsoft.graph.exclusionGroupAssignmentTarget",
      "groupId": "group-def-456"
    },
    "intent": "exclude"
  }
]
```

**Index** (optional, for analytics queries):
```php
// Add GIN index for JSONB queries
DB::statement('CREATE INDEX backup_items_assignments_gin ON backup_items USING gin (assignments)');
```

---

### Migration 2: Extend `backup_items.metadata` JSONB

**Purpose**: Store assignment summary in metadata for quick access

**Updated Schema** (`backup_items.metadata`):
```json
{
  // Existing fields
  "policy_name": "Windows Security Baseline",
  "policy_type": "settingsCatalogPolicy",
  "tenant_name": "Contoso Corp",
  
  // NEW: Assignment metadata
  "assignment_count": 5,
  "scope_tag_ids": ["0", "abc-123", "def-456"],
  "scope_tag_names": ["Default", "HR-Admins", "Finance-Admins"],
  "has_orphaned_assignments": false,
  "assignments_fetch_failed": false
}
```

**No Migration Needed**: `metadata` column already exists as JSONB, just update application code to populate these fields.

---

### Migration 3: Add `group_mapping` column to `restore_runs`

**File**: `database/migrations/xxxx_add_group_mapping_to_restore_runs.php`

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('restore_runs', function (Blueprint $table) {
            $table->json('group_mapping')->nullable()->after('results');
        });
    }

    public function down(): void
    {
        Schema::table('restore_runs', function (Blueprint $table) {
            $table->dropColumn('group_mapping');
        });
    }
};
```

**JSONB Structure** (`restore_runs.group_mapping`):
```json
{
  "source-group-abc-123": "target-group-xyz-789",
  "source-group-def-456": "target-group-uvw-012",
  "source-group-ghi-789": "SKIP"
}
```

**Usage**: Maps source tenant group IDs to target tenant group IDs during restore. Special value `"SKIP"` means do not restore assignments targeting that group.

---

## Model Changes

### BackupItem Model

**File**: `app/Models/BackupItem.php`

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class BackupItem extends Model
{
    protected $fillable = [
        'backup_set_id',
        'resource_type',
        'resource_id',
        'payload',
        'metadata',
        'assignments', // NEW
    ];

    protected $casts = [
        'payload' => 'array',
        'metadata' => 'array',
        'assignments' => 'array', // NEW
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    // Relationships
    public function backupSet()
    {
        return $this->belongsTo(BackupSet::class);
    }

    // NEW: Assignment helpers
    public function getAssignmentCountAttribute(): int
    {
        return count($this->assignments ?? []);
    }

    public function hasAssignments(): bool
    {
        return !empty($this->assignments);
    }

    public function getGroupIdsAttribute(): array
    {
        return collect($this->assignments ?? [])
            ->pluck('target.groupId')
            ->filter()
            ->unique()
            ->values()
            ->toArray();
    }

    public function getScopeTagIdsAttribute(): array
    {
        return $this->metadata['scope_tag_ids'] ?? ['0'];
    }

    public function getScopeTagNamesAttribute(): array
    {
        return $this->metadata['scope_tag_names'] ?? ['Default'];
    }

    public function hasOrphanedAssignments(): bool
    {
        return $this->metadata['has_orphaned_assignments'] ?? false;
    }

    public function assignmentsFetchFailed(): bool
    {
        return $this->metadata['assignments_fetch_failed'] ?? false;
    }

    // NEW: Scope for filtering policies with assignments
    public function scopeWithAssignments($query)
    {
        return $query->whereNotNull('assignments')
                    ->whereRaw('json_array_length(assignments) > 0');
    }
}
```

---

### RestoreRun Model

**File**: `app/Models/RestoreRun.php`

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class RestoreRun extends Model
{
    protected $fillable = [
        'backup_set_id',
        'target_tenant_id',
        'status',
        'results',
        'group_mapping', // NEW
        'started_at',
        'completed_at',
    ];

    protected $casts = [
        'results' => 'array',
        'group_mapping' => 'array', // NEW
        'started_at' => 'datetime',
        'completed_at' => 'datetime',
    ];

    // Relationships
    public function backupSet()
    {
        return $this->belongsTo(BackupSet::class);
    }

    public function targetTenant()
    {
        return $this->belongsTo(Tenant::class, 'target_tenant_id');
    }

    // NEW: Group mapping helpers
    public function hasGroupMapping(): bool
    {
        return !empty($this->group_mapping);
    }

    public function getMappedGroupId(string $sourceGroupId): ?string
    {
        return $this->group_mapping[$sourceGroupId] ?? null;
    }

    public function isGroupSkipped(string $sourceGroupId): bool
    {
        return $this->group_mapping[$sourceGroupId] === 'SKIP';
    }

    public function getUnmappedGroupIds(array $sourceGroupIds): array
    {
        return array_diff($sourceGroupIds, array_keys($this->group_mapping ?? []));
    }

    public function addGroupMapping(string $sourceGroupId, string $targetGroupId): void
    {
        $mapping = $this->group_mapping ?? [];
        $mapping[$sourceGroupId] = $targetGroupId;
        $this->group_mapping = $mapping;
    }

    // NEW: Assignment restore outcomes
    public function getAssignmentRestoreOutcomes(): array
    {
        return $this->results['assignment_outcomes'] ?? [];
    }

    public function getSuccessfulAssignmentsCount(): int
    {
        return count(array_filter(
            $this->getAssignmentRestoreOutcomes(),
            fn($outcome) => $outcome['status'] === 'success'
        ));
    }

    public function getFailedAssignmentsCount(): int
    {
        return count(array_filter(
            $this->getAssignmentRestoreOutcomes(),
            fn($outcome) => $outcome['status'] === 'failed'
        ));
    }

    public function getSkippedAssignmentsCount(): int
    {
        return count(array_filter(
            $this->getAssignmentRestoreOutcomes(),
            fn($outcome) => $outcome['status'] === 'skipped'
        ));
    }
}
```

---

### Policy Model (Extensions)

**File**: `app/Models/Policy.php`

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Policy extends Model
{
    // Existing code...

    // NEW: Assignment relationship (virtual, data comes from Graph API)
    public function getAssignmentsAttribute(): ?array
    {
        // This is fetched on-demand from Graph API, not stored in DB
        // Cached for 5 minutes to reduce API calls
        return Cache::remember("policy_assignments:{$this->id}", 300, function () {
            return app(AssignmentFetcher::class)->fetch($this->tenant_id, $this->graph_id);
        });
    }

    public function hasAssignments(): bool
    {
        return !empty($this->assignments);
    }

    // NEW: Scope for policies that support assignments
    public function scopeSupportsAssignments($query)
    {
        // Only Settings Catalog policies support assignments in Phase 1
        return $query->where('type', 'settingsCatalogPolicy');
    }
}
```

---

## Service Layer Data Structures

### AssignmentFetcher Service

**Output Structure**:
```php
[
    [
        'id' => 'abc-123-def',
        'target' => [
            '@odata.type' => '#microsoft.graph.groupAssignmentTarget',
            'groupId' => 'group-abc-123',
            'deviceAndAppManagementAssignmentFilterId' => null,
            'deviceAndAppManagementAssignmentFilterType' => 'none',
        ],
        'intent' => 'apply',
        'settings' => null,
        'source' => 'direct',
    ],
    // ... more assignments
]
```

---

### GroupResolver Service

**Input**: Array of group IDs  
**Output**:
```php
[
    'group-abc-123' => [
        'id' => 'group-abc-123',
        'displayName' => 'All Users',
        'orphaned' => false,
    ],
    'group-def-456' => [
        'id' => 'group-def-456',
        'displayName' => null,
        'orphaned' => true, // Group doesn't exist in tenant
    ],
]
```

---

### ScopeTagResolver Service

**Input**: Array of scope tag IDs  
**Output**:
```php
[
    [
        'id' => '0',
        'displayName' => 'Default',
    ],
    [
        'id' => 'abc-123-def',
        'displayName' => 'HR-Admins',
    ],
]
```

---

### AssignmentRestoreService

**Input**: Policy ID, assignments array, group mapping  
**Output**:
```php
[
    [
        'status' => 'success',
        'assignment' => [...],
        'assignment_id' => 'new-abc-123',
    ],
    [
        'status' => 'failed',
        'assignment' => [...],
        'error' => 'Group not found: xyz-789',
        'request_id' => 'abc-def-ghi',
    ],
    [
        'status' => 'skipped',
        'assignment' => [...],
    ],
]
```

---

## Audit Log Entries

### New Action Types

**File**: `config/audit_log_actions.php` (if exists, or add to model)

```php
return [
    // Existing actions...

    // NEW: Assignment backup/restore actions
    'backup.assignments.included' => 'Backup created with assignments',
    'backup.assignments.fetch_failed' => 'Failed to fetch assignments during backup',
    
    'restore.group_mapping.applied' => 'Group mapping applied during restore',
    'restore.assignment.created' => 'Assignment created during restore',
    'restore.assignment.failed' => 'Assignment failed to restore',
    'restore.assignment.skipped' => 'Assignment skipped (group mapping)',
    
    'policy.assignments.viewed' => 'Policy assignments viewed',
];
```

### Example Audit Log Entries

```php
// Backup with assignments
AuditLog::create([
    'user_id' => auth()->id(),
    'tenant_id' => $tenant->id,
    'action' => 'backup.assignments.included',
    'resource_type' => 'backup_set',
    'resource_id' => $backupSet->id,
    'metadata' => [
        'policy_count' => 15,
        'assignment_count' => 47,
    ],
]);

// Group mapping applied
AuditLog::create([
    'user_id' => auth()->id(),
    'tenant_id' => $targetTenant->id,
    'action' => 'restore.group_mapping.applied',
    'resource_type' => 'restore_run',
    'resource_id' => $restoreRun->id,
    'metadata' => [
        'source_tenant_id' => $sourceTenant->id,
        'group_mapping' => $restoreRun->group_mapping,
        'mapped_count' => 5,
        'skipped_count' => 2,
    ],
]);

// Assignment created
AuditLog::create([
    'user_id' => auth()->id(),
    'tenant_id' => $targetTenant->id,
    'action' => 'restore.assignment.created',
    'resource_type' => 'assignment',
    'resource_id' => $assignmentId,
    'metadata' => [
        'policy_id' => $policyId,
        'target_group_id' => $targetGroupId,
        'intent' => 'apply',
    ],
]);
```

---

## PostgreSQL Indexes

### Recommended Indexes

```sql
-- GIN index for JSONB assignment queries (optional, for analytics)
CREATE INDEX backup_items_assignments_gin ON backup_items USING gin (assignments);

-- Index for filtering policies with assignments
CREATE INDEX backup_items_assignments_not_null 
  ON backup_items (id) 
  WHERE assignments IS NOT NULL;

-- Index for restore runs with group mapping
CREATE INDEX restore_runs_group_mapping_not_null 
  ON restore_runs (id) 
  WHERE group_mapping IS NOT NULL;

-- Composite index for tenant + resource type queries
CREATE INDEX backup_items_tenant_type_idx 
  ON backup_items (tenant_id, resource_type, created_at DESC);
```

---

## Data Size Estimates

### Storage Impact

| Entity | Existing Size | Assignment Data | Total Size | Growth |
|--------|--------------|----------------|-----------|--------|
| `backup_items` (1 policy) | ~10-50 KB | ~2-5 KB | ~12-55 KB | +20-40% |
| `backup_items` (100 policies) | ~1-5 MB | ~200-500 KB | ~1.2-5.5 MB | +20-40% |
| `restore_runs` (with mapping) | ~5-10 KB | ~1-2 KB | ~6-12 KB | +20% |

**Rationale**: Assignments are relatively small JSON objects. Even policies with 20+ assignments stay under 10 KB for assignment data.

---

## Migration Rollback Strategy

### If Rollback Needed

```php
// Rollback Migration 1
php artisan migrate:rollback --step=1
// Drops `backup_items.assignments` column

// Rollback Migration 2
php artisan migrate:rollback --step=1
// Drops `restore_runs.group_mapping` column
```

**Data Loss**: Rolling back will lose all stored assignments and group mappings. Backups can be re-created with assignments after rolling forward again.

**Safe Rollback**: Since assignments are optional (controlled by checkbox), existing backups without assignments remain functional.

---

## Validation Rules

### BackupItem Validation

```php
// In BackupItem model or Form Request
public static function assignmentsValidationRules(): array
{
    return [
        'assignments' => ['nullable', 'array'],
        'assignments.*.id' => ['required', 'string'],
        'assignments.*.target' => ['required', 'array'],
        'assignments.*.target.@odata.type' => ['required', 'string'],
        'assignments.*.target.groupId' => ['required_if:assignments.*.target.@odata.type,#microsoft.graph.groupAssignmentTarget', 'string'],
        'assignments.*.intent' => ['required', 'string', 'in:apply,exclude'],
    ];
}
```

### RestoreRun Group Mapping Validation

```php
// In RestoreRun model or Form Request
public static function groupMappingValidationRules(): array
{
    return [
        'group_mapping' => ['nullable', 'array'],
        'group_mapping.*' => ['string'], // Target group ID or "SKIP"
    ];
}
```

---

## Summary

### Database Changes
- ✅ `backup_items.assignments` (JSONB, nullable)
- ✅ `backup_items.metadata` (extend with assignment summary)
- ✅ `restore_runs.group_mapping` (JSONB, nullable)

### Model Enhancements
- ✅ `BackupItem`: Assignment accessors, scopes, helpers
- ✅ `RestoreRun`: Group mapping helpers, outcome methods
- ✅ `Policy`: Virtual assignments relationship (cached)

### Indexes
- ✅ GIN index on `backup_items.assignments` (optional)
- ✅ Partial indexes for non-null checks

### Data Structures
- ✅ Assignment JSON schema defined
- ✅ Group mapping JSON schema defined
- ✅ Audit log action types defined

---

**Status**: Data Model Complete  
**Next Document**: `quickstart.md`
