450 lines
20 KiB
JavaScript
450 lines
20 KiB
JavaScript
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;
|
|
}; |