diff --git a/backend/.env.example b/backend/.env.example index 8fa3f1d..0b99aae 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,21 +1,15 @@ -# Backend Environment Variables -# Copy this to .env and fill in your actual values - -# Supabase Configuration (get from https://app.supabase.com → Project Settings → API) +# Supabase Configuration SUPABASE_URL=https://your-project-id.supabase.co -SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key -# HuggingFace API Token (optional - get from https://huggingface.co/settings/tokens) -# If not provided, crisis detection will use keyword-based fallback +# HuggingFace API Token (Optional) HUGGINGFACE_API_TOKEN=hf_YourTokenHere -# Frontend URL (for CORS whitelist) -FRONTEND_URL=http://localhost:3000 - # Server Configuration PORT=3001 +FRONTEND_URL=http://localhost:3000 -# Rate Limiting (optional - defaults shown) +# Rate Limiting RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX_REQUESTS=100 diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e7a659..849b461 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,7 +15,8 @@ "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", - "ws": "^8.16.0" + "ws": "^8.16.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -1900,6 +1901,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 1c6f262..652deb1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,12 @@ "start": "node dist/index.js", "setup-db": "ts-node src/scripts/setupDatabase.ts" }, - "keywords": ["mental-health", "websocket", "express", "crisis-detection"], + "keywords": [ + "mental-health", + "websocket", + "express", + "crisis-detection" + ], "author": "OpenMindWell Contributors", "license": "MIT", "dependencies": { @@ -19,7 +24,8 @@ "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", - "ws": "^8.16.0" + "ws": "^8.16.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..0f56a99 --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const envSchema = z.object({ + // Supabase Configuration + SUPABASE_URL: z.string().url(), + SUPABASE_ANON_KEY: z.string().min(1), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), + + // HuggingFace API Token (Optional) + HUGGINGFACE_API_TOKEN: z.string().optional(), + + // Server Configuration + FRONTEND_URL: z.string().url().default('http://localhost:3000'), + PORT: z.coerce.number().default(3001), + + // Rate Limiting + RATE_LIMIT_WINDOW_MS: z.coerce.number().default(900000), + RATE_LIMIT_MAX_REQUESTS: z.coerce.number().default(100), +}); + +const _env = envSchema.safeParse(process.env); + +if (!_env.success) { + console.error('Invalid environment variables:'); + console.error(JSON.stringify(_env.error.format(), null, 4)); + process.exit(1); +} + +export const env = _env.data; diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 3337f48..ac5d48f 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,60 +1,22 @@ -import dotenv from 'dotenv'; +import { env } from './env'; -dotenv.config(); - -interface Config { +const config = { supabase: { - url: string; - anonKey: string; - serviceRoleKey: string; - }; - huggingface: { - apiToken?: string; - }; - server: { - port: number; - frontendUrl: string; - }; - rateLimit: { - windowMs: number; - maxRequests: number; - }; -} - -const config: Config = { - supabase: { - url: process.env.SUPABASE_URL || '', - anonKey: process.env.SUPABASE_ANON_KEY || '', - serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY || '', + url: env.SUPABASE_URL, + anonKey: env.SUPABASE_ANON_KEY, + serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY, }, huggingface: { - apiToken: process.env.HUGGINGFACE_API_TOKEN, + apiToken: env.HUGGINGFACE_API_TOKEN, }, server: { - port: parseInt(process.env.PORT || '3001', 10), - frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', + port: env.PORT, + frontendUrl: env.FRONTEND_URL, }, rateLimit: { - windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), - maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10), + windowMs: env.RATE_LIMIT_WINDOW_MS, + maxRequests: env.RATE_LIMIT_MAX_REQUESTS, }, }; -// Validation -const requiredEnvVars = [ - 'SUPABASE_URL', - 'SUPABASE_ANON_KEY', - 'SUPABASE_SERVICE_ROLE_KEY', - 'FRONTEND_URL', -]; - -const missingVars = requiredEnvVars.filter((varName) => !process.env[varName]); - -if (missingVars.length > 0) { - throw new Error( - `Missing required environment variables: ${missingVars.join(', ')}\n` + - 'Please check your .env file and ensure all required variables are set.' - ); -} - export default config; diff --git a/backend/src/services/chatServer.ts b/backend/src/services/chatServer.ts index 58127df..db029dd 100644 --- a/backend/src/services/chatServer.ts +++ b/backend/src/services/chatServer.ts @@ -2,18 +2,26 @@ import WebSocket from 'ws'; import { supabase } from '../lib/supabase'; import { detectCrisis, getCrisisResourcesMessage } from './crisisDetection'; +export interface AuthenticatedWebSocket extends WebSocket { + isAlive?: boolean; + roomId?: string; + userId?: string; + nickname?: string; +} + interface ChatMessage { - type: 'join' | 'leave' | 'chat' | 'crisis_alert'; + type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'typing'; roomId?: string; userId?: string; nickname?: string; content?: string; riskLevel?: string; timestamp?: string; + isTyping?: boolean; } interface RoomMember { - ws: WebSocket; + ws: AuthenticatedWebSocket; userId: string; nickname: string; } @@ -30,7 +38,8 @@ export class ChatServer { // Heartbeat to detect dead connections setInterval(() => { - this.wss.clients.forEach((ws: any) => { + this.wss.clients.forEach((client) => { + const ws = client as AuthenticatedWebSocket; if (ws.isAlive === false) { return ws.terminate(); } @@ -40,12 +49,13 @@ export class ChatServer { }, 30000); } - private handleConnection(ws: WebSocket) { + private handleConnection(client: WebSocket) { + const ws = client as AuthenticatedWebSocket; console.log('New WebSocket connection'); - (ws as any).isAlive = true; + ws.isAlive = true; ws.on('pong', () => { - (ws as any).isAlive = true; + ws.isAlive = true; }); ws.on('message', async (data: string) => { @@ -68,7 +78,7 @@ export class ChatServer { }); } - private async handleMessage(ws: WebSocket, message: ChatMessage) { + private async handleMessage(ws: AuthenticatedWebSocket, message: ChatMessage) { switch (message.type) { case 'join': await this.handleJoin(ws, message); @@ -79,12 +89,30 @@ export class ChatServer { case 'chat': await this.handleChatMessage(ws, message); break; + case 'typing': + this.handleTyping(ws, message); + break; default: ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' })); } } - private async handleJoin(ws: WebSocket, message: ChatMessage) { + private handleTyping(ws: AuthenticatedWebSocket, message: ChatMessage) { + const { roomId, userId, nickname } = ws; + + if (roomId && userId) { + const isTyping = typeof message.isTyping === 'boolean' ? message.isTyping : false; + // Broadcast to other users in the room + this.broadcastToRoom(roomId, { + type: 'typing', + userId, + nickname, + isTyping, + }, userId); // Exclude sender + } + } + + private async handleJoin(ws: AuthenticatedWebSocket, message: ChatMessage) { const { roomId, userId, nickname } = message; if (!roomId || !userId || !nickname) { @@ -102,9 +130,9 @@ export class ChatServer { room.add({ ws, userId, nickname }); // Store connection metadata - (ws as any).roomId = roomId; - (ws as any).userId = userId; - (ws as any).nickname = nickname; + ws.roomId = roomId; + ws.userId = userId; + ws.nickname = nickname; // Fetch recent messages from database (without profile join - nicknames come from messages) const { data: messages, error } = await supabase @@ -148,10 +176,8 @@ export class ChatServer { } } - private handleLeave(ws: WebSocket, message: ChatMessage) { - const roomId = (ws as any).roomId; - const userId = (ws as any).userId; - const nickname = (ws as any).nickname; + private handleLeave(ws: AuthenticatedWebSocket, message: ChatMessage) { + const { roomId, userId, nickname } = ws; if (roomId && userId) { const room = this.rooms.get(roomId); @@ -170,17 +196,23 @@ export class ChatServer { nickname, timestamp: new Date().toISOString(), }); + + // Clear typing indicator for leaving user + this.broadcastToRoom(roomId, { + type: 'typing', + userId, + nickname, + isTyping: false, + }); console.log(`${nickname} left room ${roomId}`); } } } - private async handleChatMessage(ws: WebSocket, message: ChatMessage) { + private async handleChatMessage(ws: AuthenticatedWebSocket, message: ChatMessage) { const { content } = message; - const roomId = (ws as any).roomId; - const userId = (ws as any).userId; - const nickname = (ws as any).nickname; + const { roomId, userId, nickname } = ws; if (!content || !roomId || !userId) { ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields' })); @@ -238,9 +270,8 @@ export class ChatServer { } } - private handleDisconnect(ws: WebSocket) { - const roomId = (ws as any).roomId; - const userId = (ws as any).userId; + private handleDisconnect(ws: AuthenticatedWebSocket) { + const { roomId, userId } = ws; if (roomId && userId) { const room = this.rooms.get(roomId); @@ -250,19 +281,27 @@ export class ChatServer { room.delete(member); } }); + + // Clear typing indicator for disconnected user + this.broadcastToRoom(roomId, { + type: 'typing', + userId, + nickname: ws.nickname, + isTyping: false, + }); } } console.log('WebSocket disconnected'); } - private broadcastToRoom(roomId: string, message: any) { + private broadcastToRoom(roomId: string, message: any, excludeUserId?: string) { const room = this.rooms.get(roomId); if (!room) return; const messageStr = JSON.stringify(message); room.forEach((member) => { - if (member.ws.readyState === WebSocket.OPEN) { + if (member.userId !== excludeUserId && member.ws.readyState === WebSocket.OPEN) { member.ws.send(messageStr); } }); diff --git a/backend/src/services/crisisDetection.ts b/backend/src/services/crisisDetection.ts index 796bab2..0bca547 100644 --- a/backend/src/services/crisisDetection.ts +++ b/backend/src/services/crisisDetection.ts @@ -97,7 +97,7 @@ async function analyzeWithHuggingFace( return null; } - const result: HuggingFaceResponse[][] = await response.json(); + const result = (await response.json()) as HuggingFaceResponse[][]; return result[0] || null; } catch (error) { console.error('Error calling HuggingFace API:', error); @@ -110,48 +110,22 @@ async function analyzeWithHuggingFace( */ function analyzeWithKeywords(message: string): CrisisDetectionResult { const lowerMessage = message.toLowerCase(); - const triggeredKeywords: string[] = []; let highestRiskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical' = 'none'; + const triggeredKeywords: string[] = []; - // Check critical keywords - for (const keyword of CRISIS_KEYWORDS.critical) { - if (lowerMessage.includes(keyword)) { - triggeredKeywords.push(keyword); - highestRiskLevel = 'critical'; - } - } - - // Check high-risk keywords - if (highestRiskLevel !== 'critical') { - for (const keyword of CRISIS_KEYWORDS.high) { - if (lowerMessage.includes(keyword)) { - triggeredKeywords.push(keyword); - if (highestRiskLevel !== 'high') { - highestRiskLevel = 'high'; - } - } - } - } - - // Check medium-risk keywords - if (highestRiskLevel === 'none' || highestRiskLevel === 'low') { - for (const keyword of CRISIS_KEYWORDS.medium) { - if (lowerMessage.includes(keyword)) { - triggeredKeywords.push(keyword); - if (highestRiskLevel !== 'medium' && highestRiskLevel !== 'high') { - highestRiskLevel = 'medium'; - } - } - } - } - - // Check low-risk keywords - if (highestRiskLevel === 'none') { - for (const keyword of CRISIS_KEYWORDS.low) { - if (lowerMessage.includes(keyword)) { - triggeredKeywords.push(keyword); - highestRiskLevel = 'low'; - } + const riskMap = { + critical: CRISIS_KEYWORDS.critical, + high: CRISIS_KEYWORDS.high, + medium: CRISIS_KEYWORDS.medium, + low: CRISIS_KEYWORDS.low, + } as const; + + for (const [level, keywords] of Object.entries(riskMap)) { + const found = keywords.filter((k) => lowerMessage.includes(k)); + if (found.length > 0) { + triggeredKeywords.push(...found); + highestRiskLevel = level as typeof highestRiskLevel; + break; } } diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 07a1652..b3bdaa3 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -24,6 +24,8 @@ interface ChatRoomProps { onClose: () => void; } +const EMPTY_SET = new Set(); + export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) { const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); @@ -76,13 +78,26 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) type: 'system', }, ]); - } else if (message.type === 'crisis_alert') { - setShowCrisisAlert(true); - setTimeout(() => setShowCrisisAlert(false), 10000); + } else if (message.type === 'typing') { + const { userId, isTyping } = message; + setTypingUsers((prev) => { + const newSet = new Set(prev); + if (userId) { + if (isTyping) { + newSet.add(userId); + } else { + newSet.delete(userId); + } + } + return newSet; + }); } }, []); - const { isConnected, connectionError, sendMessage } = useWebSocket({ + const [typingUsers, setTypingUsers] = useState>(new Set()); + const typingTimeoutRef = useRef>(); + + const { isConnected, connectionError, sendMessage, sendTyping } = useWebSocket({ roomId: room.id, userId: currentUser.id, nickname: currentUser.nickname, @@ -95,11 +110,38 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) }, }); + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + + // Debounce typing indicator + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } else { + sendTyping(true); + } + + typingTimeoutRef.current = setTimeout(() => { + sendTyping(false); + typingTimeoutRef.current = undefined; + }, 2000); + }; + + useEffect(() => { // Scroll to bottom when new messages arrive messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); + useEffect(() => { + // Clear typing timeout and reset typing users on unmount + return () => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + setTypingUsers(prev => prev.size > 0 ? EMPTY_SET : prev); + }; + }, []); + const handleSendMessage = (e: React.FormEvent) => { e.preventDefault(); @@ -248,13 +290,20 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
+ {/* Typing Indicator */} + {typingUsers.size > 0 && ( +
+ {Array.from(typingUsers).join(', ')} {typingUsers.size === 1 ? 'is' : 'are'} typing... +
+ )} + {/* Input */}
setInputValue(e.target.value)} + onChange={handleInputChange} placeholder={isConnected ? 'Type your message...' : 'Connecting...'} disabled={!isConnected} className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed" diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 3a4f270..6f7680e 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; interface ChatMessage { - type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'history' | 'error'; + type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'history' | 'error' | 'typing'; roomId?: string; userId?: string; nickname?: string; @@ -10,6 +10,7 @@ interface ChatMessage { timestamp?: string; messages?: any[]; message?: string; + isTyping?: boolean; } interface UseWebSocketOptions { @@ -34,11 +35,13 @@ export function useWebSocket({ const wsRef = useRef(null); const [isConnected, setIsConnected] = useState(false); const [connectionError, setConnectionError] = useState(null); - const reconnectTimeoutRef = useRef(); + const reconnectTimeoutRef = useRef>(); const reconnectAttemptsRef = useRef(0); + const isManualCloseRef = useRef(false); const maxReconnectAttempts = 2; const connect = useCallback(() => { + isManualCloseRef.current = false; if (wsRef.current?.readyState === WebSocket.OPEN) { return; } @@ -87,7 +90,7 @@ export function useWebSocket({ onDisconnect?.(); // Attempt to reconnect with longer delays - if (reconnectAttemptsRef.current < maxReconnectAttempts) { + if (!isManualCloseRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) { reconnectAttemptsRef.current += 1; const delay = Math.min(3000 * Math.pow(2, reconnectAttemptsRef.current), 10000); console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current})`); @@ -95,7 +98,7 @@ export function useWebSocket({ reconnectTimeoutRef.current = setTimeout(() => { connect(); }, delay); - } else { + } else if (!isManualCloseRef.current) { setConnectionError('Connection lost. Please refresh to reconnect.'); } }; @@ -124,6 +127,7 @@ export function useWebSocket({ ); } + isManualCloseRef.current = true; wsRef.current.close(); wsRef.current = null; } @@ -164,6 +168,19 @@ export function useWebSocket({ isConnected, connectionError, sendMessage, + sendTyping: (isTyping: boolean) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: 'typing', + roomId, + userId, + nickname, + isTyping, + }) + ); + } + }, reconnect: connect, disconnect, }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fe2cf57..8c7ef29 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,13 +4,11 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001 async function apiFetch(endpoint: string, options: RequestInit = {}) { const { data: { session } } = await (await import('./supabase')).supabase.auth.getSession(); - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options.headers, - }; + const headers = new Headers(options.headers); + headers.set('Content-Type', 'application/json'); if (session?.access_token) { - headers['Authorization'] = `Bearer ${session.access_token}`; + headers.set('Authorization', `Bearer ${session.access_token}`); } const response = await fetch(`${API_BASE_URL}${endpoint}`, {