import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, ConnectedSocket, MessageBody, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { Logger, UseGuards } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { VoiceService } from './voice.service'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; interface AuthenticatedSocket extends Socket { tenantId?: string; userId?: string; tenantSlug?: string; } @WebSocketGateway({ namespace: 'voice', cors: { origin: true, credentials: true, }, }) export class VoiceGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; private readonly logger = new Logger(VoiceGateway.name); private connectedUsers: Map = new Map(); private activeCallsByUser: Map = new Map(); // userId -> callSid constructor( private readonly jwtService: JwtService, private readonly voiceService: VoiceService, private readonly tenantDbService: TenantDatabaseService, ) { // Set gateway reference in service to avoid circular dependency this.voiceService.setGateway(this); } async handleConnection(client: AuthenticatedSocket) { try { // Extract token from handshake auth const token = client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1]; if (!token) { this.logger.warn('❌ Client connection rejected: No token provided'); client.disconnect(); return; } // Verify JWT token const payload = await this.jwtService.verifyAsync(token); // Extract domain from origin header (e.g., http://tenant1.routebox.co:3001) // The domains table stores just the subdomain part (e.g., "tenant1") const origin = client.handshake.headers.origin || client.handshake.headers.referer; let domain = 'localhost'; if (origin) { try { const url = new URL(origin); const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost // Extract first part of subdomain as domain // tenant1.routebox.co -> tenant1 // localhost -> localhost domain = hostname.split('.')[0]; } catch (error) { this.logger.warn(`Failed to parse origin: ${origin}`); } } client.tenantId = domain; // Store the subdomain as tenantId client.userId = payload.sub; client.tenantSlug = domain; // Same as subdomain this.connectedUsers.set(client.userId, client); this.logger.log( `✓ Client connected: ${client.id} (User: ${client.userId}, Domain: ${domain})`, ); this.logger.log(`Total connected users in ${domain}: ${this.getConnectedUsers(domain).length}`); // Send current call state if any active call const activeCallSid = this.activeCallsByUser.get(client.userId); if (activeCallSid) { const callState = await this.voiceService.getCallState( activeCallSid, client.tenantId, ); client.emit('call:state', callState); } } catch (error) { this.logger.error('❌ Authentication failed', error); client.disconnect(); } } handleDisconnect(client: AuthenticatedSocket) { if (client.userId) { this.connectedUsers.delete(client.userId); this.logger.log(`✓ Client disconnected: ${client.id} (User: ${client.userId})`); this.logger.log(`Remaining connected users: ${this.connectedUsers.size}`); } } /** * Initiate outbound call */ @SubscribeMessage('call:initiate') async handleInitiateCall( @ConnectedSocket() client: AuthenticatedSocket, @MessageBody() data: { toNumber: string }, ) { try { this.logger.log(`Initiating call from user ${client.userId} to ${data.toNumber}`); const result = await this.voiceService.initiateCall({ tenantId: client.tenantId, userId: client.userId, toNumber: data.toNumber, }); this.activeCallsByUser.set(client.userId, result.callSid); client.emit('call:initiated', { callSid: result.callSid, toNumber: data.toNumber, status: 'queued', }); return { success: true, callSid: result.callSid }; } catch (error) { this.logger.error('Failed to initiate call', error); client.emit('call:error', { message: error.message || 'Failed to initiate call', }); return { success: false, error: error.message }; } } /** * Accept incoming call */ @SubscribeMessage('call:accept') async handleAcceptCall( @ConnectedSocket() client: AuthenticatedSocket, @MessageBody() data: { callSid: string }, ) { try { this.logger.log(`User ${client.userId} accepting call ${data.callSid}`); await this.voiceService.acceptCall({ callSid: data.callSid, tenantId: client.tenantId, userId: client.userId, }); this.activeCallsByUser.set(client.userId, data.callSid); client.emit('call:accepted', { callSid: data.callSid }); return { success: true }; } catch (error) { this.logger.error('Failed to accept call', error); return { success: false, error: error.message }; } } /** * Reject incoming call */ @SubscribeMessage('call:reject') async handleRejectCall( @ConnectedSocket() client: AuthenticatedSocket, @MessageBody() data: { callSid: string }, ) { try { this.logger.log(`User ${client.userId} rejecting call ${data.callSid}`); await this.voiceService.rejectCall(data.callSid, client.tenantId); client.emit('call:rejected', { callSid: data.callSid }); return { success: true }; } catch (error) { this.logger.error('Failed to reject call', error); return { success: false, error: error.message }; } } /** * End active call */ @SubscribeMessage('call:end') async handleEndCall( @ConnectedSocket() client: AuthenticatedSocket, @MessageBody() data: { callSid: string }, ) { try { this.logger.log(`User ${client.userId} ending call ${data.callSid}`); await this.voiceService.endCall(data.callSid, client.tenantId); this.activeCallsByUser.delete(client.userId); client.emit('call:ended', { callSid: data.callSid }); return { success: true }; } catch (error) { this.logger.error('Failed to end call', error); return { success: false, error: error.message }; } } /** * Send DTMF tones */ @SubscribeMessage('call:dtmf') async handleDtmf( @ConnectedSocket() client: AuthenticatedSocket, @MessageBody() data: { callSid: string; digit: string }, ) { try { await this.voiceService.sendDtmf( data.callSid, data.digit, client.tenantId, ); return { success: true }; } catch (error) { this.logger.error('Failed to send DTMF', error); return { success: false, error: error.message }; } } /** * Emit incoming call notification to specific user */ async notifyIncomingCall(userId: string, callData: any) { const socket = this.connectedUsers.get(userId); if (socket) { socket.emit('call:incoming', callData); this.logger.log(`Notified user ${userId} of incoming call`); } else { this.logger.warn(`User ${userId} not connected to receive call notification`); } } /** * Emit call status update to user */ async notifyCallUpdate(userId: string, callData: any) { const socket = this.connectedUsers.get(userId); if (socket) { socket.emit('call:update', callData); } } /** * Emit AI transcript to user */ async notifyAiTranscript(userId: string, data: { callSid: string; transcript: string; isFinal: boolean }) { const socket = this.connectedUsers.get(userId); if (socket) { socket.emit('ai:transcript', data); } } /** * Emit AI suggestion to user */ async notifyAiSuggestion(userId: string, data: any) { const socket = this.connectedUsers.get(userId); if (socket) { socket.emit('ai:suggestion', data); } } /** * Emit AI action result to user */ async notifyAiAction(userId: string, data: any) { const socket = this.connectedUsers.get(userId); if (socket) { socket.emit('ai:action', data); } } /** * Get connected users for a tenant */ getConnectedUsers(tenantDomain?: string): string[] { const userIds: string[] = []; for (const [userId, socket] of this.connectedUsers.entries()) { // If tenantDomain specified, filter by tenant if (!tenantDomain || socket.tenantSlug === tenantDomain) { userIds.push(userId); } } return userIds; } }