Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -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;
58 changes: 10 additions & 48 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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;
87 changes: 63 additions & 24 deletions backend/src/services/chatServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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();
}
Expand All @@ -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) => {
Expand All @@ -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);
Expand All @@ -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
}
Comment on lines +103 to +112
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleTyping trusts ws.roomId/userId metadata without verifying the socket is still a member of that room. Because handleLeave removes the member from the room set but doesn’t clear ws.roomId/userId, a client that sends leave (but keeps the connection open) can still broadcast typing events to the room. Consider either clearing ws.roomId/ws.userId/ws.nickname on leave, and/or checking membership in this.rooms.get(roomId) before broadcasting.

Suggested change
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
}
// Ensure the socket is still a member of the room before broadcasting
if (!roomId || !userId) {
return;
}
const roomMembers = this.rooms.get(roomId);
if (!roomMembers) {
return;
}
const isMember = Array.from(roomMembers).some((member) => member.ws === ws);
if (!isMember) {
return;
}
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
);

Copilot uses AI. Check for mistakes.
}

private async handleJoin(ws: AuthenticatedWebSocket, message: ChatMessage) {
const { roomId, userId, nickname } = message;

if (!roomId || !userId || !nickname) {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Comment thread
Shreenath-14 marked this conversation as resolved.
Expand All @@ -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' }));
Expand Down Expand Up @@ -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) {
Comment thread
Shreenath-14 marked this conversation as resolved.
const room = this.rooms.get(roomId);
Expand All @@ -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);
}
});
Expand Down
Loading