- 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
172 lines
7.8 KiB
JavaScript
172 lines
7.8 KiB
JavaScript
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;
|