plan: workspace chooser v1 — research, data-model, contracts, quickstart
This commit is contained in:
parent
1f2ee8c8a3
commit
dd4fb6071f
5
.github/agents/copilot-instructions.md
vendored
5
.github/agents/copilot-instructions.md
vendored
@ -35,6 +35,8 @@ ## Active Technologies
|
||||
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture)
|
||||
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
|
||||
- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser)
|
||||
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -54,9 +56,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 107-workspace-chooser: Added PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4
|
||||
- 106-required-permissions-sidebar-context: Middleware sidebar-context fix for workspace-scoped pages
|
||||
- 105-entra-admin-roles-evidence-findings: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
|
||||
- 104-provider-permission-posture: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
|
||||
- 103-ia-scope-filter-semantics: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
126
specs/107-workspace-chooser/contracts/routes.md
Normal file
126
specs/107-workspace-chooser/contracts/routes.md
Normal file
@ -0,0 +1,126 @@
|
||||
# Routes Contract: Workspace Chooser v1
|
||||
|
||||
**Feature**: 107-workspace-chooser | **Date**: 2026-02-22
|
||||
|
||||
## Routes (existing, behavior changes)
|
||||
|
||||
### `GET /admin` (named: `admin.home`)
|
||||
|
||||
**Change**: After workspace auto-resume, redirect uses the shared `WorkspaceRedirectResolver` instead of inline branching.
|
||||
|
||||
**Middleware**: `web`, `panel:admin`, `ensure-correct-guard:web`, `FilamentAuthenticate`, `ensure-workspace-selected`
|
||||
|
||||
**Behavior (updated)**:
|
||||
1. `ensure-workspace-selected` middleware handles auto-resume (may set workspace + redirect before this handler runs).
|
||||
2. If workspace is resolved, apply tenant-count branching.
|
||||
3. If no workspace, redirect to `/admin/choose-workspace`.
|
||||
|
||||
---
|
||||
|
||||
### `GET /admin/choose-workspace` (Filament Page: `ChooseWorkspace`)
|
||||
|
||||
**Change**: Page now displays metadata (role, tenant count), cleaner empty state, "Manage workspaces" link instead of "Create workspace" header action.
|
||||
|
||||
**Middleware**: Standard admin panel middleware. `ensure-workspace-selected` allows this path (exempted in `isWorkspaceOptionalPath()`).
|
||||
|
||||
**Query params**:
|
||||
- `?choose=1` — forces chooser display (bypasses auto-resume). The middleware redirects here when this param is present.
|
||||
|
||||
**Response**: Filament page with workspace cards.
|
||||
|
||||
**Livewire actions**:
|
||||
- `selectWorkspace(int $workspaceId)` — validates membership, sets workspace context, emits audit event, redirects via tenant-count branching.
|
||||
|
||||
---
|
||||
|
||||
### `POST /admin/switch-workspace` (named: `admin.switch-workspace`)
|
||||
|
||||
**Change**: No structural change. This route continues to serve the context-bar dropdown workspace switcher. In a future iteration, audit logging may be added here as well.
|
||||
|
||||
**Controller**: `SwitchWorkspaceController`
|
||||
|
||||
**Request body**: `workspace_id` (required, integer)
|
||||
|
||||
**Middleware**: `web`, `auth`, `ensure-correct-guard:web`
|
||||
|
||||
---
|
||||
|
||||
## Middleware Contract: `ensure-workspace-selected`
|
||||
|
||||
### Algorithm (v1 — 7-step)
|
||||
|
||||
```
|
||||
Step 1: If path is workspace-optional → ALLOW (no redirect)
|
||||
Step 2: If query has `choose=1` → REDIRECT to /admin/choose-workspace?choose=1
|
||||
Step 3: If session.current_workspace_id is set:
|
||||
- If membership valid + not archived → ALLOW
|
||||
- Else: clear session + flash warning → REDIRECT to chooser
|
||||
Step 4: Load user's selectable workspace memberships (not archived)
|
||||
Step 5: If exactly 1 → auto-select, audit log (single_membership) → REDIRECT via tenant branching
|
||||
Step 6: If last_workspace_id set:
|
||||
- If valid membership + selectable → auto-select, audit log (last_used) → REDIRECT via tenant branching
|
||||
- Else: clear last_workspace_id + flash warning → REDIRECT to chooser
|
||||
Step 7: Else → REDIRECT to chooser
|
||||
```
|
||||
|
||||
### Exempt Paths (workspace-optional)
|
||||
|
||||
- `/admin/workspaces*`
|
||||
- `/admin/choose-workspace`
|
||||
- `/admin/no-access`
|
||||
- `/admin/onboarding`
|
||||
- `/admin/settings/workspace`
|
||||
- `/admin/operations/{id}` (existing exemption)
|
||||
- `/admin/t/*` (tenant-scoped routes)
|
||||
- Routes with `.auth.` in name
|
||||
|
||||
---
|
||||
|
||||
## User Menu Contract
|
||||
|
||||
### "Switch workspace" menu item
|
||||
|
||||
**Location**: Admin panel user menu (registered via `AdminPanelProvider::panel()` → `->userMenuItems()`)
|
||||
|
||||
**Visibility**: Only when current user has > 1 workspace membership.
|
||||
|
||||
**URL**: `/admin/choose-workspace?choose=1`
|
||||
|
||||
**Icon**: `heroicon-o-arrows-right-left`
|
||||
|
||||
---
|
||||
|
||||
## Audit Event Contracts
|
||||
|
||||
### `workspace.auto_selected`
|
||||
|
||||
**Trigger**: Middleware auto-resume (steps 5 or 6).
|
||||
|
||||
**Payload** (in `audit_logs.metadata`):
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "auto",
|
||||
"reason": "single_membership" | "last_used",
|
||||
"prev_workspace_id": null
|
||||
}
|
||||
```
|
||||
|
||||
### `workspace.selected`
|
||||
|
||||
**Trigger**: Manual selection from chooser (via `selectWorkspace()`).
|
||||
|
||||
**Payload** (in `audit_logs.metadata`):
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "manual",
|
||||
"reason": "chooser",
|
||||
"prev_workspace_id": 42
|
||||
}
|
||||
```
|
||||
|
||||
Both events use `WorkspaceAuditLogger::log()` with:
|
||||
- `action`: `AuditActionId::WorkspaceAutoSelected->value` or `AuditActionId::WorkspaceSelected->value`
|
||||
- `resource_type`: `'workspace'`
|
||||
- `resource_id`: `(string) $workspace->getKey()`
|
||||
142
specs/107-workspace-chooser/data-model.md
Normal file
142
specs/107-workspace-chooser/data-model.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Data Model: Workspace Chooser v1
|
||||
|
||||
**Feature**: 107-workspace-chooser | **Date**: 2026-02-22
|
||||
|
||||
## Existing Entities (No Changes)
|
||||
|
||||
### workspaces
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| id | bigint (PK) | NO | Auto-increment |
|
||||
| name | varchar(255) | NO | Display name |
|
||||
| slug | varchar(255) | YES | URL-safe identifier |
|
||||
| archived_at | timestamp | YES | Soft-archive marker; non-null = archived |
|
||||
| created_at | timestamp | NO | |
|
||||
| updated_at | timestamp | NO | |
|
||||
|
||||
### workspace_memberships
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| id | bigint (PK) | NO | Auto-increment |
|
||||
| workspace_id | bigint (FK) | NO | → workspaces.id |
|
||||
| user_id | bigint (FK) | NO | → users.id |
|
||||
| role | varchar(255) | NO | 'owner', 'admin', 'member' |
|
||||
| created_at | timestamp | NO | |
|
||||
| updated_at | timestamp | NO | |
|
||||
|
||||
### users (relevant columns only)
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| last_workspace_id | bigint (FK) | YES | → workspaces.id; auto-resume preference |
|
||||
|
||||
### audit_logs (relevant columns only)
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| id | bigint (PK) | NO | |
|
||||
| workspace_id | bigint | YES | → workspaces.id |
|
||||
| tenant_id | bigint | YES | NULL for workspace-scoped events |
|
||||
| actor_id | bigint | YES | → users.id |
|
||||
| actor_email | varchar | YES | |
|
||||
| actor_name | varchar | YES | |
|
||||
| action | varchar | NO | stable action ID string |
|
||||
| resource_type | varchar | YES | |
|
||||
| resource_id | varchar | YES | |
|
||||
| status | varchar | NO | 'success' / 'failure' |
|
||||
| metadata | jsonb | YES | additional context (sanitized) |
|
||||
| recorded_at | timestamp | NO | |
|
||||
|
||||
### Session (in-memory / store-backed)
|
||||
|
||||
| Key | Type | Notes |
|
||||
|-----|------|-------|
|
||||
| `current_workspace_id` | int | Set by `WorkspaceContext::setCurrentWorkspace()` |
|
||||
|
||||
## New Data (Enum Values Only)
|
||||
|
||||
### AuditActionId Enum — New Cases
|
||||
|
||||
```php
|
||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||
case WorkspaceSelected = 'workspace.selected';
|
||||
```
|
||||
|
||||
### Audit Log Metadata Schema (for workspace selection events)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"method": "auto" | "manual",
|
||||
"reason": "single_membership" | "last_used" | "chooser",
|
||||
"prev_workspace_id": 123 | null // previous workspace if switching
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Relationships (relevant to this feature)
|
||||
|
||||
```text
|
||||
User ──< WorkspaceMembership >── Workspace
|
||||
│ │
|
||||
└── last_workspace_id ────────────┘
|
||||
|
||||
User ──< AuditLog >── Workspace
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
| Field | Rule | Source |
|
||||
|-------|------|--------|
|
||||
| Workspace selectability | `archived_at IS NULL` | `WorkspaceContext::isWorkspaceSelectable()` |
|
||||
| Membership check | `workspace_memberships WHERE user_id AND workspace_id` | `WorkspaceContext::isMember()` |
|
||||
| `choose` param | `?choose=1` (truthy string) | Middleware step 2 |
|
||||
| Non-member selection attempt | abort(404) | FR deny-as-not-found |
|
||||
|
||||
## State Transitions
|
||||
|
||||
The workspace selection flow is a session-context transition, not a data state machine.
|
||||
|
||||
```text
|
||||
[No Session] ──auto-resume──> [Active Workspace Session]
|
||||
[No Session] ──chooser──────> [Active Workspace Session]
|
||||
[Active Session] ──switch───> [Active Workspace Session (different)]
|
||||
[Active Session] ──revoked──> [No Session] + warning flash
|
||||
[Active Session] ──archived─> [No Session] + warning flash
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Chooser Page Query (FR-003, FR-011)
|
||||
|
||||
```php
|
||||
Workspace::query()
|
||||
->whereIn('id', function ($query) use ($user) {
|
||||
$query->from('workspace_memberships')
|
||||
->select('workspace_id')
|
||||
->where('user_id', $user->getKey());
|
||||
})
|
||||
->whereNull('archived_at')
|
||||
->withCount('tenants')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
```
|
||||
|
||||
Joined via subquery (no N+1). `withCount('tenants')` adds a single correlated subquery. Result includes `tenants_count` attribute.
|
||||
|
||||
### Role Retrieval for Display
|
||||
|
||||
```php
|
||||
// Eager-load membership pivot to get role per workspace
|
||||
// Option A: Join workspace_memberships in the query
|
||||
// Option B: Use $workspace->pivot->role when loaded via user relationship
|
||||
|
||||
// Preferred: load memberships separately keyed by workspace_id
|
||||
$memberships = WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->pluck('role', 'workspace_id');
|
||||
|
||||
// Then in view: $memberships[$workspace->id] ?? 'member'
|
||||
```
|
||||
|
||||
Single query, keyed by workspace_id, accessed in O(1) per card.
|
||||
106
specs/107-workspace-chooser/plan.md
Normal file
106
specs/107-workspace-chooser/plan.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Implementation Plan: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point
|
||||
|
||||
**Branch**: `107-workspace-chooser` | **Date**: 2026-02-22 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/107-workspace-chooser/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Refactor the workspace resolution flow to provide an enterprise-grade auto-resume, explicit switch/manage separation, enhanced metadata in the chooser, and audit events for all workspace selection transitions. The primary changes are:
|
||||
|
||||
1. **Refactor `EnsureWorkspaceSelected` middleware** to implement the spec's 7-step auto-resume algorithm with stale-membership detection and flash warnings.
|
||||
2. **Upgrade the `ChooseWorkspace` page** with role badges, tenant counts, "Manage workspaces" link (capability-gated), and cleaned-up empty state (no "Create workspace" header action).
|
||||
3. **Add audit events** for workspace auto-selection and manual selection via new `AuditActionId` enum cases + `WorkspaceAuditLogger` calls.
|
||||
4. **Add "Switch workspace" user menu entry** visible only when user has >1 workspace membership.
|
||||
5. **Support `?choose=1` forced chooser** bypass parameter in middleware.
|
||||
|
||||
No new tables, no new columns, no Microsoft Graph calls. All changes are DB-only, session-based, and synchronous.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 / Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`)
|
||||
**Testing**: Pest v4 (feature tests as Livewire component tests + HTTP tests)
|
||||
**Target Platform**: Web (Sail/Docker locally, Dokploy for staging/production)
|
||||
**Project Type**: Web application (Laravel monolith)
|
||||
**Performance Goals**: Chooser page < 200ms DB time with 50 workspace memberships; no N+1 queries
|
||||
**Constraints**: Session-based workspace context (all tabs share); no new tables/columns
|
||||
**Scale/Scope**: Single Filament page refactor + 1 middleware refactor + 2 enum values + user menu entry + ~17 tests
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- [x] **Inventory-first**: N/A — this feature does not interact with Inventory. Workspace selection is a session context operation.
|
||||
- [x] **Read/write separation**: The only write is updating `users.last_workspace_id` (convenience preference) and creating audit log entries. No destructive mutations — no preview/confirmation needed for preference persistence. Audit events fire on every selection.
|
||||
- [x] **Graph contract path**: N/A — no Microsoft Graph calls in this feature. All data is local (workspaces, memberships, session).
|
||||
- [x] **Deterministic capabilities**: `Capabilities::WORKSPACE_MANAGE` is referenced via the canonical registry constant. No new capabilities introduced.
|
||||
- [x] **RBAC-UX**: Feature operates in the `/admin` plane only. Non-member workspace selection returns 404 (deny-as-not-found) via `WorkspaceContext::isMember()`. "Manage workspaces" link gated by `workspace.manage` capability. No cross-plane access introduced.
|
||||
- [x] **Workspace isolation**: Middleware ensures workspace membership on every `/admin/*` request. Stale sessions are cleared and redirected. Non-members get 404.
|
||||
- [x] **Destructive actions**: No destructive actions in this feature. The re-selection is a non-destructive context switch.
|
||||
- [x] **Global search**: No changes to global search behavior.
|
||||
- [x] **Tenant isolation**: Not directly affected. After workspace selection, the existing tenant-count branching routes to tenant-scoped flows.
|
||||
- [x] **Run observability**: N/A — workspace selection is a synchronous, DB-only, < 2s session operation. No `OperationRun` needed. Selection events are audit-logged.
|
||||
- [x] **Automation**: N/A — no queued/scheduled operations.
|
||||
- [x] **Data minimization**: Audit log stores only `actor_id`, `workspace_id`, `method`, `reason`, `prev_workspace_id` — no secrets/tokens/PII.
|
||||
- [x] **Badge semantics (BADGE-001)**: Role badge in chooser renders the workspace membership role. Simple color-mapped Filament badge (no status-like semantics, just a label). The workspace membership role is a tag/category, not a status — exempt from `BadgeCatalog`. Verified: no `BadgeDomain` exists for workspace roles.
|
||||
- [x] **Filament UI Action Surface Contract**: ChooseWorkspace is a custom context-selector page, not a CRUD Resource. Spec includes UI Action Matrix with explicit exemption documented. No header actions (v1), "Open" per workspace, empty state with specific title + CTA.
|
||||
- [x] **Filament UI UX-001**: This is a context-selector page, not a Create/Edit/View resource page. UX-001 Main/Aside layout does not apply. Exemption documented in spec.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/107-workspace-chooser/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
│ └── routes.md
|
||||
└── tasks.md # Phase 2 output (created by /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Http/
|
||||
│ └── Middleware/
|
||||
│ └── EnsureWorkspaceSelected.php # MODIFY — refactor to spec algorithm
|
||||
├── Filament/
|
||||
│ └── Pages/
|
||||
│ └── ChooseWorkspace.php # MODIFY — metadata, remove Create action, audit
|
||||
├── Providers/
|
||||
│ └── Filament/
|
||||
│ └── AdminPanelProvider.php # MODIFY — add user menu item
|
||||
├── Support/
|
||||
│ ├── Audit/
|
||||
│ │ └── AuditActionId.php # MODIFY — add 2 enum cases
|
||||
│ └── Workspaces/
|
||||
│ └── WorkspaceContext.php # MODIFY — add clearSession + audit helper
|
||||
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
└── pages/
|
||||
└── choose-workspace.blade.php # MODIFY — metadata cards, empty state, manage link
|
||||
|
||||
tests/
|
||||
└── Feature/
|
||||
└── Workspaces/
|
||||
├── EnsureWorkspaceSelectedMiddlewareTest.php # NEW
|
||||
├── ChooseWorkspacePageTest.php # NEW
|
||||
└── WorkspaceSwitchUserMenuTest.php # NEW
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith structure. All changes are in existing directories. No new folders needed.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No Constitution Check violations. No justifications needed.
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| — | — | — |
|
||||
87
specs/107-workspace-chooser/quickstart.md
Normal file
87
specs/107-workspace-chooser/quickstart.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Quickstart: Workspace Chooser v1
|
||||
|
||||
**Feature**: 107-workspace-chooser | **Date**: 2026-02-22
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Branch: `107-workspace-chooser` checked out
|
||||
- Sail running: `vendor/bin/sail up -d`
|
||||
- Existing workspace + user fixtures (factory-based)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase A: Foundation (no visible changes)
|
||||
|
||||
1. **Add `AuditActionId` enum cases** — `WorkspaceAutoSelected`, `WorkspaceSelected`
|
||||
2. **Extract `WorkspaceRedirectResolver`** — shared tenant-count branching helper (DRY the 4 current copies)
|
||||
3. **Tests for redirect resolver** — verify 0/1/>1 tenant branching
|
||||
|
||||
### Phase B: Middleware Refactor (core behavior change)
|
||||
|
||||
4. **Refactor `EnsureWorkspaceSelected`** — implement 7-step algorithm from spec
|
||||
- Step 1: workspace-optional path bypass (keep existing `isWorkspaceOptionalPath()`)
|
||||
- Step 2: `?choose=1` handling (new)
|
||||
- Step 3: stale session detection + flash warning (enhanced)
|
||||
- Step 4-5: single membership auto-resume + audit (new)
|
||||
- Step 6: `last_workspace_id` auto-resume + audit (new)
|
||||
- Step 7: fallback to chooser (existing)
|
||||
5. **Middleware tests** — all 7 steps covered
|
||||
|
||||
### Phase C: Chooser Page Upgrade (UI changes)
|
||||
|
||||
6. **Refactor `ChooseWorkspace` page**:
|
||||
- Remove "Create workspace" header action
|
||||
- Add `withCount('tenants')` to query
|
||||
- Load membership roles keyed by workspace_id
|
||||
- Expose `getWorkspaceRole()` and `getWorkspaceMemberships()` for Blade
|
||||
7. **Update `choose-workspace.blade.php`**:
|
||||
- Add role badge per card
|
||||
- Add tenant count per card
|
||||
- Add "Manage workspaces" link (capability-gated)
|
||||
- Update empty state (spec copy)
|
||||
- Replace form POST with `wire:click="selectWorkspace({{ $workspace->id }})"`
|
||||
8. **Add audit logging in `selectWorkspace()`** — emit `workspace.selected` with metadata
|
||||
9. **Chooser page tests** — metadata display, empty state, manage link visibility, audit events
|
||||
|
||||
### Phase D: User Menu Integration
|
||||
|
||||
10. **Register "Switch workspace" in `AdminPanelProvider`** — `userMenuItems()` with visibility condition
|
||||
11. **User menu tests** — visible when >1 workspace, hidden when 1
|
||||
|
||||
### Phase E: Cleanup & Verification
|
||||
|
||||
12. **Replace inline tenant-branching** in `SwitchWorkspaceController` and `routes/web.php` with `WorkspaceRedirectResolver`
|
||||
13. **Run full test suite** — verify no regressions
|
||||
14. **Pint formatting** — `vendor/bin/sail bin pint --dirty`
|
||||
15. **Commit + push**
|
||||
|
||||
## Key Files to Understand First
|
||||
|
||||
| File | Why |
|
||||
|------|-----|
|
||||
| `app/Http/Middleware/EnsureWorkspaceSelected.php` | The middleware being refactored |
|
||||
| `app/Filament/Pages/ChooseWorkspace.php` | The page being upgraded |
|
||||
| `app/Support/Workspaces/WorkspaceContext.php` | The workspace session manager |
|
||||
| `app/Services/Audit/WorkspaceAuditLogger.php` | Where audit events are emitted |
|
||||
| `app/Support/Audit/AuditActionId.php` | Where enum cases are added |
|
||||
| `app/Http/Controllers/SwitchWorkspaceController.php` | POST switch (redirect resolver integration) |
|
||||
| `routes/web.php` (lines 36-82) | `/admin` route with duplicated branching |
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Run workspace-related tests
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Workspaces/
|
||||
|
||||
# Run specific middleware test
|
||||
vendor/bin/sail artisan test --compact --filter=EnsureWorkspaceSelected
|
||||
|
||||
# Run chooser page test
|
||||
vendor/bin/sail artisan test --compact --filter=ChooseWorkspacePage
|
||||
|
||||
# Format
|
||||
vendor/bin/sail bin pint --dirty
|
||||
|
||||
# Full suite
|
||||
vendor/bin/sail artisan test --compact
|
||||
```
|
||||
106
specs/107-workspace-chooser/research.md
Normal file
106
specs/107-workspace-chooser/research.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Research: Workspace Chooser v1
|
||||
|
||||
**Feature**: 107-workspace-chooser | **Date**: 2026-02-22
|
||||
|
||||
## R1: Middleware Refactor Strategy
|
||||
|
||||
**Question**: Should we create a new `EnsureActiveWorkspace` middleware or refactor the existing `EnsureWorkspaceSelected`?
|
||||
|
||||
- **Decision**: Refactor the existing `EnsureWorkspaceSelected` middleware in-place.
|
||||
- **Rationale**: The existing middleware is already registered in both `AdminPanelProvider` and `TenantPanelProvider` middleware stacks as `'ensure-workspace-selected'`, and referenced by alias in `bootstrap/app.php`. Creating a new class would require changing all registration points and updating existing tests. The current class already handles the same responsibilities — it just doesn't implement them according to the spec.
|
||||
- **Alternatives considered**:
|
||||
- New `EnsureActiveWorkspace` class: rejected because it would require renaming the middleware alias everywhere, with no functional benefit beyond a name change. The alias can remain `ensure-workspace-selected` for backward compatibility.
|
||||
|
||||
## R2: Audit Event Integration Pattern
|
||||
|
||||
**Question**: How should workspace selection audit events be emitted?
|
||||
|
||||
- **Decision**: Call `WorkspaceAuditLogger::log()` directly from the middleware (for auto-selections) and from `ChooseWorkspace::selectWorkspace()` (for manual selections). No events/listeners needed.
|
||||
- **Rationale**: `WorkspaceAuditLogger` is a simple synchronous service — no queue, no listener. The codebase pattern (used in workspace membership, settings, alert destinations, baselines, etc.) is direct `$logger->log(...)` calls at the mutation point. Workspace selection audit is similarly < 1ms DB insert.
|
||||
- **Alternatives considered**:
|
||||
- Laravel Events + Listeners: rejected — overkill for a synchronous log write. No other systems need to react to workspace selection events.
|
||||
- Observer on `User` model (`last_workspace_id` change): rejected — would miss cases where only the session changes (auto-resume from session), and would conflate preference persistence with audit semantics.
|
||||
|
||||
## R3: `AuditActionId` Enum Values
|
||||
|
||||
**Question**: What enum values and string representations to use?
|
||||
|
||||
- **Decision**: Add two cases:
|
||||
- `WorkspaceAutoSelected = 'workspace.auto_selected'` — for auto-resume (single membership or last-used).
|
||||
- `WorkspaceSelected = 'workspace.selected'` — for manual selection from chooser.
|
||||
- **Rationale**: Follows the existing naming pattern (`case CamelName = 'snake.dotted_value'`). The `method` (auto/manual) and `reason` (single_membership/last_used/chooser) are stored in the audit log's `metadata` JSONB, not in separate enum values.
|
||||
- **Alternatives considered**:
|
||||
- Three separate enum values (one per reason): rejected — metadata provides sufficient granularity; enum values should represent the action type, not the trigger.
|
||||
|
||||
## R4: Redirect After Selection (Tenant-Count Branching)
|
||||
|
||||
**Question**: Where does redirect logic live? Should it be deduplicated?
|
||||
|
||||
- **Decision**: Extract the tenant-count branching logic into a shared helper method on `WorkspaceContext` or a dedicated `WorkspaceRedirectResolver` to avoid duplicating it across:
|
||||
1. `EnsureWorkspaceSelected` middleware (auto-resume redirects)
|
||||
2. `ChooseWorkspace::selectWorkspace()` (manual selection redirect)
|
||||
3. `SwitchWorkspaceController::__invoke()` (POST switch redirect)
|
||||
4. `routes/web.php` `/admin` route handler
|
||||
|
||||
Currently, the same branching logic (0 tenants → managed-tenants, 1 → tenant dashboard, >1 → choose-tenant) is copy-pasted in all four locations.
|
||||
- **Rationale**: DRY — the branching is identical in all cases and is the single authority for "where to go after workspace is set." A single method eliminates the risk of divergence as new conditions are added.
|
||||
- **Alternatives considered**:
|
||||
- Leave duplicated: rejected — 4 copies of the same logic is a maintenance hazard.
|
||||
- Put on `ChooseWorkspace` page: rejected — the middleware and controller both need it but don't have access to the page class.
|
||||
|
||||
## R5: `?choose=1` Handling Location
|
||||
|
||||
**Question**: Should the `?choose=1` forced-chooser parameter be handled in the middleware or in the page?
|
||||
|
||||
- **Decision**: Handle in the middleware — step 2 of the algorithm. If `choose=1` is present, redirect to `/admin/choose-workspace?choose=1` and skip auto-resume logic.
|
||||
- **Rationale**: The middleware is the single entry point for all `/admin/*` requests. Handling it there prevents auto-resume from overriding the explicit user intent to see the chooser.
|
||||
- **Alternatives considered**:
|
||||
- Handle in `ChooseWorkspace` page: rejected — the middleware would auto-resume BEFORE the page loads, so the user would never see the chooser.
|
||||
|
||||
## R6: User Menu Integration
|
||||
|
||||
**Question**: How to add "Switch workspace" to the Filament user menu?
|
||||
|
||||
- **Decision**: Register via `->userMenuItems()` in `AdminPanelProvider::panel()`. Use `Filament\Navigation\MenuItem::make()` with `->url('/admin/choose-workspace?choose=1')` and a `->visible()` callback that checks workspace membership count > 1.
|
||||
- **Rationale**: This is the documented Filament v5 pattern from the constitution + blueprint. The menu item is a navigation-only action (URL link), not a destructive action, so no confirmation needed.
|
||||
- **Alternatives considered**:
|
||||
- Context bar link only: rejected — specification explicitly requires a user menu entry (FR-008).
|
||||
- Adding to both user menu and context bar: the context bar already has "Switch workspace" — the user menu entry provides an additional discovery point per spec.
|
||||
|
||||
## R7: Badge Rendering for Workspace Role
|
||||
|
||||
**Question**: Should workspace membership role badges use `BadgeCatalog`/`BadgeDomain`?
|
||||
|
||||
- **Decision**: No. Workspace membership role is a **tag/category** (owner, admin, member), not a status-like value. Per constitution (BADGE-001), tag/category chips are not governed by `BadgeCatalog`. Use a simple Filament `<x-filament::badge>` with a color mapping (e.g., owner → primary, admin → warning, member → gray).
|
||||
- **Rationale**: The role is static metadata, not a state transition. No existing `BadgeDomain` for workspace roles. Adding one would be over-engineering for 3 static values.
|
||||
- **Alternatives considered**:
|
||||
- Create a `WorkspaceRoleBadgeDomain`: rejected — violates the "tag, not status" exemption in BADGE-001.
|
||||
|
||||
## R8: Blade Template vs. Livewire Component for Chooser
|
||||
|
||||
**Question**: Should the chooser cards remain as a Blade template or be converted to a Livewire component?
|
||||
|
||||
- **Decision**: Keep as Blade template rendered by the existing Livewire-backed `ChooseWorkspace` Filament Page. The page class already extends `Filament\Pages\Page` (which is Livewire). The `wire:click` for "Open" calls the existing `selectWorkspace()` method. No separate Livewire component needed.
|
||||
- **Rationale**: The existing pattern works. The page is already a Livewire component (all Filament Pages are). Converting to a separate Livewire component adds complexity with no benefit — the chooser has no real-time reactive needs.
|
||||
- **Alternatives considered**:
|
||||
- Separate `WorkspaceChooserCard` Livewire component: rejected — unnecessary abstraction for a simple card grid.
|
||||
|
||||
## R9: Existing `SwitchWorkspaceController` Coexistence
|
||||
|
||||
**Question**: The chooser currently uses POST to `SwitchWorkspaceController`. Should we switch to Livewire `wire:click` or keep the POST?
|
||||
|
||||
- **Decision**: Migrate the chooser page to use Livewire `wire:click` calling `selectWorkspace($workspaceId)` (which already exists). The `SwitchWorkspaceController` is still needed for the `workspace-switcher.blade.php` partial (context-bar dropdown) which uses a form POST. Both paths converge on `WorkspaceContext::setCurrentWorkspace()`.
|
||||
- **Rationale**: The Livewire path already exists in `ChooseWorkspace::selectWorkspace()` — the blade template just needs to call it via `wire:click` instead of a form POST. This simplifies the chooser page and makes audit integration easier (audit logging happens in the PHP method, not in a controller).
|
||||
- **Alternatives considered**:
|
||||
- Keep form POST from chooser: rejected — the `selectWorkspace()` method is where we add audit logging. Using `wire:click` means a single code path.
|
||||
- Remove `SwitchWorkspaceController` entirely: deferred — the context-bar dropdown still uses it. Can be unified in a future PR.
|
||||
|
||||
## R10: Flash Warning Implementation
|
||||
|
||||
**Question**: How to show "Your access to {workspace_name} was removed" when stale membership detected?
|
||||
|
||||
- **Decision**: Use Filament database notifications or session flash + `Filament\Notifications\Notification::make()->danger()`. Since the middleware redirects to the chooser, and the chooser is a Filament page, Filament's notification system renders flash/database notifications automatically.
|
||||
- **Rationale**: The chooser is a Filament page — Filament's notification toast system is already wired. Session-based `Notification::make()` works for redirect→page scenarios.
|
||||
- **Alternatives considered**:
|
||||
- Custom Blade banner: rejected — Filament notifications already solve this and are consistent with the rest of the app.
|
||||
- Session flash only (no Filament notification): rejected — the Filament notification system provides better UX (auto-dismiss, consistent styling).
|
||||
Loading…
Reference in New Issue
Block a user