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 (
A matching Direct Message group will be created automatically.
} +Delete {selected.name}? This also deletes the associated direct message group. This cannot be undone.
-{members.size} selected
+A matching Direct Message group will be created automatically with the same name.
-Delete {selected?.name}? This also deletes the associated direct message group. Cannot be undone.
+{members.size} user{members.size !== 1 ? 's' : ''} selected
-