## 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
8.6 KiB
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):
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:
HasFactoryDerivesWorkspaceIdFromTenant(auto-setsworkspace_idfrom tenant)
Guarded: [] (mass assignment protection via policy layer)
Casts:
protected function casts(): array
{
return [
'summary' => 'array',
'options' => 'array',
'generated_at' => 'datetime',
'expires_at' => 'datetime',
'file_size' => 'integer',
];
}
Relationships:
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:
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:
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
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
case ReviewPackGenerate = 'tenant.review_pack.generate';
Added after EntraAdminRolesScan. Value follows existing namespace.action convention.
5. BadgeDomain Enum — Add case
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
// 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
'exports' => [
'driver' => 'local',
'root' => storage_path('app/private/exports'),
'serve' => false,
'throw' => true,
],
8. config/tenantpilot.php — Add review_pack section
'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
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
# 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
}