Files
rosterchirp-dev/backend/src/routes/groups.js
2026-03-16 10:48:52 -04:00

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;
};