Files
openStack/backend/src/routes/chat.js
T
x3 ab751ea7ef 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
2026-09-22 14:41:42 +02:00

123 lines
5.4 KiB
JavaScript

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 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);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows } = await query(
`SELECT m.*, u.name AS author_name, u.avatar AS author_avatar
FROM chat_messages m LEFT JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`,
[req.params.workspaceId],
);
const withReactions = await attachReactions(rows.reverse());
return res.json({ messages: withReactions });
});
// POST /api/chat/:workspaceId { body } — post + realtime broadcast + notify others
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 = '', 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, 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, reactions: [] };
const io = req.app.get('io');
io?.to(`chat:${wsId}`).emit('chat:message', { message: msg });
// Notify all other members
const { rows: members } = await query(
'SELECT user_id FROM workspace_members WHERE workspace_id = $1 AND user_id <> $2',
[wsId, req.user.id],
);
const { rows: wsRows } = await query('SELECT name FROM workspaces WHERE id = $1', [wsId]);
for (const m of members) {
await query(
`INSERT INTO notifications (user_id, type, title, body, link)
VALUES ($1,'chat',$2,$3,$4)`,
[m.user_id, `💬 ${author[0]?.name || 'Someone'} in ${wsRows[0]?.name || ''}`,
String(body).trim().slice(0, 140), '/chat'],
);
io?.to(`user:${m.user_id}`).emit('notification:new', { type: 'chat' });
}
return res.status(201).json({ message: msg });
});
// 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];
if (!msg) return res.status(404).json({ error: 'Not found' });
const role = await getRole(msg.workspace_id, req.user.id);
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 && role !== 'owner' && role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
await query('DELETE FROM chat_messages WHERE id = $1', [msg.id]);
req.app.get('io')?.to(`chat:${msg.workspace_id}`).emit('chat:deleted', { id: msg.id });
return res.status(204).end();
});
export default router;