TenantAtlas/specs/107-workspace-chooser/data-model.md
ahmido e15eee8f26 fix: consolidate tenant creation + harden selection flows (#131)
## Summary
- Removes the legacy Tenant CRUD create page (`/admin/tenants/create`) so tenant creation is handled exclusively via the onboarding wizard.
- Updates tenant selection flows and pages to prevent Livewire polling/notification-related 404s on workspace-scoped routes.
- Aligns empty-state UX with enterprise patterns (avoid duplicate CTAs).

## Key changes
- Tenant creation
  - Removed `CreateTenant` page + route from `TenantResource`.
  - `TenantResource::canCreate()` now returns `false` (CRUD creation disabled).
  - Tenants list now surfaces an **Add tenant** action that links to onboarding (`admin.onboarding`).
- Onboarding wizard
  - Removed redundant legacy step-cards from the blade view (Wizard schema is the source of truth).
  - Disabled topbar on the onboarding page to avoid lazy-loaded notifications.
- Choose tenant
  - Enterprise UI redesign + workspace context.
  - Uses Livewire `selectTenant()` instead of a form POST.
  - Disabled topbar and gated BODY_END hook to avoid background polling.
- Baseline profiles
  - Hide header create action when table is empty to avoid duplicate CTAs.

## Tests
- `vendor/bin/sail artisan test --compact --filter='Onboarding|ManagedTenantOnboarding'`
- `vendor/bin/sail artisan test --compact --filter='ManagedTenantsLivewireUpdate'`
- `vendor/bin/sail artisan test --compact --filter='TenantSetup|TenantResourceAuth|TenantAdminAuth|ListTenants'`
- `vendor/bin/sail artisan test --compact --filter='BaselineProfile'`
- `vendor/bin/sail artisan test --compact --filter='ChooseTenant|TenantMake|TenantScoping|AdminTenantScoped|AdminHomeRedirect|WorkspaceContext'`

## Notes
- Filament v5 / Livewire v4 compatible.
- No new assets introduced; no deploy pipeline changes required.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #131
2026-02-22 19:54:24 +00:00

4.4 KiB

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

case WorkspaceAutoSelected = 'workspace.auto_selected';
case WorkspaceSelected = 'workspace.selected';

Audit Log Metadata Schema (for workspace selection events)

{
  "method": "auto" | "manual",
  "reason": "single_membership" | "last_used" | "chooser" | "context_bar",
  "prev_workspace_id": 123 | null   // previous workspace if switching
}

Entity Relationships (relevant to this feature)

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.

[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)

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

// 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.