# Data Model: 109 — Tenant Review Pack Export v1 **Date**: 2026-02-23 **Branch**: `109-review-pack-export` --- ## New Entities ### 1. `review_packs` Table | Column | Type | Constraints | Notes | |--------|------|-------------|-------| | `id` | `bigint` | PK, auto-increment | | | `workspace_id` | `bigint` | FK → workspaces, NOT NULL, cascadeOnDelete | Auto-derived via `DerivesWorkspaceIdFromTenant` | | `tenant_id` | `bigint` | FK → tenants, NOT NULL, cascadeOnDelete | | | `operation_run_id` | `bigint` | FK → operation_runs, nullable, nullOnDelete | Link to generating run; null if run is purged | | `initiated_by_user_id` | `bigint` | FK → users, nullable, nullOnDelete | User who triggered generation | | `status` | `string` | NOT NULL, default `'queued'` | Enum string: queued, generating, ready, failed, expired | | `fingerprint` | `string(64)` | nullable | SHA-256 of inputs for content dedupe | | `previous_fingerprint` | `string(64)` | nullable | Prior pack fingerprint for lineage | | `summary` | `jsonb` | default `'{}'` | `data_freshness`, counts, etc. | | `options` | `jsonb` | default `'{}'` | `include_pii`, `include_operations` flags | | `file_disk` | `string` | nullable | Storage disk name (e.g., `exports`) | | `file_path` | `string` | nullable | Relative file path within disk | | `file_size` | `bigint` | nullable | File size in bytes | | `sha256` | `string(64)` | nullable | SHA-256 of the generated ZIP file | | `generated_at` | `timestampTz` | nullable | When generation completed | | `expires_at` | `timestampTz` | nullable | Retention deadline | | `created_at` | `timestampTz` | auto | | | `updated_at` | `timestampTz` | auto | | **Indexes:** | Index | Type | Purpose | |-------|------|---------| | `(workspace_id, tenant_id, fingerprint)` | unique (where fingerprint IS NOT NULL) | Content dedupe within a tenant | | `(workspace_id, tenant_id, generated_at)` | btree | List page pagination/sort | | `(status, expires_at)` | btree | Prune command query | **Partial Unique Index (PostgreSQL):** ```sql CREATE UNIQUE INDEX review_packs_fingerprint_unique ON review_packs (workspace_id, tenant_id, fingerprint) WHERE fingerprint IS NOT NULL AND status NOT IN ('expired', 'failed'); ``` Rationale: Only active (non-expired, non-failed) packs participate in fingerprint uniqueness. Expired and failed packs can share a fingerprint with newer ready packs. --- ### 2. `ReviewPack` Model **Namespace:** `App\Models\ReviewPack` **Traits:** - `HasFactory` - `DerivesWorkspaceIdFromTenant` (auto-sets `workspace_id` from tenant) **Guarded:** `[]` (mass assignment protection via policy layer) **Casts:** ```php protected function casts(): array { return [ 'summary' => 'array', 'options' => 'array', 'generated_at' => 'datetime', 'expires_at' => 'datetime', 'file_size' => 'integer', ]; } ``` **Relationships:** ```php public function workspace(): BelongsTo → Workspace::class public function tenant(): BelongsTo → Tenant::class public function operationRun(): BelongsTo → OperationRun::class public function initiator(): BelongsTo → User::class (FK: initiated_by_user_id) ``` **Scopes:** ```php public function scopeReady(Builder $q): Builder → where('status', 'ready') public function scopeExpired(Builder $q): Builder → where('status', 'expired') public function scopePastRetention(Builder $q): Builder → where('expires_at', '<', now()) public function scopeForTenant(Builder $q, int $tenantId): Builder public function scopeLatestReady(Builder $q): Builder → ready()->latest('generated_at') ``` **Constants:** ```php const STATUS_QUEUED = 'queued'; const STATUS_GENERATING = 'generating'; const STATUS_READY = 'ready'; const STATUS_FAILED = 'failed'; const STATUS_EXPIRED = 'expired'; ``` --- ### 3. `ReviewPackStatus` Enum **Namespace:** `App\Support\ReviewPackStatus` ```php enum ReviewPackStatus: string { case Queued = 'queued'; case Generating = 'generating'; case Ready = 'ready'; case Failed = 'failed'; case Expired = 'expired'; } ``` --- ## Modified Entities ### 4. `OperationRunType` Enum — Add case ```php case ReviewPackGenerate = 'tenant.review_pack.generate'; ``` Added after `EntraAdminRolesScan`. Value follows existing `namespace.action` convention. --- ### 5. `BadgeDomain` Enum — Add case ```php case ReviewPackStatus = 'review_pack_status'; ``` **Badge Mapper:** `App\Support\Badges\Mappers\ReviewPackStatusBadge` | Value | Label | Color | |-------|-------|-------| | `queued` | Queued | warning | | `generating` | Generating | info | | `ready` | Ready | success | | `failed` | Failed | danger | | `expired` | Expired | gray | --- ### 6. `Capabilities` — Add constants ```php // Review packs public const REVIEW_PACK_VIEW = 'review_pack.view'; public const REVIEW_PACK_MANAGE = 'review_pack.manage'; ``` Added after Entra roles section. Auto-discovered by `Capabilities::all()` via reflection. --- ### 7. `config/filesystems.php` — Add `exports` disk ```php 'exports' => [ 'driver' => 'local', 'root' => storage_path('app/private/exports'), 'serve' => false, 'throw' => true, ], ``` --- ### 8. `config/tenantpilot.php` — Add `review_pack` section ```php 'review_pack' => [ 'retention_days' => (int) env('TENANTPILOT_REVIEW_PACK_RETENTION_DAYS', 90), 'hard_delete_grace_days' => (int) env('TENANTPILOT_REVIEW_PACK_HARD_DELETE_GRACE_DAYS', 30), 'download_url_ttl_minutes' => (int) env('TENANTPILOT_REVIEW_PACK_DOWNLOAD_URL_TTL_MINUTES', 60), 'include_pii_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_PII_DEFAULT', true), 'include_operations_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_OPERATIONS_DEFAULT', true), ], ``` --- ## Entity Relationship Diagram (Simplified) ``` Workspace (1) ──< ReviewPack (N) Tenant (1) ──< ReviewPack (N) OperationRun (1) ──< ReviewPack (0..1) User (1) ──< ReviewPack (N) [initiated_by_user_id] ``` ReviewPack reads (but does not mutate) during generation: - StoredReport (via workspace_id + tenant_id + report_type) - Finding (via tenant_id + status + severity) - Tenant (RBAC hardening fields) - OperationRun (recent runs, last 30 days) --- ## State Machine: `review_packs.status` ``` ┌──────────┐ Create ─────>│ queued │ └─────┬────┘ │ Job starts v ┌──────────────┐ │ generating │ └──┬────────┬──┘ Success │ │ Failure v v ┌───────┐ ┌────────┐ │ ready │ │ failed │ └───┬───┘ └────────┘ │ expires_at passed (prune cmd) v ┌─────────┐ │ expired │ └─────────┘ ``` Transitions are one-way. A failed pack is never retried (user triggers a new generation). An expired pack is never un-expired. --- ## Migration Order 1. `YYYY_MM_DD_HHMMSS_create_review_packs_table.php` — Creates table, indexes, partial unique index. No modifications to existing tables required. `OperationRunType`, `BadgeDomain`, `Capabilities`, and config changes are code-only (no migrations). --- ## Factory: `ReviewPackFactory` ```php # database/factories/ReviewPackFactory.php class ReviewPackFactory extends Factory { protected $model = ReviewPack::class; public function definition(): array { return [ 'tenant_id' => Tenant::factory(), 'status' => ReviewPackStatus::Ready->value, 'fingerprint' => fake()->sha256(), 'summary' => ['data_freshness' => [...]], 'options' => ['include_pii' => true, 'include_operations' => true], 'file_disk' => 'exports', 'file_path' => 'packs/' . fake()->uuid() . '.zip', 'file_size' => fake()->numberBetween(1024, 1024 * 1024), 'sha256' => fake()->sha256(), 'generated_at' => now(), 'expires_at' => now()->addDays(90), ]; } // States public function queued(): static → status queued, nullify file fields + generated_at public function generating(): static → status generating, nullify file fields + generated_at public function ready(): static → status ready (default) public function failed(): static → status failed, nullify file fields public function expired(): static → status expired, expires_at in the past } ```