diff --git a/.env.example b/.env.example index 5806dc4..3967f84 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.27 +JAMA_VERSION=0.9.28 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index 5110b08..5477107 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.27", + "version": "0.9.28", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js index d7ede63..31bdce1 100644 --- a/backend/src/routes/usergroups.js +++ b/backend/src/routes/usergroups.js @@ -52,115 +52,6 @@ router.get('/', authMiddleware, adminMiddleware, (req, res) => { res.json({ groups }); }); -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 - `).all(req.params.id); - res.json({ group, members }); -}); - -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(); - 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' }); - } - 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; - const ugResult = db.prepare(`INSERT INTO user_groups (name, dm_group_id) VALUES (?, ?)`).run(name.trim(), dmGroupId); - const ugId = ugResult.lastInsertRowid; - - const validIds = Array.isArray(memberIds) ? memberIds.map(Number).filter(Boolean) : []; - for (const uid of validIds) { - 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 }); -}); - -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; - - if (name && name.trim() !== ug.name) { - 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); - } - } - - 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); - 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); - 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); - } - } - } - 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); - // 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); - } - } - } - } - } - } - - const updated = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id); - res.json({ group: updated }); -}); - -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' }); - 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); - for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id }); - } - db.prepare('DELETE FROM user_groups WHERE id = ?').run(ug.id); - res.json({ success: true }); -}); - // ── MULTI-GROUP DMs ─────────────────────────────────────────────────────────── router.get('/multigroup', authMiddleware, adminMiddleware, (req, res) => { @@ -276,3 +167,113 @@ router.delete('/multigroup/:id', authMiddleware, adminMiddleware, (req, res) => return router; }; + +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 + `).all(req.params.id); + res.json({ group, members }); +}); + +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(); + 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' }); + } + 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; + const ugResult = db.prepare(`INSERT INTO user_groups (name, dm_group_id) VALUES (?, ?)`).run(name.trim(), dmGroupId); + const ugId = ugResult.lastInsertRowid; + + const validIds = Array.isArray(memberIds) ? memberIds.map(Number).filter(Boolean) : []; + for (const uid of validIds) { + 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 }); +}); + +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; + + if (name && name.trim() !== ug.name) { + 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); + } + } + + 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); + 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); + 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); + } + } + } + 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); + // 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); + } + } + } + } + } + } + + const updated = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id); + res.json({ group: updated }); +}); + +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' }); + 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); + for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id }); + } + db.prepare('DELETE FROM user_groups WHERE id = ?').run(ug.id); + res.json({ success: true }); +}); + diff --git a/build.sh b/build.sh index e497137..b363269 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.27}" +VERSION="${1:-0.9.28}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 710424e..8534ccf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.27", + "version": "0.9.28", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index b4f282d..02326d4 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -15,6 +15,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupManager, o const { user } = useAuth(); const drawerRef = useRef(null); const isAdmin = user?.role === 'admin'; + const isMobile = window.matchMedia('(pointer: coarse)').matches || window.innerWidth < 768; // Close on outside click useEffect(() => { @@ -54,14 +55,15 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupManager, o
Menu
{item(NAV_ICON.messages, 'Messages', onMessages)} - {item(NAV_ICON.schedules, 'Schedules', () => {}, true)} + {!isMobile && item(NAV_ICON.schedules, 'Schedules', () => {}, true)} {isAdmin && ( <>
Admin
{item(NAV_ICON.users, 'User Manager', onUsers)} - {features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager)} + {features.groupManager && !isMobile && item(NAV_ICON.groups, 'Group Manager', onGroupManager)} {features.branding && item(NAV_ICON.branding, 'Branding', onBranding)} + {!isMobile && item(NAV_ICON.schedules, 'Schedule Manager', () => {}, true)} {item(NAV_ICON.settings, 'Settings', onSettings)} )}