# PROMPT 04 — SignalR Hub Service
## Prerequisites: Prompts 01–03 complete

---

## Your Task
Implement `core/services/collab-hub.service.ts` — the sole owner of the
SignalR connection. Everything real-time goes through this service.

---

## Connection Setup

```typescript
// Build connection in connect() method, NOT in constructor:
this._connection = new HubConnectionBuilder()
  .withUrl(this.config.hubUrl, {
    accessTokenFactory: () => this.authService.getToken(),  // inject AuthService
    transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
  })
  .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])  // retry schedule ms
  .configureLogging(environment.production ? LogLevel.Warning : LogLevel.Information)
  .build();
```

---

## Connection State Signals

```typescript
// These are public, readonly, reactive:
readonly connectionState: Signal<HubConnectionState>
// Wrap HubConnectionState enum from @microsoft/signalr

readonly isConnected    = computed(() =>
  this.connectionState() === HubConnectionState.Connected);

readonly isReconnecting = computed(() =>
  this.connectionState() === HubConnectionState.Reconnecting);

readonly connectionError = signal<string | null>(null);  // expose as readonly
```

Update `connectionState` signal by listening to:
- `connection.onreconnecting()` → set Reconnecting
- `connection.onreconnected()` → set Connected, re-join session, restart heartbeat
- `connection.onclose()` → set Disconnected, if unexpected error set connectionError

---

## Hub Invoke Methods (Client → Server)

Generate each as an `async` method. All must:
1. Guard: `if (!this.isConnected()) { console.warn(...); return; }`
2. Wrap in try/catch → on error: `this._connectionError.set(this.mapError(err))`
3. Be fully typed — no `any`

```typescript
async joinSession(sessionId: string): Promise<void>
async leaveSession(sessionId: string): Promise<void>
async syncRoute(payload: RouteSyncPayload): Promise<void>
async syncCursor(payload: CursorPayload): Promise<void>
async syncFormState(payload: FormSyncPayload): Promise<void>
async requestControl(sessionId: string): Promise<void>
async sendChatMessage(sessionId: string, text: string): Promise<void>
async broadcastAnnotation(payload: AnnotationPayload): Promise<void>
async sendHeartbeat(sessionId: string): Promise<void>
```

---

## Server Event Streams (Server → Client)

Each event is a `private Subject<T>` registered in `setupEventHandlers()`,
exposed publicly as `.asObservable()`:

```typescript
// Generate all of these pairs:
private readonly _participantJoined$    = new Subject<CollabParticipant>();
readonly participantJoined$             = this._participantJoined$.asObservable();

private readonly _participantLeft$      = new Subject<{ userId: string; displayName: string }>();
readonly participantLeft$               = this._participantLeft$.asObservable();

private readonly _controlGranted$       = new Subject<{ userId: string; displayName: string }>();
readonly controlGranted$                = this._controlGranted$.asObservable();

private readonly _controlRevoked$       = new Subject<{ userId: string }>();
readonly controlRevoked$                = this._controlRevoked$.asObservable();

private readonly _controlRequested$     = new Subject<{ userId: string; displayName: string }>();
readonly controlRequested$              = this._controlRequested$.asObservable();

private readonly _routeChanged$         = new Subject<RouteSyncPayload>();
readonly routeChanged$                  = this._routeChanged$.asObservable();

private readonly _cursorMoved$          = new Subject<CursorPayload>();
readonly cursorMoved$                   = this._cursorMoved$.asObservable();

private readonly _formStateChanged$     = new Subject<FormSyncPayload>();
readonly formStateChanged$              = this._formStateChanged$.asObservable();

private readonly _chatMessageReceived$  = new Subject<ChatMessage>();
readonly chatMessageReceived$           = this._chatMessageReceived$.asObservable();

private readonly _annotationReceived$   = new Subject<AnnotationPayload>();
readonly annotationReceived$            = this._annotationReceived$.asObservable();

private readonly _sessionEnded$         = new Subject<{ reason: string }>();
readonly sessionEnded$                  = this._sessionEnded$.asObservable();

private readonly _sessionPaused$        = new Subject<void>();
readonly sessionPaused$                 = this._sessionPaused$.asObservable();

private readonly _sessionResumed$       = new Subject<void>();
readonly sessionResumed$                = this._sessionResumed$.asObservable();

private readonly _errorOccurred$        = new Subject<{ code: string; message: string }>();
readonly errorOccurred$                 = this._errorOccurred$.asObservable();
```

Register handlers in `setupEventHandlers()` after connection is built:
```typescript
private setupEventHandlers(): void {
  this._connection.on('ParticipantJoined', (p) => this._participantJoined$.next(p));
  this._connection.on('ParticipantLeft',   (p) => this._participantLeft$.next(p));
  // ... all others
}
```

---

## Heartbeat

```typescript
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;

startHeartbeat(sessionId: string): void {
  this.stopHeartbeat();
  this.heartbeatInterval = setInterval(
    () => this.sendHeartbeat(sessionId),
    COLLAB_HEARTBEAT_INTERVAL_MS
  );
}

stopHeartbeat(): void {
  if (this.heartbeatInterval) {
    clearInterval(this.heartbeatInterval);
    this.heartbeatInterval = null;
  }
}
```

---

## Error Mapping

```typescript
private readonly ERROR_MESSAGES: Record<string, string> = {
  'NOT_IN_SESSION':     'You are not a participant in this session.',
  'UNAUTHORIZED':       'You do not have permission to perform this action.',
  'SESSION_FULL':       'This session has reached maximum capacity.',
  'SESSION_NOT_ACTIVE': 'This session is no longer active.',
};

private mapError(error: unknown): string {
  if (error instanceof Error) {
    const code = this.extractErrorCode(error.message);
    return this.ERROR_MESSAGES[code] ?? error.message;
  }
  return 'An unexpected error occurred.';
}
```

---

## Reconnection Handler

```typescript
this._connection.onreconnected(async (connectionId) => {
  this._connectionState.set(HubConnectionState.Connected);
  // Re-join current session automatically
  const sessionId = untracked(() => this.sessionState.currentSessionId());
  if (sessionId) {
    await this.joinSession(sessionId);
    this.startHeartbeat(sessionId);
  }
});
```

---

## Full Service Skeleton

```typescript
@Injectable({ providedIn: 'root' })
export class CollabHubService implements OnDestroy {

  private readonly authService  = inject(AuthService);
  private readonly sessionState = inject(SessionState);
  private readonly config       = inject(COLLAB_CONFIG);

  private _connection!: HubConnection;
  private _connectionState = signal<HubConnectionState>(HubConnectionState.Disconnected);

  // All subjects and public streams listed above...

  // Public API:
  async connect(): Promise<void>
  async disconnect(): Promise<void>

  // All invoke methods...
  // All event streams...
  // Heartbeat methods...

  ngOnDestroy(): void {
    this.stopHeartbeat();
    this.disconnect();
    // Complete all subjects to prevent memory leaks:
    [this._participantJoined$, this._participantLeft$, /* all others */].forEach(s => s.complete());
  }
}
```

---

## Verification Checklist
- [ ] Connection is NOT started in constructor
- [ ] All subjects completed in `ngOnDestroy`
- [ ] `untracked()` used when reading signals inside `onreconnected` callback
- [ ] Heartbeat interval cleared on `ngOnDestroy`
- [ ] Error mapping covers all known error codes
- [ ] All invoke methods guard against disconnected state
- [ ] `connectionState` signal updated on all connection lifecycle events
