index.js

/**
 * Cloudflare Calls Backend Server (Express)
 *
 * Illustrates how to:
 * 1. Store each participant’s local track offers in memory.
 * 2. Perform the Cloudflare Calls track negotiation on the server.
 */

require('dotenv').config();
const express = require('express');
const fetch = require('node-fetch');
const path = require('path');
const jwt = require('jsonwebtoken');
const WebSocket = require('ws');
const crypto = require('crypto');
const http = require('http');

const app = express();
app.use(express.json());
app.use(express.static('public'));

const AUTH_REQUIRED = true; // You can turn off auth for your demo if you want
const port = process.env.PORT || 5000;
const CLOUDFLARE_APP_ID = process.env.CLOUDFLARE_APP_ID;
const CLOUDFLARE_APP_SECRET = process.env.CLOUDFLARE_APP_SECRET;
const SECRET_KEY = process.env.JWT_SECRET || 'thisisjustademokey';
const CLOUDFLARE_CALLS_BASE_URL = process.env.CLOUDFLARE_APPS_URL || 'https://rtc.live.cloudflare.com/v1/apps';
const CLOUDFLARE_BASE_PATH = `${CLOUDFLARE_CALLS_BASE_URL}/${CLOUDFLARE_APP_ID}`;
const DEBUG = process.env.DEBUG === 'true' || false;

// Middleware to verify token from the Authorization header
function verifyToken(req, res, next) {
    const authHeader = req.headers['authorization'];
    if (!AUTH_REQUIRED) return next();

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).json({ error: 'Unauthorized: No token provided' });
    }

    const token = authHeader.split(' ')[1];

    try {
        const decoded = jwt.verify(token, SECRET_KEY);
        req.user = decoded; // Attach decoded token data to the request object
        next();
    } catch (err) {
        return res.status(403).json({ error: 'Forbidden: Invalid token' });
    }
}

// Example token generation endpoint
// Has no usefulness in production, just facilitates the demo
app.post('/auth/token', (req, res) => {
    const { username } = req.body;
    const userId = crypto.randomUUID(); // Generate unique user ID

    // Generate a token with arbitrary JSON payload
    const token = jwt.sign({ 
        userId,
        username, 
        role: 'demo',
        isModerator: true // In production, this would come from your database
    }, SECRET_KEY, { 
        expiresIn: '8h' 
    });

    // Store initial user info
    users.set(userId, {
        userId,
        username,
        isModerator: true,
        role: 'demo'
    });

    res.json({ token });
});

/**
 * In-memory storage for rooms and participants.
 * @typedef {Object} Room
 * @property {string} userId - Unique identifier for the user.
 * @property {string} sessionId - Unique identifier for the session.
 * @property {number} createdAt - Timestamp when the participant was added.
 * @property {Array} offers - Array of offer objects.
 */

/**
 * @type {Object.<string, Array<Room>>}
 */
const rooms = new Map();  // Using Map instead of plain object for better key handling

const wsConnections = {};

// Add this near the top with other in-memory storage
const users = new Map(); // Store user info

// Helper function to serialize room data
function serializeRoom(roomId, roomData) {
    return {
        roomId,
        name: roomData.name || '',
        metadata: roomData.metadata || {},
        participantCount: roomData.participants.length,
        createdAt: roomData.createdAt
    };
}

/* ------------------------------------------------------------------
   Basic endpoints
------------------------------------------------------------------ */

/**
 * @api {post} /api/rooms Create a new room
 * @apiName CreateRoom
 * @apiGroup Rooms
 *
 * @apiSuccess {String} roomId The unique ID of the created room.
 * @apiError (404) NotFound Room not found.
 */
app.post('/api/rooms', verifyToken, (req, res) => {
    const roomId = crypto.randomUUID();
    const { name, metadata } = req.body;
    
    rooms.set(roomId, {
        name: name || '',
        metadata: metadata || {},
        participants: [],
        createdAt: Date.now()
    });
    
    res.json(serializeRoom(roomId, rooms.get(roomId)));
});

/**
 * @api {get} /inspect-rooms Inspect all rooms (development only)
 * @apiName InspectRooms
 * @apiGroup Rooms
 * @apiDescription Retrieve all rooms and their participants (development mode only).
 *
 * @apiSuccess {Object} rooms Object containing all rooms and participants.
 */
if (process.env.NODE_ENV === 'development') {
    app.get('/inspect-rooms', (req, res) => {
        const debug = {
            rooms: Object.fromEntries(rooms),
            roomCount: rooms.size,
            users: Array.from(users.entries()),
            wsConnections: Object.keys(wsConnections),
            raw: rooms,
        };

        res.json(debug);
    });
}

/**
 * @api {post} /api/rooms/:roomId/join Join a room
 * @apiName JoinRoom
 * @apiGroup Rooms
 *
 * @apiParam {String} roomId The ID of the room to join.
 * @apiBody {String} userId The user's unique identifier.
 *
 * @apiSuccess {String} sessionId The session ID of the participant.
 * @apiSuccess {Array} otherSessions List of other participants in the room.
 * @apiError (404) NotFound Room not found.
 * @apiError (500) ServerError Failed to create Calls session.
 */
app.post('/api/rooms/:roomId/join', verifyToken, async (req, res) => {
    const { roomId } = req.params;
    const { userId } = req.user;

    if (!rooms.has(roomId)) {
        return res.status(404).json({ error: 'Room not found' });
    }

    const room = rooms.get(roomId);
    
    const response = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/new`, {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}` }
    });
    const sessionResponse = await response.json();
    if (!sessionResponse.sessionId) {
        return res.status(500).json({ error: 'Could not create Calls session' });
    }

    const participant = {
        userId,
        sessionId: sessionResponse.sessionId,
        createdAt: Date.now(),
        publishedTracks: []
    };

    room.participants.push(participant);
    rooms.set(roomId, room);

    const otherParticipants = room.participants
        .filter(p => p.userId !== userId)
        .map(p => ({
            userId: p.userId,
            sessionId: p.sessionId,
            publishedTracks: p.publishedTracks
        }));

    broadcastToRoom(roomId, {
        type: 'participant-joined',
        payload: {
            userId,
            username: users.get(userId).username,
            sessionId: participant.sessionId,
        },
    }, userId);

    res.json({
        sessionId: participant.sessionId,
        otherSessions: otherParticipants
    });
});

/**
 * @api {post} /api/rooms/:roomId/sessions/:sessionId/publish Publish Tracks
 * @apiName PublishTracks
 * @apiGroup Sessions
 *
 * @apiParam {String} roomId The ID of the room.
 * @apiParam {String} sessionId The session ID of the participant.
 * @apiBody {Object} offer The SDP offer.
 * @apiBody {Array} tracks Array of track objects.
 *
 * @apiSuccess {Object} data Response from Cloudflare Calls API.
 * @apiError (404) NotFound Session not found in this room.
 */
app.post('/api/rooms/:roomId/sessions/:sessionId/publish', verifyToken, async (req, res) => {
    const { roomId, sessionId } = req.params;
    const { offer, tracks } = req.body;

    const room = rooms.get(roomId);
    if (!room) {
        return res.status(404).json({ error: 'Room not found' });
    }

    const participant = room.participants.find(p => p.sessionId === sessionId);
    if (!participant) {
        return res.status(404).json({ error: 'Session not found in this room' });
    }

    // Store these trackName(s) in participant.publishedTracks
    for (const t of tracks) {
        if (!participant.publishedTracks.includes(t.trackName)) {
            participant.publishedTracks.push(t.trackName);
        }
    }

    rooms.set(roomId, room);
    // Now call Cloudflare to finalize the push
    const cfResp = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/tracks/new`, {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            sessionDescription: offer,
            tracks
        })
    });
    const data = await cfResp.json();
    if (data.sessionDescription) {
        // Emit a 'track-published' event to other participants in the room
        broadcastToRoom(roomId, {
            type: 'track-published',
            payload: {
                sessionId,
                trackNames: tracks.map(t => t.trackName)
            }
        }, participant.userId);
    }
    return res.json(data);
});

/**
 * @api {post} /api/rooms/:roomId/sessions/:sessionId/unpublish Unpublish Track
 * @apiName UnpublishTrack
 * @apiGroup Sessions
 * 
 * @apiParam {String} roomId The ID of the room
 * @apiParam {String} sessionId The session ID of the track owner
 * 
 * @apiHeader {String} Authorization Bearer token
 * 
 * @apiError (403) Forbidden User is not authorized to force unpublish others' tracks
 */
app.post('/api/rooms/:roomId/sessions/:sessionId/unpublish', verifyToken, async (req, res) => {
    try {
        const { roomId, sessionId } = req.params;
        const { trackName, mid, force, sessionDescription } = req.body;

        // If trying to force unpublish someone else's track
        if (force && sessionId !== req.user.sessionId) {
            // Check if user is moderator
            if (!req.user.isModerator) {
                return res.status(403).json({ 
                    errorCode: 'NOT_AUTHORIZED',
                    errorDescription: 'Only moderators can force unpublish other participants\' tracks'
                });
            }
        }

        if (DEBUG) console.log('Unpublishing track:', { roomId, sessionId, trackName, mid });

        if (!mid) {
            return res.status(400).json({ 
                errorCode: 'INVALID_REQUEST',
                errorDescription: 'mid is required to unpublish a track.'
            });
        }

        if (!sessionDescription) {
            return res.status(400).json({
                errorCode: 'INVALID_REQUEST',
                errorDescription: 'sessionDescription is required to unpublish a track.'
            });
        }

        // Call Cloudflare API to close the track
        const cfUrl = `${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/tracks/close`;
        if (DEBUG) console.log('Calling Cloudflare API:', cfUrl);
        
        const requestBody = {
            tracks: [{
                mid: mid.toString()
            }],
            force: Boolean(force),
            sessionDescription
        };

        if (DEBUG) console.log('Request body:', JSON.stringify(requestBody, null, 2));
        
        const response = await fetch(cfUrl, {
            method: 'PUT',
            headers: {
                'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(requestBody)
        });

        const data = await response.json();
        if (DEBUG) console.log('Cloudflare API response:', data);

        broadcastToRoom(roomId, {
            type: 'track-unpublished',
            payload: { sessionId, trackName }
        }, sessionId);

        res.json(data);

    } catch (error) {
        console.error('Detailed error unpublishing track:', error);
        res.status(500).json({ 
            errorCode: 'UNPUBLISH_ERROR',
            errorDescription: error.message 
        });
    }
});

/**
 * @api {post} /api/rooms/:roomId/sessions/:sessionId/pull Pull remote tracks
 * @apiName PullTracks
 * @apiGroup Sessions
 *
 * @apiParam {String} roomId The ID of the room.
 * @apiParam {String} sessionId The session ID of the participant.
 * @apiBody {String} remoteSessionId The session ID of the remote participant.
 * @apiBody {String} trackName The exact name of the track to pull.
 *
 * @apiSuccess {Object} data Response from Cloudflare Calls API.
 * @apiError (404) NotFound Room or Session not found.
 */
app.post('/api/rooms/:roomId/sessions/:sessionId/pull', verifyToken, async (req, res) => {
    const { roomId, sessionId } = req.params;
    const { remoteSessionId, trackName } = req.body;

    const room = rooms.get(roomId);
    if (!room) {
        return res.status(404).json({ error: 'Room not found' });
    }

    const participant = room.participants.find(p => p.sessionId === sessionId);
    if (!participant) {
        return res.status(404).json({ error: 'Session not found in this room' });
    }

    const tracksToPull = [{
        location: 'remote',
        sessionId: remoteSessionId,
        trackName
    }];

    const cfResp = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/tracks/new`, {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ tracks: tracksToPull })
    });
    const data = await cfResp.json();
    return res.json(data);
});

/**
 * @apiDefine Error404
 * @apiError 404 Room or Participant not found.
 */

/**
 * @apiDefine Error400
 * @apiError 400 Error from Cloudflare Calls API.
 */

/* ------------------------------------------------------------------
   Renegotiate, Publish, and Data Channels Endpoints
------------------------------------------------------------------ */

/**
 * @api {put} /api/rooms/:roomId/sessions/:sessionId/renegotiate Renegotiate Session
 * @apiName RenegotiateSession
 * @apiGroup Sessions
 * @apiDescription Renegotiate an existing session with new SDP offer
 * @apiParam {String} roomId Room identifier
 * @apiParam {String} sessionId Session identifier
 * @apiBody {Object} sessionDescription WebRTC session description
 * @apiBody {String} sessionDescription.sdp SDP offer
 * @apiBody {String} sessionDescription.type Type of SDP message
 *
 * @apiSuccess {Object} data Response from Cloudflare Calls API
 */
app.put('/api/rooms/:roomId/sessions/:sessionId/renegotiate', verifyToken, async (req, res) => {
    const { sessionId } = req.params;
    const { sdp, type } = req.body; // The client's answer
    const body = {
        sessionDescription: { sdp, type },
    };
    const cfResp = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/renegotiate`, {
        method: 'PUT',
        headers: {
            'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(body),
    });
    const result = await cfResp.json();
    if (result.errorCode) {
        return res.status(400).json(result);
    }
    res.json(result);
});

/**
 * @api {post} /api/rooms/:roomId/sessions/:sessionId/datachannels/new Manage Data Channels
 * @apiName ManageDataChannels
 * @apiGroup Sessions
 * @apiDescription Manage data channel subscriptions
 * @apiParam {String} roomId Room identifier
 * @apiParam {String} sessionId Session identifier
 * @apiBody {Array} dataChannels Array of data channel names
 *
 * @apiSuccess {Object} response Response from Cloudflare Calls API.
 *
 * @apiUse Error404
 * @apiUse Error400
 */
app.post('/api/rooms/:roomId/sessions/:sessionId/datachannels/new', verifyToken, async (req, res) => {
    const { roomId, sessionId } = req.params;
    const { dataChannels } = req.body;

    // Check that this room and session exist in memory
    const room = rooms.get(roomId);
    if (!room) {
        return res.status(404).json({ error: 'Room not found' });
    }

    // Forward this datachannels request to Cloudflare
    // The official CF endpoint is:
    //   POST /v1/apps/:APP_ID/sessions/:sessionId/datachannels/new
    // with a JSON body { dataChannels: [...] }.

    const cfUrl = `${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/datachannels/new`;
    const cfResp = await fetch(cfUrl, {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ dataChannels })
    });

    const data = await cfResp.json();
    if (data.errorCode) {
        return res.status(400).json(data);
    }

    // Optionally, if the user is publishing a channel, you could record that in `participant.publishedDataChannels` in memory
    dataChannels.forEach(dc => {
        if (dc.location === 'local') {
            // E.g. store in participant.publishedDataChannels = [...(existing), dc.dataChannelName];
        }
    });

    res.json(data); // Return the CF Calls response to the client
});

/* ------------------------------------------------------------------
   Participants and Tracks Endpoints
------------------------------------------------------------------ */

/**
 * @api {get} /api/rooms/:roomId/participants Get Participants
 * @apiName GetParticipants
 * @apiGroup Participants
 * @apiDescription Retrieves a list of all participants in a specified room along with their published tracks.
 *
 * @apiParam {String} roomId The ID of the room.
 *
 * @apiSuccess {Object} participants An object containing an array of participants.
 *
 * @apiUse Error404
 */
app.get('/api/rooms/:roomId/participants', verifyToken, (req, res) => {
    const { roomId } = req.params;
    
    if (!rooms.has(roomId)) {
        return res.status(404).json({ error: 'Room not found' });
    }
    
    const room = rooms.get(roomId);
    res.json({ participants: room.participants });
});

/**
 * @api {get} /api/rooms/:roomId/participant/:sessionId/tracks Get Participant Tracks
 * @apiName GetParticipantTracks
 * @apiGroup Participants
 * @apiDescription Retrieves a list of tracks for a specific participant in a room.
 *
 * @apiParam {String} roomId The ID of the room.
 * @apiParam {String} sessionId The session ID of the participant.
 *
 * @apiSuccess {Object} publishedTracks Array of published track names.
 *
 * @apiUse Error404
 */
app.get('/api/rooms/:roomId/participant/:sessionId/tracks', verifyToken, async (req, res) => {
    const { sessionId, roomId } = req.params;

    if (!rooms.has(roomId)) {
        return res.status(404).json({ error: 'Room not found' });
    }

    const room = rooms.get(roomId);
    const participant = room.participants.find(p => p.sessionId === sessionId);

    if (!participant) {
        return res.status(404).json({ error: 'Participant not found' });
    }

    res.json(participant.publishedTracks);
});

/* ------------------------------------------------------------------
   ICE Servers Endpoint
------------------------------------------------------------------ */

/**
 * @api {get} /api/ice-servers Get ICE Servers
 * @apiName GetICEServers
 * @apiGroup ICEServers
 * @apiDescription Generates TURN credentials and returns the iceServers configuration.
 *
 * @apiSuccess {Object} iceServers iceServers configuration.
 *
 * @apiError 500 Failed to generate ICE servers.
 */
app.get('/api/ice-servers', verifyToken, (req, res) => {
    if (!process.env.CLOUDFLARE_TURN_ID || !process.env.CLOUDFLARE_TURN_TOKEN) {
        return res.json({
            iceServers: [
                { urls: 'stun:stun.cloudflare.com:3478' },
            ]
        });
    }

    try {
        const lifetime = 600; // Credentials valid for 10 minutes (600 seconds)
        const timestamp = Math.floor(Date.now() / 1000) + lifetime;
        const username = `${timestamp}:${process.env.CLOUDFLARE_TURN_ID}`;

        // Create HMAC-SHA256 hash using CLOUDFLARE_TURN_TOKEN as the key
        const hmac = crypto.createHmac('sha256', process.env.CLOUDFLARE_TURN_TOKEN);
        hmac.update(username);
        const credential = hmac.digest('base64');

        const iceServers = {
            iceServers: [
                { urls: 'stun:stun.cloudflare.com:3478' },
                {
                    urls: 'turn:turn.cloudflare.com:3478?transport=udp',
                    username,
                    credential
                },
                {
                    urls: 'turn:turn.cloudflare.com:3478?transport=tcp',
                    username,
                    credential
                },
                {
                    urls: 'turns:turn.cloudflare.com:5349?transport=tcp',
                    username,
                    credential
                }
            ]
        };

        res.json(iceServers);
    } catch (error) {
        console.error('Error generating ICE servers:', error);
        res.status(500).json({ error: 'Failed to generate ICE servers' });
    }
});

/* ------------------------------------------------------------------
   Basic WebSocket for "participant joined" etc.
------------------------------------------------------------------ */

/**
 * Sets up the WebSocket server and handles incoming connections and messages.
 */
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
    if (DEBUG) console.log('New WebSocket connection.');
    // ws.setNoDelay(true);

    ws.isAuthenticated = false;

    ws.on('message', (message) => {
        let data;
        try {
            data = JSON.parse(message);
        } catch {
            console.warn('Received invalid JSON message via WebSocket.');
            return;
        }
        switch (data.type) {
            case 'join-websocket':
                handleWSJoin(ws, data.payload);
                break;
            case 'data-message':
                if (AUTH_REQUIRED && !ws.isAuthenticated) {
                    ws.send(JSON.stringify({ error: 'Unauthorized: Please authenticate first' }));
                    if (DEBUG) console.log('Unauthenticated websocket request to send data-message');
                    return;
                }
                handleDataMessage(ws, data.payload);
                break;
            default:
                console.warn(`Unknown message type: ${data.type}`);
                break;
        }
    });
    ws.on('close', () => handleWSDisconnect(ws));
});

/**
 * Handles incoming data messages from clients and broadcasts them.
 * @param {WebSocket} ws - The WebSocket connection from the sender.
 * @param {Object} payload - The payload containing from, to, and message.
 */
function handleDataMessage(ws, payload) {
    const { from, to, message } = payload;
    if (!from || !message) {
        console.warn('Invalid data-message payload:', payload);
        return;
    }

    // Get room ID from the session ID
    const roomId = getRoomIdBySessionId(from);
    if (!roomId) {
        console.warn(`Room not found for session: ${from}`);
        return;
    }

    // Broadcast to all participants in the room except the sender
    broadcastToRoom(roomId, {
        type: 'data-message',
        payload: {
            from,
            data: message
        }
    }, from);
}

/**
 * Utility function to get roomId by userId.
 * Assumes each user is in only one room.
 * @param {string} userId - The user's unique identifier.
 * @returns {string|null} - The room ID if found, otherwise null.
 */
function getRoomIdByUserId(userId) {
    for (const [roomId, room] of rooms.entries()) {
        if (room.participants.find(p => p.userId === userId)) {
            return roomId;
        }
    }
    return null;
}

/**
 * Utility function to get WebSocket connection by userId.
 * @param {string} userId - The user's unique identifier.
 * @returns {WebSocket|null} - The WebSocket connection if found, otherwise null.
 */
function getWebSocketByUserId(userId) {
    for (const users of Object.values(wsConnections)) {
        if (users[userId]) {
            return users[userId];
        }
    }
    return null;
}

/**
 * Handles a WebSocket join request by authenticating and adding the user to wsConnections.
 * @param {WebSocket} ws - The WebSocket connection.
 * @param {Object} payload - The payload containing roomId, userId, and token.
 * @param {string} payload.roomId - The ID of the room to join.
 * @param {string} payload.userId - The user's unique identifier.
 * @param {string} payload.token - The JWT token for authentication.
 */
function handleWSJoin(ws, { roomId, userId, token }) {
    if (!roomId || !userId || (AUTH_REQUIRED && !token)) {
        console.warn('Missing roomId, userId, or token in WS join');
        ws.send(JSON.stringify({ error: 'Missing roomId, userId, or token' }));
        return;
    }

    try {
        // Verify the token
        if (AUTH_REQUIRED) {
            const user = jwt.verify(token, SECRET_KEY);
        }

        ws.isAuthenticated = true;

        // Add user to the room
        if (!wsConnections[roomId]) {
            wsConnections[roomId] = {};
        }
        wsConnections[roomId][userId] = ws;

        if (DEBUG) console.log(`User ${userId} joined room ${roomId} via WS`);
        ws.send(JSON.stringify({ message: 'Joined room successfully' }));
    } catch (err) {
        if (DEBUG) console.warn('Invalid token in WS join:', err.message);
        ws.send(JSON.stringify({ error: 'Invalid or expired token' }));
    }
}

/**
 * Handles WebSocket disconnections by removing the user from wsConnections.
 * @param {WebSocket} ws - The WebSocket connection that was closed.
 */
function handleWSDisconnect(ws) {
    for (const [rId, userMap] of Object.entries(wsConnections)) {
        for (const [uId, sock] of Object.entries(userMap)) {
            if (sock === ws) {
                if (DEBUG) console.log(`User ${uId} disconnected from room ${rId}`);
                delete wsConnections[rId][uId];
            }
        }
    }
}

/**
 * Broadcasts a message to all participants in a room, optionally excluding a specific user.
 * @param {string} roomId - The ID of the room.
 * @param {Object} message - The message object to broadcast.
 * @param {string|null} excludeUserId - The user ID to exclude from broadcasting.
 */
function broadcastToRoom(roomId, message, excludeUserId = null) {
    if (DEBUG) console.log('Broadcasting to room:', { roomId, message, excludeUserId });
    if (!rooms.has(roomId)) return;

    if (!wsConnections[roomId]) return;
    for (const [userId, ws] of Object.entries(wsConnections[roomId])) {
        if (userId === excludeUserId) continue;
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify(message));
            if (DEBUG) console.log('Sent Broadcast message to user:', userId);
        }
    }
}

/**
 * @api {get} /api/rooms/:roomId/sessions/:sessionId/state Get Session State
 * @apiName GetSessionState
 * @apiGroup Sessions
 * @apiDescription Retrieves the current state of a session from Cloudflare Calls API.
 *
 * @apiParam {String} roomId The ID of the room.
 * @apiParam {String} sessionId The session ID to query.
 *
 * @apiSuccess {Object} response Session state from Cloudflare Calls API.
 * @apiSuccess {Array} response.tracks List of tracks in the session.
 * @apiSuccess {String} response.tracks.location Track location ('local' or 'remote').
 * @apiSuccess {String} response.tracks.mid Media ID of the track.
 * @apiSuccess {String} response.tracks.trackName Name/ID of the track.
 * @apiSuccess {String} response.tracks.status Track status ('active', 'inactive', or 'waiting').
 *
 * @apiError (500) SessionStateError Failed to retrieve session state.
 * @apiError (403) Forbidden Invalid or missing authentication token.
 */
app.get('/api/rooms/:roomId/sessions/:sessionId/state', verifyToken, async (req, res) => {
    const { roomId, sessionId } = req.params;

    try {
        const response = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}`, {
            headers: {
                'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`
            }
        });

        const data = await response.json();
        res.json(data);
    } catch (error) {
        console.error('Error getting session state:', error);
        res.status(500).json({ 
            errorCode: 'SESSION_STATE_ERROR',
            errorDescription: error.message 
        });
    }
});

/**
 * @api {get} /api/users/:userId Get User Info
 * @apiName GetUserInfo
 * @apiGroup Users
 * @apiDescription Get information about a user. Returns full info for own user, limited info for others.
 *
 * @apiParam {String} userId User ID or 'me' for current user
 * @apiHeader {String} Authorization Bearer token required
 *
 * @apiSuccess {String} userId User's unique identifier
 * @apiSuccess {String} username User's display name
 * @apiSuccess {Boolean} [isModerator] Whether user is moderator (only included for own user)
 * @apiSuccess {String} [role] User's role (only included for own user)
 *
 * @apiError (403) Forbidden Invalid or missing token
 * @apiError (404) NotFound User not found
 */
app.get('/api/users/:userId', verifyToken, (req, res) => {
    const { userId } = req.params;
    
    // Handle 'me' request
    if (userId === 'me') {
        const userInfo = users.get(req.user.userId);
        if (!userInfo) {
            return res.status(404).json({
                errorCode: 'USER_NOT_FOUND',
                errorDescription: 'Current user not found'
            });
        }
        return res.json(userInfo);
    }

    // Handle specific user request
    const requestedUser = users.get(userId);
    if (!requestedUser) {
        return res.status(404).json({
            errorCode: 'USER_NOT_FOUND',
            errorDescription: 'User not found'
        });
    }

    // Return limited info for other users
    return res.json({
        userId: requestedUser.userId,
        username: requestedUser.username
    });
});

/**
 * @api {get} /api/users/:userId Get User Info
 * @apiName GetUserInfo
 * @apiGroup Users
 * @apiDescription Get information about a user. Returns full info for own user, limited info for others.
 *
 * @apiParam {String} userId User ID or 'me' for current user
 * @apiHeader {String} Authorization Bearer token required
 *
 * @apiSuccess {String} userId User's unique identifier
 * @apiSuccess {String} username User's display name
 * @apiSuccess {Boolean} [isModerator] Whether user is moderator (only included for own user)
 * @apiSuccess {String} [role] User's role (only included for own user)
 *
 * @apiError (403) Forbidden Invalid or missing token
 * @apiError (404) NotFound User not found
 */
app.get('/api/users/:userId', verifyToken, (req, res) => {
    const { userId } = req.params;
    
    // Handle 'me' request
    if (userId === 'me') {
        const userInfo = users.get(req.user.userId);
        if (!userInfo) {
            return res.status(404).json({
                errorCode: 'USER_NOT_FOUND',
                errorDescription: 'Current user not found'
            });
        }
        return res.json(userInfo);
    }

    // Handle specific user request
    const requestedUser = users.get(userId);
    if (!requestedUser) {
        return res.status(404).json({
            errorCode: 'USER_NOT_FOUND',
            errorDescription: 'User not found'
        });
    }

    // Return limited info for other users
    return res.json({
        userId: requestedUser.userId,
        username: requestedUser.username
    });
});

app.post('/api/rooms/:roomId/leave', verifyToken, async (req, res) => {
    const { roomId } = req.params;
    const { sessionId } = req.body;

    if (!rooms.has(roomId)) {
        return res.status(404).json({ error: 'Room not found' });
    }

    const room = rooms.get(roomId);
    const participantIndex = room.participants.findIndex(p => p.sessionId === sessionId);

    if (participantIndex !== -1) {
        const participant = room.participants[participantIndex];
        room.participants.splice(participantIndex, 1);

        // Notify other participants about the leave
        broadcastToRoom(roomId, {
            type: 'participant-left',
            payload: {
                sessionId,
                userId: participant.userId,
                metadata: participant.metadata
            }
        }, sessionId);

        // If room is empty, delete it
        if (room.participants.length === 0) {
            rooms.delete(roomId);
        }
    }

    res.json({ success: true });
});

process.on('SIGINT', () => {
    users.clear();
    process.exit();
});

server.listen(port, () => {
    console.log(`Server listening on http://localhost:${port}`);
});

/**
 * @api {post} /api/rooms/:roomId/sessions/:sessionId/track-status Update Track Status
 * @apiName UpdateTrackStatus
 * @apiGroup Sessions
 * @apiDescription Updates the enabled/disabled status of a track
 *
 * @apiParam {String} roomId The ID of the room
 * @apiParam {String} sessionId The session ID
 * @apiBody {String} trackId The track ID
 * @apiBody {String} kind The track kind ('audio' or 'video')
 * @apiBody {Boolean} enabled Whether the track should be enabled
 * @apiBody {Boolean} [force] Whether to force the status change
 *
 * @apiSuccess {Object} result Operation result
 * @apiError (403) Forbidden Not authorized to update track status
 */
app.post('/api/rooms/:roomId/sessions/:sessionId/track-status', verifyToken, async (req, res) => {
    try {
        const { roomId, sessionId } = req.params;
        const { trackId, kind, enabled, force } = req.body;

        // If trying to force change someone else's track
        if (force && sessionId !== req.user.sessionId) {
            if (!req.user.isModerator) {
                return res.status(403).json({
                    errorCode: 'NOT_AUTHORIZED',
                    errorDescription: 'Only moderators can force change other participants\' tracks'
                });
            }
        }

        // Notify other participants about the track status change
        broadcastToRoom(roomId, {
            type: 'track-status-changed',
            payload: {
                sessionId,
                trackId,
                kind,
                enabled
            }
        }, sessionId);

        res.json({ success: true });
    } catch (error) {
        console.error('Error updating track status:', error);
        res.status(500).json({
            errorCode: 'UPDATE_TRACK_STATUS_ERROR',
            errorDescription: error.message
        });
    }
});

app.get('/api/rooms', verifyToken, (req, res) => {
    const roomList = Array.from(rooms.entries()).map(([roomId, room]) => 
        serializeRoom(roomId, room)
    );
    
    res.json({ rooms: roomList });
});

app.put('/api/rooms/:roomId/metadata', verifyToken, (req, res) => {
    const { roomId } = req.params;
    const { name, metadata } = req.body;
    
    if (!rooms.has(roomId)) {
        return res.status(404).json({ error: 'Room not found' });
    }
    
    const room = rooms.get(roomId);
    
    if (name !== undefined) {
        room.name = name;
    }
    
    if (metadata !== undefined) {
        room.metadata = { ...room.metadata, ...metadata };
    }
    
    rooms.set(roomId, room);
    
    // Notify room participants about the update
    broadcastToRoom(roomId, {
        type: 'room-metadata-updated',
        payload: {
            roomId,
            name: room.name,
            metadata: room.metadata
        }
    });
    
    res.json(serializeRoom(roomId, room));
});

function getRoomIdBySessionId(sessionId) {
    for (const [roomId, room] of rooms.entries()) {
        if (room.participants.find(p => p.sessionId === sessionId)) {
            return roomId;
        }
    }
    return null;
}