# PROMPT 06 — Session & Invite HTTP Services
## Prerequisites: Prompts 01–05 complete

---

## Your Task
Implement `core/services/collab-session.service.ts` and
`core/services/collab-invite.service.ts` — all REST API interactions.

---

## `collab-session.service.ts`

### Inject
```typescript
private readonly http         = inject(HttpClient);
private readonly sessionState = inject(SessionState);
private readonly participantState = inject(ParticipantState);
private readonly hubService   = inject(CollabHubService);
private readonly mapper       = inject(CollabApiMapper);
```

### Pattern: each method updates state as a side effect
```typescript
// Use tap() for state updates, catchError() for error mapping
getActiveSessions(): Observable<CollabSessionSummary[]> {
  this.sessionState.setLoading(true);
  return this.http.get<unknown[]>(`${COLLAB_API_BASE}/sessions/active`).pipe(
    map(raw => raw.map(r => this.mapper.mapSessionSummary(r))),
    tap(sessions => this.sessionState.setActiveSessions(sessions)),
    catchError(err => throwError(() => this.mapHttpError(err))),
    finalize(() => this.sessionState.setLoading(false)),
  );
}
```

### Generate ALL these methods with state side effects as shown:

```
createSession(req)         → POST /sessions
                             tap: addActiveSession, setCurrentSession

getSession(id)             → GET /sessions/{id}
                             tap: updateActiveSession

getActiveSessions()        → GET /sessions/active
                             tap: setActiveSessions

getScheduledSessions()     → GET /sessions/scheduled
                             tap: setScheduledSessions

updateStatus(id, req)      → PATCH /sessions/{id}/status
                             tap: updateCurrentSessionStatus
                             if action==='end': clearCurrentSession, hideToolbar

joinSession(id)            → GET /sessions/{id}/join
                             tap: setCurrentSession, setMyIdentity, setControl
                             then: await hubService.connect(), hubService.joinSession(id)
                             then: hubService.startHeartbeat(id)

joinViaToken(token)        → POST /invites/{token}/join
                             tap: same as joinSession

grantControl(id, userId)   → POST /sessions/{id}/control/grant
                             tap: sessionState.transferControl(myUserId, userId)
                             tap: participantState.setControl(userId === myUserId)

revokeControl(id)          → POST /sessions/{id}/control/revoke
                             tap: sessionState.transferControl(targetUserId, myUserId)
                             tap: participantState.setControl(true)
```

### Error mapping
```typescript
private mapHttpError(err: HttpErrorResponse): CollabError {
  return {
    code: err.error?.code ?? 'UNKNOWN',
    message: err.error?.message ?? 'An unexpected error occurred.',
    statusCode: err.status,
  };
}
```

---

## `collab-invite.service.ts`

### Methods:
```typescript
inviteUsers(sessionId, req): Observable<void>
  → POST /sessions/{sessionId}/invites/users
  → tap: sessionState log event (no state to update)

createLinkInvite(sessionId, req): Observable<CollabSessionInvite>
  → POST /sessions/{sessionId}/invites/link
  → maps response to CollabSessionInvite

getPendingInvites(): Observable<CollabSessionInvite[]>
  → GET /invites/pending
  → tap: store in local _pendingInvites signal (in this service)

respondToInvite(inviteId, accept): Observable<void>
  → PATCH /invites/{inviteId}/respond { accept }
  → tap: remove from _pendingInvites signal
  → if accept: automatically call sessionService.joinViaToken (get token from invite)
```

### Local state signal for pending invites:
```typescript
private readonly _pendingInvites = signal<CollabSessionInvite[]>([]);
readonly pendingInvites = this._pendingInvites.asReadonly();
readonly pendingCount   = computed(() => this._pendingInvites().length);
```

---

## `collab-config.service.ts`

```typescript
// Simple — two methods:
getConfig(): Observable<CollabConfig>
  → GET /config
  → tap: cache in local _config signal

updateConfig(req): Observable<CollabConfig>
  → PUT /config
  → tap: update _config signal

// Local signal:
private readonly _config = signal<CollabConfig | null>(null);
readonly config = this._config.asReadonly();
```

---

## Verification Checklist
- [ ] All methods use `finalize()` to clear loading state
- [ ] `joinSession` awaits hub connection before calling hub.joinSession()
- [ ] `respondToInvite(accept: true)` automatically joins the session
- [ ] All Date fields parsed from ISO strings via mapper
- [ ] Error responses mapped to `CollabError` interface
- [ ] No raw `any` in response handling — use `unknown` + mapper

---
---

# PROMPT 07 — CollabBridge Service (Public Integration API)
## Prerequisites: Prompts 01–06 complete

---

## Your Task
Implement `core/services/collab-bridge.service.ts` — the ONLY service
that external modules ever import. It is the public facade over all internal services.

---

## The Bridge Service

```typescript
@Injectable({ providedIn: 'root' })
export class CollabBridgeService implements ICollabBridgeService {

  // ── Inject internal services (private — not exposed) ────────────
  private readonly sessionState     = inject(SessionState);
  private readonly participantState = inject(ParticipantState);
  private readonly uiState          = inject(CollabUiState);
  private readonly sessionService   = inject(CollabSessionService);
  private readonly coBrowseService  = inject(CollabCoBrowseService);
  private readonly hubService       = inject(CollabHubService);
  private readonly stateManager     = inject(CollabStateManager);
  private readonly router           = inject(Router);

  // ── Public Signal API ────────────────────────────────────────────
  // These are the ONLY signals other modules should read:
  readonly isSessionActive  = this.sessionState.hasActiveSession;
  readonly hasControl       = this.participantState.hasControl;
  readonly activeSession    = computed(() => {
    const s = this.sessionState.currentSession();
    if (!s) return null;
    return {
      sessionId:             s.sessionId,
      sessionCode:           s.sessionCode,
      title:                 s.title,
      status:                s.status,
      mode:                  s.mode,
      contextModuleCode:     s.contextModuleCode,
      contextRecordLabel:    s.contextRecordLabel,
      scheduledAt:           s.scheduledAt,
      startedAt:             s.startedAt,
      activeParticipantCount: s.participants.filter(p => p.isActive).length,
    } satisfies CollabSessionSummary;
  });
  readonly participantCount  = this.sessionState.participantCount;
  readonly connectionStatus  = computed((): 'connected' | 'reconnecting' | 'disconnected' => {
    if (this.hubService.isConnected())    return 'connected';
    if (this.hubService.isReconnecting()) return 'reconnecting';
    return 'disconnected';
  });

  // ── Public Methods ───────────────────────────────────────────────

  async startSession(context: CollabContext): Promise<void> {
    // 1. Build CreateSessionRequest from context
    // 2. Call sessionService.createSession(request) — await via firstValueFrom()
    // 3. Show toolbar: uiState.showToolbar()
    // 4. Start co-browse: coBrowseService.startCoBrowse(sessionId, context)
    // 5. Show success toast: uiState.showToast(...)
  }

  notifyRouteChange(context: CollabContext): void {
    // No-op if no active session
    if (!this.isSessionActive()) return;
    // Inform co-browse engine of context change
    // This allows route sync payload to include updated module/record context
    this.coBrowseService.updateContext(context);
  }

  notifyFormChange(change: FieldChange): void {
    // No-op if no active session or no control
    if (!this.isSessionActive() || !this.hasControl()) return;
    // CoBrowse handles this via form watchers
    // This method is for one-off field changes not covered by registered forms
  }

  registerForm(formId: string, form: AbstractControl): void {
    this.coBrowseService.registerForm(formId, form);
  }

  unregisterForm(formId: string): void {
    this.coBrowseService.unregisterForm(formId);
  }

  async endSession(): Promise<void> {
    const sessionId = untracked(() => this.sessionState.currentSessionId());
    if (!sessionId) return;
    await firstValueFrom(
      this.sessionService.updateStatus(sessionId, { action: 'end' })
    );
    this.coBrowseService.stopCoBrowse();
    this.stateManager.resetAll();
  }

  async requestControl(): Promise<void> {
    const sessionId = untracked(() => this.sessionState.currentSessionId());
    if (!sessionId) return;
    await this.hubService.requestControl(sessionId);
  }
}
```

---

## `shared/directives/collab-view-only.directive.ts`

```typescript
// Disables any host element when: session is active AND user does NOT have control
@Directive({
  selector: '[collabViewOnly]',
  standalone: true,
})
export class CollabViewOnlyDirective {
  private readonly collab = inject(CollabBridgeService);

  @HostBinding('disabled')
  get isDisabled(): boolean {
    return this.collab.isSessionActive() && !this.collab.hasControl();
  }

  @HostBinding('class.collab-readonly')
  get isReadonly(): boolean {
    return this.isDisabled;
  }

  // Also use effect() to add/remove aria-readonly for accessibility
  constructor() {
    effect(() => {
      // Accessibility: set aria-readonly when view-only
    });
  }
}
```

---

## Usage Example (include as JSDoc on CollabBridgeService)

```typescript
/**
 * @example Integrating CollabSpace into an ERP module
 *
 * // ─── In component TS ───────────────────────────────────────────
 * private readonly collab = inject(CollabBridgeService);
 * readonly inSession = this.collab.isSessionActive;
 * readonly canEdit   = this.collab.hasControl;
 *
 * ngOnInit() {
 *   this.collab.notifyRouteChange({
 *     moduleCode: 'AP',
 *     recordId: this.invoiceId,
 *     recordLabel: `Invoice #${this.invoiceNumber}`,
 *     currentRoute: this.router.url,
 *   });
 *   this.collab.registerForm('ap-invoice-form', this.form);
 * }
 * ngOnDestroy() {
 *   this.collab.unregisterForm('ap-invoice-form');
 * }
 *
 * // ─── In template ────────────────────────────────────────────────
 * @if (!inSession()) {
 *   <button (click)="collab.startSession(ctx)">👥 Collaborate</button>
 * }
 * <input formControlName="amount" collabViewOnly />
 *
 * // ─── Import in component ────────────────────────────────────────
 * imports: [CollabViewOnlyDirective]
 * // From: import { CollabBridgeService, CollabViewOnlyDirective } from '@collab/index'
 */
```

---

## Verification Checklist
- [ ] `CollabBridgeService` only imports from `@collab/index` internally (no deep imports)
- [ ] `notifyRouteChange` and `notifyFormChange` are safe no-ops when session is inactive
- [ ] `untracked()` used when reading signals inside async methods
- [ ] `CollabViewOnlyDirective` does not throw when no session is active
- [ ] JSDoc example compiles without TypeScript errors
