- 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
123 lines
5.4 KiB
JavaScript
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;
|