const express = require('express'); const fs = require('fs'); const router = express.Router(); const { getDb } = require('../models/db'); const { authMiddleware, adminMiddleware } = require('../middleware/auth'); // Helper: emit group:new to all members of a group function emitGroupNew(io, groupId) { const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId); if (!group) return; if (group.type === 'public') { io.emit('group:new', { group }); } else { const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId); for (const m of members) { io.to(`user:${m.user_id}`).emit('group:new', { group }); } } } // Delete an uploaded image file from disk function deleteImageFile(imageUrl) { if (!imageUrl) return; try { const filePath = '/app' + imageUrl; if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch (e) { console.warn('[Groups] Could not delete image file:', e.message); } } // Helper: emit group:deleted to all members function emitGroupDeleted(io, groupId, members) { for (const uid of members) { io.to(`user:${uid}`).emit('group:deleted', { groupId }); } } // Helper: emit group:updated to all members function emitGroupUpdated(io, groupId) { const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId); if (!group) return; const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId); const uids = group.type === 'public' ? db.prepare("SELECT id as user_id FROM users WHERE status = 'active'").all() : members; for (const m of uids) { io.to(`user:${m.user_id}`).emit('group:updated', { group }); } } // Inject io into routes module.exports = (io) => { // Get all groups for current user router.get('/', authMiddleware, (req, res) => { const db = getDb(); const userId = req.user.id; const publicGroups = db.prepare(` SELECT g.*, (SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count, (SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message, (SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at, (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id FROM groups g WHERE g.type = 'public' ORDER BY g.is_default DESC, g.name ASC `).all(); // For direct messages, replace name with opposite user's display name const privateGroupsRaw = db.prepare(` SELECT g.*, u.name as owner_name, (SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count, (SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message, (SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at, (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id FROM groups g JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ? LEFT JOIN users u ON g.owner_id = u.id WHERE g.type = 'private' ORDER BY last_message_at DESC NULLS LAST `).all(userId); // For direct groups, set the name to the other user's display name // Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves const privateGroups = privateGroupsRaw.map(g => { if (g.is_direct) { // Backfill peer IDs for groups created before this migration if (!g.direct_peer1_id || !g.direct_peer2_id) { const peers = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 2').all(g.id); if (peers.length === 2) { db.prepare('UPDATE groups SET direct_peer1_id = ?, direct_peer2_id = ? WHERE id = ?') .run(peers[0].user_id, peers[1].user_id, g.id); g.direct_peer1_id = peers[0].user_id; g.direct_peer2_id = peers[1].user_id; } } const otherUserId = g.direct_peer1_id === userId ? g.direct_peer2_id : g.direct_peer1_id; if (otherUserId) { const other = db.prepare('SELECT display_name, name, avatar FROM users WHERE id = ?').get(otherUserId); if (other) { g.peer_id = otherUserId; g.peer_real_name = other.name; g.peer_display_name = other.display_name || null; // null if no custom display name set g.peer_avatar = other.avatar || null; g.name = other.display_name || other.name; } } } // Apply user's custom group name if set const custom = db.prepare('SELECT name FROM user_group_names WHERE user_id = ? AND group_id = ?').get(userId, g.id); if (custom) { g.owner_name_original = g.name; // original name shown in brackets in GroupInfoModal g.name = custom.name; } return g; }); res.json({ publicGroups, privateGroups }); }); // Create group router.post('/', authMiddleware, (req, res) => { const { name, type, memberIds, isReadonly, isDirect } = req.body; const db = getDb(); if (type === 'public' && req.user.role !== 'admin') { return res.status(403).json({ error: 'Only admins can create public groups' }); } // Direct message: find or create if (isDirect && memberIds && memberIds.length === 1) { const otherUserId = memberIds[0]; const userId = req.user.id; // Check if a direct group already exists between these two users const existing = db.prepare(` SELECT g.id FROM groups g JOIN group_members gm1 ON gm1.group_id = g.id AND gm1.user_id = ? JOIN group_members gm2 ON gm2.group_id = g.id AND gm2.user_id = ? WHERE g.is_direct = 1 LIMIT 1 `).get(userId, otherUserId); if (existing) { const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id); // Ensure current user is still a member (may have left) db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(existing.id, userId); // Re-set readonly to false so both can post again db.prepare("UPDATE groups SET is_readonly = 0, owner_id = NULL, updated_at = datetime('now') WHERE id = ?").run(existing.id); return res.json({ group: db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id) }); } // Get other user's display name for the group name (stored internally, overridden per-user on fetch) const otherUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(otherUserId); const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name); const result = db.prepare(` INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, direct_peer1_id, direct_peer2_id) VALUES (?, 'private', NULL, 0, 1, ?, ?) `).run(dmName, userId, otherUserId); const groupId = result.lastInsertRowid; db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, userId); db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, otherUserId); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId); // Notify both users via socket emitGroupNew(io, groupId); return res.json({ group }); } // For private groups: check if exact same set of members already exists in a group if ((type === 'private' || !type) && !isDirect && memberIds && memberIds.length > 0) { const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a, b) => a - b); const count = allMemberIds.length; // Find all private non-direct groups where the creator is a member const candidates = db.prepare(` SELECT g.id FROM groups g JOIN group_members gm ON gm.group_id = g.id AND gm.user_id = ? WHERE g.type = 'private' AND g.is_direct = 0 `).all(req.user.id); for (const candidate of candidates) { const members = db.prepare( 'SELECT user_id FROM group_members WHERE group_id = ? ORDER BY user_id' ).all(candidate.id).map(r => r.user_id); if (members.length === count && members.every((id, i) => id === allMemberIds[i])) { // Exact duplicate found — return the existing group const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(candidate.id); return res.json({ group, duplicate: true }); } } } const result = db.prepare(` INSERT INTO groups (name, type, owner_id, is_readonly, is_direct) VALUES (?, ?, ?, ?, 0) `).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0); const groupId = result.lastInsertRowid; if (type === 'public') { const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all(); const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); for (const u of allUsers) insert.run(groupId, u.id); } else { db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id); if (memberIds && memberIds.length > 0) { const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); for (const uid of memberIds) insert.run(groupId, uid); } } const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId); // Notify all members via socket emitGroupNew(io, groupId); res.json({ group }); }); // Rename group router.patch('/:id/rename', authMiddleware, (req, res) => { const { name } = req.body; const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); if (!group) return res.status(404).json({ error: 'Group not found' }); if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' }); if (group.is_direct) return res.status(403).json({ error: 'Cannot rename a direct message' }); if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' }); if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'Only owner can rename private group' }); } db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id); emitGroupUpdated(io, group.id); res.json({ success: true }); }); // Get group members router.get('/:id/members', authMiddleware, (req, res) => { const db = getDb(); const members = db.prepare(` SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status FROM group_members gm JOIN users u ON gm.user_id = u.id WHERE gm.group_id = ? ORDER BY u.name ASC `).all(req.params.id); res.json({ members }); }); // Add member to private group router.post('/:id/members', authMiddleware, (req, res) => { const { userId } = req.body; const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); if (!group) return res.status(404).json({ error: 'Group not found' }); if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' }); if (group.is_direct) return res.status(400).json({ error: 'Cannot add members to a direct message' }); if (group.owner_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'Only owner can add members' }); } db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId); // Post a system message so all members see who was added const addedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId); const addedName = addedUser?.display_name || addedUser?.name || 'Unknown'; const sysResult = db.prepare(` INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'system') `).run(group.id, userId, `${addedName} has joined the conversation.`); const sysMsg = db.prepare(` SELECT m.*, u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ? `).get(sysResult.lastInsertRowid); sysMsg.reactions = []; io.to(`group:${group.id}`).emit('message:new', sysMsg); // Join all of the added user's active sockets to the group room server-side, // so they receive messages immediately without needing a client round-trip io.in(`user:${userId}`).socketsJoin(`group:${group.id}`); // Notify the added user in real-time so their sidebar updates without a refresh io.to(`user:${userId}`).emit('group:new', { group }); res.json({ success: true }); }); // Remove a member from a private group router.delete('/:id/members/:userId', authMiddleware, (req, res) => { const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); if (!group) return res.status(404).json({ error: 'Group not found' }); if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' }); if (group.owner_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'Only owner or admin can remove members' }); } const targetId = parseInt(req.params.userId); if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' }); const removedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(targetId); const removedName = removedUser?.display_name || removedUser?.name || 'Unknown'; db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId); // Post system message so remaining members see the removal notice const sysResult = db.prepare(` INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'system') `).run(group.id, targetId, `${removedName} has been removed from the conversation.`); const sysMsg = db.prepare(` SELECT m.*, u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ? `).get(sysResult.lastInsertRowid); sysMsg.reactions = []; io.to(`group:${group.id}`).emit('message:new', sysMsg); // Remove the user from the socket room and update their sidebar io.in(`user:${targetId}`).socketsLeave(`group:${group.id}`); io.to(`user:${targetId}`).emit('group:deleted', { groupId: group.id }); res.json({ success: true }); }); // Leave private group router.delete('/:id/leave', authMiddleware, (req, res) => { const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); if (!group) return res.status(404).json({ error: 'Group not found' }); if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' }); if (group.is_managed && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is managed by an administrator. Contact an admin to be removed.' }); const userId = req.user.id; const leaverName = req.user.display_name || req.user.name; db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, userId); // Post a system message so remaining members see the leave notice const sysResult = db.prepare(` INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'system') `).run(group.id, userId, `${leaverName} has left the conversation.`); const sysMsg = db.prepare(` SELECT m.*, u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ? `).get(sysResult.lastInsertRowid); sysMsg.reactions = []; // Broadcast to remaining members in the group room io.to(`group:${group.id}`).emit('message:new', sysMsg); // Always remove leaver from socket room and their sidebar io.in(`user:${userId}`).socketsLeave(`group:${group.id}`); io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id }); if (group.is_direct) { // Make remaining user owner so they can still manage the conversation const remaining = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 1').get(group.id); if (remaining) { db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?") .run(remaining.user_id, group.id); } } res.json({ success: true }); }); // Admin take ownership router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); if (group?.is_managed) return res.status(403).json({ error: 'Managed groups are administered via the Group Manager.' }); db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id); db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(req.params.id, req.user.id); res.json({ success: true }); }); // Delete group router.delete('/:id', authMiddleware, (req, res) => { const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); if (!group) return res.status(404).json({ error: 'Group not found' }); if (group.is_default) return res.status(403).json({ error: 'Cannot delete default group' }); if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can delete public groups' }); if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'Only owner or admin can delete private groups' }); } // Collect members before deleting const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(group.id).map(m => m.user_id); // Add all active users for public groups if (group.type === 'public') { const all = db.prepare("SELECT id FROM users WHERE status = 'active'").all(); all.forEach(u => { if (!members.includes(u.id)) members.push(u.id); }); } // Collect all image files for this group before deleting const imageMessages = db.prepare("SELECT image_url FROM messages WHERE group_id = ? AND image_url IS NOT NULL").all(group.id); db.prepare('DELETE FROM groups WHERE id = ?').run(group.id); // Delete image files from disk after DB delete for (const msg of imageMessages) deleteImageFile(msg.image_url); // Notify all affected users emitGroupDeleted(io, group.id, members); res.json({ success: true }); }); // Set or update user's custom name for a group router.patch('/:id/custom-name', authMiddleware, (req, res) => { const db = getDb(); const groupId = parseInt(req.params.id); const userId = req.user.id; const { name } = req.body; if (!name || !name.trim()) { // Empty name = remove custom name (revert to owner name) db.prepare('DELETE FROM user_group_names WHERE user_id = ? AND group_id = ?').run(userId, groupId); return res.json({ success: true, name: null }); } db.prepare(` INSERT INTO user_group_names (user_id, group_id, name) VALUES (?, ?, ?) ON CONFLICT(user_id, group_id) DO UPDATE SET name = excluded.name `).run(userId, groupId, name.trim()); res.json({ success: true, name: name.trim() }); }); return router; };