TenantAtlas/specs/109-review-pack-export/contracts/api-contracts.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

7.7 KiB

API Contracts: 109 — Tenant Review Pack Export v1

Date: 2026-02-23 Branch: 109-review-pack-export


Overview

This feature introduces three interaction surfaces:

  1. Filament Resource — ReviewPackResource (list, view pages + modal generate action)
  2. Filament Widget — Tenant Dashboard card
  3. HTTP Route — Signed download endpoint

All Filament interactions are handled through Livewire/Filament's built-in request model (no custom REST endpoints). The only non-Filament HTTP endpoint is the signed file download route.


1. Signed Download Route

GET /admin/review-packs/{reviewPack}/download

Route Name: admin.review-packs.download

Authentication: Signed URL (via URL::signedRoute()). No active session required.

Authorization: URL is generated only if requesting user has REVIEW_PACK_VIEW capability in the pack's workspace+tenant scope. Non-members cannot obtain a valid URL. Signature validation is done by middleware.

Parameters:

Parameter Location Type Required Notes
reviewPack path integer yes ReviewPack record ID
signature query string yes Auto-injected by URL::signedRoute()
expires query integer yes Auto-injected by URL::signedRoute()

Response (Success — 200):

Content-Type: application/zip
Content-Disposition: attachment; filename="review-pack-{tenant_external_id}-{YYYY-MM-DD}.zip"
Content-Length: {file_size}
X-Review-Pack-SHA256: {sha256}

{binary ZIP content streamed from exports disk}

Response (Expired Signature — 403):

{ "message": "Invalid signature." }

Response (Pack Expired or Not Ready — 404):

{ "message": "Not Found" }

Response (Pack Not Found / Wrong Workspace — 404):

{ "message": "Not Found" }

Implementation Notes:

  • Route middleware: signed (Laravel built-in)
  • Controller validates reviewPack->status === 'ready' before streaming
  • File streamed via Storage::disk($pack->file_disk)->download($pack->file_path, ...)
  • Response includes X-Review-Pack-SHA256 header for client-side integrity verification

2. Signed URL Generation (Internal)

Not an HTTP endpoint. URL is generated server-side when user clicks "Download" in Filament.

// Service method
public function generateDownloadUrl(ReviewPack $pack): string
{
    return URL::signedRoute(
        'admin.review-packs.download',
        ['reviewPack' => $pack->id],
        now()->addMinutes(config('tenantpilot.review_pack.download_url_ttl_minutes', 60))
    );
}

Authorization check happens before URL generation (Gate/Policy), not at download time (signature-only at download).


3. Filament Actions Contract

ReviewPackResource — Header Action: "Generate Pack"

Trigger: Modal action button on List page header Capability Required: REVIEW_PACK_MANAGE Modal Fields:

Field Type Default Notes
include_pii Toggle true Label: "Include display names (PII)"
include_operations Toggle true Label: "Include operations log"

Behavior:

  1. Check for active OperationRun of type tenant.review_pack.generate for this tenant → if exists, show error notification "Generation already in progress" and abort.
  2. Compute input fingerprint → check for existing ready unexpired pack with same fingerprint → if exists, show info notification "Identical pack already exists" with download link and abort.
  3. Create OperationRun (type: tenant.review_pack.generate, status: queued).
  4. Create ReviewPack (status: queued, linked to OperationRun).
  5. Dispatch GenerateReviewPackJob.
  6. Show success notification: "Review pack generation started."

ReviewPackResource — Row Action: "Download"

Visibility: status === 'ready' Capability Required: REVIEW_PACK_VIEW Behavior: Generate signed URL → open in new tab (->openUrlInNewTab())

ReviewPackResource — Row Action: "Expire"

Visibility: status === 'ready' Capability Required: REVIEW_PACK_MANAGE Destructive: Yes → ->requiresConfirmation() + ->color('danger') Behavior:

  1. Set status = expired.
  2. Delete file from exports disk.
  3. Show success notification: "Review pack expired."

ReviewPack View Page — Header Action: "Regenerate"

Capability Required: REVIEW_PACK_MANAGE Destructive: Yes (if a ready pack exists) → ->requiresConfirmation() Behavior: Same as "Generate Pack" but with the current pack's options pre-filled and previous_fingerprint set to the current pack's fingerprint.


4. Tenant Dashboard Widget Contract

TenantReviewPackCard Widget

Location: Tenant dashboard Data: Latest ReviewPack for current tenant (eager-loaded)

Display States:

Pack State Card Content Actions
No pack exists "No review pack yet" "Generate first pack" (REVIEW_PACK_MANAGE)
queued / generating Status badge + "Generation in progress"
ready Status badge + generated_at + expires_at + file_size "Download" (REVIEW_PACK_VIEW) + "Generate new" (REVIEW_PACK_MANAGE)
failed Status badge + failure reason (sanitized) "Retry" = Generate (REVIEW_PACK_MANAGE)
expired Status badge + "Expired on {date}" "Generate new" (REVIEW_PACK_MANAGE)

5. Job Contract

GenerateReviewPackJob

Queue: default Implements: ShouldQueue Unique: Via OperationRun active-run dedupe (not Laravel's ShouldBeUnique)

Input:

public function __construct(
    public int $reviewPackId,
    public int $operationRunId,
) {}

Steps:

  1. Load ReviewPack + OperationRun; abort if either missing.
  2. Mark OperationRun as running, ReviewPack as generating.
  3. Collect data: StoredReports, Findings, Tenant hardening, recent OperationRuns.
  4. Compute data_freshness timestamps per source.
  5. Build in-memory file map (filenames → content).
  6. Apply PII redaction if options.include_pii === false.
  7. Assemble ZIP to temp file (alphabetical insertion order).
  8. Compute SHA-256 of ZIP.
  9. Store ZIP on exports disk.
  10. Update ReviewPack: status=ready, fingerprint, sha256, file_size, file_path, file_disk, generated_at, summary.
  11. Mark OperationRun as completed, outcome=success.
  12. Send ReviewPackStatusNotification (ready) to initiator.

On Failure:

  1. Update ReviewPack: status=failed.
  2. Mark OperationRun as completed, outcome=failed, with reason_code in context.
  3. Send ReviewPackStatusNotification (failed) to initiator.
  4. Re-throw exception for queue worker visibility.

6. Artisan Command Contract

tenantpilot:review-pack:prune

Signature: tenantpilot:review-pack:prune {--hard-delete}

Behavior:

  1. Query ReviewPacks where status = 'ready' AND expires_at < now().
  2. For each: set status = expired, delete file from disk.
  3. If --hard-delete: query ReviewPacks where status = 'expired' AND updated_at < now() - grace_days. Hard-delete these rows.
  4. Output summary: {n} packs expired, {m} packs hard-deleted.

Schedule: daily() + withoutOverlapping()


7. Notification Contract

ReviewPackStatusNotification

Channel: Database (Filament notification system) Recipients: Initiator user (via initiated_by_user_id)

Payload (ready):

title: "Review pack ready"
body: "Review pack for {tenant_name} is ready for download."
actions: [View → ViewReviewPack page URL]

Payload (failed):

title: "Review pack generation failed"
body: "Review pack for {tenant_name} could not be generated: {sanitized_reason}."
actions: [View → ViewReviewPack page URL]