# Phase C: Presence Tracking & Enhancements

**Status**: Not Started  
**Priority**: Medium (Nice-to-have, enables future features)  
**Estimated Effort**: 3-4 days  
**Dependencies**: Phase B complete (broadcasting infrastructure operational)

---

## Overview

Implement Redis-backed **presence tracking** to know who is currently viewing/editing each record in real-time. This enables "Who's viewing" UI, avatar displays, and future features like collaborative cursors or field-level locking.

This phase is **optional** but provides a foundation for richer collaboration features.

---

## Goals

- Track which users/tabs currently have a record open
- Track which tabs have unsaved changes ("dirty")
- Enable "Who's viewing" avatar display in record editors
- Provide accurate presence data even when websockets are unstable (via heartbeats + TTL)

---

## Non-Goals

- Collaborative cursors (future enhancement)
- Field-level locking (future enhancement)
- Chat/comments (separate feature)

---

## Prerequisites

- Phase B complete (broadcasting working, dirty tracking in place)
- Redis available and configured
- Tenant-aware Redis key patterns established

---

## Technical Design

### Data Model

#### Redis Key Structure

For each record with active viewers:

**Key**: `tenant_{tenantId}_record_presence:{recordType}:{recordId}`

**Value**: Redis Hash, one field per tab:

```
tab_abc123: {
    "user_id": 42,
    "user_name": "Alice",
    "mode": "edit",
    "dirty": true,
    "last_seen_at": "2026-01-21T19:30:15Z"
}

tab_def456: {
    "user_id": 42,
    "user_name": "Alice",
    "mode": "view",
    "dirty": false,
    "last_seen_at": "2026-01-21T19:30:12Z"
}
```

**TTL**: Set per-tab-field expiry at 90 seconds (tabs must heartbeat every 30s; if 3 heartbeats missed, consider stale)

---

### Backend Implementation

#### 1. Create Presence Service

**File**: `src/Domain/Broadcasting/Services/RecordPresenceService.php`

```php
<?php

namespace Domain\Broadcasting\Services;

use Illuminate\Support\Facades\Redis;
use App\Services\TenantService;

class RecordPresenceService
{
    private TenantService $tenantService;

    public function __construct(TenantService $tenantService)
    {
        $this->tenantService = $tenantService;
    }

    /**
     * Register or update a tab's presence for a record
     */
    public function updatePresence(
        string $recordType,
        int $recordId,
        string $tabId,
        int $userId,
        string $userName,
        string $mode = 'view',
        bool $dirty = false
    ): void {
        $key = $this->getPresenceKey($recordType, $recordId);

        $data = [
            'user_id' => $userId,
            'user_name' => $userName,
            'mode' => $mode,
            'dirty' => $dirty,
            'last_seen_at' => now()->toISOString(),
        ];

        // Store presence data
        Redis::hset($key, $tabId, json_encode($data));

        // Set expiry on the whole hash (90 seconds)
        Redis::expire($key, 90);
    }

    /**
     * Remove a tab's presence
     */
    public function removePresence(string $recordType, int $recordId, string $tabId): void
    {
        $key = $this->getPresenceKey($recordType, $recordId);
        Redis::hdel($key, $tabId);
    }

    /**
     * Get all current presence data for a record
     */
    public function getPresence(string $recordType, int $recordId): array
    {
        $key = $this->getPresenceKey($recordType, $recordId);
        $rawData = Redis::hgetall($key);

        if (empty($rawData)) {
            return [];
        }

        $presence = [];
        foreach ($rawData as $tabId => $json) {
            $data = json_decode($json, true);
            
            // Filter out stale entries (> 90s old)
            $lastSeen = new \DateTime($data['last_seen_at']);
            $now = new \DateTime();
            $diff = $now->getTimestamp() - $lastSeen->getTimestamp();

            if ($diff < 90) {
                $presence[$tabId] = $data;
            } else {
                // Clean up stale entry
                Redis::hdel($key, $tabId);
            }
        }

        return $presence;
    }

    /**
     * Get unique users currently viewing (for avatar display)
     */
    public function getUniqueViewers(string $recordType, int $recordId): array
    {
        $presence = $this->getPresence($recordType, $recordId);
        $users = [];

        foreach ($presence as $tabData) {
            $userId = $tabData['user_id'];
            if (!isset($users[$userId])) {
                $users[$userId] = [
                    'id' => $userId,
                    'name' => $tabData['user_name'],
                    'tabs' => 0,
                    'has_dirty_tab' => false,
                ];
            }

            $users[$userId]['tabs']++;
            if ($tabData['dirty']) {
                $users[$userId]['has_dirty_tab'] = true;
            }
        }

        return array_values($users);
    }

    private function getPresenceKey(string $recordType, int $recordId): string
    {
        $tenantId = $this->tenantService->getCurrentTenantId();
        return "tenant_{$tenantId}_record_presence:{$recordType}:{$recordId}";
    }
}
```

#### 2. Add Presence Endpoints

**File**: `routes/api.php`

All endpoints extend `BaseApiController` and follow API versioning structure (`/api/v1/*`):

```php
use Domain\Broadcasting\Services\RecordPresenceService;

Route::prefix('v1')->middleware(['web', 'auth', 'throttle:200,1'])->group(function () {
    Route::prefix('presence')->group(function () {
        // Heartbeat endpoint (called every 30s from frontend)
        Route::post('{type}/{id}/heartbeat', [PresenceController::class, 'heartbeat']);
        
        // Get current viewers (for "Who's viewing" UI)
        Route::get('{type}/{id}/viewers', [PresenceController::class, 'viewers']);
        
        // Unregister presence (called on page close)
        Route::delete('{type}/{id}/unregister', [PresenceController::class, 'unregister']);
    });
});
```

**Controller** (extends BaseApiController):

```php
namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Api\BaseApiController;
use Domain\Broadcasting\Services\RecordPresenceService;
use Illuminate\Http\Request;

class PresenceController extends BaseApiController
{
    public function heartbeat(Request $request, string $type, int $id, RecordPresenceService $service)
    {
        $validated = $request->validate([
            'tab_id' => 'required|string',
            'mode' => 'required|in:view,edit',
            'dirty' => 'required|boolean',
        ]);

        $service->updatePresence(
            recordType: $type,
            recordId: $id,
            tabId: $validated['tab_id'],
            userId: auth()->id(),
            userName: auth()->user()->name,
            mode: $validated['mode'],
            dirty: $validated['dirty']
        );

        return $this->successResponse(['registered' => true]);
    }

    public function viewers(string $type, int $id, RecordPresenceService $service)
    {
        $viewers = $service->getUniqueViewers($type, $id);
        return $this->successResponse(['viewers' => $viewers]);
    }

    public function unregister(Request $request, string $type, int $id, RecordPresenceService $service)
    {
        $validated = $request->validate(['tab_id' => 'required|string']);
        $service->removePresence($type, $id, $validated['tab_id']);
        return $this->successResponse(['unregistered' => true]);
    }
}
```

---

### Frontend Implementation

#### 1. Add Presence Heartbeat to Record Editors

**Livewire/Alpine.js**:

```blade
<div x-data="{
    tabId: 'tab_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
    heartbeatInterval: null,
    isDirty: @entangle('isDirty'),
    viewers: [],
    
    init() {
        // Initial presence registration
        this.sendHeartbeat();
        
        // Start heartbeat every 30 seconds
        this.heartbeatInterval = setInterval(() => {
            this.sendHeartbeat();
        }, 30000);
        
        // Load current viewers
        this.loadViewers();
        
        // Clean up on page close
        window.addEventListener('beforeunload', () => {
            this.unregister();
        });
    },
    
    async sendHeartbeat() {
        try {
            await fetch('/api/v1/presence/{{ $recordType }}/{{ $record->id }}/heartbeat', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
                },
                body: JSON.stringify({
                    tab_id: this.tabId,
                    mode: this.isDirty ? 'edit' : 'view',
                    dirty: this.isDirty,
                }),
            });
        } catch (e) {
            console.warn('Presence heartbeat failed:', e);
        }
    },
    
    async loadViewers() {
        try {
            const response = await fetch('/api/v1/presence/{{ $recordType }}/{{ $record->id }}/viewers');
            const data = await response.json();
            this.viewers = data.data.viewers.filter(v => v.id !== {{ auth()->id() }});
        } catch (e) {
            console.warn('Failed to load viewers:', e);
        }
    },
    
    async unregister() {
        try {
            await fetch('/api/v1/presence/{{ $recordType }}/{{ $record->id }}/unregister', {
                method: 'DELETE',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
                },
                body: JSON.stringify({
                    tab_id: this.tabId,
                }),
            });
        } catch (e) {
            // Best effort
        }
    },
    
    destroy() {
        if (this.heartbeatInterval) {
            clearInterval(this.heartbeatInterval);
        }
        this.unregister();
    }
}">
    {{-- Who's viewing UI --}}
    <div x-show="viewers.length > 0" class="flex items-center gap-2 mb-4">
        <span class="text-sm text-gray-600">Currently viewing:</span>
        <div class="flex -space-x-2">
            <template x-for="viewer in viewers.slice(0, 3)" :key="viewer.id">
                <div 
                    class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-xs font-semibold border-2 border-white"
                    :title="viewer.name"
                    x-text="viewer.name.substring(0, 1).toUpperCase()">
                </div>
            </template>
            <div x-show="viewers.length > 3" 
                 class="w-8 h-8 rounded-full bg-gray-300 text-gray-700 flex items-center justify-center text-xs border-2 border-white"
                 x-text="'+' + (viewers.length - 3)">
            </div>
        </div>
    </div>

    {{-- Rest of component --}}
</div>
```

#### 2. React Hook for Presence

**File**: `resources/js/hooks/useRecordPresence.js`

```javascript
import { useEffect, useState } from 'react';

export function useRecordPresence(recordType, recordId, isDirty) {
    const [viewers, setViewers] = useState([]);
    const [tabId] = useState(() => 
        'tab_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
    );

    useEffect(() => {
        // Initial registration
        sendHeartbeat();

        // Start heartbeat
        const interval = setInterval(() => {
            sendHeartbeat();
        }, 30000);

        // Load viewers initially and every 60s
        loadViewers();
        const viewerInterval = setInterval(loadViewers, 60000);

        // Cleanup on unmount
        return () => {
            clearInterval(interval);
            clearInterval(viewerInterval);
            unregister();
        };
    }, [recordType, recordId]);

    // Update heartbeat when dirty state changes
    useEffect(() => {
        sendHeartbeat();
    }, [isDirty]);

    async function sendHeartbeat() {
        try {
            await fetch(`/api/v1/presence/${recordType}/${recordId}/heartbeat`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
                },
                body: JSON.stringify({
                    tab_id: tabId,
                    mode: isDirty ? 'edit' : 'view',
                    dirty: isDirty,
                }),
            });
        } catch (e) {
            console.warn('Presence heartbeat failed:', e);
        }
    }

    async function loadViewers() {
        try {
            const response = await fetch(`/api/v1/presence/${recordType}/${recordId}/viewers`);
            const data = await response.json();
            setViewers(data.data.viewers);
        } catch (e) {
            console.warn('Failed to load viewers:', e);
        }
    }

    async function unregister() {
        try {
            await fetch(`/api/v1/presence/${recordType}/${recordId}/unregister`, {
                method: 'DELETE',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
                },
                body: JSON.stringify({ tab_id: tabId }),
            });
        } catch (e) {
            // Best effort
        }
    }

    return { viewers, tabId };
}
```

**Usage**:

```jsx
export function FlowEditor({ flow }) {
    const [isDirty, setIsDirty] = useState(false);
    const { viewers } = useRecordPresence('flow', flow.id, isDirty);

    return (
        <div>
            {viewers.length > 0 && (
                <div className="flex items-center gap-2 mb-4">
                    <span className="text-sm text-gray-600">Currently viewing:</span>
                    <div className="flex -space-x-2">
                        {viewers.slice(0, 3).map(viewer => (
                            <div 
                                key={viewer.id}
                                className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-xs font-semibold border-2 border-white"
                                title={viewer.name}>
                                {viewer.name[0].toUpperCase()}
                            </div>
                        ))}
                        {viewers.length > 3 && (
                            <div className="w-8 h-8 rounded-full bg-gray-300 text-gray-700 flex items-center justify-center text-xs border-2 border-white">
                                +{viewers.length - 3}
                            </div>
                        )}
                    </div>
                </div>
            )}

            {/* Form fields */}
        </div>
    );
}
```

---

## Optional Enhancements

### 1. Replace `updated_at` with `lock_version` Integer

For precision and to avoid timestamp resolution edge cases:

**Migration**:

```php
Schema::table('flows', function (Blueprint $table) {
    $table->bigInteger('lock_version')->default(0)->after('updated_at');
});
```

**Model**:

```php
class Flow extends Model
{
    protected static function booted()
    {
        static::saving(function ($flow) {
            if ($flow->isDirty() && !$flow->wasRecentlyCreated) {
                $flow->lock_version++;
            }
        });
    }
}
```

**Controller**:

```php
public function update(Request $request, $id)
{
    $record = Flow::findOrFail($id);
    
    // Compare lock_version instead of updated_at
    if ($request->input('client_lock_version') != $record->lock_version) {
        return response()->json([...], 409);
    }

    $record->update($validated);
    return response()->json(['lock_version' => $record->lock_version]);
}
```

### 2. Field-Level Presence ("Who's editing what")

Track which specific fields each user is editing:

**Extended presence data**:

```json
{
    "user_id": 42,
    "user_name": "Alice",
    "dirty": true,
    "active_field": "description",
    "last_seen_at": "..."
}
```

Show field-level indicators in the UI (e.g., highlight or badge next to the field).

### 3. Presence Broadcast Events

Instead of polling `/viewers` every 60s, broadcast presence changes:

```php
// When presence is updated
broadcast(new PresenceUpdated($recordType, $recordId, $viewers));
```

Frontend listens and updates the viewers list in real-time.

---

## Testing

### Backend Tests

```php
describe('RecordPresenceService', function () {
    it('registers presence', function () {
        $service = app(RecordPresenceService::class);
        
        $service->updatePresence(
            recordType: 'flow',
            recordId: 1,
            tabId: 'test_tab',
            userId: 42,
            userName: 'Alice',
            mode: 'edit',
            dirty: true
        );

        $presence = $service->getPresence('flow', 1);
        
        expect($presence)->toHaveKey('test_tab');
        expect($presence['test_tab']['user_name'])->toBe('Alice');
        expect($presence['test_tab']['dirty'])->toBeTrue();
    });

    it('removes stale presence', function () {
        $service = app(RecordPresenceService::class);
        
        // Register with old timestamp
        Redis::hset('tenant_1_record_presence:flow:1', 'stale_tab', json_encode([
            'user_id' => 42,
            'user_name' => 'Alice',
            'last_seen_at' => now()->subMinutes(5)->toISOString(),
        ]));

        $presence = $service->getPresence('flow', 1);
        
        expect($presence)->not->toHaveKey('stale_tab');
    });

    it('aggregates unique viewers', function () {
        $service = app(RecordPresenceService::class);
        
        // Same user, two tabs
        $service->updatePresence('flow', 1, 'tab1', 42, 'Alice', 'edit', true);
        $service->updatePresence('flow', 1, 'tab2', 42, 'Alice', 'view', false);
        
        // Different user
        $service->updatePresence('flow', 1, 'tab3', 99, 'Bob', 'view', false);

        $viewers = $service->getUniqueViewers('flow', 1);
        
        expect($viewers)->toHaveCount(2);
        expect($viewers[0]['tabs'])->toBe(2);
        expect($viewers[0]['has_dirty_tab'])->toBeTrue();
    });
});
```

---

## Acceptance Criteria

- [ ] `PresenceController` extends `BaseApiController` and uses standardized response methods
- [ ] All presence endpoints versioned under `/api/v1/presence/*`
- [ ] Presence heartbeats sent every 30 seconds
- [ ] Presence data stored in Redis with tenant-aware keys
- [ ] Stale presence entries (>90s) automatically cleaned up
- [ ] "Who's viewing" UI displays unique user avatars
- [ ] Viewer list excludes current user
- [ ] Presence unregistered on tab close (best-effort)
- [ ] Works across multiple tabs for same user
- [ ] Heartbeat includes dirty state
- [ ] All endpoints return `BaseApiController` success/error format
- [ ] Presence endpoints documented in API structure

---

## Performance & Scaling

- **Redis load**: One `HSET` per tab per 30s (very low)
- **Memory**: ~200 bytes per active tab per record (negligible)
- **TTL cleanup**: Automatic via Redis expiry
- **API load**: Heartbeat endpoint must be fast (<10ms)

---

## Future Enhancements

- Real-time presence broadcasts (no polling)
- Field-level edit indicators
- Collaborative cursors
- Live typing indicators
- Session replay for debugging conflicts

---

## Summary

Phase C adds Redis-backed presence tracking to enable "Who's viewing" UI and lays the groundwork for future collaborative features. It's optional but provides significant value for teams working on shared records.
