227 lines
7.7 KiB
Markdown
227 lines
7.7 KiB
Markdown
# 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]
|
|
```
|