diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index b1b09cc..d9072f6 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -16,6 +16,8 @@ ## Active Technologies - PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting) - PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard) - PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard) +- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical) +- PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical) - PHP 8.4.15 (feat/005-bulk-operations) @@ -35,9 +37,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) +- 078-operations-tenantless-canonical: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] - 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x -- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 -- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 diff --git a/specs/078-operations-tenantless-canonical/contracts/routes.md b/specs/078-operations-tenantless-canonical/contracts/routes.md new file mode 100644 index 0000000..628fb1e --- /dev/null +++ b/specs/078-operations-tenantless-canonical/contracts/routes.md @@ -0,0 +1,108 @@ +# Route Contracts: Operations Tenantless Canonical Migration + +**Feature**: 078-operations-tenantless-canonical +**Date**: 2026-02-06 + +--- + +## Canonical Routes (Retained — No Changes) + +### GET /admin/operations + +| Property | Value | +|----------|-------| +| **Route name** | `admin.operations.index` | +| **Handler** | `App\Filament\Pages\Monitoring\Operations` | +| **Middleware** | `web`, `panel:admin`, `ensure-correct-guard:web`, `DenyNonMemberTenantAccess`, Filament middleware, `ensure-workspace-selected`, `ensure-filament-tenant-selected` | +| **Auth** | Requires authentication + workspace membership | +| **Scope** | Workspace-level (shows all runs in workspace) | +| **Response** | 200 HTML (Livewire page) | + +### GET /admin/operations/{run} + +| Property | Value | +|----------|-------| +| **Route name** | `admin.operations.view` | +| **Handler** | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | +| **Middleware** | `web`, `panel:admin`, `ensure-correct-guard:web`, `DenyNonMemberTenantAccess`, Filament middleware | +| **Auth** | Requires authentication + workspace membership for `$run->workspace_id` | +| **Model binding** | `{run}` resolves to `OperationRun` by ID | +| **Non-member** | 404 (deny-as-not-found) | +| **Not found** | 404 (Laravel model binding) | +| **Response** | 200 HTML (Livewire page with infolist) | + +--- + +## Decommissioned Routes (Removed After Migration) + +### GET /admin/t/{tenant}/operations/r/{record} + +| Property | Value | +|----------|-------| +| **Route name** | `filament.admin.resources.operations.view` | +| **Status** | ❌ **REMOVED** — route no longer registered | +| **After migration** | Natural 404 | +| **Previously** | `ViewOperationRun` (Filament ViewRecord page) | + +### GET /admin/t/{tenant}/operations + +| Property | Value | +|----------|-------| +| **Route name** | `filament.admin.resources.operations.index` | +| **Status** | ❌ **REMOVED** — route no longer registered | +| **After migration** | Natural 404 (or optional 302 → `/admin/operations` per FR-078-012) | +| **Previously** | `ListOperationRuns` (Filament ListRecords page) | + +--- + +## Link Generation Contracts + +### OperationRunLinks::view($run, $tenant) + +| Property | Value | +|----------|-------| +| **Returns** | `route('admin.operations.view', ['run' => $run])` | +| **Delegates to** | `OperationRunLinks::tenantlessView($run)` | +| **Tenant parameter** | Ignored (no-op) | +| **Change** | None — already canonical | + +### OperationRunLinks::tenantlessView($run) + +| Property | Value | +|----------|-------| +| **Returns** | `route('admin.operations.view', ['run' => $run])` | +| **Change** | None | + +### OperationRunLinks::index() + +| Property | Value | +|----------|-------| +| **Returns** | `route('admin.operations.index')` | +| **Change** | None | + +### OperationRunLinks::related($run, $tenant) + +| Property | Value | +|----------|-------| +| **Returns** | Array of up to 11 contextual link arrays | +| **Change** | None — consumed by `TenantlessOperationRunViewer` header actions | + +--- + +## Test Route Assertions + +### Positive (must work) + +| Test | Route | Expected | +|------|-------|----------| +| T-078-001 | `GET /admin/operations/{run}` | 200 (member) | +| T-078-001 | `GET /admin/operations/{run}` | 200 (run with `tenant_id = null`) | + +### Negative (must 404) + +| Test | Route | Expected | +|------|-------|----------| +| T-078-001 | `GET /admin/operations/{run}` | 404 (non-member) | +| T-078-002 | `GET /admin/t/{tenant}/operations/r/{record}` | 404 (any user) | +| T-078-004 | Route name `filament.admin.resources.operations.view` | Not registered | +| T-078-004 | Route name `filament.admin.resources.operations.index` | Not registered | diff --git a/specs/078-operations-tenantless-canonical/data-model.md b/specs/078-operations-tenantless-canonical/data-model.md new file mode 100644 index 0000000..b77b137 --- /dev/null +++ b/specs/078-operations-tenantless-canonical/data-model.md @@ -0,0 +1,87 @@ +# Data Model: Operations Tenantless Canonical Migration + +**Feature**: 078-operations-tenantless-canonical +**Date**: 2026-02-06 + +--- + +## Overview + +This feature does **not** introduce new models or migrations. It reorganizes how existing models are rendered and routed. + +## Entities (Existing — No Changes) + +### OperationRun + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `bigint` (PK) | Auto-increment | +| `workspace_id` | `bigint` (FK) | Required. Authorization boundary. | +| `tenant_id` | `bigint` (FK, nullable) | Null for workspace-level runs (e.g., onboarding). | +| `type` | `string` | Operation type slug (e.g., `policy.sync`, `restore.execute`). | +| `status` | `enum` | `OperationRunStatus`: Queued, Running, Completed, Cancelled. | +| `outcome` | `enum` (nullable) | `OperationRunOutcome`: Succeeded, PartiallySucceeded, Failed. | +| `context` | `jsonb` | Contains `target_scope`, `verification_report`, etc. | +| `summary_counts` | `jsonb` | `{total, processed, succeeded, failed, skipped}` | +| `failure_summary` | `jsonb` (nullable) | Sanitized failure details. | +| `initiated_by` | `bigint` (FK, nullable) | User who started the run. | +| `started_at` | `timestamp` (nullable) | | +| `completed_at` | `timestamp` (nullable) | | +| `created_at` | `timestamp` | | +| `updated_at` | `timestamp` | | + +**Relationships**: `belongsTo Workspace`, `belongsTo Tenant` (nullable), `belongsTo User` (initiated_by) + +### WorkspaceMembership (Authorization Boundary) + +The `OperationRunPolicy::view()` checks: +1. User must be a member of `$run->workspace_id` +2. Returns `Response::denyAsNotFound()` if not a member + +No changes to this model or policy logic. + +## Routing Changes (No Model Impact) + +### Routes Removed + +| Route Name | Pattern | Handler | +|------------|---------|---------| +| `filament.admin.resources.operations.index` | `GET /admin/t/{tenant}/operations` | `ListOperationRuns` | +| `filament.admin.resources.operations.view` | `GET /admin/t/{tenant}/operations/r/{record}` | `ViewOperationRun` | + +### Routes Retained (Unchanged) + +| Route Name | Pattern | Handler | +|------------|---------|---------| +| `admin.operations.index` | `GET /admin/operations` | `Operations.php` | +| `admin.operations.view` | `GET /admin/operations/{run}` | `TenantlessOperationRunViewer` | + +### Files Deleted + +| File | Reason | +|------|--------| +| `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` | Replaced by TenantlessOperationRunViewer | +| `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php` | Replaced by Operations.php | +| `app/Livewire/Monitoring/OperationsDetail.php` | Dead code | +| `resources/views/livewire/monitoring/operations-detail.blade.php` | Dead code (blade for OperationsDetail) | + +### Files Modified + +| File | Change | +|------|--------| +| `app/Filament/Resources/OperationRunResource.php` | `getPages()` returns `[]` | +| `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Reuse infolist via schema, add related links | +| `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with `{{ $this->infolist }}` | +| `app/Filament/Widgets/Operations/OperationsKpiHeader.php` | Hide stats when no tenant context | + +### Test Files Requiring Updates + +| File | Change Required | +|------|----------------| +| `tests/Feature/Verification/VerificationAuthorizationTest.php` | Replace `OperationRunResource::getUrl('view')` with `route('admin.operations.view')` | +| `tests/Feature/OpsUx/FailureSanitizationTest.php` | Replace `OperationRunResource::getUrl('view')` with canonical route; replace `ViewOperationRun` mount | +| `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` | Update guard regex to account for headless resource | +| `tests/Feature/Verification/VerificationDbOnlyTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` | +| `tests/Feature/Verification/VerificationReportRenderingTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` | +| `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` | Remove `ListOperationRuns` test; add route-not-registered assertion | +| `tests/Feature/Monitoring/OperationsTenantScopeTest.php` | Remove `ListOperationRuns` test | diff --git a/specs/078-operations-tenantless-canonical/plan.md b/specs/078-operations-tenantless-canonical/plan.md new file mode 100644 index 0000000..ea8ecfe --- /dev/null +++ b/specs/078-operations-tenantless-canonical/plan.md @@ -0,0 +1,282 @@ +# Implementation Plan: Operations Tenantless Canonical Migration + +**Branch**: `078-operations-tenantless-canonical` | **Date**: 2025-07-13 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/078-operations-tenantless-canonical/spec.md` + +## Summary + +Make Operations detail **fully canonical** at `/admin/operations/{run}` by converting `TenantlessOperationRunViewer` to reuse `OperationRunResource::infolist()` via Filament v5's unified schema system, decommissioning auto-generated tenant-scoped resource pages (they naturally 404 after route removal), cleaning up dead code, and updating all affected tests. + +Key approach: Filament v5 deprecated `InteractsWithInfolists` — every `Page` already has `InteractsWithSchemas`. The existing `OperationRunResource::infolist()` is `public static` with no `$this` references and already handles `Filament::getTenant()` returning null. This means `TenantlessOperationRunViewer` can define `infolist(Schema $schema)` to delegate directly, achieving full visual parity with zero code duplication. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Filament Infolists (schema-based) +**Storage**: PostgreSQL (no new migrations — read-only model changes) +**Testing**: Pest v4 (Feature tests) +**Target Platform**: Web (Laravel Sail / Docker) +**Project Type**: Web application (monolith) +**Performance Goals**: DB-only rendering (no external calls on page load) +**Constraints**: Tenantless pages must render without `Filament::getTenant()`; no new dependencies +**Scale/Scope**: ~15 files modified/deleted, ~8 test files updated, 0 new migrations + +## Constitution Check + +*GATE: All pass. No violations.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| Inventory-first | N/A | No inventory changes | +| Read/write separation | Pass | Feature is read-only (rendering changes only) | +| Graph contract path | N/A | No Graph calls | +| Deterministic capabilities | N/A | No capability changes | +| RBAC-UX planes | Pass | Workspace-level auth only; non-member = 404 (RBAC-UX-002) | +| RBAC-UX destructive | N/A | No destructive actions | +| RBAC-UX global search | Pass | Resource has `$shouldRegisterNavigation = false`, no `$recordTitleAttribute` | +| Tenant isolation | Pass | Reads workspace-scoped; no cross-tenant access | +| Run observability | N/A | No new operations; monitoring pages remain DB-only | +| Automation | N/A | No queued/scheduled work | +| Data minimization | Pass | No new data stored | +| Badge semantics | Pass | Existing `BadgeRenderer` reused via infolist — no new badge mappings | + +**Post-design re-check**: Same results — no constitution violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/078-operations-tenantless-canonical/ ++-- spec.md # Feature specification ++-- plan.md # This file ++-- research.md # Phase 0: Filament v5 schema research ++-- data-model.md # Phase 1: Entity & routing changes ++-- quickstart.md # Phase 1: Verification steps ++-- contracts/ +| +-- routes.md # Route contract (before/after) ++-- checklists/ +| +-- requirements.md # Spec quality checklist ++-- tasks.md # Phase 2 output (created by /speckit.tasks) +``` + +### Source Code (files touched) + +```text +app/ ++-- Filament/ +| +-- Resources/ +| | +-- OperationRunResource.php # getPages() returns [] +| | +-- OperationRunResource/Pages/ +| | +-- ViewOperationRun.php # DELETE +| | +-- ListOperationRuns.php # DELETE +| +-- Pages/ +| | +-- Operations/ +| | +-- TenantlessOperationRunViewer.php # Infolist reuse + related links +| +-- Widgets/ +| +-- Operations/ +| +-- OperationsKpiHeader.php # Hide stats when no tenant ++-- Livewire/ + +-- Monitoring/ + +-- OperationsDetail.php # DELETE (dead code) + +resources/views/ ++-- filament/pages/operations/ +| +-- tenantless-operation-run-viewer.blade.php # Replace HTML with infolist render ++-- livewire/monitoring/ + +-- operations-detail.blade.php # DELETE (dead code) + +tests/Feature/ ++-- Operations/ +| +-- TenantlessOperationRunViewerTest.php # Update infolist assertions ++-- Monitoring/ +| +-- OperationsCanonicalUrlsTest.php # Remove ListOperationRuns, add route-gone +| +-- OperationsTenantScopeTest.php # Remove ListOperationRuns reference ++-- Verification/ +| +-- VerificationAuthorizationTest.php # Canonical route instead of getUrl +| +-- VerificationDbOnlyTest.php # TenantlessViewer replaces ViewOperationRun +| +-- VerificationReportRenderingTest.php # TenantlessViewer replaces ViewOperationRun ++-- OpsUx/ +| +-- FailureSanitizationTest.php # Canonical route + TenantlessViewer +| +-- CanonicalViewRunLinksTest.php # Update guard regex ++-- 078/ # NEW spec-specific tests + +-- CanonicalDetailRenderTest.php + +-- LegacyRoutesReturnNotFoundTest.php + +-- KpiHeaderTenantlessTest.php + +-- VerificationReportTenantlessTest.php + +-- TenantListRedirectTest.php + +-- RelatedLinksOnDetailTest.php +``` + +**Structure Decision**: Standard Laravel monolith. No new directories except `tests/Feature/078/` for spec tests. + +## Complexity Tracking + +> No constitution violations to justify. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | N/A | N/A | + +--- + +## Implementation Phases + +### Phase A — Headless Resource + Dead Code Cleanup + +**Goal**: Remove auto-generated routes; delete dead code. +**Risk**: Low — removing unused pages and dead code. +**Tests first**: T-078-004 (routes not registered), T-078-002 (legacy URLs 404). + +| Step | File | Change | +|------|------|--------| +| A.1 | `OperationRunResource.php` | `getPages()` returns `[]` | +| A.2 | `ViewOperationRun.php` | Delete file | +| A.3 | `ListOperationRuns.php` | Delete file | +| A.4 | `OperationsDetail.php` | Delete file (dead code) | +| A.5 | `operations-detail.blade.php` | Delete file (dead code) | +| A.6 | `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` | New: T-078-002 + T-078-004 | +| A.7 | 7 existing test files | Replace `ViewOperationRun` / `ListOperationRuns` / `getUrl('view')` references | + +**Exit criteria**: All existing tests pass; legacy URLs return 404; no routes registered for resource. + +### Phase B — Infolist Reuse on TenantlessOperationRunViewer + +**Goal**: Replace hand-coded Blade with Filament schema-based infolist for full visual parity. +**Risk**: Medium — schema auto-discovery on standalone Page needs verification. +**Tests first**: T-078-001 (canonical detail renders), T-078-008 (verification report tenantless). + +| Step | File | Change | +|------|------|--------| +| B.1 | `TenantlessOperationRunViewer.php` | Add `infolist(Schema $schema)` — delegates to `OperationRunResource::infolist($schema)` | +| B.2 | `TenantlessOperationRunViewer.php` | Add `defaultInfolist(Schema $schema)` — `->record($this->run)->columns(2)` | +| B.3 | `TenantlessOperationRunViewer.php` | Add `content(Schema $schema)` — returns `EmbeddedSchema::make('infolist')` | +| B.4 | `TenantlessOperationRunViewer.php` | Add `public bool $opsUxIsTabHidden = false` property (polling callback) | +| B.5 | `tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with infolist render tag | +| B.6 | `tests/Feature/078/CanonicalDetailRenderTest.php` | New: T-078-001 + T-078-005 | +| B.7 | `tests/Feature/078/VerificationReportTenantlessTest.php` | New: T-078-008 | + +**Exit criteria**: Canonical detail shows identical layout to old tenant-scoped view; verification report section renders. + +### Phase C — Contextual Navigation (Related Links) + +**Goal**: Replace "Admin details" button with `OperationRunLinks::related()` action group. +**Risk**: Low — mechanism already exists, just wiring. +**Tests first**: T-078-010 (related links appear), T-078-005 (no "Admin details" link). + +| Step | File | Change | +|------|------|--------| +| C.1 | `TenantlessOperationRunViewer.php` | Add `getHeaderActions()` using `OperationRunLinks::related()` | +| C.2 | `TenantlessOperationRunViewer.php` | Remove "Admin details" button code (~line 61) | +| C.3 | `tests/Feature/078/RelatedLinksOnDetailTest.php` | New: T-078-010 | + +**Exit criteria**: Related links render for different run types; "Admin details" link absent. + +### Phase D — KPI Header Tenantless Handling + +**Goal**: Hide KPI stats when no tenant context (not workspace-scoped — deferred). +**Risk**: Low — conditional early return. +**Tests first**: T-078-006 (KPI hidden in tenantless mode). + +| Step | File | Change | +|------|------|--------| +| D.1 | `OperationsKpiHeader.php` | `getStats()`: if `Filament::getTenant()` is null then return `[]` | +| D.2 | `tests/Feature/078/KpiHeaderTenantlessTest.php` | New: T-078-006 | + +**Exit criteria**: Operations page without tenant context renders without errors; KPI section hidden. + +### Phase E — Optional List Redirect (FR-078-012) + +**Goal**: Convenience redirect for decommissioned list URL. +**Risk**: Low — single route addition. +**Tests first**: T-078-009 (302 redirect). + +| Step | File | Change | +|------|------|--------| +| E.1 | `routes/web.php` | Add redirect: `/admin/t/{tenant}/operations` 302 to `/admin/operations` | +| E.2 | `tests/Feature/078/TenantListRedirectTest.php` | New: T-078-009 | + +**Exit criteria**: Tenant-scoped list URL redirects; no other URLs affected. + +--- + +## Key Design Decisions + +### D-001 — Schema-based infolist (not InteractsWithInfolists) + +Filament v5 unified forms/infolists/tables into the schema system. `InteractsWithInfolists` is deprecated. Every `Page` already has `InteractsWithSchemas` via `BasePage`. Define `infolist(Schema $schema)` on the page and it auto-discovers via reflection. + +**See**: [research.md](research.md) R-001 + +### D-002 — Empty getPages() (not resource exclusion) + +Returning `[]` from `getPages()` cleanly prevents all route registration while keeping the class available for `::table()` and `::infolist()` reuse. Simpler than excluding from panel discovery. + +**See**: [research.md](research.md) R-003 + +### D-003 — Natural 404 (not redirect handlers) + +After route decommission, legacy detail URLs naturally 404. No redirect handlers needed — simplest approach, zero information leakage, zero maintenance. + +**See**: spec.md clarifications (FR-078-005/006 removed) + +### D-004 — KPI hidden (not workspace-scoped queries) + +Phase 1 hides KPI when no tenant. Full workspace-scoped KPI requires refactoring 6 queries + `ActiveRuns::existForWorkspace()` — deferred to separate spec. + +**See**: [research.md](research.md) R-004, R-005 + +--- + +## Risk Assessment + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Schema auto-discovery fails on standalone Page | Medium | Low | Research confirms it works (R-001); fallback: static builder extraction | +| Test updates miss a reference to deleted pages | Low | Medium | `grep -r` sweep for ViewOperationRun and ListOperationRuns before PR | +| Infolist polling breaks without opsUxIsTabHidden | Low | Medium | Add property explicitly; existing poll logic has null-safe fallback | +| KPI widget error when tenant is null | Low | Low | Already returns [] on null; this change makes it explicit | + +--- + +## Test Strategy + +### New Tests (spec-specific in tests/Feature/078/) + +| Test ID | File | Coverage | +|---------|------|----------| +| T-078-001 | CanonicalDetailRenderTest.php | Detail renders with/without tenant_id | +| T-078-002 | LegacyRoutesReturnNotFoundTest.php | Legacy detail URLs return 404 | +| T-078-004 | LegacyRoutesReturnNotFoundTest.php | Route names not registered | +| T-078-005 | CanonicalDetailRenderTest.php | No "Admin details" link | +| T-078-006 | KpiHeaderTenantlessTest.php | KPI hidden without tenant | +| T-078-008 | VerificationReportTenantlessTest.php | Verification report renders tenantless | +| T-078-009 | TenantListRedirectTest.php | List redirect 302 | +| T-078-010 | RelatedLinksOnDetailTest.php | Related links in header actions | + +### Updated Tests (existing) + +| File | Change | +|------|--------| +| VerificationAuthorizationTest.php | `getUrl('view')` replaced with `route('admin.operations.view')` | +| FailureSanitizationTest.php | `getUrl('view')` replaced with canonical route; ViewOperationRun replaced with TenantlessOperationRunViewer | +| CanonicalViewRunLinksTest.php | Update guard regex for headless resource | +| VerificationDbOnlyTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer | +| VerificationReportRenderingTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer | +| OperationsCanonicalUrlsTest.php | Remove ListOperationRuns test, add route-gone assertion | +| OperationsTenantScopeTest.php | Remove ListOperationRuns reference | + +### Focused Test Command + +```bash +vendor/bin/sail artisan test --compact \ + tests/Feature/078/ \ + tests/Feature/Operations/TenantlessOperationRunViewerTest.php \ + tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \ + tests/Feature/Monitoring/OperationsTenantScopeTest.php \ + tests/Feature/Verification/VerificationAuthorizationTest.php \ + tests/Feature/Verification/VerificationDbOnlyTest.php \ + tests/Feature/Verification/VerificationReportRenderingTest.php \ + tests/Feature/OpsUx/FailureSanitizationTest.php \ + tests/Feature/OpsUx/CanonicalViewRunLinksTest.php +``` diff --git a/specs/078-operations-tenantless-canonical/quickstart.md b/specs/078-operations-tenantless-canonical/quickstart.md new file mode 100644 index 0000000..8d9d45e --- /dev/null +++ b/specs/078-operations-tenantless-canonical/quickstart.md @@ -0,0 +1,78 @@ +# Quickstart: Operations Tenantless Canonical Migration + +**Feature**: 078-operations-tenantless-canonical +**Branch**: `078-operations-tenantless-canonical` + +--- + +## Prerequisites + +- Laravel Sail running (`vendor/bin/sail up -d`) +- Database migrated (`vendor/bin/sail artisan migrate`) +- At least one workspace with a user member +- At least one `OperationRun` record (with and without `tenant_id`) + +## Verification Steps + +### 1. Canonical detail renders + +```bash +# Visit as authenticated workspace member +# URL: /admin/operations/{run_id} +# Expected: Full infolist renders (summary, target scope, verification report, counts, context JSON) +``` + +### 2. Auto-generated tenant routes are gone + +```bash +vendor/bin/sail artisan route:list --name=filament.admin.resources.operations +# Expected: No routes listed (empty output) +``` + +### 3. Canonical list still works + +```bash +# Visit: /admin/operations +# Expected: Workspace-scoped table with status tabs, filters +``` + +### 4. Run tests + +```bash +# Run the focused test pack for this spec: +vendor/bin/sail artisan test --compact \ + tests/Feature/Operations/TenantlessOperationRunViewerTest.php \ + tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \ + tests/Feature/Monitoring/OperationsTenantScopeTest.php \ + tests/Feature/Verification/VerificationAuthorizationTest.php \ + tests/Feature/OpsUx/FailureSanitizationTest.php \ + tests/Feature/OpsUx/CanonicalViewRunLinksTest.php \ + tests/Feature/Verification/VerificationDbOnlyTest.php \ + tests/Feature/Verification/VerificationReportRenderingTest.php + +# Expected: All pass +``` + +### 5. Pint formatting + +```bash +vendor/bin/sail bin pint --dirty +``` + +## Key Files to Inspect + +| File | What to check | +|------|---------------| +| `app/Filament/Resources/OperationRunResource.php` | `getPages()` returns `[]` | +| `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Uses schema-based infolist, has related links header | +| `app/Filament/Widgets/Operations/OperationsKpiHeader.php` | Returns empty stats when no tenant context | +| `app/Filament/Pages/Monitoring/Operations.php` | Unchanged — still reuses `OperationRunResource::table()` | + +## What Was Deleted + +| File | Why | +|------|-----| +| `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` | Replaced by TenantlessOperationRunViewer | +| `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php` | Replaced by Operations.php | +| `app/Livewire/Monitoring/OperationsDetail.php` | Dead code | +| `resources/views/livewire/monitoring/operations-detail.blade.php` | Dead code | diff --git a/specs/078-operations-tenantless-canonical/research.md b/specs/078-operations-tenantless-canonical/research.md new file mode 100644 index 0000000..d678bab --- /dev/null +++ b/specs/078-operations-tenantless-canonical/research.md @@ -0,0 +1,93 @@ +# Research: Operations Tenantless Canonical Migration + +**Feature**: 078-operations-tenantless-canonical +**Date**: 2026-02-06 + +--- + +## R-001 — Filament v5 Infolist Reuse on Standalone Pages + +**Decision**: Use the native `InteractsWithSchemas` mechanism (already on every `Page`). No need for `InteractsWithInfolists` or `HasInfolists`. + +**Rationale**: In Filament v5, the schema system is unified — forms, infolists, and tables all go through `InteractsWithSchemas`. The `InteractsWithInfolists` trait is fully deprecated (every method proxies to schema equivalents). `HasInfolists` is an empty marker interface. Every `Filament\Pages\Page` already extends `BasePage` which uses `InteractsWithSchemas` and implements `HasSchemas`. + +**How it works**: +1. Define `public function infolist(Schema $schema): Schema` on the Page +2. `InteractsWithSchemas` auto-discovers it via reflection +3. Render via `{{ $this->infolist }}` (magic property) or `EmbeddedSchema::make('infolist')` + +**Alternatives considered**: +- Adding `InteractsWithInfolists` trait → Rejected: deprecated shim, adds no functionality +- Extracting a static builder from `OperationRunResource` → Not needed: `infolist()` is already `static` + +**Source files verified**: +- `vendor/filament/infolists/src/Concerns/InteractsWithInfolists.php` — all methods `@deprecated` +- `vendor/filament/infolists/src/Contracts/HasInfolists.php` — empty interface +- `vendor/filament/support/src/Pages/BasePage.php` — `implements HasSchemas`, uses `InteractsWithSchemas` +- `vendor/filament/support/src/Concerns/InteractsWithSchemas.php` — auto-discovers methods by name + +--- + +## R-002 — OperationRunResource::infolist() Compatibility + +**Decision**: Call `OperationRunResource::infolist($schema)` directly from the standalone Page. + +**Rationale**: The method is `public static`, uses no `$this` references, and already handles tenantless context gracefully (`Filament::getTenant()` returns null → falls back to `OperationRunLinks::tenantlessView()`). + +**Requirements for the standalone Page**: +1. `public function infolist(Schema $schema): Schema` → delegates to `OperationRunResource::infolist($schema)` +2. `public function defaultInfolist(Schema $schema): Schema` → sets `->record($this->run)->columns(2)` +3. `public bool $opsUxIsTabHidden = false` property → needed for polling callback in infolist +4. Override `content()` or render via Blade `{{ $this->infolist }}` + +**Alternatives considered**: +- Extracting infolist into a shared trait → Overengineered for one consumer +- Keeping hand-coded Blade → Defeats the single-source goal + +--- + +## R-003 — Headless Resource Pattern (Empty getPages) + +**Decision**: Return `[]` from `OperationRunResource::getPages()` to eliminate all auto-generated routes. + +**Rationale**: `Resource::routes()` iterates `getPages()` in a `foreach` — empty array means zero route registrations. The resource class is retained as a static utility providing `::table()` and `::infolist()` schema builders. + +**Impact**: The following route names will no longer exist: +- `filament.admin.resources.operations.index` +- `filament.admin.resources.operations.view` + +**Files that reference these routes** (need updating): +- `tests/Feature/Verification/VerificationAuthorizationTest.php` (L38, L74) — `OperationRunResource::getUrl('view', ...)` +- `tests/Feature/OpsUx/FailureSanitizationTest.php` (L58) — `OperationRunResource::getUrl('view', ...)` +- `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` (L25) — guard test scanning for stale references (update regex) +- `tests/Feature/Verification/VerificationDbOnlyTest.php` — `ViewOperationRun` Livewire test +- `tests/Feature/OpsUx/FailureSanitizationTest.php` — `ViewOperationRun` Livewire test +- `tests/Feature/Verification/VerificationReportRenderingTest.php` — `ViewOperationRun` Livewire test +- `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` (L121) — `ListOperationRuns` Livewire test +- `tests/Feature/Monitoring/OperationsTenantScopeTest.php` (L119) — `ListOperationRuns` Livewire test + +**Alternatives considered**: +- Keeping a dummy page that redirects → Adds complexity, spec says natural 404 +- Excluding resource from discovery → More fragile than empty `getPages()` + +--- + +## R-004 — KPI Header Tenantless Behavior + +**Decision**: Hide `OperationsKpiHeader` when no tenant context is available (Phase 1). + +**Rationale**: The widget runs 6 queries all scoped by `tenant_id`. Without tenant context, all queries return 0. Showing zeros is misleading. Workspace-scoped queries are a larger refactor deferred to a separate spec. + +**Implementation**: Check `Filament::getTenant()` in the widget's `getStats()` — return empty array if null. Or conditionally register the widget on the page based on tenant presence. + +**Source**: `app/Filament/Widgets/Operations/OperationsKpiHeader.php` — 131 lines, 4 stat cards, `$isLazy = false` + +--- + +## R-005 — Legacy List Redirect (FR-078-012) + +**Decision**: Optional 302 redirect from `/admin/t/{tenant}/operations` to `/admin/operations`. + +**Rationale**: Unlike detail URLs which naturally 404 after page decommission, the list URL pattern may still be reached through Filament's navigation system during the transition period. A simple redirect avoids confusion. + +**Note**: This is the only redirect in the spec. All detail-level legacy URLs naturally 404.