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
+56 -6
View File
@@ -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];