# 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):** ```json { "message": "Invalid signature." } ``` **Response (Pack Expired or Not Ready — 404):** ```json { "message": "Not Found" } ``` **Response (Pack Not Found / Wrong Workspace — 404):** ```json { "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. ```php // 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:** ```php 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] ```