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.