# 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]
```
