TenantAtlas/specs/107-workspace-chooser/data-model.md
Ahmed Darrazi d67e2c84bc fix: resolve 5 consistency issues from project analysis (F1–F7)
F1: Replace 'EnsureActiveWorkspace' with 'EnsureWorkspaceSelected' in spec (3 occurrences) — aligns with R1 decision
F2: Add audit logging to SwitchWorkspaceController (T036) + context_bar reason — closes FR-005 gap
F3: Remove stale WorkspaceContext.php MODIFY from plan project structure
F4/F5: Add WorkspaceRedirectResolver.php + 2 missing test files to plan project structure
F6: Add single-workspace sub-case (EC3) note to T030
F7: Add archived workspace scenario (EC2) note to T024
2026-02-22 15:18:26 +01: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.