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;