diff --git a/backend/package.json b/backend/package.json index bf85851..729ebc8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.11.7", + "version": "0.11.9", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/index.js b/backend/src/index.js index 97f3e0b..09f48fc 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -137,36 +137,45 @@ io.use(async (socket, next) => { }); // ── Online user tracking ────────────────────────────────────────────────────── -const onlineUsers = new Map(); // userId → Set +// Key is `${schema}:${userId}` — user IDs are per-schema integers, so two tenants +// can have the same integer ID for completely different people. Without the schema +// prefix, tenant A's user 5 and tenant B's user 5 would collide: push notifications +// could be suppressed for the wrong user, and users:online would leak IDs across tenants. +const onlineUsers = new Map(); // `${schema}:${userId}` → Set io.on('connection', async (socket) => { const userId = socket.user.id; const schema = socket.schema; + // Prefix rooms with schema so tenant rooms never collide (IDs are per-schema only) + const R = (type, id) => `${schema}:${type}:${id}`; + // Scoped key for the onlineUsers map — must match schema for correct tenant isolation + const onlineKey = `${schema}:${userId}`; - if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set()); - onlineUsers.get(userId).add(socket.id); + if (!onlineUsers.has(onlineKey)) onlineUsers.set(onlineKey, new Set()); + onlineUsers.get(onlineKey).add(socket.id); // Update last_online exec(schema, 'UPDATE users SET last_online = NOW() WHERE id = $1', [userId]).catch(() => {}); - io.emit('user:online', { userId }); - socket.join(`user:${userId}`); + io.to(R('schema', 'all')).emit('user:online', { userId }); + socket.join(R('user', userId)); + socket.join(R('schema', 'all')); // tenant-scoped broadcast room for public group events // Join socket rooms for all groups this user belongs to try { const publicGroups = await query(schema, "SELECT id FROM groups WHERE type = 'public'"); - for (const g of publicGroups) socket.join(`group:${g.id}`); + for (const g of publicGroups) socket.join(R('group', g.id)); const privateGroups = await query(schema, 'SELECT group_id FROM group_members WHERE user_id = $1', [userId] ); - for (const g of privateGroups) socket.join(`group:${g.group_id}`); + for (const g of privateGroups) socket.join(R('group', g.group_id)); } catch (e) { console.error('[Socket] Room join error:', e.message); } - socket.on('group:join-room', ({ groupId }) => socket.join(`group:${groupId}`)); - socket.on('group:leave-room', ({ groupId }) => socket.leave(`group:${groupId}`)); + socket.on('group:join-room', ({ groupId }) => socket.join(R('group', groupId))); + socket.on('group:leave-room', ({ groupId }) => socket.leave(R('group', groupId))); // ── New message ───────────────────────────────────────────────────────────── socket.on('message:send', async (data) => { @@ -213,7 +222,7 @@ io.on('connection', async (socket) => { `, [msgId]); message.reactions = []; - io.to(`group:${groupId}`).emit('message:new', message); + io.to(R('group', groupId)).emit('message:new', message); // Push notifications for private groups if (group.type === 'private') { @@ -223,14 +232,15 @@ io.on('connection', async (socket) => { const senderName = socket.user.display_name || socket.user.name || 'Someone'; for (const m of members) { if (m.user_id === userId) continue; - if (!onlineUsers.has(m.user_id)) { - sendPushToUser(m.user_id, { + const memberKey = `${schema}:${m.user_id}`; + if (!onlineUsers.has(memberKey)) { + sendPushToUser(schema, m.user_id, { title: senderName, body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100), url: '/', groupId, badge: 1, }).catch(() => {}); } else { - for (const sid of onlineUsers.get(m.user_id)) { + for (const sid of onlineUsers.get(memberKey)) { io.to(sid).emit('notification:new', { type: 'private_message', groupId, fromUser: socket.user }); } } @@ -252,11 +262,12 @@ io.on('connection', async (socket) => { [mentioned.id, msgId, groupId, userId] ); const notif = { id: nr.rows[0].id, type: 'mention', groupId, messageId: msgId, fromUser: socket.user }; - if (onlineUsers.has(mentioned.id)) { - for (const sid of onlineUsers.get(mentioned.id)) io.to(sid).emit('notification:new', notif); + const mentionedKey = `${schema}:${mentioned.id}`; + if (onlineUsers.has(mentionedKey)) { + for (const sid of onlineUsers.get(mentionedKey)) io.to(sid).emit('notification:new', notif); } const senderName = socket.user.display_name || socket.user.name || 'Someone'; - sendPushToUser(mentioned.id, { + sendPushToUser(schema, mentioned.id, { title: `${senderName} mentioned you`, body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100), url: '/', badge: 1, @@ -301,7 +312,7 @@ io.on('connection', async (socket) => { WHERE r.message_id=$1 `, [messageId]); - io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId, reactions }); + io.to(R('group', message.group_id)).emit('reaction:updated', { messageId, reactions }); } catch (e) { console.error('[Socket] reaction:toggle error:', e.message); } @@ -338,7 +349,7 @@ io.on('connection', async (socket) => { 'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1', [messageId] ); - io.to(`group:${message.group_id}`).emit('message:deleted', { messageId, groupId: message.group_id }); + io.to(R('group', message.group_id)).emit('message:deleted', { messageId, groupId: message.group_id }); } catch (e) { console.error('[Socket] message:delete error:', e.message); } @@ -346,24 +357,29 @@ io.on('connection', async (socket) => { // ── Typing indicators ─────────────────────────────────────────────────────── socket.on('typing:start', ({ groupId }) => { - socket.to(`group:${groupId}`).emit('typing:start', { userId, groupId, user: socket.user }); + socket.to(R('group', groupId)).emit('typing:start', { userId, groupId, user: socket.user }); }); socket.on('typing:stop', ({ groupId }) => { - socket.to(`group:${groupId}`).emit('typing:stop', { userId, groupId }); + socket.to(R('group', groupId)).emit('typing:stop', { userId, groupId }); }); socket.on('users:online', () => { - socket.emit('users:online', { userIds: [...onlineUsers.keys()] }); + // Return only the user IDs for this tenant by filtering keys matching this schema prefix + const prefix = `${schema}:`; + const userIds = [...onlineUsers.keys()] + .filter(k => k.startsWith(prefix)) + .map(k => parseInt(k.slice(prefix.length), 10)); + socket.emit('users:online', { userIds }); }); // ── Disconnect ────────────────────────────────────────────────────────────── socket.on('disconnect', () => { - if (onlineUsers.has(userId)) { - onlineUsers.get(userId).delete(socket.id); - if (onlineUsers.get(userId).size === 0) { - onlineUsers.delete(userId); + if (onlineUsers.has(onlineKey)) { + onlineUsers.get(onlineKey).delete(socket.id); + if (onlineUsers.get(onlineKey).size === 0) { + onlineUsers.delete(onlineKey); exec(schema, 'UPDATE users SET last_online=NOW() WHERE id=$1', [userId]).catch(() => {}); - io.emit('user:offline', { userId }); + io.to(R('schema', 'all')).emit('user:offline', { userId }); } } }); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 0637764..3b68131 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -3,6 +3,8 @@ const bcrypt = require('bcryptjs'); const { query, queryOne, queryResult, exec, getOrCreateSupportGroup } = require('../models/db'); const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth'); +const R = (schema, type, id) => `${schema}:${type}:${id}`; + module.exports = function(io) { const router = express.Router(); @@ -25,7 +27,7 @@ module.exports = function(io) { const token = generateToken(user.id); const ua = req.headers['user-agent'] || ''; const device = await setActiveSession(req.schema, user.id, token, ua); - if (io) io.to(`user:${user.id}`).emit('session:displaced', { device }); + if (io) io.to(R(req.schema,'user',user.id)).emit('session:displaced', { device }); const { password: _, ...userSafe } = user; res.json({ token, user: userSafe, mustChangePassword: !!user.must_change_password, rememberMe: !!rememberMe }); @@ -87,10 +89,10 @@ module.exports = function(io) { SELECT m.*, u.name AS user_name, u.display_name AS user_display_name, u.avatar AS user_avatar FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = $1 `, [mr.rows[0].id]); - if (newMsg) { newMsg.reactions = []; io.to(`group:${groupId}`).emit('message:new', newMsg); } + if (newMsg) { newMsg.reactions = []; io.to(R(req.schema,'group',groupId)).emit('message:new', newMsg); } const admins = await query(req.schema, "SELECT id FROM users WHERE role = 'admin' AND status = 'active'"); - for (const a of admins) io.to(`user:${a.id}`).emit('notification:new', { type: 'support', groupId }); + for (const a of admins) io.to(R(req.schema,'user',a.id)).emit('notification:new', { type: 'support', groupId }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index 428b2bc..915f415 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -10,16 +10,19 @@ function deleteImageFile(imageUrl) { catch (e) { console.warn('[Groups] Could not delete image:', e.message); } } +// Schema-aware room name helper +const R = (schema, type, id) => `${schema}:${type}:${id}`; + module.exports = (io) => { async function emitGroupNew(schema, io, groupId) { const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]); if (!group) return; if (group.type === 'public') { - io.emit('group:new', { group }); + io.to(R(schema, 'schema', 'all')).emit('group:new', { group }); } else { const members = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]); - for (const m of members) io.to(`user:${m.user_id}`).emit('group:new', { group }); + for (const m of members) io.to(R(schema, 'user', m.user_id)).emit('group:new', { group }); } } @@ -32,7 +35,7 @@ async function emitGroupUpdated(schema, io, groupId) { } else { uids = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]); } - for (const m of uids) io.to(`user:${m.user_id}`).emit('group:updated', { group }); + for (const m of uids) io.to(R(schema, 'user', m.user_id)).emit('group:updated', { group }); } // GET all groups for current user @@ -240,9 +243,9 @@ router.post('/:id/members', authMiddleware, async (req, res) => { [mr.rows[0].id] ); sysMsg.reactions = []; - io.to(`group:${group.id}`).emit('message:new', sysMsg); - io.in(`user:${userId}`).socketsJoin(`group:${group.id}`); - io.to(`user:${userId}`).emit('group:new', { group }); + io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg); + io.in(R(req.schema,'user',userId)).socketsJoin(R(req.schema,'group',group.id)); + io.to(R(req.schema,'user',userId)).emit('group:new', { group }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -276,9 +279,9 @@ router.delete('/:id/members/:userId', authMiddleware, async (req, res) => { [mr.rows[0].id] ); sysMsg.reactions = []; - io.to(`group:${group.id}`).emit('message:new', sysMsg); - io.in(`user:${targetId}`).socketsLeave(`group:${group.id}`); - io.to(`user:${targetId}`).emit('group:deleted', { groupId: group.id }); + io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg); + io.in(R(req.schema,'user',targetId)).socketsLeave(R(req.schema,'group',group.id)); + io.to(R(req.schema,'user',targetId)).emit('group:deleted', { groupId: group.id }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -302,9 +305,9 @@ router.delete('/:id/leave', authMiddleware, async (req, res) => { [mr.rows[0].id] ); sysMsg.reactions = []; - io.to(`group:${group.id}`).emit('message:new', sysMsg); - io.in(`user:${userId}`).socketsLeave(`group:${group.id}`); - io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id }); + io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg); + io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',group.id)); + io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: group.id }); if (group.is_direct) { const remaining = await queryOne(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 1', [group.id]); if (remaining) await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [remaining.user_id, group.id]); @@ -340,7 +343,7 @@ router.delete('/:id', authMiddleware, async (req, res) => { const imageMessages = await query(req.schema, 'SELECT image_url FROM messages WHERE group_id=$1 AND image_url IS NOT NULL', [group.id]); await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [group.id]); for (const msg of imageMessages) deleteImageFile(msg.image_url); - for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: group.id }); + for (const uid of members) io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: group.id }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js index 44d08ae..db2bfaa 100644 --- a/backend/src/routes/messages.js +++ b/backend/src/routes/messages.js @@ -10,6 +10,8 @@ function deleteImageFile(imageUrl) { catch (e) { console.warn('[Messages] Could not delete image:', e.message); } } +const R = (schema, type, id) => `${schema}:${type}:${id}`; + module.exports = function(io) { const router = express.Router(); const { authMiddleware } = require('../middleware/auth'); @@ -98,7 +100,7 @@ module.exports = function(io) { WHERE m.id=$1 `, [r.rows[0].id]); message.reactions = []; - io.to(`group:${req.params.groupId}`).emit('message:new', message); + io.to(R(req.schema,'group',req.params.groupId)).emit('message:new', message); res.json({ message }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -121,7 +123,7 @@ module.exports = function(io) { [r.rows[0].id] ); message.reactions = []; - io.to(`group:${req.params.groupId}`).emit('message:new', message); + io.to(R(req.schema,'group',req.params.groupId)).emit('message:new', message); res.json({ message }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -140,7 +142,7 @@ module.exports = function(io) { const imageUrl = message.image_url; await exec(req.schema, 'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1', [message.id]); deleteImageFile(imageUrl); - io.to(`group:${message.group_id}`).emit('message:deleted', { messageId: message.id, groupId: message.group_id }); + io.to(R(req.schema,'group',message.group_id)).emit('message:deleted', { messageId: message.id, groupId: message.group_id }); res.json({ success: true, messageId: message.id }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -164,7 +166,7 @@ module.exports = function(io) { 'SELECT r.emoji, r.user_id, u.name AS user_name FROM reactions r JOIN users u ON r.user_id=u.id WHERE r.message_id=$1', [message.id] ); - io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId: message.id, reactions }); + io.to(R(req.schema,'group',message.group_id)).emit('reaction:updated', { messageId: message.id, reactions }); res.json({ reactions }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js index 8e5ac46..20d6509 100644 --- a/backend/src/routes/usergroups.js +++ b/backend/src/routes/usergroups.js @@ -3,6 +3,8 @@ const router = express.Router(); const { query, queryOne, queryResult, exec } = require('../models/db'); const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth'); +const R = (schema, type, id) => `${schema}:${type}:${id}`; + module.exports = function(io) { // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -18,14 +20,14 @@ async function postSysMsg(schema, groupId, actorId, content) { u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me, u.allow_dm AS user_allow_dm FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1 `, [r.rows[0].id]); - if (msg) { msg.reactions = []; io.to(`group:${groupId}`).emit('message:new', msg); } + if (msg) { msg.reactions = []; io.to(R(schema,'group',groupId)).emit('message:new', msg); } } async function addUserSilent(schema, dmGroupId, userId) { await exec(schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [dmGroupId, userId]); - io.in(`user:${userId}`).socketsJoin(`group:${dmGroupId}`); + io.in(R(schema,'user',userId)).socketsJoin(R(schema,'group',dmGroupId)); const dmGroup = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]); - if (dmGroup) io.to(`user:${userId}`).emit('group:new', { group: dmGroup }); + if (dmGroup) io.to(R(schema,'user',userId)).emit('group:new', { group: dmGroup }); } async function addUser(schema, dmGroupId, userId, actorId) { @@ -36,8 +38,8 @@ async function addUser(schema, dmGroupId, userId, actorId) { async function removeUser(schema, dmGroupId, userId, actorId) { await exec(schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [dmGroupId, userId]); - io.in(`user:${userId}`).socketsLeave(`group:${dmGroupId}`); - io.to(`user:${userId}`).emit('group:deleted', { groupId: dmGroupId }); + io.in(R(schema,'user',userId)).socketsLeave(R(schema,'group',dmGroupId)); + io.to(R(schema,'user',userId)).emit('group:deleted', { groupId: dmGroupId }); const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]); await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`); } @@ -154,8 +156,8 @@ router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (re `, [mg.id, uid]); if (!stillIn) { await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]); - io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`); - io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id }); + io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',mg.dm_group_id)); + io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id }); } } await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group has been removed from this conversation.`); @@ -173,7 +175,7 @@ router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (r if (mg.dm_group_id) { const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [mg.dm_group_id])).map(r => r.user_id); await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [mg.dm_group_id]); - for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id }); + for (const uid of members) io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id }); } await exec(req.schema, 'DELETE FROM multi_group_dms WHERE id=$1', [mg.id]); res.json({ success: true }); @@ -281,8 +283,8 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => `, [mg.id, uid]); if (!stillIn) { await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]); - io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`); - io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id }); + io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',mg.dm_group_id)); + io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id }); } } if (addedUids.length > 0) await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `Members were added to group "${ug.name}" and have joined this conversation.`); @@ -303,7 +305,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => if (ug.dm_group_id) { const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [ug.dm_group_id])).map(r => r.user_id); await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [ug.dm_group_id]); - for (const uid of members) { io.in(`user:${uid}`).socketsLeave(`group:${ug.dm_group_id}`); io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id }); } + for (const uid of members) { io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',ug.dm_group_id)); io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id }); } } await exec(req.schema, 'DELETE FROM user_groups WHERE id=$1', [ug.id]); res.json({ success: true }); diff --git a/build.sh b/build.sh index f1cde3d..03b7d90 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.11.7}" +VERSION="${1:-0.11.9}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index dab016c..4b0c562 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.11.7", + "version": "0.11.9", "private": true, "scripts": { "dev": "vite",