Files
rosterchirp-dev/backend/src/routes/groups.js

378 lines
21 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
catch (e) { console.warn('[Groups] Could not delete image:', e.message); }
}
// Schema-aware room name helper
const R = (schema, type, id) => `${schema}:${type}:${id}`;
module.exports = (io) => {
async function emitGroupNew(schema, io, groupId) {
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
if (!group) return;
if (group.type === 'public') {
io.to(R(schema, 'schema', 'all')).emit('group:new', { group });
} else {
const members = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
for (const m of members) io.to(R(schema, 'user', m.user_id)).emit('group:new', { group });
}
}
async function emitGroupUpdated(schema, io, groupId) {
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
if (!group) return;
let uids;
if (group.type === 'public') {
uids = await query(schema, "SELECT id AS user_id FROM users WHERE status='active'");
} else {
uids = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
}
for (const m of uids) io.to(R(schema, 'user', m.user_id)).emit('group:updated', { group });
}
// GET all groups for current user
router.get('/', authMiddleware, async (req, res) => {
try {
const userId = req.user.id;
const publicGroups = await query(req.schema, `
SELECT g.*,
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE 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=FALSE 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=FALSE 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
`);
const privateGroupsRaw = await query(req.schema, `
SELECT g.*, u.name AS owner_name,
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE 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=FALSE 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=FALSE 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=$1
LEFT JOIN users u ON g.owner_id=u.id WHERE g.type='private'
ORDER BY last_message_at DESC NULLS LAST
`, [userId]);
const privateGroups = await Promise.all(privateGroupsRaw.map(async g => {
if (g.is_direct) {
if (!g.direct_peer1_id || !g.direct_peer2_id) {
const peers = await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 2', [g.id]);
if (peers.length === 2) {
await exec(req.schema, 'UPDATE groups SET direct_peer1_id=$1, direct_peer2_id=$2 WHERE id=$3', [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 = await queryOne(req.schema, 'SELECT display_name, name, avatar FROM users WHERE id=$1', [otherUserId]);
if (other) {
g.peer_id = otherUserId; g.peer_real_name = other.name;
g.peer_display_name = other.display_name || null; g.peer_avatar = other.avatar || null;
g.name = other.display_name || other.name;
}
}
}
const custom = await queryOne(req.schema, 'SELECT name FROM user_group_names WHERE user_id=$1 AND group_id=$2', [userId, g.id]);
if (custom) { g.owner_name_original = g.name; g.name = custom.name; }
return g;
}));
res.json({ publicGroups, privateGroups });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST create group
router.post('/', authMiddleware, async (req, res) => {
const { name, type, memberIds, isReadonly, isDirect } = req.body;
try {
if (type === 'public' && req.user.role !== 'admin')
return res.status(403).json({ error: 'Only admins can create public groups' });
// Direct message
if (isDirect && memberIds?.length === 1) {
const otherUserId = memberIds[0], userId = req.user.id;
// U2U restriction check — admins always exempt
if (req.user.role !== 'admin') {
// Get all user groups the initiating user belongs to
const initiatorGroups = await query(req.schema,
'SELECT user_group_id FROM user_group_members WHERE user_id = $1', [userId]
);
const initiatorGroupIds = initiatorGroups.map(r => r.user_group_id);
// Get all user groups the target user belongs to
const targetGroups = await query(req.schema,
'SELECT user_group_id FROM user_group_members WHERE user_id = $1', [otherUserId]
);
const targetGroupIds = targetGroups.map(r => r.user_group_id);
// Least-restrictive-wins: the initiator needs at least ONE group
// that has no restriction against ALL of the target's groups.
// If initiatorGroups is empty, no restrictions apply (user not in any managed group).
if (initiatorGroupIds.length > 0 && targetGroupIds.length > 0) {
// For each initiator group, check if it is restricted from ANY of the target groups
let canDm = false;
for (const igId of initiatorGroupIds) {
const restrictions = await query(req.schema,
'SELECT blocked_group_id FROM user_group_dm_restrictions WHERE restricting_group_id = $1',
[igId]
);
const blockedIds = new Set(restrictions.map(r => r.blocked_group_id));
// This initiator group is unrestricted if none of the target's groups are blocked
const isRestricted = targetGroupIds.some(tgId => blockedIds.has(tgId));
if (!isRestricted) { canDm = true; break; }
}
if (!canDm) {
return res.status(403).json({
error: 'Direct messages with this user are not permitted.',
code: 'DM_RESTRICTED'
});
}
}
}
const existing = await queryOne(req.schema, `
SELECT g.id FROM groups g
JOIN group_members gm1 ON gm1.group_id=g.id AND gm1.user_id=$1
JOIN group_members gm2 ON gm2.group_id=g.id AND gm2.user_id=$2
WHERE g.is_direct=TRUE LIMIT 1
`, [userId, otherUserId]);
if (existing) {
await exec(req.schema, "UPDATE groups SET is_readonly=FALSE, owner_id=NULL, updated_at=NOW() WHERE id=$1", [existing.id]);
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [existing.id, userId]);
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [existing.id]) });
}
const otherUser = await queryOne(req.schema, 'SELECT name, display_name FROM users WHERE id=$1', [otherUserId]);
const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name);
const r = await queryResult(req.schema,
"INSERT INTO groups (name,type,owner_id,is_readonly,is_direct,direct_peer1_id,direct_peer2_id) VALUES ($1,'private',NULL,FALSE,TRUE,$2,$3) RETURNING id",
[dmName, userId, otherUserId]
);
const groupId = r.rows[0].id;
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]);
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]);
await emitGroupNew(req.schema, io, groupId);
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
}
// Check for duplicate private group
if ((type === 'private' || !type) && !isDirect && memberIds?.length > 0) {
const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a,b) => a-b);
const candidates = await query(req.schema,
'SELECT g.id FROM groups g JOIN group_members gm ON gm.group_id=g.id AND gm.user_id=$1 WHERE g.type=\'private\' AND g.is_direct=FALSE',
[req.user.id]
);
for (const c of candidates) {
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 ORDER BY user_id', [c.id])).map(r => r.user_id);
if (members.length === allMemberIds.length && members.every((id,i) => id === allMemberIds[i]))
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [c.id]), duplicate: true });
}
}
const r = await queryResult(req.schema,
'INSERT INTO groups (name,type,owner_id,is_readonly,is_direct) VALUES ($1,$2,$3,$4,FALSE) RETURNING id',
[name, type||'private', req.user.id, !!isReadonly]
);
const groupId = r.rows[0].id;
if (type === 'public') {
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
} else {
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, req.user.id]);
if (memberIds?.length > 0) {
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
for (const uid of memberIds) {
if (defaultAdmin && uid === defaultAdmin.id) continue;
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, uid]);
}
}
}
await emitGroupNew(req.schema, io, groupId);
res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// PATCH rename
router.patch('/:id/rename', authMiddleware, async (req, res) => {
const { name } = req.body;
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [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' });
await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name, group.id]);
await emitGroupUpdated(req.schema, io, group.id);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// GET members
router.get('/:id/members', authMiddleware, async (req, res) => {
try {
const members = await query(req.schema,
'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=$1 ORDER BY u.name ASC',
[req.params.id]
);
res.json({ members });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST add member
router.post('/:id/members', authMiddleware, async (req, res) => {
const { userId } = req.body;
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [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' });
const targetUser = await queryOne(req.schema, 'SELECT is_default_admin FROM users WHERE id=$1', [userId]);
if (targetUser?.is_default_admin) return res.status(400).json({ error: 'Default admin cannot be added to private groups' });
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [group.id, userId]);
const addedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
const mr = await queryResult(req.schema,
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
[group.id, userId, `${addedName} has joined the conversation.`]
);
const sysMsg = await queryOne(req.schema,
'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=$1',
[mr.rows[0].id]
);
sysMsg.reactions = [];
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg);
io.in(R(req.schema,'user',userId)).socketsJoin(R(req.schema,'group',group.id));
io.to(R(req.schema,'user',userId)).emit('group:new', { group });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// DELETE remove member
router.delete('/:id/members/:userId', authMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [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);
// Admins can remove the owner only if the owner is a deleted user (orphan cleanup)
const targetUser = await queryOne(req.schema, 'SELECT status FROM users WHERE id=$1', [targetId]);
const isDeletedOrphan = targetUser?.status === 'deleted';
if (targetId === group.owner_id && !isDeletedOrphan && req.user.role !== 'admin') {
return res.status(400).json({ error: 'Cannot remove the group owner' });
}
if (targetId === group.owner_id && !isDeletedOrphan) {
return res.status(400).json({ error: 'Cannot remove the group owner' });
}
const removedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [targetId]);
const removedName = removedUser?.display_name || removedUser?.name || 'Unknown';
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, targetId]);
const mr = await queryResult(req.schema,
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
[group.id, targetId, `${removedName} has been removed from the conversation.`]
);
const sysMsg = await queryOne(req.schema,
'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=$1',
[mr.rows[0].id]
);
sysMsg.reactions = [];
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg);
io.in(R(req.schema,'user',targetId)).socketsLeave(R(req.schema,'group',group.id));
io.to(R(req.schema,'user',targetId)).emit('group:deleted', { groupId: group.id });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// DELETE leave
router.delete('/:id/leave', authMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [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.' });
const userId = req.user.id;
const leaverName = req.user.display_name || req.user.name;
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, userId]);
const mr = await queryResult(req.schema,
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
[group.id, userId, `${leaverName} has left the conversation.`]
);
const sysMsg = await queryOne(req.schema,
'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=$1',
[mr.rows[0].id]
);
sysMsg.reactions = [];
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg);
io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',group.id));
io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: group.id });
if (group.is_direct) {
const remaining = await queryOne(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 1', [group.id]);
if (remaining) await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [remaining.user_id, group.id]);
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST take-ownership
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
if (group?.is_managed) return res.status(403).json({ error: 'Managed groups are administered via the Group Manager.' });
await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [req.user.id, req.params.id]);
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.params.id, req.user.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// DELETE group
router.delete('/:id', authMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [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' });
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [group.id])).map(m => m.user_id);
if (group.type === 'public') {
const all = await query(req.schema, "SELECT id FROM users WHERE status='active'");
for (const u of all) if (!members.includes(u.id)) members.push(u.id);
}
const imageMessages = await query(req.schema, 'SELECT image_url FROM messages WHERE group_id=$1 AND image_url IS NOT NULL', [group.id]);
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [group.id]);
for (const msg of imageMessages) deleteImageFile(msg.image_url);
for (const uid of members) io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: group.id });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// PATCH custom-name
router.patch('/:id/custom-name', authMiddleware, async (req, res) => {
const { name } = req.body;
const groupId = parseInt(req.params.id), userId = req.user.id;
try {
if (!name?.trim()) {
await exec(req.schema, 'DELETE FROM user_group_names WHERE user_id=$1 AND group_id=$2', [userId, groupId]);
return res.json({ success: true, name: null });
}
await exec(req.schema,
'INSERT INTO user_group_names (user_id,group_id,name) VALUES ($1,$2,$3) ON CONFLICT (user_id,group_id) DO UPDATE SET name=EXCLUDED.name',
[userId, groupId, name.trim()]
);
res.json({ success: true, name: name.trim() });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;
};