diff --git a/.env.example b/.env.example index 0921f2d..5806dc4 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ PROJECT_NAME=jama # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.9.26 +JAMA_VERSION=0.9.27 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index 41fafc0..5110b08 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.26", + "version": "0.9.27", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 6bc1116..0262491 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -163,7 +163,7 @@ function initDb() { CREATE TABLE IF NOT EXISTS user_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, - dm_group_id INTEGER, -- paired private group in groups table + dm_group_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL @@ -173,10 +173,31 @@ function initDb() { CREATE TABLE IF NOT EXISTS user_group_members ( user_group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, + joined_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_group_id, user_id), FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + + -- Multi-group DMs: admin-created DMs whose members are user groups + CREATE TABLE IF NOT EXISTS multi_group_dms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + dm_group_id INTEGER, -- paired private group in groups table + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL + ); + + -- User groups that are members of a multi-group DM + CREATE TABLE IF NOT EXISTS multi_group_dm_members ( + multi_group_dm_id INTEGER NOT NULL, + user_group_id INTEGER NOT NULL, + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (multi_group_dm_id, user_group_id), + FOREIGN KEY (multi_group_dm_id) REFERENCES multi_group_dms(id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE + ); `); // Initialize default settings @@ -306,11 +327,34 @@ function initDb() { CREATE TABLE IF NOT EXISTS user_group_members ( user_group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, + joined_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_group_id, user_id), FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); + db.exec(` + CREATE TABLE IF NOT EXISTS multi_group_dms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + dm_group_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS multi_group_dm_members ( + multi_group_dm_id INTEGER NOT NULL, + user_group_id INTEGER NOT NULL, + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (multi_group_dm_id, user_group_id), + FOREIGN KEY (multi_group_dm_id) REFERENCES multi_group_dms(id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE + ) + `); + // Migration: add joined_at to user_group_members if missing + try { db.exec("ALTER TABLE user_group_members ADD COLUMN joined_at TEXT NOT NULL DEFAULT (datetime('now'))"); } catch(e) {} console.log('[DB] Migration: user_groups tables ready'); } catch (e) { console.error('[DB] user_groups migration error:', e.message); } diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js index 4b7fb96..4b7f9fe 100644 --- a/backend/src/routes/messages.js +++ b/backend/src/routes/messages.js @@ -55,6 +55,17 @@ router.get('/group/:groupId', authMiddleware, (req, res) => { if (!group) return res.status(403).json({ error: 'Access denied' }); const { before, limit = 50 } = req.query; + + // For managed groups: find when this user joined so we can hide older messages + let joinedAt = null; + if (group.is_managed) { + const membership = db.prepare('SELECT joined_at FROM group_members WHERE group_id = ? AND user_id = ?').get(group.id, req.user.id); + if (membership?.joined_at) { + // Strip time — they can see messages from the start of the day they joined + joinedAt = membership.joined_at.slice(0, 10); // 'YYYY-MM-DD' + } + } + let query = ` 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, u.allow_dm as user_allow_dm, @@ -69,6 +80,12 @@ router.get('/group/:groupId', authMiddleware, (req, res) => { `; const params = [req.params.groupId]; + // Enforce join-date visibility for managed groups + if (joinedAt) { + query += ` AND date(m.created_at) >= ?`; + params.push(joinedAt); + } + if (before) { query += ' AND m.id < ?'; params.push(before); diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js index efbd2de..d7ede63 100644 --- a/backend/src/routes/usergroups.js +++ b/backend/src/routes/usergroups.js @@ -19,7 +19,7 @@ function postSysMsg(db, groupId, userId, content) { } function addUserToDmGroup(db, dmGroupId, userId, actorId) { - db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(dmGroupId, userId); + db.prepare("INSERT OR IGNORE INTO group_members (group_id, user_id, joined_at) VALUES (?, ?, datetime('now'))").run(dmGroupId, userId); io.in(`user:${userId}`).socketsJoin(`group:${dmGroupId}`); const dmGroup = db.prepare('SELECT * FROM groups WHERE id = ?').get(dmGroupId); io.to(`user:${userId}`).emit('group:new', { group: dmGroup }); @@ -35,114 +35,111 @@ function removeUserFromDmGroup(db, dmGroupId, userId, actorId) { postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has been removed from the conversation.`); } -// ── List all user groups ─────────────────────────────────────────────────────── +// Get all user_ids for a user group +function getUserIdsForGroup(db, userGroupId) { + return db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(userGroupId).map(r => r.user_id); +} + +// ── USER GROUPS ─────────────────────────────────────────────────────────────── router.get('/', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const groups = db.prepare(` - SELECT ug.*, g.name as dm_name, + SELECT ug.*, (SELECT COUNT(*) FROM user_group_members WHERE user_group_id = ug.id) as member_count - FROM user_groups ug - LEFT JOIN groups g ON g.id = ug.dm_group_id - ORDER BY ug.name ASC + FROM user_groups ug ORDER BY ug.name ASC `).all(); res.json({ groups }); }); -// ── Get single user group with members ──────────────────────────────────────── - router.get('/:id', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id); if (!group) return res.status(404).json({ error: 'Not found' }); const members = db.prepare(` SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status - FROM user_group_members ugm - JOIN users u ON u.id = ugm.user_id - WHERE ugm.user_group_id = ? - ORDER BY u.name ASC + FROM user_group_members ugm JOIN users u ON u.id = ugm.user_id + WHERE ugm.user_group_id = ? ORDER BY u.name ASC `).all(req.params.id); res.json({ group, members }); }); -// ── Create user group ───────────────────────────────────────────────────────── - router.post('/', authMiddleware, adminMiddleware, (req, res) => { const { name, memberIds = [] } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); const db = getDb(); - - // Check unique name if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?)').get(name.trim())) { return res.status(400).json({ error: 'A group with that name already exists' }); } - - // Create the paired managed DM group in groups table const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get(); const dmResult = db.prepare(` INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, is_managed) VALUES (?, 'private', ?, 0, 0, 1) `).run(name.trim(), admin?.id || req.user.id); const dmGroupId = dmResult.lastInsertRowid; - - // Create the user group - const ugResult = db.prepare(` - INSERT INTO user_groups (name, dm_group_id) VALUES (?, ?) - `).run(name.trim(), dmGroupId); + const ugResult = db.prepare(`INSERT INTO user_groups (name, dm_group_id) VALUES (?, ?)`).run(name.trim(), dmGroupId); const ugId = ugResult.lastInsertRowid; - // Add members to both const validIds = Array.isArray(memberIds) ? memberIds.map(Number).filter(Boolean) : []; - const addMember = db.prepare('INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)'); for (const uid of validIds) { - addMember.run(ugId, uid); - db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(dmGroupId, uid); - io.in(`user:${uid}`).socketsJoin(`group:${dmGroupId}`); - const dmGroup = db.prepare('SELECT * FROM groups WHERE id = ?').get(dmGroupId); - io.to(`user:${uid}`).emit('group:new', { group: dmGroup }); + db.prepare("INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)").run(ugId, uid); + addUserToDmGroup(db, dmGroupId, uid, req.user.id); } - const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(ugId); res.json({ group }); }); -// ── Update user group (name + members) ──────────────────────────────────────── - router.patch('/:id', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id); if (!ug) return res.status(404).json({ error: 'Not found' }); - const { name, memberIds } = req.body; - // Rename if (name && name.trim() !== ug.name) { - const conflict = db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), ug.id); - if (conflict) return res.status(400).json({ error: 'Name already in use' }); + if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), ug.id)) { + return res.status(400).json({ error: 'Name already in use' }); + } db.prepare("UPDATE user_groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), ug.id); if (ug.dm_group_id) { db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), ug.dm_group_id); } } - // Sync members if (Array.isArray(memberIds) && ug.dm_group_id) { const newIds = new Set(memberIds.map(Number).filter(Boolean)); const current = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(ug.id).map(r => r.user_id); const currentSet = new Set(current); - - // Add new members for (const uid of newIds) { if (!currentSet.has(uid)) { - db.prepare('INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)').run(ug.id, uid); + db.prepare("INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)").run(ug.id, uid); addUserToDmGroup(db, ug.dm_group_id, uid, req.user.id); + // Also add to any multi-group DMs that include this user group + const mgDms = db.prepare('SELECT mgd.dm_group_id FROM multi_group_dm_members mgdm JOIN multi_group_dms mgd ON mgd.id = mgdm.multi_group_dm_id WHERE mgdm.user_group_id = ?').all(ug.id); + for (const mg of mgDms) { + if (mg.dm_group_id) addUserToDmGroup(db, mg.dm_group_id, uid, req.user.id); + } } } - // Remove dropped members for (const uid of currentSet) { if (!newIds.has(uid)) { db.prepare('DELETE FROM user_group_members WHERE user_group_id = ? AND user_id = ?').run(ug.id, uid); - removeUserFromDmGroup(db, ug.dm_group_id, uid, req.user.id); + // Only remove from DM group if user isn't in another user group that also has access + const otherUgMemberships = db.prepare(` + SELECT ugm.user_group_id FROM user_group_members ugm + WHERE ugm.user_id = ? AND ugm.user_group_id != ? + AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = ? AND gm.user_id = ?) + `).all(uid, ug.id, ug.dm_group_id, uid); + if (otherUgMemberships.length === 0) { + removeUserFromDmGroup(db, ug.dm_group_id, uid, req.user.id); + // Remove from multi-group DMs they got access through this group + const mgDms = db.prepare('SELECT mgd.dm_group_id FROM multi_group_dm_members mgdm JOIN multi_group_dms mgd ON mgd.id = mgdm.multi_group_dm_id WHERE mgdm.user_group_id = ?').all(ug.id); + for (const mg of mgDms) { + if (mg.dm_group_id) { + const stillMember = db.prepare('SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?').get(mg.dm_group_id, uid); + if (stillMember) removeUserFromDmGroup(db, mg.dm_group_id, uid, req.user.id); + } + } + } } } } @@ -151,14 +148,10 @@ router.patch('/:id', authMiddleware, adminMiddleware, (req, res) => { res.json({ group: updated }); }); -// ── Delete user group ───────────────────────────────────────────────────────── - router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id); if (!ug) return res.status(404).json({ error: 'Not found' }); - - // Notify all DM group members before deleting if (ug.dm_group_id) { const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(ug.dm_group_id).map(r => r.user_id); db.prepare('DELETE FROM groups WHERE id = ?').run(ug.dm_group_id); @@ -168,5 +161,118 @@ router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => { res.json({ success: true }); }); +// ── MULTI-GROUP DMs ─────────────────────────────────────────────────────────── + +router.get('/multigroup', authMiddleware, adminMiddleware, (req, res) => { + const db = getDb(); + const dms = db.prepare(` + SELECT mgd.*, + (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id = mgd.id) as group_count + FROM multi_group_dms mgd ORDER BY mgd.name ASC + `).all(); + // Attach member user group IDs + for (const dm of dms) { + dm.memberGroupIds = db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(dm.id).map(r => r.user_group_id); + } + res.json({ dms }); +}); + +router.post('/multigroup', authMiddleware, adminMiddleware, (req, res) => { + const { name, userGroupIds = [] } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); + if (userGroupIds.length < 2) return res.status(400).json({ error: 'At least two user groups required' }); + const db = getDb(); + if (db.prepare('SELECT id FROM multi_group_dms WHERE LOWER(name) = LOWER(?)').get(name.trim())) { + return res.status(400).json({ error: 'Name already in use' }); + } + const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get(); + const dmResult = db.prepare(`INSERT INTO groups (name, type, owner_id, is_managed) VALUES (?, 'private', ?, 1)`).run(name.trim(), admin?.id || req.user.id); + const dmGroupId = dmResult.lastInsertRowid; + const mgResult = db.prepare(`INSERT INTO multi_group_dms (name, dm_group_id) VALUES (?, ?)`).run(name.trim(), dmGroupId); + const mgId = mgResult.lastInsertRowid; + + const validGroupIds = userGroupIds.map(Number).filter(Boolean); + const addedUsers = new Set(); + for (const ugId of validGroupIds) { + db.prepare('INSERT OR IGNORE INTO multi_group_dm_members (multi_group_dm_id, user_group_id) VALUES (?, ?)').run(mgId, ugId); + const uids = getUserIdsForGroup(db, ugId); + for (const uid of uids) { + if (!addedUsers.has(uid)) { + addedUsers.add(uid); + addUserToDmGroup(db, dmGroupId, uid, req.user.id); + } + } + const ug = db.prepare('SELECT name FROM user_groups WHERE id = ?').get(ugId); + if (ug) postSysMsg(db, dmGroupId, req.user.id, `Group "${ug.name}" has been added to this conversation.`); + } + + const dm = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(mgId); + dm.memberGroupIds = validGroupIds; + res.json({ dm }); +}); + +router.patch('/multigroup/:id', authMiddleware, adminMiddleware, (req, res) => { + const db = getDb(); + const mg = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id); + if (!mg) return res.status(404).json({ error: 'Not found' }); + const { name, userGroupIds } = req.body; + + if (name && name.trim() !== mg.name) { + if (db.prepare('SELECT id FROM multi_group_dms WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), mg.id)) { + return res.status(400).json({ error: 'Name already in use' }); + } + db.prepare("UPDATE multi_group_dms SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), mg.id); + if (mg.dm_group_id) { + db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), mg.dm_group_id); + } + } + + if (Array.isArray(userGroupIds) && mg.dm_group_id) { + const newGroupIds = new Set(userGroupIds.map(Number).filter(Boolean)); + const currentGroupIds = new Set(db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(mg.id).map(r => r.user_group_id)); + + // Add new user groups + for (const ugId of newGroupIds) { + if (!currentGroupIds.has(ugId)) { + db.prepare("INSERT OR IGNORE INTO multi_group_dm_members (multi_group_dm_id, user_group_id) VALUES (?, ?)").run(mg.id, ugId); + const uids = getUserIdsForGroup(db, ugId); + for (const uid of uids) addUserToDmGroup(db, mg.dm_group_id, uid, req.user.id); + const ug = db.prepare('SELECT name FROM user_groups WHERE id = ?').get(ugId); + if (ug) postSysMsg(db, mg.dm_group_id, req.user.id, `Group "${ug.name}" has been added to this conversation.`); + } + } + // Remove dropped user groups + for (const ugId of currentGroupIds) { + if (!newGroupIds.has(ugId)) { + db.prepare('DELETE FROM multi_group_dm_members WHERE multi_group_dm_id = ? AND user_group_id = ?').run(mg.id, ugId); + const uids = getUserIdsForGroup(db, ugId); + for (const uid of uids) { + const stillInOtherGroup = db.prepare('SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id = mgdm.user_group_id WHERE mgdm.multi_group_dm_id = ? AND ugm.user_id = ?').get(mg.id, uid); + if (!stillInOtherGroup) removeUserFromDmGroup(db, mg.dm_group_id, uid, req.user.id); + } + const ug = db.prepare('SELECT name FROM user_groups WHERE id = ?').get(ugId); + if (ug) postSysMsg(db, mg.dm_group_id, req.user.id, `Group "${ug.name}" has been removed from this conversation.`); + } + } + } + + const updated = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id); + updated.memberGroupIds = db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(mg.id).map(r => r.user_group_id); + res.json({ dm: updated }); +}); + +router.delete('/multigroup/:id', authMiddleware, adminMiddleware, (req, res) => { + const db = getDb(); + const mg = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id); + if (!mg) return res.status(404).json({ error: 'Not found' }); + if (mg.dm_group_id) { + const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(mg.dm_group_id).map(r => r.user_id); + db.prepare('DELETE FROM groups WHERE id = ?').run(mg.dm_group_id); + for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id }); + } + db.prepare('DELETE FROM multi_group_dms WHERE id = ?').run(mg.id); + res.json({ success: true }); +}); + return router; }; diff --git a/build.sh b/build.sh index 7eb7410..e497137 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.26}" +VERSION="${1:-0.9.27}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index fdd0048..710424e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.26", + "version": "0.9.27", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/GlobalBar.jsx b/frontend/src/components/GlobalBar.jsx index e4e8b2d..f315c09 100644 --- a/frontend/src/components/GlobalBar.jsx +++ b/frontend/src/components/GlobalBar.jsx @@ -29,28 +29,28 @@ export default function GlobalBar({ isMobile, showSidebar, onBurger }) { return (
- {/* Burger menu button */} - - -
- {appName} - {appName} + {/* Left side: burger + logo + title grouped together */} +
+ +
+ {appName} + {appName} +
{!connected && ( diff --git a/frontend/src/components/GroupManagerModal.jsx b/frontend/src/components/GroupManagerModal.jsx index 2d08e97..3e346ca 100644 --- a/frontend/src/components/GroupManagerModal.jsx +++ b/frontend/src/components/GroupManagerModal.jsx @@ -1,21 +1,21 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; import Avatar from './Avatar.jsx'; -// ── Shared user list checkbox ───────────────────────────────────────────────── +// ── Shared user checkbox list ───────────────────────────────────────────────── function UserCheckList({ allUsers, selectedIds, onChange }) { const [search, setSearch] = useState(''); const filtered = allUsers.filter(u => - (u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) || - u.email?.toLowerCase().includes(search.toLowerCase()) + (u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ); return (
- setSearch(e.target.value)} style={{ marginBottom: 8 }} /> -
+ setSearch(e.target.value)} style={{ marginBottom: 8 }} /> +
{filtered.map(u => ( -
); } +// ── User Group checkbox list ────────────────────────────────────────────────── +function GroupCheckList({ allGroups, selectedIds, onChange }) { + return ( +
+ {allGroups.map(g => ( + + ))} + {allGroups.length === 0 &&
No user groups yet
} +
+ ); +} + // ── All Groups tab ──────────────────────────────────────────────────────────── function AllGroupsTab({ allUsers, onRefresh }) { const toast = useToast(); const [groups, setGroups] = useState([]); - const [selected, setSelected] = useState(null); // selected user group - const [members, setMembers] = useState(new Set()); + const [selected, setSelected] = useState(null); + const [savedMembers, setSavedMembers] = useState(new Set()); // members as last saved + const [members, setMembers] = useState(new Set()); // current checkbox state const [editName, setEditName] = useState(''); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [showDelete, setShowDelete] = useState(false); - const load = () => api.getUserGroups().then(({ groups }) => setGroups(groups)).catch(() => {}); - useEffect(() => { load(); }, []); + const load = useCallback(() => + api.getUserGroups().then(({ groups }) => setGroups(groups)).catch(() => {}), []); + useEffect(() => { load(); }, [load]); const selectGroup = async (g) => { - const { group, members: mems } = await api.getUserGroup(g.id); - setSelected(group); - setEditName(group.name); - setMembers(new Set(mems.map(m => m.id))); + setShowDelete(false); + const { members: mems } = await api.getUserGroup(g.id); + const ids = new Set(mems.map(m => m.id)); + setSelected(g); + setEditName(g.name); + setMembers(ids); + setSavedMembers(ids); + }; + + const clearSelection = () => { + setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set()); setShowDelete(false); }; const handleSave = async () => { if (!editName.trim()) return toast('Name required', 'error'); setSaving(true); try { - await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members] }); - toast('Group updated', 'success'); - load(); - setSelected(prev => ({ ...prev, name: editName.trim() })); - onRefresh(); + if (selected) { + await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members] }); + toast('Group updated', 'success'); + // Refresh saved state + const { members: fresh } = await api.getUserGroup(selected.id); + const freshIds = new Set(fresh.map(m => m.id)); + setSavedMembers(freshIds); + setMembers(freshIds); + setSelected(prev => ({ ...prev, name: editName.trim() })); + } else { + await api.createUserGroup({ name: editName.trim(), memberIds: [...members] }); + toast(`Group "${editName.trim()}" created`, 'success'); + clearSelection(); + } + load(); onRefresh(); } catch (e) { toast(e.message, 'error'); } finally { setSaving(false); } }; @@ -71,188 +110,218 @@ function AllGroupsTab({ allUsers, onRefresh }) { try { await api.deleteUserGroup(selected.id); toast('Group deleted', 'success'); - setSelected(null); setShowDelete(false); - load(); onRefresh(); + clearSelection(); load(); onRefresh(); } catch (e) { toast(e.message, 'error'); } finally { setDeleting(false); } }; + // Delete only enabled when group selected AND no saved members remain + const canDelete = selected && savedMembers.size === 0; + const isCreating = !selected; + return ( -
+
{/* Group list */} -
-
User Groups
-
- {groups.map(g => ( - - ))} - {groups.length === 0 &&
No groups yet
} -
+
+
User Groups
+ + {groups.map(g => ( + + ))} + {groups.length === 0 &&
No groups yet
}
- {/* Edit panel */} + {/* Form panel */}
- {!selected ? ( -
Select a group to edit
- ) : ( -
-
- - setEditName(e.target.value)} style={{ marginTop: 6 }} /> +
+
+ + setEditName(e.target.value)} + placeholder="e.g. Coaches" style={{ marginTop: 6 }} /> + {isCreating &&

A matching Direct Message group will be created automatically.

} +
+
+ +
+
-
- -
- -
-
-
- - - -
- {showDelete && ( -
-

Delete {selected.name}? This also deletes the associated direct message group. This cannot be undone.

-
- - -
-
+

{members.size} selected

+
+
+ + {!isCreating && } + {!isCreating && ( + )}
- )} -
-
- ); -} - -// ── Create Group tab ────────────────────────────────────────────────────────── -function CreateGroupTab({ allUsers, onRefresh }) { - const toast = useToast(); - const [name, setName] = useState(''); - const [members, setMembers] = useState(new Set()); - const [saving, setSaving] = useState(false); - - const handleCreate = async () => { - if (!name.trim()) return toast('Group name required', 'error'); - setSaving(true); - try { - await api.createUserGroup({ name: name.trim(), memberIds: [...members] }); - toast(`Group "${name.trim()}" created`, 'success'); - setName(''); setMembers(new Set()); - onRefresh(); - } catch (e) { toast(e.message, 'error'); } - finally { setSaving(false); } - }; - - return ( -
-
- - setName(e.target.value)} placeholder="e.g. Coaches" style={{ marginTop: 6 }} onKeyDown={e => e.key === 'Enter' && handleCreate()} /> -

A matching Direct Message group will be created automatically with the same name.

-
-
- -
- + {showDelete && ( +
+

Delete {selected?.name}? This also deletes the associated direct message group. Cannot be undone.

+
+ + +
+
+ )}
-

{members.size} user{members.size !== 1 ? 's' : ''} selected

-
-
- -
); } // ── Direct Messages tab ─────────────────────────────────────────────────────── -function DirectMessagesTab({ allUsers }) { +function DirectMessagesTab({ allUserGroups, onRefresh }) { const toast = useToast(); - const [groups, setGroups] = useState([]); // user groups - const [dmGroups, setDmGroups] = useState([]); // managed DM groups - const [selectedDm, setSelectedDm] = useState(null); + const [dms, setDms] = useState([]); + const [selected, setSelected] = useState(null); + const [savedGroupIds, setSavedGroupIds] = useState(new Set()); + const [groupIds, setGroupIds] = useState(new Set()); const [dmName, setDmName] = useState(''); - const [dmGroupMembers, setDmGroupMembers] = useState(new Set()); // selected user group IDs const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [showDelete, setShowDelete] = useState(false); - const load = async () => { - const [ugRes] = await Promise.all([api.getUserGroups()]); - setGroups(ugRes.groups || []); - // Also need managed DM groups — these are the paired DMs (from groups table, is_managed=1, is_direct=0) - // We get them via the user groups list (each has dm_group_id) - setDmGroups(ugRes.groups || []); + const load = useCallback(() => + api.getMultiGroupDms().then(({ dms }) => setDms(dms)).catch(() => {}), []); + useEffect(() => { load(); }, [load]); + + const clearSelection = () => { + setSelected(null); setDmName(''); setGroupIds(new Set()); setSavedGroupIds(new Set()); setShowDelete(false); }; - useEffect(() => { load(); }, []); - const selectDm = (g) => { - setSelectedDm(g); - setDmName(g.name); - // Pre-select this group (since each managed DM maps 1:1 to a user group) - setDmGroupMembers(new Set([g.id])); + const selectDm = (dm) => { + setShowDelete(false); + setSelected(dm); + setDmName(dm.name); + const ids = new Set(dm.memberGroupIds || []); + setGroupIds(ids); + setSavedGroupIds(ids); }; const handleSave = async () => { if (!dmName.trim()) return toast('Name required', 'error'); - if (!selectedDm) return; - saving || setSaving(true); + if (groupIds.size < 2) return toast('Select at least two user groups', 'error'); + setSaving(true); try { - await api.updateUserGroup(selectedDm.id, { name: dmName.trim() }); - toast('Direct message group renamed', 'success'); - load(); - setSelectedDm(null); setDmName(''); setDmGroupMembers(new Set()); + if (selected) { + await api.updateMultiGroupDm(selected.id, { name: dmName.trim(), userGroupIds: [...groupIds] }); + toast('Multi-group DM updated', 'success'); + const freshDms = await api.getMultiGroupDms(); + const fresh = freshDms.dms.find(d => d.id === selected.id); + if (fresh) { const ids = new Set(fresh.memberGroupIds || []); setSavedGroupIds(ids); setGroupIds(ids); setSelected(fresh); } + } else { + await api.createMultiGroupDm({ name: dmName.trim(), userGroupIds: [...groupIds] }); + toast(`Multi-group DM "${dmName.trim()}" created`, 'success'); + clearSelection(); + } + load(); onRefresh(); } catch (e) { toast(e.message, 'error'); } finally { setSaving(false); } }; - const handleCancel = () => { setSelectedDm(null); setDmName(''); setDmGroupMembers(new Set()); }; + const handleDelete = async () => { + setDeleting(true); + try { + await api.deleteMultiGroupDm(selected.id); + toast('Deleted', 'success'); + clearSelection(); load(); onRefresh(); + } catch (e) { toast(e.message, 'error'); } + finally { setDeleting(false); } + }; + + const canDelete = selected && savedGroupIds.size === 0; + const isCreating = !selected; return ( -
-
-
Managed DM Groups
- {dmGroups.map(g => ( - + {dms.map(dm => ( + ))} - {dmGroups.length === 0 &&
No managed DM groups
} + {dms.length === 0 &&
No multi-group DMs yet
}
-
- {!selectedDm ? ( -
Select a group to edit its name
- ) : ( -
-
- - setDmName(e.target.value)} style={{ marginTop: 6 }} /> -

Renaming this also renames the paired user group.

-
-
- - -
+ {/* Form panel */} +
+
+
+ + setDmName(e.target.value)} + placeholder="e.g. Coaches + Players" style={{ marginTop: 6 }} />
- )} +
+ +

Select two or more user groups. All members of each group will have access to this conversation.

+ +

{groupIds.size} group{groupIds.size !== 1 ? 's' : ''} selected

+
+
+ + {!isCreating && } + {!isCreating && ( + + )} +
+ {showDelete && ( +
+

Delete {selected?.name}? This also deletes the associated direct message group. Cannot be undone.

+
+ + +
+
+ )} +
); @@ -262,43 +331,31 @@ function DirectMessagesTab({ allUsers }) { export default function GroupManagerModal({ onClose }) { const [tab, setTab] = useState('all'); const [allUsers, setAllUsers] = useState([]); + const [allUserGroups, setAllUserGroups] = useState([]); const [refreshKey, setRefreshKey] = useState(0); const onRefresh = () => setRefreshKey(k => k + 1); useEffect(() => { - // Fetch all active users (ignore allow_dm — admin can add anyone) api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status === 'active'))).catch(() => {}); + api.getUserGroups().then(({ groups }) => setAllUserGroups(groups)).catch(() => {}); }, [refreshKey]); - const tabs = [ - { id: 'all', label: 'All Groups' }, - { id: 'create', label: 'Create Group' }, - { id: 'dm', label: 'Direct Messages' }, - ]; - return (
e.target === e.currentTarget && onClose()}>
- {/* Header */}

Group Manager

- - {/* Tab buttons */}
- {tabs.map(t => ( - - ))} + +
- - {/* Tab content */} -
- {tab === 'all' && } - {tab === 'create' && } - {tab === 'dm' && } +
+ {tab === 'all' && } + {tab === 'dm' && }
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 1816ec1..875e782 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -109,6 +109,11 @@ export const api = { createUserGroup: (body) => req('POST', '/usergroups', body), updateUserGroup: (id, body) => req('PATCH', `/usergroups/${id}`, body), deleteUserGroup: (id) => req('DELETE', `/usergroups/${id}`), + // Multi-group DMs + getMultiGroupDms: () => req('GET', '/usergroups/multigroup'), + createMultiGroupDm: (body) => req('POST', '/usergroups/multigroup', body), + updateMultiGroupDm: (id, body) => req('PATCH', `/usergroups/multigroup/${id}`, body), + deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`), uploadLogo: (file) => { const form = new FormData(); form.append('logo', file); return req('POST', '/settings/logo', form);