plan: add implementation plan and design artifacts for 078
- plan.md: 5 implementation phases (A-E), constitution check, risk assessment, test strategy - research.md: 5 findings (R-001 through R-005) on Filament v5 schema reuse - data-model.md: entity documentation, routing changes, file deletion/modification map - contracts/routes.md: canonical vs decommissioned route contracts - quickstart.md: verification steps for implementors - Updated copilot agent context with plan technologies
This commit is contained in:
parent
3aa8f27213
commit
e57157016c
6
.github/agents/copilot-instructions.md
vendored
6
.github/agents/copilot-instructions.md
vendored
@ -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
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
108
specs/078-operations-tenantless-canonical/contracts/routes.md
Normal file
108
specs/078-operations-tenantless-canonical/contracts/routes.md
Normal file
@ -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 |
|
||||
87
specs/078-operations-tenantless-canonical/data-model.md
Normal file
87
specs/078-operations-tenantless-canonical/data-model.md
Normal file
@ -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 |
|
||||
282
specs/078-operations-tenantless-canonical/plan.md
Normal file
282
specs/078-operations-tenantless-canonical/plan.md
Normal file
@ -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
|
||||
```
|
||||
78
specs/078-operations-tenantless-canonical/quickstart.md
Normal file
78
specs/078-operations-tenantless-canonical/quickstart.md
Normal file
@ -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 |
|
||||
93
specs/078-operations-tenantless-canonical/research.md
Normal file
93
specs/078-operations-tenantless-canonical/research.md
Normal file
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user