TenantAtlas/specs/109-review-pack-export/data-model.md
ahmido 9f5c99317b Fix Review Pack generation UX + notifications (#133)
## 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
2026-02-23 19:42:52 +00:00

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:

  • HasFactory
  • DerivesWorkspaceIdFromTenant (auto-sets workspace_id from 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

  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

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