## Summary - Fixes misleading “queued / running in background” message when Review Pack generation request reuses an existing ready pack (fingerprint dedupe). - Improves resilience of Filament/Livewire interactions by ensuring the Livewire intercept shim applies after Livewire initializes. - Aligns Review Pack operation notifications with Ops-UX patterns (queued + completed notifications) and removes the old ReviewPackStatusNotification. ## Key Changes - Review Pack generate action now: - Shows queued toast only when a new pack is actually created/queued. - Shows a “Review pack already available” success notification with a link when dedupe returns an existing pack. ## Tests - `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackGenerationTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/LivewireInterceptShimTest.php` ## Notes - No global search behavior changes for ReviewPacks (still excluded). - Destructive actions remain confirmation-gated (`->requiresConfirmation()`). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #133
275 lines
8.6 KiB
Markdown
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
|
|
}
|
|
```
|