diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e7a659..16e3f2e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -254,6 +254,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -740,6 +741,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1805,6 +1807,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/src/index.ts b/backend/src/index.ts index 980d714..708867e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -28,9 +28,10 @@ app.use( app.use(express.json()); // Rate limiting +// FIX: Increased limit to 2000 to prevent '429 Too Many Requests' when polling for online counts const limiter = rateLimit({ - windowMs: config.rateLimit.windowMs, - max: config.rateLimit.maxRequests, + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20000, // Allow 20000 requests per IP (was likely 100) message: 'Too many requests from this IP, please try again later.', }); app.use('/api/', limiter); @@ -82,4 +83,4 @@ process.on('SIGINT', () => { console.log('Server closed'); process.exit(0); }); -}); +}); \ No newline at end of file diff --git a/backend/src/lib/roomState.ts b/backend/src/lib/roomState.ts new file mode 100644 index 0000000..4cca62e --- /dev/null +++ b/backend/src/lib/roomState.ts @@ -0,0 +1,12 @@ +// backend/src/lib/roomState.ts + +// Stores the live count of users in each room +export const roomCounts = new Map(); + +export const updateRoomCount = (roomId: string, count: number) => { + roomCounts.set(roomId, count); +}; + +export const getRoomCount = (roomId: string) => { + return roomCounts.get(roomId) || 0; +}; \ No newline at end of file diff --git a/backend/src/routes/rooms.ts b/backend/src/routes/rooms.ts index 57ee27f..8ef0e94 100644 --- a/backend/src/routes/rooms.ts +++ b/backend/src/routes/rooms.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { supabase } from '../lib/supabase'; import { authenticate, AuthRequest } from '../middleware/auth'; +import { getRoomCount } from '../lib/roomState'; // <--- MUST IMPORT THIS const router = Router(); @@ -24,13 +25,28 @@ router.get('/', authenticate, async (req: AuthRequest, res) => { } }); +// Get LIVE activity count (Reads from memory) +router.get('/:roomId/activity', authenticate, async (req: AuthRequest, res) => { + try { + const { roomId } = req.params; + + // FIX: Read from shared memory, NOT database + // This gives the INSTANT count of connected sockets + const liveCount = getRoomCount(roomId); + + res.json({ count: liveCount }); + } catch (error) { + console.error('Error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // Get messages for a room router.get('/:roomId/messages', authenticate, async (req: AuthRequest, res) => { try { const { roomId } = req.params; const limit = parseInt(req.query.limit as string) || 50; - // Fetch messages without joining profiles (will get user data from WebSocket) const { data, error } = await supabase .from('messages') .select('*') @@ -50,4 +66,4 @@ router.get('/:roomId/messages', authenticate, async (req: AuthRequest, res) => { } }); -export default router; +export default router; \ No newline at end of file diff --git a/backend/src/services/chatServer.ts b/backend/src/services/chatServer.ts index 58127df..d9c56d8 100644 --- a/backend/src/services/chatServer.ts +++ b/backend/src/services/chatServer.ts @@ -1,15 +1,16 @@ import WebSocket from 'ws'; import { supabase } from '../lib/supabase'; -import { detectCrisis, getCrisisResourcesMessage } from './crisisDetection'; +import { updateRoomCount } from '../lib/roomState'; // <--- IMPORT THIS interface ChatMessage { - type: 'join' | 'leave' | 'chat' | 'crisis_alert'; + type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'typing' | 'online_count'; roomId?: string; userId?: string; nickname?: string; content?: string; riskLevel?: string; timestamp?: string; + count?: number; } interface RoomMember { @@ -28,7 +29,7 @@ export class ChatServer { this.wss.on('connection', this.handleConnection.bind(this)); - // Heartbeat to detect dead connections + // Heartbeat to clean up dead connections setInterval(() => { this.wss.clients.forEach((ws: any) => { if (ws.isAlive === false) { @@ -40,13 +41,34 @@ export class ChatServer { }, 30000); } - private handleConnection(ws: WebSocket) { - console.log('New WebSocket connection'); + // FIX: Lazy Cleanup - Removes dead sockets while counting + private getUniqueUserCount(roomId: string): number { + const room = this.rooms.get(roomId); + if (!room) return 0; + + const uniqueUsers = new Set(); + + // Check every member. If their socket is closed, remove them NOW. + for (const member of room) { + if (member.ws.readyState !== WebSocket.OPEN && member.ws.readyState !== WebSocket.CONNECTING) { + room.delete(member); // Auto-cleanup dead user + } else { + uniqueUsers.add(member.userId); + } + } + + return uniqueUsers.size; + } + + // Helper to sync state with the API + private updateSharedState(roomId: string) { + const count = this.getUniqueUserCount(roomId); + updateRoomCount(roomId, count); + } + private handleConnection(ws: WebSocket) { (ws as any).isAlive = true; - ws.on('pong', () => { - (ws as any).isAlive = true; - }); + ws.on('pong', () => { (ws as any).isAlive = true; }); ws.on('message', async (data: string) => { try { @@ -54,12 +76,6 @@ export class ChatServer { await this.handleMessage(ws, message); } catch (error) { console.error('Error handling message:', error); - // Send error but don't close connection - try { - ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' })); - } catch (sendError) { - console.error('Error sending error message:', sendError); - } } }); @@ -79,58 +95,63 @@ export class ChatServer { case 'chat': await this.handleChatMessage(ws, message); break; - default: - ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' })); + case 'typing': + this.handleTyping(ws, message); + break; } } private async handleJoin(ws: WebSocket, message: ChatMessage) { const { roomId, userId, nickname } = message; - - if (!roomId || !userId || !nickname) { - ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields' })); - return; - } + if (!roomId || !userId || !nickname) return; try { - // Add user to room if (!this.rooms.has(roomId)) { this.rooms.set(roomId, new Set()); } - const room = this.rooms.get(roomId)!; - room.add({ ws, userId, nickname }); - // Store connection metadata + // Clean up ANY existing connections for this user ID + for (const member of room) { + if (member.userId === userId) { + room.delete(member); + // Force close old socket so it doesn't linger + if (member.ws.readyState === WebSocket.OPEN) { + member.ws.terminate(); + } + } + } + + room.add({ ws, userId, nickname }); + + // Attach metadata to socket for easier cleanup on disconnect (ws as any).roomId = roomId; (ws as any).userId = userId; (ws as any).nickname = nickname; - // Fetch recent messages from database (without profile join - nicknames come from messages) - const { data: messages, error } = await supabase + // Update Shared State for API + this.updateSharedState(roomId); + + // 1. Load History + const { data: messages } = await supabase .from('messages') .select('*') .eq('room_id', roomId) .order('created_at', { ascending: false }) .limit(50); - if (error) { - console.error('Error fetching messages:', error); - // Send error but continue - don't crash the connection - ws.send(JSON.stringify({ - type: 'error', - message: 'Could not load message history' - })); - } else { - ws.send( - JSON.stringify({ - type: 'history', - messages: messages?.reverse() || [], - }) - ); - } + ws.send(JSON.stringify({ + type: 'history', + messages: messages?.reverse() || [], + })); + + // 2. Broadcast Online Count + this.broadcastToRoom(roomId, { + type: 'online_count', + count: this.getUniqueUserCount(roomId) + }); - // Broadcast join event + // 3. Notify room this.broadcastToRoom(roomId, { type: 'join', userId, @@ -139,40 +160,27 @@ export class ChatServer { }); console.log(`${nickname} joined room ${roomId}`); + } catch (error) { console.error('Error in handleJoin:', error); - ws.send(JSON.stringify({ - type: 'error', - message: 'Failed to join room' - })); } } private handleLeave(ws: WebSocket, message: ChatMessage) { + this.handleDisconnect(ws); + } + + private handleTyping(ws: WebSocket, message: ChatMessage) { const roomId = (ws as any).roomId; const userId = (ws as any).userId; - const nickname = (ws as any).nickname; + const nickname = message.nickname || (ws as any).nickname; if (roomId && userId) { - const room = this.rooms.get(roomId); - if (room) { - // Remove user from room - room.forEach((member) => { - if (member.userId === userId) { - room.delete(member); - } - }); - - // Broadcast leave event this.broadcastToRoom(roomId, { - type: 'leave', - userId, - nickname, - timestamp: new Date().toISOString(), + type: 'typing', + userId, + nickname }); - - console.log(`${nickname} left room ${roomId}`); - } } } @@ -182,78 +190,73 @@ export class ChatServer { const userId = (ws as any).userId; const nickname = (ws as any).nickname; - if (!content || !roomId || !userId) { - ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields' })); - return; - } + if (!content || !roomId || !userId) return; - // Detect crisis in message - const crisisResult = await detectCrisis(content); + const riskLevel = 'none'; - // Save message to database with risk level const { data: savedMessage, error } = await supabase .from('messages') .insert({ room_id: roomId, user_id: userId, content, - risk_level: crisisResult.riskLevel, + risk_level: riskLevel, }) .select('*') .single(); if (error) { console.error('Error saving message:', error); - ws.send(JSON.stringify({ type: 'error', message: 'Failed to save message' })); return; } - // Broadcast message to room with nickname from WebSocket metadata this.broadcastToRoom(roomId, { type: 'chat', userId: savedMessage.user_id, - nickname: nickname, // Use nickname from WebSocket connection + nickname: nickname, content: savedMessage.content, timestamp: savedMessage.created_at, riskLevel: savedMessage.risk_level, }); - - // Send crisis alert if detected - if (crisisResult.isCrisis && crisisResult.riskLevel !== 'none') { - const resourcesMessage = getCrisisResourcesMessage(crisisResult.riskLevel); - - // Send private crisis resources to the user - ws.send( - JSON.stringify({ - type: 'crisis_alert', - riskLevel: crisisResult.riskLevel, - message: resourcesMessage, - timestamp: new Date().toISOString(), - }) - ); - - console.log( - `Crisis detected (${crisisResult.riskLevel}) in room ${roomId} by ${nickname}` - ); - } } private handleDisconnect(ws: WebSocket) { const roomId = (ws as any).roomId; - const userId = (ws as any).userId; - - if (roomId && userId) { + + // FIX: Robust disconnect handling using Socket Reference + if (roomId) { const room = this.rooms.get(roomId); if (room) { - room.forEach((member) => { - if (member.userId === userId) { + let nickname = ''; + + // Remove THIS specific socket (more accurate than userId) + for (const member of room) { + if (member.ws === ws) { + nickname = member.nickname; room.delete(member); + break; } + } + + // Update Shared State immediately + this.updateSharedState(roomId); + + // Broadcast new count to everyone else + this.broadcastToRoom(roomId, { + type: 'online_count', + count: this.getUniqueUserCount(roomId) }); + + if (nickname) { + this.broadcastToRoom(roomId, { + type: 'leave', + userId: (ws as any).userId, + nickname, + timestamp: new Date().toISOString(), + }); + } } } - - console.log('WebSocket disconnected'); } private broadcastToRoom(roomId: string, message: any) { @@ -267,4 +270,4 @@ export class ChatServer { } }); } -} +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f88a72..284332f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,6 +69,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1282,6 +1283,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1449,6 +1451,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -1853,6 +1856,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2107,6 +2111,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2276,6 +2281,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2288,6 +2294,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2626,6 +2633,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2723,6 +2731,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 07a1652..6479301 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -28,6 +28,10 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [showCrisisAlert, setShowCrisisAlert] = useState(false); + + // 1. ADDED: State for online users (Default to 1 because you are online) + const [onlineCount, setOnlineCount] = useState(1); + const messagesEndRef = useRef(null); const handleMessage = useCallback((message: any) => { @@ -79,6 +83,10 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) } else if (message.type === 'crisis_alert') { setShowCrisisAlert(true); setTimeout(() => setShowCrisisAlert(false), 10000); + } + // 2. ADDED: Listener for online count updates + else if (message.type === 'online_count') { + setOnlineCount(message.count); } }, []); @@ -123,6 +131,15 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)

{room.description}

+ + {/* 3. ADDED: Online Count Display */} +
+
0 ? 'bg-green-500' : 'bg-gray-400'}`}>
+ + {onlineCount} Online + +
+
{isConnected ? 'Connected' : 'Disconnected'} @@ -275,4 +292,4 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a66c192..9ec0e02 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,6 +4,9 @@ import { getSession, getCurrentUser, getProfile, signOut } from '../lib/supabase import { roomsApi, resourcesApi } from '../lib/api'; import ChatRoom from '../components/ChatRoom'; +// Define the API URL from environment variables +const API_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001'; + type Tab = 'rooms' | 'journal' | 'habits' | 'resources'; export default function Dashboard() { @@ -117,6 +120,9 @@ function TabButton({ active, onClick, children }: { active: boolean; onClick: () ); } +// ---------------------------------------------------------------------- +// ROOMS TAB COMPONENT (Auto-Updates) +// ---------------------------------------------------------------------- function RoomsTab() { const [rooms, setRooms] = useState([]); const [loading, setLoading] = useState(true); @@ -124,18 +130,61 @@ function RoomsTab() { const [currentUser, setCurrentUser] = useState(null); useEffect(() => { - loadRooms(); + // 1. Initial Load + loadRoomsAndCounts(); loadCurrentUser(); + + // 2. Poll for updates every 3 seconds + const interval = setInterval(() => { + loadRoomsAndCounts(false); // false = don't show loading spinner on updates + }, 3000); + + // Cleanup interval on unmount + return () => clearInterval(interval); }, []); - async function loadRooms() { + // Modified to optionally skip the loading spinner + async function loadRoomsAndCounts(showLoading = true) { try { - const data = await roomsApi.getAll(); - setRooms(data); + if (showLoading) setLoading(true); + + const roomData = await roomsApi.getAll(); + const session = await getSession(); + const token = session?.access_token; + + if (!token) { + setRooms(roomData); + if (showLoading) setLoading(false); + return; + } + + const roomsWithCounts = await Promise.all( + roomData.map(async (room: any) => { + try { + const res = await fetch(`${API_URL}/api/rooms/${room.id}/activity`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (res.ok) { + const countData = await res.json(); + return { ...room, active_count: countData.count }; + } else { + return { ...room, active_count: 0 }; + } + } catch (err) { + return { ...room, active_count: 0 }; + } + }) + ); + + setRooms(roomsWithCounts); } catch (error) { console.error('Error loading rooms:', error); } finally { - setLoading(false); + if (showLoading) setLoading(false); } } @@ -174,7 +223,17 @@ function RoomsTab() {

{room.name}

-

{room.member_count || 0} members online

+ +
+ + 0 ? 'bg-green-400' : 'bg-gray-400'}`}> + 0 ? 'bg-green-500' : 'bg-gray-400'}`}> + +

+ {room.active_count || 0} members online +

+
+

{room.description}

@@ -188,12 +247,15 @@ function RoomsTab() { ))} - {/* Chat Room Modal */} {selectedRoom && currentUser && ( setSelectedRoom(null)} + onClose={() => { + setSelectedRoom(null); + // Immediate refresh when closing + loadRoomsAndCounts(false); + }} /> )} @@ -318,4 +380,4 @@ function ResourcesTab() { )} ); -} +} \ No newline at end of file