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

143 lines
4.4 KiB
Markdown

# 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" | "context_bar",
"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.