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:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user