/** * Call Routes * API endpoints for voice/video calls * Ported from AeThex-Connect */ import { Router, Request, Response } from "express"; import { supabase } from "./supabase.js"; import { requireAuth } from "./auth.js"; import crypto from "crypto"; const router = Router(); // Helper to get user ID from session function getUserId(req: Request): string | null { return (req.session as any)?.userId || null; } // ==================== CALL ROUTES ==================== /** * POST /api/calls/initiate - Start a new call */ router.post("/initiate", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { conversationId, type, participants } = req.body; if (!conversationId || !type) { return res.status(400).json({ error: "conversationId and type are required" }); } if (!["voice", "video"].includes(type)) { return res.status(400).json({ error: "type must be 'voice' or 'video'" }); } // Verify user is participant in conversation const { data: participant } = await supabase .from("conversation_participants") .select("*") .eq("conversation_id", conversationId) .eq("user_id", userId) .single(); if (!participant) { return res.status(403).json({ error: "Not a participant in this conversation" }); } // Get conversation details const { data: conversation, error: convError } = await supabase .from("conversations") .select("*") .eq("id", conversationId) .single(); if (convError || !conversation) { return res.status(404).json({ error: "Conversation not found" }); } // Determine if group call const isGroupCall = (participants && participants.length > 1) || conversation.type === "group"; const sfuRoomId = isGroupCall ? `room-${crypto.randomUUID()}` : null; // Create call record const { data: call, error: callError } = await supabase .from("calls") .insert({ conversation_id: conversationId, type, initiator_id: userId, status: "ringing", sfu_room_id: sfuRoomId }) .select() .single(); if (callError) throw callError; // Add initiator as participant await supabase .from("call_participants") .insert({ call_id: call.id, user_id: userId, joined_at: new Date().toISOString() }); // Get target participants (either provided or from conversation) let targetParticipants: string[] = participants || []; if (targetParticipants.length === 0) { const { data: convParticipants } = await supabase .from("conversation_participants") .select("user_id") .eq("conversation_id", conversationId) .neq("user_id", userId); targetParticipants = (convParticipants || []).map(p => p.user_id); } // Add target participants with ringing status for (const targetId of targetParticipants) { await supabase .from("call_participants") .insert({ call_id: call.id, user_id: targetId }); } // Generate TURN credentials const turnCredentials = await generateTURNCredentials(userId); res.json({ call: { id: call.id, conversationId, type, status: "ringing", initiatorId: userId, isGroupCall, sfuRoomId, participants: targetParticipants.map(id => ({ userId: id, status: "ringing" })), turnCredentials, createdAt: call.created_at } }); } catch (err: any) { console.error("[Calls] Initiate error:", err); res.status(500).json({ error: err.message }); } }); /** * POST /api/calls/:callId/answer - Answer incoming call */ router.post("/:callId/answer", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { callId } = req.params; const { accept } = req.body; // Get call const { data: call, error: callError } = await supabase .from("calls") .select("*") .eq("id", callId) .single(); if (callError || !call) { return res.status(404).json({ error: "Call not found" }); } if (!accept) { // Reject call await endCall(callId, userId, "rejected"); return res.json({ call: { id: callId, status: "ended", endReason: "rejected" } }); } // Accept call - update participant await supabase .from("call_participants") .update({ joined_at: new Date().toISOString() }) .eq("call_id", callId) .eq("user_id", userId); // Mark call as active for 1-on-1 calls if (!call.sfu_room_id) { await supabase .from("calls") .update({ status: "active", started_at: new Date().toISOString() }) .eq("id", callId); } // Generate TURN credentials const turnCredentials = await generateTURNCredentials(userId); const response: any = { call: { id: callId, status: "active", turnCredentials } }; // Include SFU config for group calls if (call.sfu_room_id) { response.call.sfuConfig = { roomId: call.sfu_room_id, routerRtpCapabilities: { codecs: [ { kind: "audio", mimeType: "audio/opus", clockRate: 48000, channels: 2 }, { kind: "video", mimeType: "video/VP8", clockRate: 90000 }, { kind: "video", mimeType: "video/VP9", clockRate: 90000 } ] } }; } res.json(response); } catch (err: any) { console.error("[Calls] Answer error:", err); res.status(500).json({ error: err.message }); } }); /** * POST /api/calls/:callId/reject - Reject incoming call */ router.post("/:callId/reject", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { callId } = req.params; const result = await endCall(callId, userId, "rejected"); res.json({ call: result }); } catch (err: any) { console.error("[Calls] Reject error:", err); res.status(500).json({ error: err.message }); } }); /** * POST /api/calls/:callId/end - End active call */ router.post("/:callId/end", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { callId } = req.params; const result = await endCall(callId, userId, "hangup"); res.json({ call: result }); } catch (err: any) { console.error("[Calls] End error:", err); res.status(500).json({ error: err.message }); } }); /** * PATCH /api/calls/:callId/media - Update media state */ router.patch("/:callId/media", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { callId } = req.params; const mediaState = req.body; await supabase .from("call_participants") .update({ media_state: mediaState }) .eq("call_id", callId) .eq("user_id", userId); res.json({ mediaState }); } catch (err: any) { console.error("[Calls] Media update error:", err); res.status(500).json({ error: err.message }); } }); /** * GET /api/calls/turn-credentials - Get TURN server credentials */ router.get("/turn-credentials", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const credentials = await generateTURNCredentials(userId); res.json({ credentials }); } catch (err: any) { console.error("[Calls] TURN credentials error:", err); res.status(500).json({ error: err.message }); } }); /** * GET /api/calls/:callId - Get call details */ router.get("/:callId", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { callId } = req.params; // Get call with participants const { data: call, error: callError } = await supabase .from("calls") .select("*") .eq("id", callId) .single(); if (callError || !call) { return res.status(404).json({ error: "Call not found" }); } // Get participants const { data: participants } = await supabase .from("call_participants") .select("*") .eq("call_id", callId); res.json({ call: { ...call, participants: participants || [] } }); } catch (err: any) { console.error("[Calls] Get call error:", err); res.status(500).json({ error: err.message }); } }); /** * GET /api/calls - Get user's recent calls */ router.get("/", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const limit = parseInt(req.query.limit as string) || 20; // Get calls where user was a participant const { data: participantData } = await supabase .from("call_participants") .select("call_id") .eq("user_id", userId); const callIds = (participantData || []).map(p => p.call_id); if (callIds.length === 0) { return res.json({ calls: [] }); } const { data: calls, error } = await supabase .from("calls") .select("*") .in("id", callIds) .order("created_at", { ascending: false }) .limit(limit); if (error) throw error; res.json({ calls: calls || [] }); } catch (err: any) { console.error("[Calls] List error:", err); res.status(500).json({ error: err.message }); } }); // ==================== HELPER FUNCTIONS ==================== /** * End a call */ async function endCall(callId: string, userId: string, reason: string) { // Get call const { data: call, error } = await supabase .from("calls") .select("*") .eq("id", callId) .single(); if (error || !call) { throw new Error("Call not found"); } // Calculate duration let duration = null; if (call.started_at) { const now = new Date(); const started = new Date(call.started_at); duration = Math.floor((now.getTime() - started.getTime()) / 1000); } // Update call await supabase .from("calls") .update({ status: "ended", ended_at: new Date().toISOString(), duration_seconds: duration, end_reason: reason }) .eq("id", callId); // Update participant await supabase .from("call_participants") .update({ left_at: new Date().toISOString() }) .eq("call_id", callId) .eq("user_id", userId); return { id: callId, status: "ended", duration, endedBy: userId, reason }; } /** * Generate TURN server credentials */ async function generateTURNCredentials(userId: string) { const turnSecret = process.env.TURN_SECRET; const turnHost = process.env.TURN_SERVER_HOST || "turn.aethex.network"; const turnPort = process.env.TURN_SERVER_PORT || "3478"; // If no TURN secret configured, return public STUN servers only if (!turnSecret) { return { urls: [ "stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302" ], username: null, credential: null, ttl: 86400 }; } const timestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hour TTL const username = `${timestamp}:${userId}`; // Generate credential using HMAC const hmac = crypto.createHmac("sha1", turnSecret); hmac.update(username); const credential = hmac.digest("base64"); // Store in database await supabase .from("turn_credentials") .upsert({ user_id: userId, username, credential, expires_at: new Date(timestamp * 1000).toISOString() }, { onConflict: "user_id" }); return { urls: [ `stun:${turnHost}:${turnPort}`, `turn:${turnHost}:${turnPort}`, `turn:${turnHost}:${turnPort}?transport=tcp` ], username, credential, ttl: 86400 }; } export default router;