TenantAtlas/specs/109-review-pack-export/data-model.md

275 lines
8.6 KiB
Markdown

# 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
}
```