feat(backend): DMs, reactions, presence, per-user permissions, chat upgrades

- Private 1:1 conversations: find-or-create, message list (marks read),
  send with notifications, edit/delete, message search, unread counts
- Emoji reactions with toggle endpoint + realtime reaction:updated events
- Global online presence (presence:online) + chat/dm typing indicators
  and dm:join rooms; chat messages gain attachments, edit timestamps,
  search endpoint and edit route
- Per-user permission overrides (allow/deny/inherit) that beat group
  rights; workspace admin endpoints to view and set them
This commit is contained in:
x3
2026-09-22 14:41:42 +02:00
parent 0bb4e5c1cd
commit ab751ea7ef
8 changed files with 416 additions and 8 deletions
+50
View File
@@ -212,6 +212,56 @@ CREATE TABLE IF NOT EXISTS group_members (
PRIMARY KEY (group_id, user_id) 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 -- Ensure workspace owner is always a member with role=owner
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$ CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
BEGIN BEGIN
+4
View File
@@ -22,6 +22,8 @@ import chatRoutes from './routes/chat.js';
import teamRoutes from './routes/teams.js'; import teamRoutes from './routes/teams.js';
import groupRoutes from './routes/groups.js'; import groupRoutes from './routes/groups.js';
import uploadRoutes from './routes/upload.js'; import uploadRoutes from './routes/upload.js';
import dmRoutes from './routes/dm.js';
import reactionRoutes from './routes/reactions.js';
import { initSocket } from './socket.js'; import { initSocket } from './socket.js';
const app = express(); const app = express();
@@ -64,6 +66,8 @@ app.use('/api/chat', chatRoutes);
app.use('/api/teams', teamRoutes); app.use('/api/teams', teamRoutes);
app.use('/api/groups', groupRoutes); app.use('/api/groups', groupRoutes);
app.use('/api/upload', uploadRoutes); app.use('/api/upload', uploadRoutes);
app.use('/api/dm', dmRoutes);
app.use('/api/reactions', reactionRoutes);
app.use('/uploads', express.static('uploads')); app.use('/uploads', express.static('uploads'));
app.get('/api/health', async (_req, res) => { app.get('/api/health', async (_req, res) => {
+56 -6
View File
@@ -6,6 +6,23 @@ import { getRole } from './guards.js';
const router = Router(); const router = Router();
router.use(requireAuth); 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 // GET /api/chat/:workspaceId — last 100 messages
router.get('/:workspaceId', async (req, res) => { router.get('/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id); 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`, WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`,
[req.params.workspaceId], [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 // 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 wsId = req.params.workspaceId;
const role = await getRole(wsId, req.user.id); const role = await getRole(wsId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' }); 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) { if (String(body).trim().length < 1 || String(body).length > 2000) {
return res.status(400).json({ error: 'Body must be 1-2000 chars' }); return res.status(400).json({ error: 'Body must be 1-2000 chars' });
} }
const { rows } = await query( const { rows } = await query(
'INSERT INTO chat_messages (workspace_id, user_id, body) VALUES ($1,$2,$3) RETURNING *', 'INSERT INTO chat_messages (workspace_id, user_id, body, attachment) VALUES ($1,$2,$3,$4) RETURNING *',
[wsId, req.user.id, String(body).trim()], [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 { 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'); const io = req.app.get('io');
io?.to(`chat:${wsId}`).emit('chat:message', { message: msg }); io?.to(`chat:${wsId}`).emit('chat:message', { message: msg });
// Notify all other members // Notify all other members
@@ -54,7 +72,39 @@ router.post('/:workspaceId', async (req, res) => {
return res.status(201).json({ message: msg }); 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) => { router.delete('/message/:id', async (req, res) => {
const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]); const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]);
const msg = rows[0]; const msg = rows[0];
+171
View File
@@ -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;
+7 -1
View File
@@ -41,11 +41,17 @@ export async function requireAdmin(req, res, next) {
export const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite']; 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) { export async function hasPerm(workspaceId, userId, perm) {
const role = await getRole(workspaceId, userId); const role = await getRole(workspaceId, userId);
if (role === 'owner' || role === 'admin') return true; if (role === 'owner' || role === 'admin') return true;
if (!role) return false; 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( const { rows } = await query(
`SELECT 1 FROM group_members gm JOIN groups g ON g.id = gm.group_id `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`, WHERE g.workspace_id = $1 AND gm.user_id = $2 AND $3 = ANY(g.permissions) LIMIT 1`,
+59
View File
@@ -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;
+39 -1
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { query } from '../config/db.js'; import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.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(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
@@ -114,6 +114,44 @@ router.post('/:id/members', requireWorkspace('viewer'), async (req, res) => {
return res.status(201).json({ invite: inv[0] }); 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+) // PATCH /api/workspaces/:id/members/:userId { role } (admin+)
router.patch('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => { router.patch('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => {
const { role } = req.body ?? {}; const { role } = req.body ?? {};
+30
View File
@@ -30,6 +30,7 @@ export function initSocket(httpServer, corsOrigin) {
}); });
const presence = new Map(); // room -> Map(socketId -> { userId, email, name }) const presence = new Map(); // room -> Map(socketId -> { userId, email, name })
const online = new Map(); // userId -> connection count (global presence)
function emitPresence(room) { function emitPresence(room) {
const members = [...(presence.get(room)?.values() ?? [])]; const members = [...(presence.get(room)?.values() ?? [])];
@@ -40,6 +41,9 @@ export function initSocket(httpServer, corsOrigin) {
const user = socket.data.user; const user = socket.data.user;
// Personal room for notifications (bell). // Personal room for notifications (bell).
socket.join(`user:${user.id}`); 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 }) => { socket.on('chat:join', async ({ workspaceId }) => {
try { try {
@@ -110,10 +114,36 @@ export function initSocket(httpServer, corsOrigin) {
socket.to(`page:${pageId}`).emit('typing', { userId: user.id, name }); 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', () => { socket.on('disconnect', () => {
for (const [room, members] of presence) { for (const [room, members] of presence) {
if (members.delete(socket.id)) emitPresence(room); 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()]);
}); });
}); });