From d2d583798c883e10ae8d5e61102d3a6f10703be2 Mon Sep 17 00:00:00 2001 From: ayush123-4 Date: Thu, 12 Feb 2026 11:46:50 +0530 Subject: [PATCH 1/3] Display list of logged-in users in chat UI #1379 --- client/src/infra/rest/apis/chat/index.ts | 18 ++ client/src/infra/rest/apis/chat/typing.ts | 6 + .../src/modules/chats/v1/constants/index.ts | 4 + client/src/modules/chats/v1/index.tsx | 162 +++++++++++++++++- client/src/modules/chats/v1/typings/index.ts | 3 + .../src/controllers/chat/get-online-users.js | 43 +++++ server/src/controllers/chat/presence.js | 21 +++ server/src/routes/api/chat.routes.js | 11 ++ server/src/routes/index.js | 2 + server/src/stores/online-users.js | 36 ++++ 10 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 client/src/infra/rest/apis/chat/index.ts create mode 100644 client/src/infra/rest/apis/chat/typing.ts create mode 100644 client/src/modules/chats/v1/constants/index.ts create mode 100644 client/src/modules/chats/v1/typings/index.ts create mode 100644 server/src/controllers/chat/get-online-users.js create mode 100644 server/src/controllers/chat/presence.js create mode 100644 server/src/routes/api/chat.routes.js create mode 100644 server/src/stores/online-users.js diff --git a/client/src/infra/rest/apis/chat/index.ts b/client/src/infra/rest/apis/chat/index.ts new file mode 100644 index 00000000..b876ed78 --- /dev/null +++ b/client/src/infra/rest/apis/chat/index.ts @@ -0,0 +1,18 @@ +import { post, get } from '../..'; +import { ApiResponse } from '../../typings'; +import { OnlineUser } from './typing'; + +export const reportPresence = async () => { + return post>( + '/api/chat/presence', + true, + undefined + ); +}; + +export const getOnlineUsers = async () => { + return get>( + '/api/chat/online-users', + true + ); +}; diff --git a/client/src/infra/rest/apis/chat/typing.ts b/client/src/infra/rest/apis/chat/typing.ts new file mode 100644 index 00000000..b20859a4 --- /dev/null +++ b/client/src/infra/rest/apis/chat/typing.ts @@ -0,0 +1,6 @@ +import { USER_PERSONAL_LIMITED_INFO } from '../../typings'; + +export interface OnlineUser { + _id: string; + personal_info: USER_PERSONAL_LIMITED_INFO; +} diff --git a/client/src/modules/chats/v1/constants/index.ts b/client/src/modules/chats/v1/constants/index.ts new file mode 100644 index 00000000..288c7a8c --- /dev/null +++ b/client/src/modules/chats/v1/constants/index.ts @@ -0,0 +1,4 @@ +export const CHATS_SIDEBAR_WIDTH = 280; + +export const ONLINE_USERS_POLL_INTERVAL_MS = 45 * 1000; // 45 seconds +export const PRESENCE_REFRESH_INTERVAL_MS = 60 * 1000; // 1 minute diff --git a/client/src/modules/chats/v1/index.tsx b/client/src/modules/chats/v1/index.tsx index f6dc52e6..6d2a2cd9 100644 --- a/client/src/modules/chats/v1/index.tsx +++ b/client/src/modules/chats/v1/index.tsx @@ -1,9 +1,165 @@ -import { Box } from '@mui/material'; +import { useEffect, useState, useCallback } from 'react'; +import { + Box, + List, + ListItemButton, + Avatar, + Typography, + useTheme, + CircularProgress, +} from '@mui/material'; +import { getOnlineUsers, reportPresence } from '../../../infra/rest/apis/chat'; +import { OnlineUser } from '../../../infra/rest/apis/chat/typing'; +import { CHATS_SIDEBAR_WIDTH, ONLINE_USERS_POLL_INTERVAL_MS, PRESENCE_REFRESH_INTERVAL_MS } from './constants'; const Chats = () => { + const theme = useTheme(); + const [onlineUsers, setOnlineUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedUser, setSelectedUser] = useState(null); + + const fetchPresenceAndOnlineUsers = useCallback(async () => { + try { + await reportPresence(); + const res = await getOnlineUsers(); + if (res.status === 'success' && res.data) { + setOnlineUsers(res.data); + } + } catch { + setOnlineUsers([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchPresenceAndOnlineUsers(); + }, [fetchPresenceAndOnlineUsers]); + + useEffect(() => { + const pollInterval = setInterval(fetchPresenceAndOnlineUsers, ONLINE_USERS_POLL_INTERVAL_MS); + return () => clearInterval(pollInterval); + }, [fetchPresenceAndOnlineUsers]); + + useEffect(() => { + const presenceInterval = setInterval(reportPresence, PRESENCE_REFRESH_INTERVAL_MS); + return () => clearInterval(presenceInterval); + }, []); + return ( - -

Chats

+ + + + + Chats + + + Logged-in users + + + + {loading ? ( + + + + ) : onlineUsers.length === 0 ? ( + + + No other users online. Open this page in another browser or ask a friend to open the chat page. + + + ) : ( + + {onlineUsers.map((user) => ( + setSelectedUser(user)} + sx={{ + gap: 1.5, + py: 1.5, + }} + > + + {(user.personal_info?.fullname?.[0] || user.personal_info?.username?.[0] || '?').toUpperCase()} + + + + {user.personal_info?.fullname || user.personal_info?.username || 'Unknown'} + + + @{user.personal_info?.username || '—'} + + + + ))} + + )} + + + + {!selectedUser ? ( + + Select a user to start a chat session. Real-time messaging will be available in a future update. + + ) : ( + + + {(selectedUser.personal_info?.fullname?.[0] || selectedUser.personal_info?.username?.[0] || '?').toUpperCase()} + + + Chat with {selectedUser.personal_info?.fullname || selectedUser.personal_info?.username || 'Unknown'} + + + @{selectedUser.personal_info?.username || '—'} + + + Chat session prepared. Real-time messaging will be available in a future update. + + + )} + ); }; diff --git a/client/src/modules/chats/v1/typings/index.ts b/client/src/modules/chats/v1/typings/index.ts new file mode 100644 index 00000000..4e994c8d --- /dev/null +++ b/client/src/modules/chats/v1/typings/index.ts @@ -0,0 +1,3 @@ +import { OnlineUser } from '../../../infra/rest/apis/chat/typing'; + +export type SelectedUser = OnlineUser | null; diff --git a/server/src/controllers/chat/get-online-users.js b/server/src/controllers/chat/get-online-users.js new file mode 100644 index 00000000..ca811356 --- /dev/null +++ b/server/src/controllers/chat/get-online-users.js @@ -0,0 +1,43 @@ +/** + * GET /api/chat/online-users - List users currently online (for chat) + * Excludes the current user. Returns limited profile for display. + */ + +import mongoose from 'mongoose'; +import USER from '../../models/user.model.js'; +import { sendResponse } from '../../utils/response.js'; +import { getOnlineUserIds } from '../../stores/online-users.js'; + +const getOnlineUsers = async (req, res) => { + try { + const { user_id } = req.user; + if (!user_id) { + return sendResponse(res, 401, 'User ID not found in token'); + } + + const onlineIds = getOnlineUserIds().filter((id) => String(id) !== String(user_id)); + if (onlineIds.length === 0) { + return sendResponse(res, 200, 'Online users fetched successfully', []); + } + + const objectIds = onlineIds.map((id) => new mongoose.Types.ObjectId(id)); + const users = await USER.find({ _id: { $in: objectIds } }) + .select('_id personal_info.fullname personal_info.username personal_info.profile_img') + .lean(); + + const data = users.map((u) => ({ + _id: u._id, + personal_info: { + fullname: u.personal_info?.fullname ?? '', + username: u.personal_info?.username ?? '', + profile_img: u.personal_info?.profile_img ?? '', + }, + })); + + return sendResponse(res, 200, 'Online users fetched successfully', data); + } catch (err) { + return sendResponse(res, 500, err.message || 'Internal Server Error'); + } +}; + +export default getOnlineUsers; diff --git a/server/src/controllers/chat/presence.js b/server/src/controllers/chat/presence.js new file mode 100644 index 00000000..c046712e --- /dev/null +++ b/server/src/controllers/chat/presence.js @@ -0,0 +1,21 @@ +/** + * POST /api/chat/presence - Register/refresh current user's presence (e.g. when opening chat page) + */ + +import { sendResponse } from '../../utils/response.js'; +import { setPresence } from '../../stores/online-users.js'; + +const presence = async (req, res) => { + try { + const { user_id } = req.user; + if (!user_id) { + return sendResponse(res, 401, 'User ID not found in token'); + } + setPresence(String(user_id)); + return sendResponse(res, 200, 'Presence updated'); + } catch (err) { + return sendResponse(res, 500, err.message || 'Internal Server Error'); + } +}; + +export default presence; diff --git a/server/src/routes/api/chat.routes.js b/server/src/routes/api/chat.routes.js new file mode 100644 index 00000000..c023e8cb --- /dev/null +++ b/server/src/routes/api/chat.routes.js @@ -0,0 +1,11 @@ +import express from 'express'; +import authenticateUser from '../../middlewares/auth.middleware.js'; +import presence from '../../controllers/chat/presence.js'; +import getOnlineUsers from '../../controllers/chat/get-online-users.js'; + +const chatRoutes = express.Router(); + +chatRoutes.post('/presence', authenticateUser, presence); +chatRoutes.get('/online-users', authenticateUser, getOnlineUsers); + +export default chatRoutes; diff --git a/server/src/routes/index.js b/server/src/routes/index.js index d0a675f0..4dd1c79c 100644 --- a/server/src/routes/index.js +++ b/server/src/routes/index.js @@ -16,6 +16,7 @@ import notificationRoutes from './api/notification.routes.js'; import collectionRoutes from './api/collections.routes.js'; import collaborationRoutes from './api/collaboration.routes.js'; import feedbackRoutes from './api/feedback.routes.js'; +import chatRoutes from './api/chat.routes.js'; const router = express.Router(); @@ -30,5 +31,6 @@ router.use('/notification', generalLimiter, notificationRoutes); router.use('/collection', generalLimiter, collectionRoutes); router.use('/collaboration', generalLimiter, collaborationRoutes); router.use('/feedback', generalLimiter, feedbackRoutes); +router.use('/chat', generalLimiter, chatRoutes); export default router; diff --git a/server/src/stores/online-users.js b/server/src/stores/online-users.js new file mode 100644 index 00000000..e5b55038 --- /dev/null +++ b/server/src/stores/online-users.js @@ -0,0 +1,36 @@ +/** + * In-memory store for online (chat) presence. + * Keys: user_id (string), values: lastSeen (number timestamp). + * When Socket.IO is added (sub-issue #1378), this can be replaced by Redis or socket-backed presence. + */ + +const PRESENCE_TTL_MS = 2 * 60 * 1000; // 2 minutes + +/** @type {Map} */ +const presenceMap = new Map(); + +/** + * Mark user as online (refresh lastSeen). + * @param {string} userId + */ +export const setPresence = (userId) => { + if (!userId) return; + presenceMap.set(String(userId), Date.now()); +}; + +/** + * Remove stale entries and return list of user IDs currently considered online. + * @returns {string[]} + */ +export const getOnlineUserIds = () => { + const now = Date.now(); + const ids = []; + for (const [id, lastSeen] of presenceMap.entries()) { + if (now - lastSeen < PRESENCE_TTL_MS) { + ids.push(id); + } else { + presenceMap.delete(id); + } + } + return ids; +}; From 0fe2837c06381b27eaf50d5f224d54416fd1c07c Mon Sep 17 00:00:00 2001 From: ayush123-4 Date: Fri, 13 Feb 2026 13:29:48 +0530 Subject: [PATCH 2/3] Implement one-to-one real-time chat using Socket.IO --- server/index.js | 15 +- server/package.json | 1 + server/src/config/socket.io.js | 197 ++++++++++++++++++ server/src/constants/db.js | 1 + .../src/controllers/chat/get-conversations.js | 102 +++++++++ server/src/controllers/chat/get-messages.js | 53 +++++ server/src/middlewares/auth.limiter.js | 20 ++ server/src/models/message.model.js | 7 + server/src/routes/api/chat.routes.js | 4 + server/src/schemas/message.schema.js | 41 ++++ server/src/stores/online-users.js | 12 +- 11 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 server/src/config/socket.io.js create mode 100644 server/src/controllers/chat/get-conversations.js create mode 100644 server/src/controllers/chat/get-messages.js create mode 100644 server/src/models/message.model.js create mode 100644 server/src/schemas/message.schema.js diff --git a/server/index.js b/server/index.js index 626a3309..f1678b0c 100644 --- a/server/index.js +++ b/server/index.js @@ -1,4 +1,17 @@ +import { createServer } from 'http'; import server from './src/server.js'; import { PORT } from './src/config/env.js'; +import { initializeSocketIO } from './src/config/socket.io.js'; -server.listen(PORT, () => console.log(`Server running on port ${PORT}`)); +const httpServer = createServer(server); + +// Initialize Socket.IO +const io = initializeSocketIO(httpServer); + +// Make io available globally if needed +server.set('io', io); + +httpServer.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + console.log(`Socket.IO server initialized`); +}); diff --git a/server/package.json b/server/package.json index b1d4f016..7299f9bc 100644 --- a/server/package.json +++ b/server/package.json @@ -49,6 +49,7 @@ "rate-limiter-flexible": "^7.3.0", "resend": "^6.1.2", "sanitize-html": "^2.17.0", + "socket.io": "^4.7.5", "winston": "^3.18.3" }, "devDependencies": { diff --git a/server/src/config/socket.io.js b/server/src/config/socket.io.js new file mode 100644 index 00000000..a28dedba --- /dev/null +++ b/server/src/config/socket.io.js @@ -0,0 +1,197 @@ +import { Server } from 'socket.io'; +import jwt from 'jsonwebtoken'; +import { JWT_SECRET_ACCESS_KEY } from './env.js'; +import { setPresence, removePresence } from '../stores/online-users.js'; + +/** + * Initialize Socket.IO server + * @param {import('http').Server} httpServer - HTTP server instance + * @returns {Server} Socket.IO server instance + */ +export const initializeSocketIO = (httpServer) => { + const io = new Server(httpServer, { + cors: { + origin: true, + credentials: true, + methods: ['GET', 'POST'], + }, + transports: ['websocket', 'polling'], + }); + + // Socket.IO authentication middleware + io.use((socket, next) => { + const token = + socket.handshake.auth?.token || + socket.handshake.headers?.authorization?.split(' ')[1]; + + if (!token) { + return next(new Error('Authentication error: No token provided')); + } + + try { + jwt.verify(token, JWT_SECRET_ACCESS_KEY, (err, decoded) => { + if (err) { + return next(new Error('Authentication error: Invalid or expired token')); + } + socket.userId = decoded.user_id; + socket.user = decoded; + next(); + }); + } catch (error) { + next(new Error('Authentication error: Token verification failed')); + } + }); + + // Store active socket connections: userId -> socketId + const activeConnections = new Map(); + + io.on('connection', (socket) => { + const userId = socket.userId; + console.log(`User ${userId} connected with socket ID: ${socket.id}`); + + // Store connection + activeConnections.set(userId, socket.id); + setPresence(userId); + + // Join user's personal room for direct messages + socket.join(`user:${userId}`); + + // Notify user is online + socket.broadcast.emit('user:online', { userId }); + + // Handle sending a message + socket.on('message:send', async (data) => { + try { + const { receiverId, message } = data; + + if (!receiverId || !message || !message.trim()) { + socket.emit('message:error', { + error: 'Receiver ID and message are required', + }); + return; + } + + // Import here to avoid circular dependency + const MESSAGE = (await import('../models/message.model.js')).default; + + // Create message in database + const newMessage = await MESSAGE.create({ + sender_id: userId, + receiver_id: receiverId, + message: message.trim(), + }); + + // Populate sender info + await newMessage.populate('sender_id', 'personal_info.fullname personal_info.username personal_info.profile_img'); + + // Prepare message data + const messageData = { + _id: newMessage._id, + sender_id: newMessage.sender_id, + receiver_id: newMessage.receiver_id, + message: newMessage.message, + read: newMessage.read, + readAt: newMessage.readAt, + createdAt: newMessage.createdAt, + updatedAt: newMessage.updatedAt, + }; + + // Send to receiver if online + const receiverSocketId = activeConnections.get(String(receiverId)); + if (receiverSocketId) { + io.to(receiverSocketId).emit('message:receive', messageData); + } + + // Confirm to sender + socket.emit('message:sent', messageData); + } catch (error) { + console.error('Error sending message:', error); + socket.emit('message:error', { + error: error.message || 'Failed to send message', + }); + } + }); + + // Handle typing indicator + socket.on('typing:start', (data) => { + const { receiverId } = data; + if (receiverId) { + const receiverSocketId = activeConnections.get(String(receiverId)); + if (receiverSocketId) { + io.to(receiverSocketId).emit('typing:start', { + senderId: userId, + }); + } + } + }); + + socket.on('typing:stop', (data) => { + const { receiverId } = data; + if (receiverId) { + const receiverSocketId = activeConnections.get(String(receiverId)); + if (receiverSocketId) { + io.to(receiverSocketId).emit('typing:stop', { + senderId: userId, + }); + } + } + }); + + // Handle marking messages as read + socket.on('message:read', async (data) => { + try { + const { senderId } = data; + + if (!senderId) { + socket.emit('message:error', { + error: 'Sender ID is required', + }); + return; + } + + // Import here to avoid circular dependency + const MESSAGE = (await import('../models/message.model.js')).default; + + // Mark messages as read + await MESSAGE.updateMany( + { + sender_id: senderId, + receiver_id: userId, + read: false, + }, + { + $set: { + read: true, + readAt: new Date(), + }, + } + ); + + // Notify sender that messages were read + const senderSocketId = activeConnections.get(String(senderId)); + if (senderSocketId) { + io.to(senderSocketId).emit('message:read', { + receiverId: userId, + }); + } + } catch (error) { + console.error('Error marking messages as read:', error); + socket.emit('message:error', { + error: error.message || 'Failed to mark messages as read', + }); + } + }); + + // Handle disconnection + socket.on('disconnect', () => { + console.log(`User ${userId} disconnected`); + activeConnections.delete(userId); + removePresence(userId); + + // Notify others that user is offline + socket.broadcast.emit('user:offline', { userId }); + }); + }); + + return io; +}; diff --git a/server/src/constants/db.js b/server/src/constants/db.js index aff54297..d6007892 100644 --- a/server/src/constants/db.js +++ b/server/src/constants/db.js @@ -7,4 +7,5 @@ export const COLLECTION_NAMES = { COLLABORATION: 'collaborations', COLLECTIONS: 'collections', FEEDBACK: 'feedbacks', + MESSAGES: 'messages', }; diff --git a/server/src/controllers/chat/get-conversations.js b/server/src/controllers/chat/get-conversations.js new file mode 100644 index 00000000..07e9bb7f --- /dev/null +++ b/server/src/controllers/chat/get-conversations.js @@ -0,0 +1,102 @@ +/** + * GET /api/chat/conversations - Get list of conversations for current user + */ + +import mongoose from 'mongoose'; +import MESSAGE from '../../models/message.model.js'; +import { sendResponse } from '../../utils/response.js'; + +const getConversations = async (req, res) => { + try { + const { user_id } = req.user; + + if (!user_id) { + return sendResponse(res, 401, 'User ID not found in token'); + } + + // Get distinct conversations (users who have sent or received messages) + const conversations = await MESSAGE.aggregate([ + { + $match: { + $or: [ + { sender_id: new mongoose.Types.ObjectId(user_id) }, + { receiver_id: new mongoose.Types.ObjectId(user_id) }, + ], + }, + }, + { + $sort: { createdAt: -1 }, + }, + { + $group: { + _id: { + $cond: [ + { $eq: ['$sender_id', new mongoose.Types.ObjectId(user_id)] }, + '$receiver_id', + '$sender_id', + ], + }, + lastMessage: { $first: '$$ROOT' }, + unreadCount: { + $sum: { + $cond: [ + { + $and: [ + { $eq: ['$receiver_id', new mongoose.Types.ObjectId(user_id)] }, + { $eq: ['$read', false] }, + ], + }, + 1, + 0, + ], + }, + }, + }, + }, + { + $lookup: { + from: 'users', + localField: '_id', + foreignField: '_id', + as: 'user', + }, + }, + { + $unwind: '$user', + }, + { + $project: { + _id: '$user._id', + personal_info: { + fullname: '$user.personal_info.fullname', + username: '$user.personal_info.username', + profile_img: '$user.personal_info.profile_img', + }, + lastMessage: { + _id: '$lastMessage._id', + message: '$lastMessage.message', + sender_id: '$lastMessage.sender_id', + receiver_id: '$lastMessage.receiver_id', + read: '$lastMessage.read', + createdAt: '$lastMessage.createdAt', + }, + unreadCount: 1, + }, + }, + { + $sort: { 'lastMessage.createdAt': -1 }, + }, + ]); + + return sendResponse( + res, + 200, + 'Conversations fetched successfully', + conversations + ); + } catch (err) { + return sendResponse(res, 500, err.message || 'Internal Server Error'); + } +}; + +export default getConversations; diff --git a/server/src/controllers/chat/get-messages.js b/server/src/controllers/chat/get-messages.js new file mode 100644 index 00000000..4d255095 --- /dev/null +++ b/server/src/controllers/chat/get-messages.js @@ -0,0 +1,53 @@ +/** + * GET /api/chat/messages/:userId - Get conversation messages between current user and another user + */ + +import mongoose from 'mongoose'; +import MESSAGE from '../../models/message.model.js'; +import { sendResponse } from '../../utils/response.js'; + +const getMessages = async (req, res) => { + try { + const { user_id } = req.user; + const { userId: otherUserId } = req.params; + const { page = 1, limit = 50 } = req.query; + + if (!user_id) { + return sendResponse(res, 401, 'User ID not found in token'); + } + + if (!otherUserId || !mongoose.Types.ObjectId.isValid(otherUserId)) { + return sendResponse(res, 400, 'Invalid user ID'); + } + + const skip = (parseInt(page) - 1) * parseInt(limit); + + // Get messages where current user is sender or receiver + const messages = await MESSAGE.find({ + $or: [ + { sender_id: user_id, receiver_id: otherUserId }, + { sender_id: otherUserId, receiver_id: user_id }, + ], + }) + .populate('sender_id', 'personal_info.fullname personal_info.username personal_info.profile_img') + .populate('receiver_id', 'personal_info.fullname personal_info.username personal_info.profile_img') + .sort({ createdAt: -1 }) + .limit(parseInt(limit)) + .skip(skip) + .lean(); + + // Reverse to show oldest first + const reversedMessages = messages.reverse(); + + return sendResponse( + res, + 200, + 'Messages fetched successfully', + reversedMessages + ); + } catch (err) { + return sendResponse(res, 500, err.message || 'Internal Server Error'); + } +}; + +export default getMessages; diff --git a/server/src/middlewares/auth.limiter.js b/server/src/middlewares/auth.limiter.js index db149c81..9e8710f2 100644 --- a/server/src/middlewares/auth.limiter.js +++ b/server/src/middlewares/auth.limiter.js @@ -15,3 +15,23 @@ const authLimiter = (req, res, next) => { }; export default authLimiter; + + + + + + + + + + + + + + + + + + + + diff --git a/server/src/models/message.model.js b/server/src/models/message.model.js new file mode 100644 index 00000000..73c555b5 --- /dev/null +++ b/server/src/models/message.model.js @@ -0,0 +1,7 @@ +import { model } from 'mongoose'; +import MESSAGE_SCHEMA from '../schemas/message.schema.js'; +import { COLLECTION_NAMES } from '../constants/db.js'; + +const MESSAGE = model(COLLECTION_NAMES.MESSAGES, MESSAGE_SCHEMA); + +export default MESSAGE; diff --git a/server/src/routes/api/chat.routes.js b/server/src/routes/api/chat.routes.js index c023e8cb..92281823 100644 --- a/server/src/routes/api/chat.routes.js +++ b/server/src/routes/api/chat.routes.js @@ -2,10 +2,14 @@ import express from 'express'; import authenticateUser from '../../middlewares/auth.middleware.js'; import presence from '../../controllers/chat/presence.js'; import getOnlineUsers from '../../controllers/chat/get-online-users.js'; +import getMessages from '../../controllers/chat/get-messages.js'; +import getConversations from '../../controllers/chat/get-conversations.js'; const chatRoutes = express.Router(); chatRoutes.post('/presence', authenticateUser, presence); chatRoutes.get('/online-users', authenticateUser, getOnlineUsers); +chatRoutes.get('/conversations', authenticateUser, getConversations); +chatRoutes.get('/messages/:userId', authenticateUser, getMessages); export default chatRoutes; diff --git a/server/src/schemas/message.schema.js b/server/src/schemas/message.schema.js new file mode 100644 index 00000000..81b04a5d --- /dev/null +++ b/server/src/schemas/message.schema.js @@ -0,0 +1,41 @@ +import { Schema } from 'mongoose'; +import { COLLECTION_NAMES } from '../constants/db.js'; + +const MESSAGE_SCHEMA = Schema( + { + sender_id: { + type: Schema.Types.ObjectId, + required: true, + ref: COLLECTION_NAMES.USERS, + index: true, + }, + receiver_id: { + type: Schema.Types.ObjectId, + required: true, + ref: COLLECTION_NAMES.USERS, + index: true, + }, + message: { + type: String, + required: true, + maxlength: [5000, 'Message should not be more than 5000 characters'], + }, + read: { + type: Boolean, + default: false, + }, + readAt: { + type: Date, + default: null, + }, + }, + { + timestamps: true, + } +); + +// Compound index for efficient querying of conversations +MESSAGE_SCHEMA.index({ sender_id: 1, receiver_id: 1, createdAt: -1 }); +MESSAGE_SCHEMA.index({ receiver_id: 1, sender_id: 1, createdAt: -1 }); + +export default MESSAGE_SCHEMA; diff --git a/server/src/stores/online-users.js b/server/src/stores/online-users.js index e5b55038..2f51be7d 100644 --- a/server/src/stores/online-users.js +++ b/server/src/stores/online-users.js @@ -1,7 +1,6 @@ /** * In-memory store for online (chat) presence. - * Keys: user_id (string), values: lastSeen (number timestamp). - * When Socket.IO is added (sub-issue #1378), this can be replaced by Redis or socket-backed presence. + * Now integrated with Socket.IO for real-time presence tracking. */ const PRESENCE_TTL_MS = 2 * 60 * 1000; // 2 minutes @@ -34,3 +33,12 @@ export const getOnlineUserIds = () => { } return ids; }; + +/** + * Remove user from presence map (called on disconnect) + * @param {string} userId + */ +export const removePresence = (userId) => { + if (!userId) return; + presenceMap.delete(String(userId)); +}; From 5079794ff055402411e584aa5c4c57824a7bd525 Mon Sep 17 00:00:00 2001 From: ayush123-4 Date: Sat, 14 Feb 2026 16:42:14 +0530 Subject: [PATCH 3/3] Fixed the FE Deployment --- client/src/modules/chats/v1/typings/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/modules/chats/v1/typings/index.ts b/client/src/modules/chats/v1/typings/index.ts index 4e994c8d..6ade43c3 100644 --- a/client/src/modules/chats/v1/typings/index.ts +++ b/client/src/modules/chats/v1/typings/index.ts @@ -1,3 +1,3 @@ -import { OnlineUser } from '../../../infra/rest/apis/chat/typing'; +import { OnlineUser } from '../../../../infra/rest/apis/chat/typing'; export type SelectedUser = OnlineUser | null;