diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 568fb5a..83d166d 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -212,6 +212,56 @@ CREATE TABLE IF NOT EXISTS group_members ( PRIMARY KEY (group_id, user_id) ); +-- Per-user permission overrides (explicit allow/deny beats group rights) +CREATE TABLE IF NOT EXISTS user_permissions ( + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL CHECK (permission IN ('teams.create','teams.edit','teams.invite','workspace.invite')), + allow BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, user_id, permission) +); + +-- Chat attachments + edit timestamps +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS attachment TEXT NOT NULL DEFAULT ''; +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ NULL; + +-- Private 1:1 conversations (DMs) +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS conversation_members ( + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + last_read_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (conversation_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_conv_members_user ON conversation_members(user_id); +CREATE TABLE IF NOT EXISTS dm_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000), + attachment TEXT NOT NULL DEFAULT '', + edited_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_dm_conv ON dm_messages(conversation_id, created_at); + +-- Emoji reactions on chat + DM messages (toggle per user+emoji) +CREATE TABLE IF NOT EXISTS reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_type TEXT NOT NULL CHECK (target_type IN ('chat','dm')), + target_id UUID NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (target_type, target_id, user_id, emoji) +); +CREATE INDEX IF NOT EXISTS idx_reactions_target ON reactions(target_type, target_id); + -- Ensure workspace owner is always a member with role=owner CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$ BEGIN diff --git a/backend/src/index.js b/backend/src/index.js index 90822de..91af972 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -22,6 +22,8 @@ import chatRoutes from './routes/chat.js'; import teamRoutes from './routes/teams.js'; import groupRoutes from './routes/groups.js'; import uploadRoutes from './routes/upload.js'; +import dmRoutes from './routes/dm.js'; +import reactionRoutes from './routes/reactions.js'; import { initSocket } from './socket.js'; const app = express(); @@ -64,6 +66,8 @@ app.use('/api/chat', chatRoutes); app.use('/api/teams', teamRoutes); app.use('/api/groups', groupRoutes); app.use('/api/upload', uploadRoutes); +app.use('/api/dm', dmRoutes); +app.use('/api/reactions', reactionRoutes); app.use('/uploads', express.static('uploads')); app.get('/api/health', async (_req, res) => { diff --git a/backend/src/routes/chat.js b/backend/src/routes/chat.js index 76bd6ce..6dc7a3f 100644 --- a/backend/src/routes/chat.js +++ b/backend/src/routes/chat.js @@ -6,6 +6,23 @@ import { getRole } from './guards.js'; const router = Router(); router.use(requireAuth); +async function attachReactions(messages) { + if (!messages.length) return messages; + const ids = messages.map((m) => m.id); + const { rows } = await query( + `SELECT target_id, emoji, COUNT(*)::int AS count, array_agg(user_id) AS users + FROM reactions WHERE target_type = 'chat' AND target_id = ANY($1) + GROUP BY target_id, emoji`, + [ids], + ); + const byMsg = {}; + for (const r of rows) { + if (!byMsg[r.target_id]) byMsg[r.target_id] = []; + byMsg[r.target_id].push({ emoji: r.emoji, count: r.count, users: r.users }); + } + return messages.map((m) => ({ ...m, reactions: byMsg[m.id] || [] })); +} + // GET /api/chat/:workspaceId — last 100 messages router.get('/:workspaceId', async (req, res) => { const role = await getRole(req.params.workspaceId, req.user.id); @@ -16,7 +33,8 @@ router.get('/:workspaceId', async (req, res) => { WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`, [req.params.workspaceId], ); - return res.json({ messages: rows.reverse() }); + const withReactions = await attachReactions(rows.reverse()); + return res.json({ messages: withReactions }); }); // POST /api/chat/:workspaceId { body } — post + realtime broadcast + notify others @@ -24,16 +42,16 @@ router.post('/:workspaceId', async (req, res) => { const wsId = req.params.workspaceId; const role = await getRole(wsId, req.user.id); if (!role) return res.status(403).json({ error: 'Not a workspace member' }); - const { body = '' } = req.body ?? {}; + const { body = '', attachment = '' } = req.body ?? {}; if (String(body).trim().length < 1 || String(body).length > 2000) { return res.status(400).json({ error: 'Body must be 1-2000 chars' }); } const { rows } = await query( - 'INSERT INTO chat_messages (workspace_id, user_id, body) VALUES ($1,$2,$3) RETURNING *', - [wsId, req.user.id, String(body).trim()], + 'INSERT INTO chat_messages (workspace_id, user_id, body, attachment) VALUES ($1,$2,$3,$4) RETURNING *', + [wsId, req.user.id, String(body).trim(), String(attachment).slice(0, 2000)], ); const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]); - const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar }; + const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar, reactions: [] }; const io = req.app.get('io'); io?.to(`chat:${wsId}`).emit('chat:message', { message: msg }); // Notify all other members @@ -54,7 +72,39 @@ router.post('/:workspaceId', async (req, res) => { return res.status(201).json({ message: msg }); }); -// DELETE /api/chat/message/:id (own or admin+) +// PATCH /api/chat/message/:id { body } (own) +router.patch('/message/:id', async (req, res) => { + const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]); + const msg = rows[0]; + if (!msg) return res.status(404).json({ error: 'Not found' }); + if (msg.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + const { body = '' } = req.body ?? {}; + if (String(body).trim().length < 1 || String(body).length > 2000) { + return res.status(400).json({ error: 'Body must be 1-2000 chars' }); + } + const { rows: updated } = await query( + 'UPDATE chat_messages SET body = $1, edited_at = now() WHERE id = $2 RETURNING *', + [String(body).trim(), msg.id], + ); + req.app.get('io')?.to(`chat:${msg.workspace_id}`).emit('chat:edited', { message: updated[0] }); + return res.json({ message: updated[0] }); +}); + +// GET /api/chat/:workspaceId/search?q= +router.get('/:workspaceId/search', async (req, res) => { + const role = await getRole(req.params.workspaceId, req.user.id); + if (!role) return res.status(403).json({ error: 'Not a workspace member' }); + const q = String(req.query.q || '').slice(0, 100); + if (q.length < 2) return res.json({ messages: [] }); + const { rows } = await query( + `SELECT m.*, u.name AS author_name FROM chat_messages m + LEFT JOIN users u ON u.id = m.user_id + WHERE m.workspace_id = $1 AND m.body ILIKE $2 + ORDER BY m.created_at DESC LIMIT 30`, + [req.params.workspaceId, `%${q}%`], + ); + return res.json({ messages: rows }); +}); router.delete('/message/:id', async (req, res) => { const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]); const msg = rows[0]; diff --git a/backend/src/routes/dm.js b/backend/src/routes/dm.js new file mode 100644 index 0000000..c76e072 --- /dev/null +++ b/backend/src/routes/dm.js @@ -0,0 +1,171 @@ +import { Router } from 'express'; +import { query } from '../config/db.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = Router(); +router.use(requireAuth); + +async function isMember(conversationId, userId) { + const { rows } = await query( + 'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2', + [conversationId, userId], + ); + return !!rows[0]; +} + +async function withReactions(type, messages) { + if (!messages.length) return messages; + const ids = messages.map((m) => m.id); + const { rows } = await query( + `SELECT target_id, emoji, COUNT(*)::int AS count, + array_agg(user_id) AS users + FROM reactions WHERE target_type = $1 AND target_id = ANY($2) + GROUP BY target_id, emoji`, + [type, ids], + ); + const byMsg = {}; + for (const r of rows) { + if (!byMsg[r.target_id]) byMsg[r.target_id] = []; + byMsg[r.target_id].push({ emoji: r.emoji, count: r.count, users: r.users }); + } + return messages.map((m) => ({ ...m, reactions: byMsg[m.id] || [] })); +} + +// GET /api/dm — my conversations (peer, last message, unread) +router.get('/', async (req, res) => { + const { rows } = await query( + `SELECT c.id, c.created_at, + (SELECT json_build_object('id', u.id, 'name', u.name, 'email', u.email, 'avatar', COALESCE(u.avatar,'')) + FROM conversation_members cm2 JOIN users u ON u.id = cm2.user_id + WHERE cm2.conversation_id = c.id AND cm2.user_id <> $1 LIMIT 1) AS peer, + (SELECT json_build_object('body', m.body, 'created_at', m.created_at, 'user_id', m.user_id) + FROM dm_messages m WHERE m.conversation_id = c.id + ORDER BY m.created_at DESC LIMIT 1) AS last_message, + (SELECT COUNT(*)::int FROM dm_messages m + JOIN conversation_members me ON me.conversation_id = c.id AND me.user_id = $1 + WHERE m.conversation_id = c.id AND m.created_at > me.last_read_at AND m.user_id <> $1) AS unread + FROM conversations c + JOIN conversation_members me ON me.conversation_id = c.id AND me.user_id = $1 + ORDER BY COALESCE((SELECT MAX(created_at) FROM dm_messages WHERE conversation_id = c.id), c.created_at) DESC`, + [req.user.id], + ); + return res.json({ conversations: rows }); +}); + +// POST /api/dm { user_id } — find-or-create 1:1 conversation +router.post('/', async (req, res) => { + const { user_id } = req.body ?? {}; + if (!user_id || user_id === req.user.id) return res.status(400).json({ error: 'Valid user_id required' }); + const { rows: target } = await query('SELECT id, name, email, avatar FROM users WHERE id = $1', [user_id]); + if (!target[0]) return res.status(404).json({ error: 'User not found' }); + const { rows: existing } = await query( + `SELECT c.id FROM conversations c + JOIN conversation_members a ON a.conversation_id = c.id AND a.user_id = $1 + JOIN conversation_members b ON b.conversation_id = c.id AND b.user_id = $2 + WHERE (SELECT COUNT(*) FROM conversation_members WHERE conversation_id = c.id) = 2 + LIMIT 1`, + [req.user.id, user_id], + ); + if (existing[0]) return res.json({ conversation: { id: existing[0].id } }); + const { rows } = await query('INSERT INTO conversations (created_by) VALUES ($1) RETURNING id', [req.user.id]); + await query('INSERT INTO conversation_members (conversation_id, user_id) VALUES ($1,$2),($1,$3)', [ + rows[0].id, req.user.id, user_id, + ]); + return res.status(201).json({ conversation: { id: rows[0].id } }); +}); + +// GET /api/dm/:id/messages?limit=100 — marks as read +router.get('/:id/messages', async (req, res) => { + if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' }); + const limit = Math.min(Number(req.query.limit) || 100, 200); + const { rows } = await query( + `SELECT m.*, u.name AS author_name, u.avatar AS author_avatar + FROM dm_messages m LEFT JOIN users u ON u.id = m.user_id + WHERE m.conversation_id = $1 ORDER BY m.created_at DESC LIMIT $2`, + [req.params.id, limit], + ); + const messages = (await withReactions('dm', rows)).reverse(); + await query( + 'UPDATE conversation_members SET last_read_at = now() WHERE conversation_id = $1 AND user_id = $2', + [req.params.id, req.user.id], + ); + return res.json({ messages }); +}); + +// GET /api/dm/:id/search?q= +router.get('/:id/search', async (req, res) => { + if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' }); + const q = String(req.query.q || '').slice(0, 100); + if (q.length < 2) return res.json({ messages: [] }); + const { rows } = await query( + `SELECT m.*, u.name AS author_name FROM dm_messages m + LEFT JOIN users u ON u.id = m.user_id + WHERE m.conversation_id = $1 AND m.body ILIKE $2 + ORDER BY m.created_at DESC LIMIT 30`, + [req.params.id, `%${q}%`], + ); + return res.json({ messages: rows }); +}); + +// POST /api/dm/:id/messages { body, attachment? } +router.post('/:id/messages', async (req, res) => { + if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' }); + const { body = '', attachment = '' } = req.body ?? {}; + if (String(body).trim().length < 1 || String(body).length > 2000) { + return res.status(400).json({ error: 'Body must be 1-2000 chars' }); + } + const { rows } = await query( + 'INSERT INTO dm_messages (conversation_id, user_id, body, attachment) VALUES ($1,$2,$3,$4) RETURNING *', + [req.params.id, req.user.id, String(body).trim(), String(attachment).slice(0, 2000)], + ); + const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]); + const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar, reactions: [] }; + const io = req.app.get('io'); + io?.to(`dm:${req.params.id}`).emit('dm:message', { message: msg }); + // Notify the other member + const { rows: others } = await query( + 'SELECT user_id FROM conversation_members WHERE conversation_id = $1 AND user_id <> $2', + [req.params.id, req.user.id], + ); + for (const o of others) { + await query( + `INSERT INTO notifications (user_id, type, title, body, link) + VALUES ($1,'chat',$2,$3,$4)`, + [o.user_id, `✉️ ${author[0]?.name || 'Someone'}`, String(body).trim().slice(0, 140), '/chat'], + ); + io?.to(`user:${o.user_id}`).emit('notification:new', { type: 'dm' }); + } + return res.status(201).json({ message: msg }); +}); + +// PATCH /api/dm/message/:mid { body } (own) +router.patch('/message/:mid', async (req, res) => { + const { rows } = await query('SELECT * FROM dm_messages WHERE id = $1', [req.params.mid]); + const msg = rows[0]; + if (!msg) return res.status(404).json({ error: 'Not found' }); + if (msg.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + const { body = '' } = req.body ?? {}; + if (String(body).trim().length < 1 || String(body).length > 2000) { + return res.status(400).json({ error: 'Body must be 1-2000 chars' }); + } + const { rows: updated } = await query( + 'UPDATE dm_messages SET body = $1, edited_at = now() WHERE id = $2 RETURNING *', + [String(body).trim(), msg.id], + ); + req.app.get('io')?.to(`dm:${msg.conversation_id}`).emit('dm:edited', { message: updated[0] }); + return res.json({ message: updated[0] }); +}); + +// DELETE /api/dm/message/:mid (own or admin) +router.delete('/message/:mid', async (req, res) => { + const { rows } = await query('SELECT * FROM dm_messages WHERE id = $1', [req.params.mid]); + const msg = rows[0]; + if (!msg) return res.status(404).json({ error: 'Not found' }); + const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]); + if (msg.user_id !== req.user.id && !me[0]?.is_admin) return res.status(403).json({ error: 'Forbidden' }); + await query('DELETE FROM dm_messages WHERE id = $1', [msg.id]); + req.app.get('io')?.to(`dm:${msg.conversation_id}`).emit('dm:deleted', { id: msg.id }); + return res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/guards.js b/backend/src/routes/guards.js index dad8c8e..9da2fb8 100644 --- a/backend/src/routes/guards.js +++ b/backend/src/routes/guards.js @@ -41,11 +41,17 @@ export async function requireAdmin(req, res, next) { export const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite']; -/** True if user holds a workspace permission via groups (owner/admin always pass). */ +/** True if user holds a workspace permission. + * owner/admin always pass; explicit per-user allow/deny beats group rights. */ export async function hasPerm(workspaceId, userId, perm) { const role = await getRole(workspaceId, userId); if (role === 'owner' || role === 'admin') return true; if (!role) return false; + const { rows: override } = await query( + 'SELECT allow FROM user_permissions WHERE workspace_id = $1 AND user_id = $2 AND permission = $3', + [workspaceId, userId, perm], + ); + if (override[0]) return !!override[0].allow; const { rows } = await query( `SELECT 1 FROM group_members gm JOIN groups g ON g.id = gm.group_id WHERE g.workspace_id = $1 AND gm.user_id = $2 AND $3 = ANY(g.permissions) LIMIT 1`, diff --git a/backend/src/routes/reactions.js b/backend/src/routes/reactions.js new file mode 100644 index 0000000..876575f --- /dev/null +++ b/backend/src/routes/reactions.js @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import { query } from '../config/db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { getRole } from './guards.js'; + +const router = Router(); +router.use(requireAuth); + +async function canSee(type, targetId, userId) { + if (type === 'chat') { + const { rows } = await query('SELECT workspace_id FROM chat_messages WHERE id = $1', [targetId]); + if (!rows[0]) return null; + const role = await getRole(rows[0].workspace_id, userId); + return role ? { room: `chat:${rows[0].workspace_id}` } : null; + } + const { rows } = await query('SELECT conversation_id FROM dm_messages WHERE id = $1', [targetId]); + if (!rows[0]) return null; + const { rows: m } = await query( + 'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2', + [rows[0].conversation_id, userId], + ); + return m[0] ? { room: `dm:${rows[0].conversation_id}` } : null; +} + +// POST /api/reactions/toggle { target_type: 'chat'|'dm', target_id, emoji } +router.post('/toggle', async (req, res) => { + const { target_type, target_id, emoji = '' } = req.body ?? {}; + if (!['chat', 'dm'].includes(target_type)) return res.status(400).json({ error: 'Invalid target' }); + const e = String(emoji).slice(0, 20); + if (!e) return res.status(400).json({ error: 'Emoji required' }); + const access = await canSee(target_type, target_id, req.user.id); + if (!access) return res.status(404).json({ error: 'Message not found' }); + const { rows: existing } = await query( + 'SELECT id FROM reactions WHERE target_type = $1 AND target_id = $2 AND user_id = $3 AND emoji = $4', + [target_type, target_id, req.user.id, e], + ); + let added; + if (existing[0]) { + await query('DELETE FROM reactions WHERE id = $1', [existing[0].id]); + added = false; + } else { + await query( + 'INSERT INTO reactions (target_type, target_id, user_id, emoji) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING', + [target_type, target_id, req.user.id, e], + ); + added = true; + } + const { rows: counts } = await query( + `SELECT emoji, COUNT(*)::int AS count, array_agg(user_id) AS users + FROM reactions WHERE target_type = $1 AND target_id = $2 GROUP BY emoji`, + [target_type, target_id], + ); + req.app.get('io')?.to(access.room).emit('reaction:updated', { + target_type, target_id, reactions: counts, by: req.user.id, added, emoji: e, + }); + return res.json({ added, reactions: counts }); +}); + +export default router; diff --git a/backend/src/routes/workspaces.js b/backend/src/routes/workspaces.js index dd68626..b724a80 100644 --- a/backend/src/routes/workspaces.js +++ b/backend/src/routes/workspaces.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import { query } from '../config/db.js'; import { requireAuth } from '../middleware/auth.js'; -import { getRole, requireWorkspace, hasPerm, ensureDefaultGroups, ROLES } from './guards.js'; +import { getRole, requireWorkspace, hasPerm, ensureDefaultGroups, PERMS, ROLES } from './guards.js'; const router = Router(); router.use(requireAuth); @@ -114,6 +114,44 @@ router.post('/:id/members', requireWorkspace('viewer'), async (req, res) => { return res.status(201).json({ invite: inv[0] }); }); +// GET /api/workspaces/:id/permissions — members with explicit overrides (admin+) +router.get('/:id/permissions', requireWorkspace('admin'), async (req, res) => { + const { rows } = await query( + `SELECT m.user_id, u.name, u.email, m.role, + (SELECT json_object_agg(permission, allow) FROM user_permissions + WHERE workspace_id = $1 AND user_id = m.user_id) AS overrides + FROM workspace_members m JOIN users u ON u.id = m.user_id + WHERE m.workspace_id = $1 ORDER BY u.name`, + [req.params.id], + ); + return res.json({ members: rows }); +}); + +// PATCH /api/workspaces/:id/permissions { user_id, permission, mode } +// mode: 'allow' | 'deny' | 'inherit' (inherit removes the override) (admin+) +router.patch('/:id/permissions', requireWorkspace('admin'), async (req, res) => { + const { user_id, permission, mode } = req.body ?? {}; + if (!PERMS.includes(permission)) return res.status(400).json({ error: 'Invalid permission' }); + if (!['allow', 'deny', 'inherit'].includes(mode)) return res.status(400).json({ error: 'Invalid mode' }); + if (!(await getRole(req.params.id, user_id))) { + return res.status(400).json({ error: 'User must be a workspace member' }); + } + if (mode === 'inherit') { + await query( + 'DELETE FROM user_permissions WHERE workspace_id = $1 AND user_id = $2 AND permission = $3', + [req.params.id, user_id, permission], + ); + } else { + await query( + `INSERT INTO user_permissions (workspace_id, user_id, permission, allow) + VALUES ($1,$2,$3,$4) + ON CONFLICT (workspace_id, user_id, permission) DO UPDATE SET allow = EXCLUDED.allow`, + [req.params.id, user_id, permission, mode === 'allow'], + ); + } + return res.json({ ok: true }); +}); + // PATCH /api/workspaces/:id/members/:userId { role } (admin+) router.patch('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => { const { role } = req.body ?? {}; diff --git a/backend/src/socket.js b/backend/src/socket.js index ee01808..0b81d9a 100644 --- a/backend/src/socket.js +++ b/backend/src/socket.js @@ -30,6 +30,7 @@ export function initSocket(httpServer, corsOrigin) { }); const presence = new Map(); // room -> Map(socketId -> { userId, email, name }) + const online = new Map(); // userId -> connection count (global presence) function emitPresence(room) { const members = [...(presence.get(room)?.values() ?? [])]; @@ -40,6 +41,9 @@ export function initSocket(httpServer, corsOrigin) { const user = socket.data.user; // Personal room for notifications (bell). socket.join(`user:${user.id}`); + // Global online presence. + online.set(user.id, (online.get(user.id) || 0) + 1); + io.emit('presence:online', [...online.keys()]); socket.on('chat:join', async ({ workspaceId }) => { try { @@ -110,10 +114,36 @@ export function initSocket(httpServer, corsOrigin) { socket.to(`page:${pageId}`).emit('typing', { userId: user.id, name }); }); + socket.on('dm:join', async ({ conversationId }) => { + try { + if (!conversationId) return; + const { rows } = await query( + 'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2', + [conversationId, user.id], + ); + if (!rows[0]) return socket.emit('error:message', { error: 'Forbidden' }); + socket.join(`dm:${conversationId}`); + } catch { + socket.emit('error:message', { error: 'Join failed' }); + } + }); + + socket.on('chat:typing', ({ workspaceId, name }) => { + socket.to(`chat:${workspaceId}`).emit('chat:typing', { userId: user.id, name }); + }); + + socket.on('dm:typing', ({ conversationId, name }) => { + socket.to(`dm:${conversationId}`).emit('dm:typing', { userId: user.id, name }); + }); + socket.on('disconnect', () => { for (const [room, members] of presence) { if (members.delete(socket.id)) emitPresence(room); } + const left = (online.get(user.id) || 1) - 1; + if (left <= 0) online.delete(user.id); + else online.set(user.id, left); + io.emit('presence:online', [...online.keys()]); }); });