import { FastifyInstance } from 'fastify'; import path from 'path'; import fs from 'fs'; import { pool } from '../db.js'; import { connections } from '../ws.js'; const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316']; export default async function chatRoutes(app: FastifyInstance) { app.addHook('preHandler', app.authenticate); // Get my chats app.get('/', async (req) => { const { id: userId } = req.user as { id: string }; const { rows } = await pool.query(` SELECT c.id, c.type, c.title, c.description, c.avatar_color, c.avatar, c.is_public, c.created_at, cm.role, cm.last_read_at, cm.is_pinned, cm.is_muted, (SELECT content FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message, (SELECT created_at FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message_at, (SELECT COUNT(*) FROM messages WHERE chat_id = c.id AND is_deleted = FALSE AND created_at > cm.last_read_at) AS unread_count, (SELECT COUNT(*) FROM chat_members WHERE chat_id = c.id) AS member_count, -- For private chats, get the other user CASE WHEN c.type = 'private' THEN ( SELECT u.display_name FROM users u JOIN chat_members cm2 ON cm2.user_id = u.id WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1 ) END AS private_name, CASE WHEN c.type = 'private' THEN ( SELECT u.avatar_color FROM users u JOIN chat_members cm2 ON cm2.user_id = u.id WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1 ) END AS private_color, CASE WHEN c.type = 'private' THEN ( SELECT u.avatar FROM users u JOIN chat_members cm2 ON cm2.user_id = u.id WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1 ) END AS private_avatar, CASE WHEN c.type = 'private' THEN ( SELECT u.id FROM users u JOIN chat_members cm2 ON cm2.user_id = u.id WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1 ) END AS private_user_id FROM chats c JOIN chat_members cm ON cm.chat_id = c.id AND cm.user_id = $1 ORDER BY cm.is_pinned DESC, COALESCE( (SELECT created_at FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1), c.created_at ) DESC `, [userId]); return rows.map(r => ({ id: r.id, type: r.type, title: r.type === 'private' ? r.private_name : r.title, description: r.description, avatarColor: r.type === 'private' ? r.private_color : r.avatar_color, avatar: r.type === 'private' ? (r.private_avatar || null) : (r.avatar || null), isPublic: r.is_public, role: r.role, lastMessage: r.last_message, lastMessageAt: r.last_message_at, unreadCount: parseInt(r.unread_count), memberCount: parseInt(r.member_count), privateUserId: r.private_user_id, createdAt: r.created_at, isPinned: r.is_pinned, isMuted: r.is_muted, })); }); // Get chat by id (with members) app.get('/:id', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; const { rows: [member] } = await pool.query( 'SELECT role, can_send_messages, can_add_members FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId] ); if (!member) return reply.status(403).send({ error: 'Not a member' }); const { rows: [chat] } = await pool.query('SELECT * FROM chats WHERE id = $1', [id]); if (!chat) return reply.status(404).send({ error: 'Not found' }); const { rows: members } = await pool.query(` SELECT u.id, u.username, u.display_name, u.avatar_color, u.avatar, u.position, u.last_seen, cm.role, cm.can_send_messages FROM chat_members cm JOIN users u ON u.id = cm.user_id WHERE cm.chat_id = $1 ORDER BY cm.role DESC, u.display_name `, [id]); const onlineSet = new Set(connections.keys()); // For private chats, resolve title/avatar/color from the other member const other = chat.type === 'private' ? members.find((m: any) => m.id !== userId) : null; return { id: chat.id, type: chat.type, title: chat.type === 'private' ? (other?.display_name || '') : chat.title, description: chat.description, avatarColor: chat.type === 'private' ? (other?.avatar_color || chat.avatar_color) : chat.avatar_color, avatar: chat.type === 'private' ? (other?.avatar || null) : (chat.avatar || null), isPublic: chat.is_public, myRole: member.role, canSendMessages: member.can_send_messages, canAddMembers: member.can_add_members, privateUserId: chat.type === 'private' ? (other?.id || null) : null, createdAt: chat.created_at, members: members.map(m => ({ id: m.id, username: m.username, displayName: m.display_name, avatarColor: m.avatar_color, avatar: m.avatar || null, position: m.position || null, role: m.role, online: onlineSet.has(m.id), lastSeen: m.last_seen, canSendMessages: m.can_send_messages, })), }; }); // Start or get private chat app.post('/private/:userId', async (req, reply) => { const { id: myId } = req.user as { id: string }; const { userId } = req.params as { userId: string }; if (myId === userId) return reply.status(400).send({ error: 'Cannot chat with yourself' }); // Check if private chat already exists const { rows } = await pool.query(` SELECT c.id FROM chats c JOIN chat_members cm1 ON cm1.chat_id = c.id AND cm1.user_id = $1 JOIN chat_members cm2 ON cm2.chat_id = c.id AND cm2.user_id = $2 WHERE c.type = 'private' LIMIT 1 `, [myId, userId]); if (rows.length > 0) return { id: rows[0].id }; // Create new private chat const { rows: [target] } = await pool.query('SELECT id FROM users WHERE id = $1', [userId]); if (!target) return reply.status(404).send({ error: 'User not found' }); const { rows: [chat] } = await pool.query( `INSERT INTO chats (type, created_by) VALUES ('private', $1) RETURNING id`, [myId] ); await pool.query( `INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member'),($1,$3,'member')`, [chat.id, myId, userId] ); return reply.status(201).send({ id: chat.id }); }); // Create group or channel app.post('/', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { type, title, description, isPublic, memberIds } = req.body as any; if (!['group', 'channel'].includes(type)) return reply.status(400).send({ error: 'Invalid type' }); if (!title) return reply.status(400).send({ error: 'Title required' }); const color = COLORS[Math.floor(Math.random() * COLORS.length)]; const { rows: [chat] } = await pool.query( `INSERT INTO chats (type, title, description, avatar_color, is_public, created_by) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, [type, title, description || '', color, !!isPublic, userId] ); // Add creator as owner await pool.query( `INSERT INTO chat_members (chat_id, user_id, role, can_add_members, can_pin_messages) VALUES ($1,$2,'owner',TRUE,TRUE)`, [chat.id, userId] ); // Add other members if (Array.isArray(memberIds) && memberIds.length > 0) { for (const mid of memberIds) { if (mid === userId) continue; await pool.query( `INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`, [chat.id, mid] ); } } return reply.status(201).send({ id: chat.id }); }); // Upload chat avatar app.put('/:id/avatar', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; const { rows: [member] } = await pool.query( 'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId] ); if (!member || !['owner', 'admin'].includes(member.role)) { return reply.status(403).send({ error: 'No permission' }); } const data = await req.file(); if (!data) return reply.status(400).send({ error: 'No file' }); if (!data.mimetype.startsWith('image/')) return reply.status(400).send({ error: 'Only images allowed' }); const ext = path.extname(data.filename) || '.jpg'; const filename = `c_${id}_${Date.now()}${ext}`; const dir = '/uploads/avatars'; fs.mkdirSync(dir, { recursive: true }); const buffer = await data.toBuffer(); fs.writeFileSync(path.join(dir, filename), buffer); const url = `/uploads/avatars/${filename}`; await pool.query('UPDATE chats SET avatar = $1 WHERE id = $2', [url, id]); return { avatar: url }; }); // Delete chat avatar app.delete('/:id/avatar', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; const { rows: [member] } = await pool.query( 'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId] ); if (!member || !['owner', 'admin'].includes(member.role)) { return reply.status(403).send({ error: 'No permission' }); } await pool.query('UPDATE chats SET avatar = NULL WHERE id = $1', [id]); return { ok: true }; }); // Update chat app.put('/:id', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; const { title, description } = req.body as any; const { rows: [member] } = await pool.query( 'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId] ); if (!member || !['owner', 'admin'].includes(member.role)) { return reply.status(403).send({ error: 'No permission' }); } await pool.query( 'UPDATE chats SET title = COALESCE($1, title), description = COALESCE($2, description) WHERE id = $3', [title, description, id] ); return { ok: true }; }); // Add member app.post('/:id/members', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; const { memberId } = req.body as { memberId: string }; const { rows: [member] } = await pool.query( 'SELECT role, can_add_members FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId] ); if (!member || (!member.can_add_members && !['owner', 'admin'].includes(member.role))) { return reply.status(403).send({ error: 'No permission' }); } await pool.query( `INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`, [id, memberId] ); return { ok: true }; }); // Remove member app.delete('/:id/members/:memberId', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id, memberId } = req.params as { id: string; memberId: string }; const { rows: [member] } = await pool.query( 'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId] ); if (!member || (!['owner', 'admin'].includes(member.role) && userId !== memberId)) { return reply.status(403).send({ error: 'No permission' }); } await pool.query('DELETE FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, memberId]); return { ok: true }; }); // Update member role/permissions app.put('/:id/members/:memberId', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id, memberId } = req.params as { id: string; memberId: string }; const { role, canSendMessages } = req.body as any; const { rows: [member] } = await pool.query( 'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId] ); if (!member || !['owner', 'admin'].includes(member.role)) { return reply.status(403).send({ error: 'No permission' }); } await pool.query( `UPDATE chat_members SET role = COALESCE($1, role), can_send_messages = COALESCE($2, can_send_messages) WHERE chat_id = $3 AND user_id = $4`, [role, canSendMessages, id, memberId] ); return { ok: true }; }); // Pin/unpin chat for current user app.put('/:id/pin', async (req) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; const { pinned } = req.body as { pinned: boolean }; await pool.query('UPDATE chat_members SET is_pinned = $1 WHERE chat_id = $2 AND user_id = $3', [pinned, id, userId]); return { ok: true }; }); // Mute/unmute chat for current user app.put('/:id/mute', async (req) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; const { muted } = req.body as { muted: boolean }; await pool.query('UPDATE chat_members SET is_muted = $1 WHERE chat_id = $2 AND user_id = $3', [muted, id, userId]); return { ok: true }; }); // Leave chat app.delete('/:id/leave', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; await pool.query('DELETE FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]); return { ok: true }; }); // Delete chat (owner only) app.delete('/:id', async (req, reply) => { const { id: userId } = req.user as { id: string }; const { id } = req.params as { id: string }; const { rows: [member] } = await pool.query( 'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId] ); if (!member || member.role !== 'owner') { return reply.status(403).send({ error: 'No permission' }); } await pool.query('DELETE FROM chats WHERE id = $1', [id]); return { ok: true }; }); }