Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions backend/database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
nickname VARCHAR(50), -- Denormalized for chat history
content TEXT NOT NULL,
risk_level VARCHAR(20) DEFAULT 'none' CHECK (risk_level IN ('none', 'low', 'medium', 'high', 'critical')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
Expand Down
17 changes: 11 additions & 6 deletions backend/src/routes/journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,14 @@ router.put('/:id', authenticate, async (req: AuthRequest, res) => {
.single();

if (error) {
// Check for "no rows found" error code from PostgREST
if (error.code === 'PGRST116') {
return res.status(404).json({ error: 'Entry not found' });
}
console.error('Error updating journal entry:', error);
return res.status(500).json({ error: 'Failed to update entry' });
}

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

res.json(data);
} catch (error) {
console.error('Error:', error);
Expand All @@ -104,17 +104,22 @@ router.delete('/:id', authenticate, async (req: AuthRequest, res) => {
const userId = req.user!.id;
const { id } = req.params;

const { error } = await supabase
const { data, error } = await supabase
.from('journal_entries')
.delete()
.eq('id', id)
.eq('user_id', userId);
.eq('user_id', userId)
.select();

if (error) {
console.error('Error deleting journal entry:', error);
return res.status(500).json({ error: 'Failed to delete entry' });
}

if (!data || data.length === 0) {
return res.status(404).json({ error: 'Entry not found' });
}

res.status(204).send();
} catch (error) {
console.error('Error:', error);
Expand Down
63 changes: 33 additions & 30 deletions backend/src/services/chatServer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import WebSocket from 'ws';
import { WebSocket, WebSocketServer } from 'ws';
import { supabase } from '../lib/supabase';
import { detectCrisis, getCrisisResourcesMessage } from './crisisDetection';

Expand All @@ -19,11 +19,12 @@ interface RoomMember {
}

export class ChatServer {
private wss: WebSocket.Server;
private rooms: Map<string, Set<RoomMember>>;
private wss: WebSocketServer;
// RoomId -> UserId -> RoomMember
private rooms: Map<string, Map<string, RoomMember>>;

constructor(server: any) {
this.wss = new WebSocket.Server({ server });
this.wss = new WebSocketServer({ server });
this.rooms = new Map();

this.wss.on('connection', this.handleConnection.bind(this));
Expand Down Expand Up @@ -95,18 +96,19 @@ export class ChatServer {
try {
// Add user to room
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, new Set());
this.rooms.set(roomId, new Map());
}

const room = this.rooms.get(roomId)!;
room.add({ ws, userId, nickname });
// Use Map to ensure unique user per room (replace old connection if same user joins)
room.set(userId, { ws, userId, nickname });

// Store connection metadata
(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)
// Fetch recent messages from database
const { data: messages, error } = await supabase
.from('messages')
.select('*')
Expand Down Expand Up @@ -157,21 +159,19 @@ export class ChatServer {
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(),
});

console.log(`${nickname} left room ${roomId}`);
if (room.has(userId)) {
room.delete(userId);

// Broadcast leave event
this.broadcastToRoom(roomId, {
type: 'leave',
userId,
nickname,
timestamp: new Date().toISOString(),
});

console.log(`${nickname} left room ${roomId}`);
}
}
}
}
Expand All @@ -190,12 +190,13 @@ export class ChatServer {
// Detect crisis in message
const crisisResult = await detectCrisis(content);

// Save message to database with risk level
// Save message to database with risk level AND nickname
const { data: savedMessage, error } = await supabase
.from('messages')
.insert({
room_id: roomId,
user_id: userId,
nickname: nickname, // Added nickname
content,
risk_level: crisisResult.riskLevel,
})
Expand All @@ -208,11 +209,11 @@ export class ChatServer {
return;
}

// Broadcast message to room with nickname from WebSocket metadata
// Broadcast message to room
this.broadcastToRoom(roomId, {
type: 'chat',
userId: savedMessage.user_id,
nickname: nickname, // Use nickname from WebSocket connection
nickname: savedMessage.nickname || nickname,
content: savedMessage.content,
timestamp: savedMessage.created_at,
riskLevel: savedMessage.risk_level,
Expand Down Expand Up @@ -245,11 +246,13 @@ export class ChatServer {
if (roomId && userId) {
const room = this.rooms.get(roomId);
if (room) {
room.forEach((member) => {
if (member.userId === userId) {
room.delete(member);
}
});
if (room.has(userId)) {
// Only remove if the socket matches (in case user reconnected quickly on another socket but same userId)
const member = room.get(userId);
if (member && member.ws === ws) {
room.delete(userId);
}
}
}
}

Expand Down
63 changes: 23 additions & 40 deletions backend/src/services/crisisDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ const CRISIS_KEYWORDS = {
const HIGH_RISK_EMOTIONS = ['sadness', 'fear', 'anger'];
const MEDIUM_RISK_EMOTIONS = ['disgust', 'surprise'];

const RISK_LEVELS = ['none', 'low', 'medium', 'high', 'critical'] as const;

/**
* Analyze message using HuggingFace emotion detection model
*/
Expand Down Expand Up @@ -97,7 +99,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);
Expand All @@ -111,46 +113,25 @@ async function analyzeWithHuggingFace(
function analyzeWithKeywords(message: string): CrisisDetectionResult {
const lowerMessage = message.toLowerCase();
const triggeredKeywords: string[] = [];
let highestRiskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical' = 'none';
let highestRiskLevel: typeof RISK_LEVELS[number] = 'none';

// Check critical keywords
for (const keyword of CRISIS_KEYWORDS.critical) {
if (lowerMessage.includes(keyword)) {
triggeredKeywords.push(keyword);
highestRiskLevel = 'critical';
}
}
// Check all keywords across all levels
// Use type assertion to iterate through keys safely
const levels = ['critical', 'high', 'medium', 'low'] as const;

// Check high-risk keywords
if (highestRiskLevel !== 'critical') {
for (const keyword of CRISIS_KEYWORDS.high) {
for (const level of levels) {
const keywords = CRISIS_KEYWORDS[level];
for (const keyword of keywords) {
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';
}
}
}
}
// Update risk level if this level is higher than current highest
const currentLevelIndex = RISK_LEVELS.indexOf(highestRiskLevel);
const newLevelIndex = RISK_LEVELS.indexOf(level);

// Check low-risk keywords
if (highestRiskLevel === 'none') {
for (const keyword of CRISIS_KEYWORDS.low) {
if (lowerMessage.includes(keyword)) {
triggeredKeywords.push(keyword);
highestRiskLevel = 'low';
if (newLevelIndex > currentLevelIndex) {
highestRiskLevel = level;
}
}
}
}
Expand All @@ -175,7 +156,7 @@ export async function detectCrisis(
if (emotions && emotions.length > 0) {
// Analyze emotion scores
const detectedEmotions = emotions.map((e) => e.label);
let riskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical' = 'none';
let riskLevel: typeof RISK_LEVELS[number] = 'none';
let maxScore = 0;

for (const emotion of emotions) {
Expand All @@ -184,7 +165,10 @@ export async function detectCrisis(
}

if (HIGH_RISK_EMOTIONS.includes(emotion.label) && emotion.score > 0.5) {
riskLevel = emotion.score > 0.7 ? 'high' : 'medium';
const potentialRisk = emotion.score > 0.7 ? 'high' : 'medium';
if (RISK_LEVELS.indexOf(potentialRisk) > RISK_LEVELS.indexOf(riskLevel)) {
riskLevel = potentialRisk;
}
} else if (
MEDIUM_RISK_EMOTIONS.includes(emotion.label) &&
emotion.score > 0.6
Expand All @@ -197,9 +181,8 @@ export async function detectCrisis(

// Also run keyword analysis and take the higher risk level
const keywordResult = analyzeWithKeywords(message);
const riskLevels = ['none', 'low', 'medium', 'high', 'critical'];
const aiRiskIndex = riskLevels.indexOf(riskLevel);
const keywordRiskIndex = riskLevels.indexOf(keywordResult.riskLevel);
const aiRiskIndex = RISK_LEVELS.indexOf(riskLevel);
const keywordRiskIndex = RISK_LEVELS.indexOf(keywordResult.riskLevel);

if (keywordRiskIndex > aiRiskIndex) {
riskLevel = keywordResult.riskLevel;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
...prev,
{
userId: message.userId!,
nickname: message.nickname!,
nickname: message.nickname || 'Anonymous',
content: message.content!,
timestamp: message.timestamp!,
riskLevel: message.riskLevel,
Expand Down Expand Up @@ -210,7 +210,7 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
: 'text-gray-600'
}`}
>
{msg.userId === currentUser.id ? 'You' : msg.nickname}
{msg.userId === currentUser.id ? 'You' : (msg.nickname || 'Anonymous')}
</span>
<span
className={`text-xs ${
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 = {
const headers: any = {
'Content-Type': 'application/json',
...options.headers,
};
Expand Down