v0.9.88 major change sqlite to postgres

This commit is contained in:
2026-03-20 10:46:29 -04:00
parent 7dc4cfcbce
commit ac7cba0f92
31 changed files with 3729 additions and 2645 deletions

View File

@@ -5,7 +5,7 @@ const fs = require('fs');
const ABOUT_FILE = '/app/data/about.json';
const DEFAULTS = {
built_with: 'Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai',
built_with: 'Node.js · Express · Socket.io · PostgreSQL · React · Vite · Claude.ai',
developer: 'Ricky Stretch',
license: 'AGPL 3.0',
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',

View File

@@ -1,130 +1,100 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { getDb, getOrCreateSupportGroup } = require('../models/db');
const bcrypt = require('bcryptjs');
const { query, queryOne, queryResult, exec, getOrCreateSupportGroup } = require('../models/db');
const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth');
module.exports = function(io) {
const router = express.Router();
const router = express.Router();
// Login
router.post('/login', (req, res) => {
const { email, password, rememberMe } = req.body;
const db = getDb();
// Login
router.post('/login', async (req, res) => {
const { email, password, rememberMe } = req.body;
try {
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE email = $1', [email]);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
if (user.status === 'suspended') {
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
return res.status(403).json({ error: 'suspended', adminEmail: admin?.email });
}
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
if (user.status === 'suspended') {
const adminUser = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
return res.status(403).json({
error: 'suspended',
adminEmail: adminUser?.email
});
}
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
if (!bcrypt.compareSync(password, user.password))
return res.status(401).json({ error: 'Invalid credentials' });
const valid = bcrypt.compareSync(password, user.password);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = generateToken(user.id);
const ua = req.headers['user-agent'] || '';
const device = await setActiveSession(req.schema, user.id, token, ua);
if (io) io.to(`user:${user.id}`).emit('session:displaced', { device });
const token = generateToken(user.id);
const ua = req.headers['user-agent'] || '';
const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class
// Kick any live socket on the same device class — it now holds a stale token
if (io) {
io.to(`user:${user.id}`).emit('session:displaced', { device });
}
const { password: _, ...userSafe } = user;
res.json({
token,
user: userSafe,
mustChangePassword: !!user.must_change_password,
rememberMe: !!rememberMe
const { password: _, ...userSafe } = user;
res.json({ token, user: userSafe, mustChangePassword: !!user.must_change_password, rememberMe: !!rememberMe });
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
// Change password
router.post('/change-password', authMiddleware, (req, res) => {
const { currentPassword, newPassword } = req.body;
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
// Change password
router.post('/change-password', authMiddleware, async (req, res) => {
const { currentPassword, newPassword } = req.body;
try {
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE id = $1', [req.user.id]);
if (!bcrypt.compareSync(currentPassword, user.password))
return res.status(400).json({ error: 'Current password is incorrect' });
if (newPassword.length < 8)
return res.status(400).json({ error: 'Password must be at least 8 characters' });
const hash = bcrypt.hashSync(newPassword, 10);
await exec(req.schema,
'UPDATE users SET password = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2',
[hash, req.user.id]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
if (!bcrypt.compareSync(currentPassword, user.password)) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
// Get current user
router.get('/me', authMiddleware, (req, res) => {
const { password, ...user } = req.user;
res.json({ user });
});
const hash = bcrypt.hashSync(newPassword, 10);
db.prepare("UPDATE users SET password = ?, must_change_password = 0, updated_at = datetime('now') WHERE id = ?").run(hash, req.user.id);
// Logout
router.post('/logout', authMiddleware, async (req, res) => {
try {
await clearActiveSession(req.schema, req.user.id, req.device);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
res.json({ success: true });
});
// Support contact form
router.post('/support', async (req, res) => {
const { name, email, message } = req.body;
if (!name?.trim() || !email?.trim() || !message?.trim())
return res.status(400).json({ error: 'All fields are required' });
if (message.trim().length > 2000)
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
try {
const groupId = await getOrCreateSupportGroup(req.schema);
if (!groupId) return res.status(500).json({ error: 'Support group unavailable' });
// Get current user
router.get('/me', authMiddleware, (req, res) => {
const { password, ...user } = req.user;
res.json({ user });
});
const admin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
if (!admin) return res.status(500).json({ error: 'No admin configured' });
// Logout — clear active session for this device class only
router.post('/logout', authMiddleware, (req, res) => {
clearActiveSession(req.user.id, req.device);
res.json({ success: true });
});
const content = `📬 **Support Request**\n**Name:** ${name.trim()}\n**Email:** ${email.trim()}\n\n${message.trim()}`;
const mr = await queryResult(req.schema,
"INSERT INTO messages (group_id, user_id, content, type) VALUES ($1,$2,$3,'text') RETURNING id",
[groupId, admin.id, content]
);
const newMsg = await queryOne(req.schema, `
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name, u.avatar AS user_avatar
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = $1
`, [mr.rows[0].id]);
if (newMsg) { newMsg.reactions = []; io.to(`group:${groupId}`).emit('message:new', newMsg); }
// Public support contact form — no auth required
router.post('/support', (req, res) => {
const { name, email, message } = req.body;
if (!name?.trim() || !email?.trim() || !message?.trim()) {
return res.status(400).json({ error: 'All fields are required' });
}
if (message.trim().length > 2000) {
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
}
const admins = await query(req.schema, "SELECT id FROM users WHERE role = 'admin' AND status = 'active'");
for (const a of admins) io.to(`user:${a.id}`).emit('notification:new', { type: 'support', groupId });
const db = getDb();
// Get or create the Support group
const groupId = getOrCreateSupportGroup();
if (!groupId) return res.status(500).json({ error: 'Support group unavailable' });
// Find a system/admin user to post as (default admin)
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
if (!admin) return res.status(500).json({ error: 'No admin configured' });
// Format the support message
const content = `📬 **Support Request**
**Name:** ${name.trim()}
**Email:** ${email.trim()}
${message.trim()}`;
const msgResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'text')
`).run(groupId, admin.id, content);
// Emit socket event so online admins see the message immediately
const newMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.id = ?
`).get(msgResult.lastInsertRowid);
if (newMsg) {
newMsg.reactions = [];
io.to(`group:${groupId}`).emit('message:new', newMsg);
}
// Notify each admin via their user channel so they can reload groups if needed
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
for (const a of admins) {
io.to(`user:${a.id}`).emit('notification:new', { type: 'support', groupId });
}
console.log(`[Support] Message from ${email} posted to Support group`);
res.json({ success: true });
});
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;
};

View File

@@ -1,450 +1,318 @@
const express = require('express');
const fs = require('fs');
const router = express.Router();
const { getDb } = require('../models/db');
const fs = require('fs');
const router = express.Router();
const { query, queryOne, queryResult, exec } = 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);
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); }
}
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.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 });
}
const members = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [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);
async function emitGroupUpdated(schema, io, groupId) {
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [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 });
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(`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];
// 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
`);
// 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);
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]);
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) });
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;
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]) });
}
// 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 });
// 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 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 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) for (const uid of memberIds) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, uid]);
}
}
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
// Notify all members via socket
emitGroupNew(io, groupId);
res.json({ group });
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 }); }
});
// Rename group
router.patch('/:id/rename', authMiddleware, (req, res) => {
// PATCH rename
router.patch('/:id/rename', authMiddleware, async (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 });
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 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 });
// 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 }); }
});
// Add member to private group
router.post('/:id/members', authMiddleware, (req, res) => {
// POST add member
router.post('/:id/members', authMiddleware, async (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 });
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' });
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(`group:${group.id}`).emit('message:new', sysMsg);
io.in(`user:${userId}`).socketsJoin(`group:${group.id}`);
io.to(`user:${userId}`).emit('group:new', { group });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// 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 });
// 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);
if (targetId === group.owner_id) 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(`group:${group.id}`).emit('message:new', sysMsg);
io.in(`user:${targetId}`).socketsLeave(`group:${group.id}`);
io.to(`user:${targetId}`).emit('group:deleted', { groupId: group.id });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// 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);
// 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(`group:${group.id}`).emit('message:new', sysMsg);
io.in(`user:${userId}`).socketsLeave(`group:${group.id}`);
io.to(`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 });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// 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 });
// 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, (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 });
// 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(`user:${uid}`).emit('group:deleted', { groupId: group.id });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// 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;
// PATCH custom-name
router.patch('/:id/custom-name', authMiddleware, async (req, res) => {
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() });
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;
};
};

View File

@@ -1,40 +1,32 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const { getDb } = require('../models/db');
const fs = require('fs');
const path = require('path');
const router = express.Router();
const { exec, queryOne } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
// help.md lives inside the backend source tree — NOT in /app/data which is
// volume-mounted and would hide files baked into the image at build time.
const HELP_FILE = path.join(__dirname, '../data/help.md');
// GET /api/help — returns markdown content
router.get('/', authMiddleware, (req, res) => {
let content = '';
const filePath = HELP_FILE;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (e) {
content = '# Getting Started\n\nHelp content is not available yet.';
}
try { content = fs.readFileSync(HELP_FILE, 'utf8'); }
catch (e) { content = '# Getting Started\n\nHelp content is not available yet.'; }
res.json({ content });
});
// GET /api/help/status — returns whether user has dismissed help
router.get('/status', authMiddleware, (req, res) => {
const db = getDb();
const user = db.prepare('SELECT help_dismissed FROM users WHERE id = ?').get(req.user.id);
res.json({ dismissed: !!user?.help_dismissed });
router.get('/status', authMiddleware, async (req, res) => {
try {
const user = await queryOne(req.schema, 'SELECT help_dismissed FROM users WHERE id = $1', [req.user.id]);
res.json({ dismissed: !!user?.help_dismissed });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/help/dismiss — set help_dismissed for current user
router.post('/dismiss', authMiddleware, (req, res) => {
router.post('/dismiss', authMiddleware, async (req, res) => {
const { dismissed } = req.body;
const db = getDb();
db.prepare("UPDATE users SET help_dismissed = ? WHERE id = ?")
.run(dismissed ? 1 : 0, req.user.id);
res.json({ success: true });
try {
await exec(req.schema, 'UPDATE users SET help_dismissed = $1 WHERE id = $2', [!!dismissed, req.user.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

312
backend/src/routes/host.js Normal file
View File

@@ -0,0 +1,312 @@
/**
* routes/host.js — JAMA-HOST control plane
*
* All routes require the HOST_ADMIN_KEY header.
* These routes operate on the 'public' schema (tenant registry).
* They provision/deprovision per-tenant schemas.
*
* APP_TYPE must be 'host' for these routes to be registered.
*/
const express = require('express');
const router = express.Router();
const {
query, queryOne, queryResult, exec,
runMigrations, ensureSchema,
seedSettings, seedEventTypes, seedAdmin,
refreshTenantCache,
} = require('../models/db');
const HOST_ADMIN_KEY = process.env.HOST_ADMIN_KEY || '';
// ── Host admin key guard ──────────────────────────────────────────────────────
function hostAdminMiddleware(req, res, next) {
if (!HOST_ADMIN_KEY) {
return res.status(503).json({ error: 'HOST_ADMIN_KEY is not configured' });
}
const key = req.headers['x-host-admin-key'] || req.headers['authorization']?.replace('Bearer ', '');
if (!key || key !== HOST_ADMIN_KEY) {
return res.status(401).json({ error: 'Invalid host admin key' });
}
next();
}
// All routes in this file require the host admin key
router.use(hostAdminMiddleware);
// ── Helpers ───────────────────────────────────────────────────────────────────
function slugToSchema(slug) {
return `tenant_${slug.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
}
function isValidSlug(slug) {
return /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(slug);
}
async function reloadTenantCache() {
const tenants = await query('public', "SELECT * FROM tenants WHERE status = 'active'");
refreshTenantCache(tenants);
return tenants;
}
// ── GET /api/host/tenants — list all tenants ──────────────────────────────────
router.get('/tenants', async (req, res) => {
try {
const tenants = await query('public',
'SELECT * FROM tenants ORDER BY created_at DESC'
);
res.json({ tenants });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /api/host/tenants/:slug — get single tenant ───────────────────────────
router.get('/tenants/:slug', async (req, res) => {
try {
const tenant = await queryOne('public',
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
res.json({ tenant });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /api/host/tenants — provision a new tenant ───────────────────────────
//
// Body: { slug, name, plan, adminEmail, adminName, adminPass, customDomain? }
//
// This:
// 1. Validates the slug (becomes subdomain + schema name)
// 2. Creates the Postgres schema
// 3. Runs all migrations in the new schema
// 4. Seeds settings, event types, and the first admin user
// 5. Records the tenant in the registry
// 6. Reloads the tenant domain cache
router.post('/tenants', async (req, res) => {
const { slug, name, plan, adminEmail, adminName, adminPass, customDomain } = req.body;
if (!slug || !name) return res.status(400).json({ error: 'slug and name are required' });
if (!isValidSlug(slug)) {
return res.status(400).json({
error: 'slug must be 3-32 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric'
});
}
const schemaName = slugToSchema(slug);
try {
// Check slug not already taken
const existing = await queryOne('public',
'SELECT id FROM tenants WHERE slug = $1', [slug]
);
if (existing) return res.status(400).json({ error: `Tenant '${slug}' already exists` });
if (customDomain) {
const domainTaken = await queryOne('public',
'SELECT id FROM tenants WHERE custom_domain = $1', [customDomain.toLowerCase()]
);
if (domainTaken) return res.status(400).json({ error: `Custom domain '${customDomain}' is already in use` });
}
console.log(`[Host] Provisioning tenant: ${slug} (schema: ${schemaName})`);
// 1. Create schema + run migrations
await runMigrations(schemaName);
// 2. Seed settings (uses env defaults unless overridden by body)
await seedSettings(schemaName);
// 3. Seed event types
await seedEventTypes(schemaName);
// 4. Seed admin user — temporarily override env vars for this tenant
const origEmail = process.env.ADMIN_EMAIL;
const origName = process.env.ADMIN_NAME;
const origPass = process.env.ADMIN_PASS;
if (adminEmail) process.env.ADMIN_EMAIL = adminEmail;
if (adminName) process.env.ADMIN_NAME = adminName;
if (adminPass) process.env.ADMIN_PASS = adminPass;
await seedAdmin(schemaName);
process.env.ADMIN_EMAIL = origEmail;
process.env.ADMIN_NAME = origName;
process.env.ADMIN_PASS = origPass;
// 5. Set app_type based on plan
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
if (plan === 'brand' || plan === 'team') {
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
}
if (plan === 'team') {
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_group_manager'");
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_schedule_manager'");
}
// 6. Register in tenants table
const tr = await queryResult('public', `
INSERT INTO tenants (slug, name, schema_name, custom_domain, plan, admin_email)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [slug, name, schemaName, customDomain?.toLowerCase() || null, plan || 'chat', adminEmail || null]);
// 7. Reload domain cache
await reloadTenantCache();
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
const tenant = tr.rows[0];
tenant.url = `https://${slug}.${baseDomain}`;
console.log(`[Host] Tenant provisioned: ${slug}${schemaName}`);
res.status(201).json({ tenant });
} catch (e) {
console.error(`[Host] Provisioning failed for ${slug}:`, e.message);
// Attempt cleanup of partially-created schema
try {
await exec('public', `DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
console.log(`[Host] Cleaned up schema ${schemaName} after failed provision`);
} catch (cleanupErr) {
console.error(`[Host] Cleanup failed:`, cleanupErr.message);
}
res.status(500).json({ error: e.message });
}
});
// ── PATCH /api/host/tenants/:slug — update tenant ─────────────────────────────
//
// Supports updating: name, plan, customDomain, status
router.patch('/tenants/:slug', async (req, res) => {
const { name, plan, customDomain, status } = req.body;
try {
const tenant = await queryOne('public',
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
if (customDomain && customDomain !== tenant.custom_domain) {
const taken = await queryOne('public',
'SELECT id FROM tenants WHERE custom_domain=$1 AND slug!=$2',
[customDomain.toLowerCase(), req.params.slug]
);
if (taken) return res.status(400).json({ error: 'Custom domain already in use' });
}
if (status && !['active','suspended'].includes(status))
return res.status(400).json({ error: 'status must be active or suspended' });
await exec('public', `
UPDATE tenants SET
name = COALESCE($1, name),
plan = COALESCE($2, plan),
custom_domain = $3,
status = COALESCE($4, status),
updated_at = NOW()
WHERE slug = $5
`, [name || null, plan || null, customDomain?.toLowerCase() ?? tenant.custom_domain, status || null, req.params.slug]);
// If plan changed, update feature flags in tenant schema
if (plan && plan !== tenant.plan) {
const s = tenant.schema_name;
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
}
await reloadTenantCache();
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
res.json({ tenant: updated });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── DELETE /api/host/tenants/:slug — deprovision tenant ───────────────────────
//
// Permanently drops the tenant's Postgres schema and all data.
// Requires confirmation: body must include { confirm: "DELETE {slug}" }
router.delete('/tenants/:slug', async (req, res) => {
const { confirm } = req.body;
if (confirm !== `DELETE ${req.params.slug}`) {
return res.status(400).json({
error: `Confirmation required. Send { "confirm": "DELETE ${req.params.slug}" } in the request body.`
});
}
try {
const tenant = await queryOne('public',
'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]
);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
console.log(`[Host] Deprovisioning tenant: ${req.params.slug} (schema: ${tenant.schema_name})`);
// Drop the entire schema — CASCADE removes all tables, indexes, triggers
await exec('public', `DROP SCHEMA IF EXISTS "${tenant.schema_name}" CASCADE`);
// Remove from registry
await exec('public', 'DELETE FROM tenants WHERE slug=$1', [req.params.slug]);
await reloadTenantCache();
console.log(`[Host] Tenant deprovisioned: ${req.params.slug}`);
res.json({ success: true, message: `Tenant '${req.params.slug}' and all its data have been permanently deleted.` });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /api/host/tenants/:slug/migrate — run pending migrations ─────────────
//
// Useful after deploying a new migration file to apply it to all tenants.
router.post('/tenants/:slug/migrate', async (req, res) => {
try {
const tenant = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
await runMigrations(tenant.schema_name);
const applied = await query(tenant.schema_name, 'SELECT * FROM schema_migrations ORDER BY version');
res.json({ success: true, migrations: applied });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /api/host/migrate-all — run pending migrations on every tenant ───────
router.post('/migrate-all', async (req, res) => {
try {
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
const results = [];
for (const t of tenants) {
try {
await runMigrations(t.schema_name);
results.push({ slug: t.slug, status: 'ok' });
} catch (e) {
results.push({ slug: t.slug, status: 'error', error: e.message });
}
}
res.json({ results });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /api/host/status — host health check ──────────────────────────────────
router.get('/status', async (req, res) => {
try {
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
res.json({
ok: true,
appType: process.env.APP_TYPE || 'selfhost',
baseDomain,
tenants: { total: parseInt(tenantCount.count), active: parseInt(active.count) },
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

@@ -1,219 +1,173 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { getDb } = require('../models/db');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { query, queryOne, queryResult, exec } = require('../models/db');
// Delete an uploaded image file from disk if it lives under /app/uploads/images
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try {
const filePath = '/app' + imageUrl; // imageUrl is like /uploads/images/img_xxx.jpg
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch (e) {
console.warn('[Messages] Could not delete image file:', e.message);
}
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
catch (e) { console.warn('[Messages] Could not delete image:', e.message); }
}
module.exports = function(io) {
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const imgStorage = multer.diskStorage({
destination: '/app/uploads/images',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2, 6)}${ext}`);
}
});
const uploadImage = multer({
storage: imgStorage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
});
const imgStorage = multer.diskStorage({
destination: '/app/uploads/images',
filename: (req, file, cb) => cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2,6)}${path.extname(file.originalname)}`),
});
const uploadImage = multer({ storage: imgStorage, limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
});
function getUserForMessage(db, userId) {
return db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ?').get(userId);
}
function canAccessGroup(db, groupId, userId) {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return null;
if (group.type === 'public') return group;
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
if (!member) return null;
return group;
}
// Get messages for group
router.get('/group/:groupId', authMiddleware, (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
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'
}
async function canAccessGroup(schema, groupId, userId) {
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
if (!group) return null;
if (group.type === 'public') return group;
const member = await queryOne(schema, 'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2', [groupId, userId]);
return member ? group : null;
}
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,
rm.content as reply_content, rm.image_url as reply_image_url,
ru.name as reply_user_name, ru.display_name as reply_user_display_name,
rm.is_deleted as reply_is_deleted
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.group_id = ?
`;
const params = [req.params.groupId];
// GET messages for group
router.get('/group/:groupId', authMiddleware, async (req, res) => {
try {
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
// Enforce join-date visibility for managed groups
if (joinedAt) {
query += ` AND date(m.created_at) >= ?`;
params.push(joinedAt);
}
const { before, limit = 50 } = req.query;
let joinedAt = null;
if (group.is_managed) {
const membership = await queryOne(req.schema,
'SELECT joined_at FROM group_members WHERE group_id=$1 AND user_id=$2',
[group.id, req.user.id]
);
if (membership?.joined_at) joinedAt = new Date(membership.joined_at).toISOString().slice(0,10);
}
if (before) {
query += ' AND m.id < ?';
params.push(before);
}
let sql = `
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,
rm.content AS reply_content, rm.image_url AS reply_image_url,
ru.name AS reply_user_name, ru.display_name AS reply_user_display_name,
rm.is_deleted AS reply_is_deleted
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.group_id = $1
`;
const params = [req.params.groupId];
let pi = 2;
if (joinedAt) { sql += ` AND m.created_at::date >= $${pi++}::date`; params.push(joinedAt); }
if (before) { sql += ` AND m.id < $${pi++}`; params.push(before); }
sql += ` ORDER BY m.created_at DESC LIMIT $${pi}`;
params.push(parseInt(limit));
query += ' ORDER BY m.created_at DESC LIMIT ?';
params.push(parseInt(limit));
const messages = await query(req.schema, sql, params);
for (const msg of messages) {
msg.reactions = await query(req.schema,
'SELECT r.emoji, r.user_id, u.name AS user_name FROM reactions r JOIN users u ON r.user_id=u.id WHERE r.message_id=$1',
[msg.id]
);
}
res.json({ messages: messages.reverse() });
} catch (e) { res.status(500).json({ error: e.message }); }
});
const messages = db.prepare(query).all(...params);
// POST send message
router.post('/group/:groupId', authMiddleware, async (req, res) => {
try {
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
const { content, replyToId, linkPreview } = req.body;
if (!content?.trim() && !req.body.imageUrl) return res.status(400).json({ error: 'Message cannot be empty' });
const r = await queryResult(req.schema,
'INSERT INTO messages (group_id,user_id,content,reply_to_id,link_preview) VALUES ($1,$2,$3,$4,$5) RETURNING id',
[req.params.groupId, req.user.id, content?.trim()||null, replyToId||null, linkPreview ? JSON.stringify(linkPreview) : null]
);
const message = 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.allow_dm AS user_allow_dm,
rm.content AS reply_content, ru.name AS reply_user_name, ru.display_name AS reply_user_display_name
FROM messages m JOIN users u ON m.user_id=u.id
LEFT JOIN messages rm ON m.reply_to_id=rm.id LEFT JOIN users ru ON rm.user_id=ru.id
WHERE m.id=$1
`, [r.rows[0].id]);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Get reactions for these messages
for (const msg of messages) {
msg.reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(msg.id);
}
// POST image message
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), async (req, res) => {
try {
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
if (!req.file) return res.status(400).json({ error: 'No image' });
const imageUrl = `/uploads/images/${req.file.filename}`;
const { content, replyToId } = req.body;
const r = await queryResult(req.schema,
"INSERT INTO messages (group_id,user_id,content,image_url,type,reply_to_id) VALUES ($1,$2,$3,$4,'image',$5) RETURNING id",
[req.params.groupId, req.user.id, content||null, imageUrl, replyToId||null]
);
const message = 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.allow_dm AS user_allow_dm FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
[r.rows[0].id]
);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
} catch (e) { res.status(500).json({ error: e.message }); }
});
res.json({ messages: messages.reverse() });
});
// DELETE message
router.delete('/:id', authMiddleware, async (req, res) => {
try {
const message = await queryOne(req.schema,
'SELECT m.*, g.type AS group_type, g.owner_id AS group_owner_id FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1',
[req.params.id]
);
if (!message) return res.status(404).json({ error: 'Message not found' });
const canDelete = message.user_id === req.user.id || req.user.role === 'admin' ||
(message.group_type === 'private' && message.group_owner_id === req.user.id);
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });
const imageUrl = message.image_url;
await exec(req.schema, 'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1', [message.id]);
deleteImageFile(imageUrl);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId: message.id, groupId: message.group_id });
res.json({ success: true, messageId: message.id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Send message
router.post('/group/:groupId', authMiddleware, (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is read-only' });
// POST reaction
router.post('/:id/reactions', authMiddleware, async (req, res) => {
const { emoji } = req.body;
try {
const message = await queryOne(req.schema, 'SELECT * FROM messages WHERE id=$1 AND is_deleted=FALSE', [req.params.id]);
if (!message) return res.status(404).json({ error: 'Message not found' });
const existing = await queryOne(req.schema,
'SELECT * FROM reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3',
[message.id, req.user.id, emoji]
);
if (existing) {
await exec(req.schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
} else {
await exec(req.schema, 'INSERT INTO reactions (message_id,user_id,emoji) VALUES ($1,$2,$3)', [message.id, req.user.id, emoji]);
}
const reactions = await query(req.schema,
'SELECT r.emoji, r.user_id, u.name AS user_name FROM reactions r JOIN users u ON r.user_id=u.id WHERE r.message_id=$1',
[message.id]
);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId: message.id, reactions });
res.json({ reactions });
} catch (e) { res.status(500).json({ error: e.message }); }
});
const { content, replyToId, linkPreview } = req.body;
if (!content?.trim() && !req.body.imageUrl) return res.status(400).json({ error: 'Message cannot be empty' });
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, reply_to_id, link_preview)
VALUES (?, ?, ?, ?, ?)
`).run(req.params.groupId, req.user.id, content?.trim() || null, replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null);
const message = 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.allow_dm as user_allow_dm,
rm.content as reply_content, ru.name as reply_user_name, ru.display_name as reply_user_display_name
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
});
// Upload image message
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
if (!req.file) return res.status(400).json({ error: 'No image' });
const imageUrl = `/uploads/images/${req.file.filename}`;
const { content, replyToId } = req.body;
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id)
VALUES (?, ?, ?, ?, 'image', ?)
`).run(req.params.groupId, req.user.id, content || null, imageUrl, replyToId || null);
const message = 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.allow_dm as user_allow_dm
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
});
// Delete message
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
const message = db.prepare('SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_readonly FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(req.params.id);
if (!message) return res.status(404).json({ error: 'Message not found' });
const canDelete = message.user_id === req.user.id ||
req.user.role === 'admin' ||
(message.group_type === 'private' && message.group_owner_id === req.user.id);
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });
const imageUrl = message.image_url;
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(message.id);
deleteImageFile(imageUrl);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId: message.id, groupId: message.group_id });
res.json({ success: true, messageId: message.id });
});
// Add/toggle reaction
router.post('/:id/reactions', authMiddleware, (req, res) => {
const { emoji } = req.body;
const db = getDb();
const message = db.prepare('SELECT * FROM messages WHERE id = ? AND is_deleted = 0').get(req.params.id);
if (!message) return res.status(404).json({ error: 'Message not found' });
// Check if user's message is from deleted/suspended user
const msgUser = db.prepare('SELECT status FROM users WHERE id = ?').get(message.user_id);
if (msgUser.status !== 'active') return res.status(400).json({ error: 'Cannot react to this message' });
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(message.id, req.user.id, emoji);
if (existing) {
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
} else {
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(message.id, req.user.id, emoji);
}
const reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(message.id);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId: message.id, reactions });
res.json({ reactions });
});
return router;
return router;
};

View File

@@ -1,104 +1,112 @@
const express = require('express');
const webpush = require('web-push');
const router = express.Router();
const { getDb } = require('../models/db');
const express = require('express');
const webpush = require('web-push');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
// Get or generate VAPID keys stored in settings
function getVapidKeys() {
const db = getDb();
let pub = db.prepare("SELECT value FROM settings WHERE key = 'vapid_public'").get();
let priv = db.prepare("SELECT value FROM settings WHERE key = 'vapid_private'").get();
// VAPID keys are stored in settings; lazily initialised on first request
let vapidPublicKey = null;
async function getVapidKeys(schema) {
const pub = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_public'");
const priv = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_private'");
if (!pub?.value || !priv?.value) {
const keys = webpush.generateVAPIDKeys();
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
ins.run('vapid_public', keys.publicKey, keys.publicKey);
ins.run('vapid_private', keys.privateKey, keys.privateKey);
await exec(schema,
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.publicKey]
);
await exec(schema,
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.privateKey]
);
console.log('[Push] Generated new VAPID keys');
return keys;
}
return { publicKey: pub.value, privateKey: priv.value };
}
function initWebPush() {
const keys = getVapidKeys();
webpush.setVapidDetails(
'mailto:admin@jama.local',
keys.publicKey,
keys.privateKey
);
async function initWebPush(schema) {
const keys = await getVapidKeys(schema);
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
return keys.publicKey;
}
// Export for use in index.js
let vapidPublicKey = null;
function getVapidPublicKey() {
if (!vapidPublicKey) vapidPublicKey = initWebPush();
return vapidPublicKey;
}
// Send a push notification to all subscriptions for a user
async function sendPushToUser(userId, payload) {
const db = getDb();
getVapidPublicKey(); // ensure webpush is configured
const subs = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
// Subscription expired — remove it
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
// Called from index.js socket push notifications — schema comes from caller
async function sendPushToUser(schema, userId, payload) {
try {
if (!vapidPublicKey) vapidPublicKey = await initWebPush(schema);
const subs = await query(schema, 'SELECT * FROM push_subscriptions WHERE user_id = $1', [userId]);
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
}
}
}
} catch (e) {
console.error('[Push] sendPushToUser error:', e.message);
}
}
// GET /api/push/vapid-public — returns VAPID public key for client subscription
router.get('/vapid-public', (req, res) => {
res.json({ publicKey: getVapidPublicKey() });
router.get('/vapid-public', async (req, res) => {
try {
if (!vapidPublicKey) vapidPublicKey = await initWebPush(req.schema);
res.json({ publicKey: vapidPublicKey });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/push/subscribe — save push subscription for current user
router.post('/subscribe', authMiddleware, (req, res) => {
router.post('/subscribe', authMiddleware, async (req, res) => {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
if (!endpoint || !keys?.p256dh || !keys?.auth)
return res.status(400).json({ error: 'Invalid subscription' });
}
const db = getDb();
const device = req.device || 'desktop';
// Delete any existing subscription for this user+device or this endpoint, then insert fresh
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ? OR (user_id = ? AND device = ?)').run(endpoint, req.user.id, device);
db.prepare('INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?)').run(req.user.id, device, endpoint, keys.p256dh, keys.auth);
res.json({ success: true });
try {
const device = req.device || 'desktop';
await exec(req.schema,
'DELETE FROM push_subscriptions WHERE endpoint = $1 OR (user_id = $2 AND device = $3)',
[endpoint, req.user.id, device]
);
await exec(req.schema,
'INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5)',
[req.user.id, device, endpoint, keys.p256dh, keys.auth]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/push/generate-vapid — admin: generate (or regenerate) VAPID keys
router.post('/generate-vapid', authMiddleware, (req, res) => {
router.post('/generate-vapid', authMiddleware, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
const db = getDb();
const keys = webpush.generateVAPIDKeys();
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
ins.run('vapid_public', keys.publicKey, keys.publicKey);
ins.run('vapid_private', keys.privateKey, keys.privateKey);
// Reinitialise webpush with new keys immediately
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
vapidPublicKey = keys.publicKey;
console.log('[Push] VAPID keys regenerated by admin');
res.json({ publicKey: keys.publicKey });
try {
const keys = webpush.generateVAPIDKeys();
await exec(req.schema,
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.publicKey]
);
await exec(req.schema,
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.privateKey]
);
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
vapidPublicKey = keys.publicKey;
res.json({ publicKey: keys.publicKey });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/push/unsubscribe — remove subscription
router.post('/unsubscribe', authMiddleware, (req, res) => {
router.post('/unsubscribe', authMiddleware, async (req, res) => {
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
const db = getDb();
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
res.json({ success: true });
try {
await exec(req.schema,
'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
[req.user.id, endpoint]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = { router, sendPushToUser, getVapidPublicKey };
module.exports = { router, sendPushToUser };

View File

@@ -1,396 +1,378 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../models/db');
const express = require('express');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
const multer = require('multer');
const multer = require('multer');
const { parse: csvParse } = require('csv-parse/sync');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
// ── Helpers ───────────────────────────────────────────────────────────────────
function canViewEvent(db, event, userId, isToolManager) {
if (isToolManager) return true;
if (event.is_public) return true;
// Private: user must be in an assigned user group
const assigned = db.prepare(`
async function isToolManagerFn(schema, user) {
if (user.role === 'admin') return true;
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
const gm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_group_managers'");
const groupIds = [...new Set([...JSON.parse(tm?.value||'[]'), ...JSON.parse(gm?.value||'[]')])];
if (!groupIds.length) return false;
const ph = groupIds.map((_,i) => `$${i+2}`).join(',');
return !!(await queryOne(schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [user.id, ...groupIds]));
}
async function canViewEvent(schema, event, userId, isToolManager) {
if (isToolManager || event.is_public) return true;
const assigned = await queryOne(schema, `
SELECT 1 FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ? AND ugm.user_id = ?
`).get(event.id, userId);
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, userId]);
return !!assigned;
}
function isToolManagerFn(db, user) {
if (user.role === 'admin') return true;
const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get();
const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get();
const groupIds = [...new Set([
...JSON.parse(tmSetting?.value || '[]'),
...JSON.parse(gmSetting?.value || '[]'),
])];
if (!groupIds.length) return false;
return !!db.prepare(`SELECT 1 FROM user_group_members WHERE user_id = ? AND user_group_id IN (${groupIds.map(()=>'?').join(',')})`).get(user.id, ...groupIds);
async function enrichEvent(schema, event) {
event.event_type = event.event_type_id
? await queryOne(schema, 'SELECT * FROM event_types WHERE id=$1', [event.event_type_id])
: null;
// recurrence_rule is JSONB in Postgres — already parsed, no need to JSON.parse
event.user_groups = await query(schema, `
SELECT ug.id, ug.name FROM event_user_groups eug
JOIN user_groups ug ON ug.id=eug.user_group_id WHERE eug.event_id=$1
`, [event.id]);
return event;
}
function enrichEvent(db, event) {
event.event_type = event.event_type_id
? db.prepare('SELECT * FROM event_types WHERE id = ?').get(event.event_type_id)
: null;
if (event.recurrence_rule && typeof event.recurrence_rule === 'string') {
try { event.recurrence_rule = JSON.parse(event.recurrence_rule); } catch(e) { event.recurrence_rule = null; }
async function applyEventUpdate(schema, eventId, fields, userGroupIds) {
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent } = fields;
await exec(schema, `
UPDATE events SET
title = COALESCE($1, title),
event_type_id = $2,
start_at = COALESCE($3, start_at),
end_at = COALESCE($4, end_at),
all_day = COALESCE($5, all_day),
location = $6,
description = $7,
is_public = COALESCE($8, is_public),
track_availability = COALESCE($9, track_availability),
recurrence_rule = $10,
updated_at = NOW()
WHERE id = $11
`, [
title?.trim() || null,
eventTypeId !== undefined ? (eventTypeId || null) : origEvent.event_type_id,
startAt || null,
endAt || null,
allDay !== undefined ? allDay : null,
location !== undefined ? (location || null) : origEvent.location,
description !== undefined ? (description || null) : origEvent.description,
isPublic !== undefined ? isPublic : null,
trackAvailability !== undefined ? trackAvailability : null,
recurrenceRule !== undefined ? recurrenceRule : origEvent.recurrence_rule,
eventId,
]);
if (Array.isArray(userGroupIds)) {
await exec(schema, 'DELETE FROM event_user_groups WHERE event_id=$1', [eventId]);
for (const ugId of userGroupIds)
await exec(schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
}
event.user_groups = db.prepare(`
SELECT ug.id, ug.name FROM event_user_groups eug
JOIN user_groups ug ON ug.id = eug.user_group_id
WHERE eug.event_id = ?
`).all(event.id);
return event;
}
// ── Event Types ───────────────────────────────────────────────────────────────
router.get('/event-types', authMiddleware, (req, res) => {
const db = getDb();
res.json({ eventTypes: db.prepare('SELECT * FROM event_types ORDER BY is_default DESC, name ASC').all() });
router.get('/event-types', authMiddleware, async (req, res) => {
try {
const eventTypes = await query(req.schema, 'SELECT * FROM event_types ORDER BY is_default DESC, name ASC');
res.json({ eventTypes });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/event-types', authMiddleware, teamManagerMiddleware, (req, res) => {
router.post('/event-types', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
return res.status(400).json({ error: 'Event type with that name already exists' });
}
const r = db.prepare(`INSERT INTO event_types (name, colour, default_user_group_id, default_duration_hrs)
VALUES (?, ?, ?, ?)`).run(name.trim(), colour || '#6366f1', defaultUserGroupId || null, defaultDurationHrs || 1.0);
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(r.lastInsertRowid) });
try {
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [name.trim()]))
return res.status(400).json({ error: 'Event type with that name already exists' });
const r = await queryResult(req.schema,
'INSERT INTO event_types (name,colour,default_user_group_id,default_duration_hrs) VALUES ($1,$2,$3,$4) RETURNING id',
[name.trim(), colour||'#6366f1', defaultUserGroupId||null, defaultDurationHrs||1.0]
);
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [r.rows[0].id]) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (name && name.trim() !== et.name) {
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), et.id))
return res.status(400).json({ error: 'Name already in use' });
}
db.prepare(`UPDATE event_types SET
name = COALESCE(?, name),
colour = COALESCE(?, colour),
default_user_group_id = ?,
default_duration_hrs = COALESCE(?, default_duration_hrs)
WHERE id = ?`).run(name?.trim() || null, colour || null, defaultUserGroupId ?? et.default_user_group_id, defaultDurationHrs || null, et.id);
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(et.id) });
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (name && name.trim() !== et.name) {
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), et.id]))
return res.status(400).json({ error: 'Name already in use' });
}
await exec(req.schema, `
UPDATE event_types SET
name = COALESCE($1, name),
colour = COALESCE($2, colour),
default_user_group_id = $3,
default_duration_hrs = COALESCE($4, default_duration_hrs)
WHERE id=$5
`, [name?.trim()||null, colour||null, defaultUserGroupId??et.default_user_group_id, defaultDurationHrs||null, et.id]);
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [et.id]) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
// Null out event_type_id on events using this type
db.prepare('UPDATE events SET event_type_id = NULL WHERE event_type_id = ?').run(et.id);
db.prepare('DELETE FROM event_types WHERE id = ?').run(et.id);
res.json({ success: true });
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
await exec(req.schema, 'UPDATE events SET event_type_id=NULL WHERE event_type_id=$1', [et.id]);
await exec(req.schema, 'DELETE FROM event_types WHERE id=$1', [et.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Events ────────────────────────────────────────────────────────────────────
// List events (with optional date range filter)
router.get('/', authMiddleware, (req, res) => {
const db = getDb();
const itm = isToolManagerFn(db, req.user);
const { from, to } = req.query;
let q = 'SELECT * FROM events WHERE 1=1';
const params = [];
if (from) { q += ' AND end_at >= ?'; params.push(from); }
if (to) { q += ' AND start_at <= ?'; params.push(to); }
q += ' ORDER BY start_at ASC';
const events = db.prepare(q).all(...params)
.filter(e => canViewEvent(db, e, req.user.id, itm))
.map(e => {
enrichEvent(db, e);
// Include current user's response so the list can show the awaiting indicator
const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(e.id, req.user.id);
router.get('/', authMiddleware, async (req, res) => {
try {
const itm = await isToolManagerFn(req.schema, req.user);
const { from, to } = req.query;
let sql = 'SELECT * FROM events WHERE 1=1';
const params = [];
let pi = 1;
if (from) { sql += ` AND end_at >= $${pi++}`; params.push(from); }
if (to) { sql += ` AND start_at <= $${pi++}`; params.push(to); }
sql += ' ORDER BY start_at ASC';
const rawEvents = await query(req.schema, sql, params);
const events = [];
for (const e of rawEvents) {
if (!(await canViewEvent(req.schema, e, req.user.id, itm))) continue;
await enrichEvent(req.schema, e);
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [e.id, req.user.id]);
e.my_response = mine?.response || null;
return e;
});
res.json({ events });
events.push(e);
}
res.json({ events });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Get single event
router.get('/:id', authMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
const itm = isToolManagerFn(db, req.user);
if (!canViewEvent(db, event, req.user.id, itm)) return res.status(403).json({ error: 'Access denied' });
enrichEvent(db, event);
// Availability (only for assigned group members / tool managers)
if (event.track_availability && itm) {
const responses = db.prepare(`
SELECT ea.response, ea.updated_at, u.id as user_id, u.name, u.display_name, u.avatar
FROM event_availability ea JOIN users u ON u.id = ea.user_id
WHERE ea.event_id = ?
`).all(req.params.id);
event.availability = responses;
// Count no-response: users in assigned groups who haven't responded
const assignedUserIds = db.prepare(`
SELECT DISTINCT ugm.user_id FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ?
`).all(req.params.id).map(r => r.user_id);
const respondedIds = new Set(responses.map(r => r.user_id));
event.no_response_count = assignedUserIds.filter(id => !respondedIds.has(id)).length;
}
// Current user's own response
const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(req.params.id, req.user.id);
event.my_response = mine?.response || null;
res.json({ event });
router.get('/me/pending', authMiddleware, async (req, res) => {
try {
const pending = await query(req.schema, `
SELECT DISTINCT e.* FROM events e
JOIN event_user_groups eug ON eug.event_id=e.id
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE ugm.user_id=$1 AND e.track_availability=TRUE
AND e.end_at >= NOW()
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id=e.id AND ea.user_id=$1)
ORDER BY e.start_at ASC
`, [req.user.id]);
const result = [];
for (const e of pending) result.push(await enrichEvent(req.schema, e));
res.json({ events: result });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Create event
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [], recurrenceRule } = req.body;
router.get('/:id', authMiddleware, async (req, res) => {
try {
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
const itm = await isToolManagerFn(req.schema, req.user);
if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
await enrichEvent(req.schema, event);
if (event.track_availability && itm) {
event.availability = await query(req.schema, `
SELECT ea.response, ea.updated_at, u.id AS user_id, u.name, u.display_name, u.avatar
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
`, [req.params.id]);
const assignedIds = (await query(req.schema, `
SELECT DISTINCT ugm.user_id FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id WHERE eug.event_id=$1
`, [req.params.id])).map(r => r.user_id);
const respondedIds = new Set(event.availability.map(r => r.user_id));
event.no_response_count = assignedIds.filter(id => !respondedIds.has(id)).length;
}
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
event.my_response = mine?.response || null;
res.json({ event });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
const db = getDb();
const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
title.trim(), eventTypeId || null, startAt, endAt,
allDay ? 1 : 0, location || null, description || null,
isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0,
recurrenceRule ? JSON.stringify(recurrenceRule) : null, req.user.id
);
const eventId = r.lastInsertRowid;
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(eventId, ugId);
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
res.json({ event: enrichEvent(db, event) });
try {
const r = await queryResult(req.schema, `
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]);
const eventId = r.rows[0].id;
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
res.json({ event: await enrichEvent(req.schema, event) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update event
router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
db.prepare(`UPDATE events SET
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
location = ?, description = ?, is_public = COALESCE(?, is_public),
track_availability = COALESCE(?, track_availability),
recurrence_rule = ?,
updated_at = datetime('now')
WHERE id = ?`).run(
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
location !== undefined ? (location || null) : event.location,
description !== undefined ? (description || null) : event.description,
isPublic !== undefined ? (isPublic ? 1 : 0) : null,
trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null,
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
req.params.id
);
// For recurring events: if scope='future', update all future occurrences too
if (recurringScope === 'future' && event.recurrence_rule) {
const futureEvents = db.prepare(`
SELECT id FROM events
WHERE id != ? AND created_by = ? AND recurrence_rule IS NOT NULL
AND start_at >= ? AND title = ?
`).all(req.params.id, event.created_by, event.start_at, event.title);
for (const fe of futureEvents) {
db.prepare(`UPDATE events SET
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
location = ?, description = ?, is_public = COALESCE(?, is_public),
track_availability = COALESCE(?, track_availability),
recurrence_rule = ?,
updated_at = datetime('now')
WHERE id = ?`).run(
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
location !== undefined ? (location || null) : event.location,
description !== undefined ? (description || null) : event.description,
isPublic !== undefined ? (isPublic ? 1 : 0) : null,
trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null,
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
fe.id
);
if (Array.isArray(userGroupIds)) {
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(fe.id);
for (const ugId of userGroupIds)
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(fe.id, ugId);
}
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
// Recurring future scope — update all future occurrences
if (recurringScope === 'future' && event.recurrence_rule) {
const futureEvents = await query(req.schema, `
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
AND start_at >= $3 AND title=$4
`, [req.params.id, event.created_by, event.start_at, event.title]);
for (const fe of futureEvents)
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
}
}
if (Array.isArray(userGroupIds)) {
// Find which groups are being removed
const prevGroupIds = db.prepare('SELECT user_group_id FROM event_user_groups WHERE event_id = ?')
.all(req.params.id).map(r => r.user_group_id);
const newGroupSet = new Set(userGroupIds.map(Number));
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
// Remove availability responses for users who are only in removed groups
for (const removedGid of removedGroupIds) {
const removedUserIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?')
.all(removedGid).map(r => r.user_id);
for (const uid of removedUserIds) {
// Check if user is still in ANY remaining group for this event
const stillAssigned = newGroupSet.size > 0 && db.prepare(`
SELECT 1 FROM user_group_members
WHERE user_id = ? AND user_group_id IN (${[...newGroupSet].map(()=>'?').join(',')})
`).get(uid, ...[...newGroupSet]);
if (!stillAssigned) {
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?')
.run(req.params.id, uid);
// Clean up availability for users removed from groups
if (Array.isArray(userGroupIds)) {
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
const newGroupSet = new Set(userGroupIds.map(Number));
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
for (const removedGid of removedGroupIds) {
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
for (const uid of removedUids) {
if (newGroupSet.size > 0) {
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
if (stillAssigned) continue;
}
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, uid]);
}
}
}
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(req.params.id);
for (const ugId of userGroupIds)
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(req.params.id, ugId);
}
const updated = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
res.json({ event: enrichEvent(db, updated) });
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
res.json({ event: await enrichEvent(req.schema, updated) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Delete event
router.delete('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'Not found' });
db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
res.json({ success: true });
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id])))
return res.status(404).json({ error: 'Not found' });
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Availability ──────────────────────────────────────────────────────────────
// Submit/update availability
router.put('/:id/availability', authMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled for this event' });
const { response } = req.body;
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
// User must be in an assigned group
const inGroup = db.prepare(`
SELECT 1 FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ? AND ugm.user_id = ?
`).get(event.id, req.user.id);
const itm = isToolManagerFn(db, req.user);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')
`).run(event.id, req.user.id, response, response);
res.json({ success: true, response });
router.put('/:id/availability', authMiddleware, async (req, res) => {
try {
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
const { response } = req.body;
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
const itm = await isToolManagerFn(req.schema, req.user);
const inGroup = await queryOne(req.schema, `
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, req.user.id]);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
`, [event.id, req.user.id, response]);
res.json({ success: true, response });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Delete availability (withdraw response)
router.delete('/:id/availability', authMiddleware, (req, res) => {
const db = getDb();
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ success: true });
router.delete('/:id/availability', authMiddleware, async (req, res) => {
try {
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Get pending availability for current user (events they need to respond to)
router.get('/me/pending', authMiddleware, (req, res) => {
const db = getDb();
const pending = db.prepare(`
SELECT e.* FROM events e
JOIN event_user_groups eug ON eug.event_id = e.id
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE ugm.user_id = ? AND e.track_availability = 1
AND e.end_at >= datetime('now')
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id = e.id AND ea.user_id = ?)
ORDER BY e.start_at ASC
`).all(req.user.id, req.user.id);
res.json({ events: pending.map(e => enrichEvent(db, e)) });
});
// Bulk availability response
router.post('/me/bulk-availability', authMiddleware, (req, res) => {
const { responses } = req.body; // [{ eventId, response }]
router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
const { responses } = req.body;
if (!Array.isArray(responses)) return res.status(400).json({ error: 'responses array required' });
const db = getDb();
const stmt = db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')`);
let saved = 0;
for (const { eventId, response } of responses) {
if (!['going','maybe','not_going'].includes(response)) continue;
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
if (!event || !event.track_availability) continue;
const inGroup = db.prepare(`SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id WHERE eug.event_id = ? AND ugm.user_id = ?`).get(eventId, req.user.id);
const itm = isToolManagerFn(db, req.user);
if (!inGroup && !itm) continue;
stmt.run(eventId, req.user.id, response, response);
saved++;
}
res.json({ success: true, saved });
try {
let saved = 0;
const itm = await isToolManagerFn(req.schema, req.user);
for (const { eventId, response } of responses) {
if (!['going','maybe','not_going'].includes(response)) continue;
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
if (!event || !event.track_availability) continue;
const inGroup = await queryOne(req.schema, `
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [eventId, req.user.id]);
if (!inGroup && !itm) continue;
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
`, [eventId, req.user.id, response]);
saved++;
}
res.json({ success: true, saved });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── CSV Bulk Import ───────────────────────────────────────────────────────────
// ── CSV Import ────────────────────────────────────────────────────────────────
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), (req, res) => {
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const rows = csvParse(req.file.buffer.toString('utf8'), { columns: true, skip_empty_lines: true, trim: true });
const db = getDb();
const results = rows.map((row, i) => {
const rows = csvParse(req.file.buffer.toString('utf8'), { columns:true, skip_empty_lines:true, trim:true });
const results = await Promise.all(rows.map(async (row, i) => {
const title = row['Event Title'] || row['event_title'] || row['title'] || '';
const startDate = row['start_date'] || row['Start Date'] || '';
const startTime = row['start_time'] || row['Start Time'] || '09:00';
const startDate = row['start_date'] || row['Start Date'] || '';
const startTime = row['start_time'] || row['Start Time'] || '09:00';
const location = row['event_location'] || row['location'] || '';
const typeName = row['event_type'] || row['Event Type'] || 'Default';
const typeName = row['event_type'] || row['Event Type'] || 'Default';
const durHrs = parseFloat(row['default_duration'] || row['duration'] || '1') || 1;
if (!title || !startDate) return { row: i + 1, title, error: 'Missing title or start date', duplicate: false };
if (!title || !startDate) return { row:i+1, title, error:'Missing title or start date', duplicate:false };
const startAt = `${startDate}T${startTime.padStart(5,'0')}:00`;
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
// Check duplicate
const dup = db.prepare('SELECT id, title FROM events WHERE title = ? AND start_at = ?').get(title, startAt);
return { row: i+1, title, startAt, endAt, location, typeName, durHrs, duplicate: !!dup, duplicateId: dup?.id, error: null };
});
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
const dup = await queryOne(req.schema, 'SELECT id,title FROM events WHERE title=$1 AND start_at=$2', [title, startAt]);
return { row:i+1, title, startAt, endAt, location, typeName, durHrs, duplicate:!!dup, duplicateId:dup?.id, error:null };
}));
res.json({ rows: results });
} catch (e) { res.status(400).json({ error: 'CSV parse error: ' + e.message }); }
});
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, (req, res) => {
const { rows } = req.body; // filtered rows from preview (client excludes skipped)
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { rows } = req.body;
if (!Array.isArray(rows)) return res.status(400).json({ error: 'rows array required' });
const db = getDb();
let imported = 0;
const stmt = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, location, is_public, track_availability, created_by)
VALUES (?, ?, ?, ?, ?, 1, 0, ?)`);
for (const row of rows) {
if (row.error || row.skip) continue;
let typeId = null;
if (row.typeName) {
let et = db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(row.typeName);
if (!et) {
// Create missing type with random colour
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
const usedColours = db.prepare('SELECT colour FROM event_types').all().map(r => r.colour);
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
const r2 = db.prepare('INSERT INTO event_types (name, colour) VALUES (?, ?)').run(row.typeName, colour);
typeId = r2.lastInsertRowid;
} else { typeId = et.id; }
try {
let imported = 0;
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
for (const row of rows) {
if (row.error || row.skip) continue;
let typeId = null;
if (row.typeName) {
let et = await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [row.typeName]);
if (!et) {
const usedColours = (await query(req.schema, 'SELECT colour FROM event_types')).map(r => r.colour);
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
const cr = await queryResult(req.schema, 'INSERT INTO event_types (name,colour) VALUES ($1,$2) RETURNING id', [row.typeName, colour]);
typeId = cr.rows[0].id;
} else { typeId = et.id; }
}
await exec(req.schema,
'INSERT INTO events (title,event_type_id,start_at,end_at,location,is_public,track_availability,created_by) VALUES ($1,$2,$3,$4,$5,TRUE,FALSE,$6)',
[row.title, typeId, row.startAt, row.endAt, row.location||null, req.user.id]
);
imported++;
}
stmt.run(row.title, typeId, row.startAt, row.endAt, row.location || null, req.user.id);
imported++;
}
res.json({ success: true, imported });
res.json({ success: true, imported });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

@@ -1,190 +1,148 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const router = express.Router();
const { getDb } = require('../models/db');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const router = express.Router();
const { query, queryOne, exec } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// Generic icon storage factory
function makeIconStorage(prefix) {
return multer.diskStorage({
destination: '/app/uploads/logos',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${prefix}_${Date.now()}${ext}`);
}
filename: (req, file, cb) => cb(null, `${prefix}_${Date.now()}${path.extname(file.originalname)}`),
});
}
const iconUploadOpts = {
const iconOpts = {
limits: { fileSize: 1 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
};
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconOpts });
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconOpts });
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconOpts });
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconUploadOpts });
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconUploadOpts });
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconUploadOpts });
// Helper: upsert a setting
async function setSetting(schema, key, value) {
await exec(schema,
"INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT(key) DO UPDATE SET value=$2, updated_at=NOW()",
[key, value]
);
}
// Get public settings (accessible by all)
router.get('/', (req, res) => {
const db = getDb();
const settings = db.prepare('SELECT key, value FROM settings').all();
const obj = {};
for (const s of settings) obj[s.key] = s.value;
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
if (admin) obj.admin_email = admin.email;
// Expose app version from Docker build arg env var
obj.app_version = process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234';
res.json({ settings: obj });
// GET /api/settings
router.get('/', async (req, res) => {
try {
const rows = await query(req.schema, 'SELECT key, value FROM settings');
const obj = {};
for (const r of rows) obj[r.key] = r.value;
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
if (admin) obj.admin_email = admin.email;
obj.app_version = process.env.JAMA_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234';
res.json({ settings: obj });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update app name (admin)
router.patch('/app-name', authMiddleware, adminMiddleware, (req, res) => {
router.patch('/app-name', authMiddleware, adminMiddleware, async (req, res) => {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(name.trim());
res.json({ success: true, name: name.trim() });
try {
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [name.trim()]);
res.json({ success: true, name: name.trim() });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Upload app logo (admin) — also generates 192x192 and 512x512 PWA icons
router.post('/logo', authMiddleware, adminMiddleware, uploadLogo.single('logo'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const logoUrl = `/uploads/logos/${req.file.filename}`;
const srcPath = req.file.path;
try {
// Generate PWA icons from the uploaded logo
const icon192Path = '/app/uploads/logos/pwa-icon-192.png';
const icon512Path = '/app/uploads/logos/pwa-icon-512.png';
await sharp(srcPath)
.resize(192, 192, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(icon192Path);
await sharp(srcPath)
.resize(512, 512, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(icon512Path);
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
// Store the PWA icon paths so the manifest can reference them
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_192', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run('/uploads/logos/pwa-icon-192.png', '/uploads/logos/pwa-icon-192.png');
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_512', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run('/uploads/logos/pwa-icon-512.png', '/uploads/logos/pwa-icon-512.png');
await sharp(req.file.path).resize(192,192,{fit:'contain',background:{r:255,g:255,b:255,alpha:0}}).png().toFile('/app/uploads/logos/pwa-icon-192.png');
await sharp(req.file.path).resize(512,512,{fit:'contain',background:{r:255,g:255,b:255,alpha:0}}).png().toFile('/app/uploads/logos/pwa-icon-512.png');
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
await setSetting(req.schema, 'pwa_icon_192', '/uploads/logos/pwa-icon-192.png');
await setSetting(req.schema, 'pwa_icon_512', '/uploads/logos/pwa-icon-512.png');
res.json({ logoUrl });
} catch (err) {
console.error('[Logo] Failed to generate PWA icons:', err.message);
// Still save the logo even if icon generation fails
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
console.error('[Logo] icon gen failed:', err.message);
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
res.json({ logoUrl });
}
});
// Upload New Chat icon (admin)
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), (req, res) => {
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const iconUrl = `/uploads/logos/${req.file.filename}`;
const db = getDb();
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_newchat', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run(iconUrl, iconUrl);
res.json({ iconUrl });
try { await setSetting(req.schema, 'icon_newchat', iconUrl); res.json({ iconUrl }); }
catch (e) { res.status(500).json({ error: e.message }); }
});
// Upload Group Info icon (admin)
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), (req, res) => {
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const iconUrl = `/uploads/logos/${req.file.filename}`;
const db = getDb();
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_groupinfo', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run(iconUrl, iconUrl);
res.json({ iconUrl });
try { await setSetting(req.schema, 'icon_groupinfo', iconUrl); res.json({ iconUrl }); }
catch (e) { res.status(500).json({ error: e.message }); }
});
// Reset all settings to defaults (admin)
router.patch('/colors', authMiddleware, adminMiddleware, (req, res) => {
router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
const { colorTitle, colorTitleDark, colorAvatarPublic, colorAvatarDm } = req.body;
const db = getDb();
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
if (colorTitle !== undefined) upd.run('color_title', colorTitle || '', colorTitle || '');
if (colorTitleDark !== undefined) upd.run('color_title_dark', colorTitleDark || '', colorTitleDark || '');
if (colorAvatarPublic !== undefined) upd.run('color_avatar_public', colorAvatarPublic || '', colorAvatarPublic || '');
if (colorAvatarDm !== undefined) upd.run('color_avatar_dm', colorAvatarDm || '', colorAvatarDm || '');
res.json({ success: true });
try {
if (colorTitle !== undefined) await setSetting(req.schema, 'color_title', colorTitle || '');
if (colorTitleDark !== undefined) await setSetting(req.schema, 'color_title_dark', colorTitleDark || '');
if (colorAvatarPublic !== undefined) await setSetting(req.schema, 'color_avatar_public', colorAvatarPublic || '');
if (colorAvatarDm !== undefined) await setSetting(req.schema, 'color_avatar_dm', colorAvatarDm || '');
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const originalName = process.env.APP_NAME || 'jama';
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName);
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run();
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512', 'color_title', 'color_title_dark', 'color_avatar_public', 'color_avatar_dm')").run();
res.json({ success: true });
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
try {
const originalName = process.env.APP_NAME || 'jama';
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')");
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Registration code ─────────────────────────────────────────────────────────
// Valid codes — in production these would be stored/validated server-side
const VALID_CODES = {
// JAMA-Team: full access — chat, branding, group manager, schedule manager
'JAMA-TEAM-2024': { appType: 'JAMA-Team', branding: true, groupManager: true, scheduleManager: true },
// JAMA-Brand: chat + branding only
'JAMA-BRAND-2024': { appType: 'JAMA-Brand', branding: true, groupManager: false, scheduleManager: false },
// Legacy codes — map to new tiers
'JAMA-FULL-2024': { appType: 'JAMA-Team', branding: true, groupManager: true, scheduleManager: true },
'JAMA-TEAM-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
'JAMA-BRAND-2024': { appType:'JAMA-Brand', branding:true, groupManager:false, scheduleManager:false },
'JAMA-FULL-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
};
router.post('/register', authMiddleware, adminMiddleware, (req, res) => {
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
const { code } = req.body;
const db = getDb();
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
if (!code?.trim()) {
// Clear registration
upd.run('registration_code', '', '');
upd.run('app_type', 'JAMA-Chat', 'JAMA-Chat');
upd.run('feature_branding', 'false', 'false');
upd.run('feature_group_manager', 'false', 'false');
upd.run('feature_schedule_manager', 'false', 'false');
return res.json({ success: true, features: { branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat' } });
}
const match = VALID_CODES[code.trim().toUpperCase()];
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
upd.run('registration_code', code.trim(), code.trim());
upd.run('app_type', match.appType || 'JAMA-Chat', match.appType || 'JAMA-Chat');
upd.run('feature_branding', match.branding ? 'true' : 'false', match.branding ? 'true' : 'false');
upd.run('feature_group_manager', match.groupManager ? 'true' : 'false', match.groupManager ? 'true' : 'false');
upd.run('feature_schedule_manager', match.scheduleManager ? 'true' : 'false', match.scheduleManager ? 'true' : 'false');
res.json({ success: true, features: { branding: match.branding, groupManager: match.groupManager, scheduleManager: match.scheduleManager, appType: match.appType } });
try {
if (!code?.trim()) {
await setSetting(req.schema, 'registration_code', '');
await setSetting(req.schema, 'app_type', 'JAMA-Chat');
await setSetting(req.schema, 'feature_branding', 'false');
await setSetting(req.schema, 'feature_group_manager', 'false');
await setSetting(req.schema, 'feature_schedule_manager', 'false');
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'JAMA-Chat'} });
}
const match = VALID_CODES[code.trim().toUpperCase()];
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
await setSetting(req.schema, 'registration_code', code.trim());
await setSetting(req.schema, 'app_type', match.appType);
await setSetting(req.schema, 'feature_branding', match.branding ? 'true' : 'false');
await setSetting(req.schema, 'feature_group_manager', match.groupManager ? 'true' : 'false');
await setSetting(req.schema, 'feature_schedule_manager', match.scheduleManager ? 'true' : 'false');
res.json({ success:true, features:{ branding:match.branding, groupManager:match.groupManager, scheduleManager:match.scheduleManager, appType:match.appType } });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Save team management group assignments
router.patch('/team', authMiddleware, adminMiddleware, (req, res) => {
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
const { toolManagers } = req.body;
const db = getDb();
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
if (toolManagers !== undefined) {
const val = JSON.stringify(toolManagers || []);
upd.run('team_tool_managers', val, val);
// Keep legacy keys in sync so existing teamManagerMiddleware still works
upd.run('team_group_managers', val, val);
upd.run('team_schedule_managers', val, val);
}
res.json({ success: true });
try {
if (toolManagers !== undefined) {
const val = JSON.stringify(toolManagers || []);
await setSetting(req.schema, 'team_tool_managers', val);
await setSetting(req.schema, 'team_group_managers', val);
await setSetting(req.schema, 'team_schedule_managers', val);
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

@@ -1,302 +1,313 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../models/db');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
module.exports = function(io) {
// ── Helpers ───────────────────────────────────────────────────────────────────
function postSysMsg(db, groupId, actorId, content) {
const r = db.prepare(`INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'system')`).run(groupId, actorId, content);
const msg = 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, u.allow_dm as user_allow_dm
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(r.lastInsertRowid);
async function postSysMsg(schema, groupId, actorId, content) {
const r = await queryResult(schema,
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
[groupId, actorId, content]
);
const msg = await queryOne(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, u.allow_dm AS user_allow_dm
FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1
`, [r.rows[0].id]);
if (msg) { msg.reactions = []; io.to(`group:${groupId}`).emit('message:new', msg); }
}
// Add user silently — no system message (used during initial creation)
function addUserSilent(db, dmGroupId, userId) {
db.prepare("INSERT OR IGNORE INTO group_members (group_id, user_id, joined_at) VALUES (?, ?, datetime('now'))").run(dmGroupId, userId);
async function addUserSilent(schema, dmGroupId, userId) {
await exec(schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [dmGroupId, userId]);
io.in(`user:${userId}`).socketsJoin(`group:${dmGroupId}`);
const dmGroup = db.prepare('SELECT * FROM groups WHERE id = ?').get(dmGroupId);
const dmGroup = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]);
if (dmGroup) io.to(`user:${userId}`).emit('group:new', { group: dmGroup });
}
// Add user with system message (used when editing existing group)
function addUser(db, dmGroupId, userId, actorId) {
addUserSilent(db, dmGroupId, userId);
const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has joined the conversation.`);
async function addUser(schema, dmGroupId, userId, actorId) {
await addUserSilent(schema, dmGroupId, userId);
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
}
// Remove user with system message
function removeUser(db, dmGroupId, userId, actorId) {
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(dmGroupId, userId);
async function removeUser(schema, dmGroupId, userId, actorId) {
await exec(schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [dmGroupId, userId]);
io.in(`user:${userId}`).socketsLeave(`group:${dmGroupId}`);
io.to(`user:${userId}`).emit('group:deleted', { groupId: dmGroupId });
const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has been removed from the conversation.`);
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
}
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);
async function getUserIdsForGroup(schema, userGroupId) {
const rows = await query(schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [userGroupId]);
return rows.map(r => r.user_id);
}
// ── Current user's group memberships (no admin required) ────────────────────────
router.get('/me', authMiddleware, (req, res) => {
const db = getDb();
const groupIds = db.prepare('SELECT user_group_id FROM user_group_members WHERE user_id = ?').all(req.user.id).map(r => r.user_group_id);
res.json({ groupIds });
});
// ── MULTI-GROUP DMs — must come before /:id ───────────────────────────────────
router.get('/multigroup', authMiddleware, teamManagerMiddleware, (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();
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, teamManagerMiddleware, (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' });
}
// Check for duplicate user group set
const newGroupIds = [...new Set(userGroupIds.map(Number).filter(Boolean))].sort();
const allDms = db.prepare('SELECT id, name FROM multi_group_dms').all();
for (const existing of allDms) {
const existingIds = db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(existing.id).map(r => r.user_group_id).sort();
if (existingIds.length === newGroupIds.length && existingIds.every((id, i) => id === newGroupIds[i])) {
return res.status(400).json({ error: `DM not created — "${existing.name}" already exists with the same member groups.` });
// GET /me — current user's user-group memberships
router.get('/me', authMiddleware, async (req, res) => {
try {
const rows = await query(req.schema, 'SELECT user_group_id FROM user_group_members WHERE user_id=$1', [req.user.id]);
const groupIds = rows.map(r => r.user_group_id);
if (groupIds.length === 0) return res.json({ userGroups: [] });
const placeholders = groupIds.map((_,i) => `$${i+1}`).join(',');
const userGroups = await query(req.schema, `SELECT * FROM user_groups WHERE id IN (${placeholders}) ORDER BY name ASC`, groupIds);
// Also resolve multi-group DMs this user can see
const mgDms = await query(req.schema, `
SELECT mgd.*, (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id=mgd.id) AS group_count
FROM multi_group_dms mgd
JOIN multi_group_dm_members mgdm ON mgdm.multi_group_dm_id=mgd.id
WHERE mgdm.user_group_id IN (${placeholders})
GROUP BY mgd.id ORDER BY mgd.name ASC
`, groupIds);
for (const dm of mgDms) {
dm.memberGroupIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [dm.id])).map(r => r.user_group_id);
}
}
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);
for (const uid of getUserIdsForGroup(db, ugId)) {
if (!addedUsers.has(uid)) { addedUsers.add(uid); addUserSilent(db, dmGroupId, uid); }
}
}
const dm = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(mgId);
dm.memberGroupIds = validGroupIds;
dm.group_count = validGroupIds.length;
res.json({ dm });
res.json({ userGroups, multiGroupDms: mgDms });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, (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' });
// GET /multigroup
router.get('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const dms = await query(req.schema, `
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
`);
for (const dm of dms) {
dm.memberGroupIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [dm.id])).map(r => r.user_group_id);
}
res.json({ dms });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /multigroup
router.post('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
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' });
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
if (!Array.isArray(userGroupIds) || userGroupIds.length < 2) return res.status(400).json({ error: 'At least 2 groups required' });
try {
// Check for existing DM with same groups
const existing = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE LOWER(name)=LOWER($1)', [name.trim()]);
if (existing) {
const existingIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [existing.id])).map(r => r.user_group_id).sort();
const newIds = [...userGroupIds].map(Number).sort();
if (JSON.stringify(existingIds) === JSON.stringify(newIds)) return res.status(400).json({ error: 'A DM with these groups already exists' });
}
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);
}
// Create the chat group
const gr = await queryResult(req.schema,
"INSERT INTO groups (name,type,is_readonly,is_managed,is_multi_group) VALUES ($1,'private',FALSE,TRUE,TRUE) RETURNING id",
[name.trim()]
);
const dmGroupId = gr.rows[0].id;
// Create multi_group_dms record
const mgr = await queryResult(req.schema,
'INSERT INTO multi_group_dms (name,dm_group_id) VALUES ($1,$2) RETURNING id',
[name.trim(), dmGroupId]
);
const mgId = mgr.rows[0].id;
// Add each user group and their members
const addedUsers = new Set();
for (const ugId of userGroupIds) {
await exec(req.schema, 'INSERT INTO multi_group_dm_members (multi_group_dm_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mgId, ugId]);
const uids = await getUserIdsForGroup(req.schema, ugId);
for (const uid of uids) {
if (!addedUsers.has(uid)) {
addedUsers.add(uid);
await addUserSilent(req.schema, dmGroupId, uid);
}
}
}
const dmGroup = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]);
res.json({ dm: { id: mgId, name: name.trim(), dm_group_id: dmGroupId, group_count: userGroupIds.length }, group: dmGroup });
} catch (e) { res.status(500).json({ error: e.message }); }
});
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));
for (const ugId of newGroupIds) {
// PATCH /multigroup/:id
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { userGroupIds } = req.body;
try {
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
if (!mg) return res.status(404).json({ error: 'Not found' });
if (!Array.isArray(userGroupIds)) return res.status(400).json({ error: 'userGroupIds required' });
const currentGroupIds = new Set((await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [mg.id])).map(r => r.user_group_id));
const newGroupSet = new Set(userGroupIds.map(Number));
for (const ugId of newGroupSet) {
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);
// Add users silently — no per-user notifications in multi-group DMs
for (const uid of getUserIdsForGroup(db, ugId)) addUserSilent(db, mg.dm_group_id, uid);
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.`);
await exec(req.schema, 'INSERT INTO multi_group_dm_members (multi_group_dm_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mg.id, ugId]);
const uids = await getUserIdsForGroup(req.schema, ugId);
for (const uid of uids) await addUserSilent(req.schema, mg.dm_group_id, uid);
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A new group has joined this conversation.`);
}
}
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);
// Remove users silently — no per-user notifications in multi-group DMs
for (const uid of getUserIdsForGroup(db, ugId)) {
const stillIn = 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 (!newGroupSet.has(ugId)) {
await exec(req.schema, 'DELETE FROM multi_group_dm_members WHERE multi_group_dm_id=$1 AND user_group_id=$2', [mg.id, ugId]);
const uids = await getUserIdsForGroup(req.schema, ugId);
for (const uid of uids) {
const stillIn = await queryOne(req.schema, `
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=$1 AND ugm.user_id=$2
`, [mg.id, uid]);
if (!stillIn) {
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(mg.dm_group_id, uid);
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_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.`);
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group 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);
updated.group_count = updated.memberGroupIds.length;
res.json({ dm: updated });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, (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 });
// DELETE /multigroup/:id
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
if (!mg) return res.status(404).json({ error: 'Not found' });
if (mg.dm_group_id) {
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [mg.dm_group_id])).map(r => r.user_id);
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [mg.dm_group_id]);
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
}
await exec(req.schema, 'DELETE FROM multi_group_dms WHERE id=$1', [mg.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── USER GROUPS ───────────────────────────────────────────────────────────────
router.get('/', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const groups = db.prepare(`
SELECT ug.*,
(SELECT COUNT(*) FROM user_group_members WHERE user_group_id = ug.id) as member_count
FROM user_groups ug ORDER BY ug.name ASC
`).all();
res.json({ groups });
// GET / — list all user groups
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const groups = await query(req.schema, `
SELECT ug.*, (SELECT COUNT(*) FROM user_group_members WHERE user_group_id=ug.id) AS member_count
FROM user_groups ug ORDER BY ug.name ASC
`);
res.json({ groups });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/:id', authMiddleware, teamManagerMiddleware, (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 });
// GET /:id
router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
if (!group) return res.status(404).json({ error: 'Not found' });
const members = await query(req.schema, `
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=$1 ORDER BY u.name ASC
`, [req.params.id]);
res.json({ group, members });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
// POST / — create user group
router.post('/', authMiddleware, teamManagerMiddleware, async (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' });
}
// Check for duplicate member set
const newIds = [...new Set((Array.isArray(memberIds) ? memberIds : []).map(Number).filter(Boolean))].sort();
if (newIds.length > 0) {
const allGroups = db.prepare('SELECT id, name FROM user_groups').all();
for (const existing of allGroups) {
const existingIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(existing.id).map(r => r.user_id).sort();
if (existingIds.length === newIds.length && existingIds.every((id, i) => id === newIds[i])) {
return res.status(400).json({ error: `Group not created — "${existing.name}" already exists with the same members.` });
}
try {
const existing = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1)', [name.trim()]);
if (existing) return res.status(400).json({ error: 'Name already in use' });
// Create the managed DM group
const gr = await queryResult(req.schema,
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
[name.trim()]
);
const dmGroupId = gr.rows[0].id;
const ugr = await queryResult(req.schema,
'INSERT INTO user_groups (name,dm_group_id) VALUES ($1,$2) RETURNING id',
[name.trim(), dmGroupId]
);
const ugId = ugr.rows[0].id;
for (const uid of memberIds) {
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ugId, uid]);
await addUserSilent(req.schema, dmGroupId, uid);
}
}
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;
for (const uid of (Array.isArray(memberIds) ? memberIds.map(Number).filter(Boolean) : [])) {
db.prepare("INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)").run(ugId, uid);
addUserSilent(db, dmGroupId, uid);
}
const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(ugId);
res.json({ group });
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [ugId]);
res.json({ userGroup: ug });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/:id', authMiddleware, teamManagerMiddleware, (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' });
// PATCH /:id
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, memberIds } = req.body;
try {
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
if (!ug) return res.status(404).json({ error: 'Not found' });
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 currentSet = new Set(db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(ug.id).map(r => r.user_id));
const addedUids = [];
const removedUids = [];
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);
// Add to UG DM with individual notification
addUser(db, ug.dm_group_id, uid, req.user.id);
addedUids.push(uid);
}
}
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);
// For managed DMs, membership is controlled solely by the user group — always remove
removeUser(db, ug.dm_group_id, uid, req.user.id);
removedUids.push(uid);
}
if (name && name.trim() !== ug.name) {
const conflict = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), ug.id]);
if (conflict) return res.status(400).json({ error: 'Name already in use' });
await exec(req.schema, 'UPDATE user_groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.id]);
if (ug.dm_group_id) await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.dm_group_id]);
}
// For multi-group DMs: add/remove users silently, post group-level notification once
const mgDms = db.prepare('SELECT mgd.id, 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) continue;
for (const uid of addedUids) addUserSilent(db, mg.dm_group_id, uid);
for (const uid of removedUids) {
const stillInMg = 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 (!stillInMg) {
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(mg.dm_group_id, uid);
io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
if (Array.isArray(memberIds) && ug.dm_group_id) {
const newIds = new Set(memberIds.map(Number).filter(Boolean));
const currentSet = new Set((await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [ug.id])).map(r => r.user_id));
const addedUids = [], removedUids = [];
for (const uid of newIds) {
if (!currentSet.has(uid)) {
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, uid]);
await addUser(req.schema, ug.dm_group_id, uid, req.user.id);
addedUids.push(uid);
}
}
for (const uid of currentSet) {
if (!newIds.has(uid)) {
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, uid]);
await removeUser(req.schema, ug.dm_group_id, uid, req.user.id);
removedUids.push(uid);
}
}
if (addedUids.length > 0) postSysMsg(db, mg.dm_group_id, req.user.id, `Members were added to group "${ug.name}" and have joined this conversation.`);
if (removedUids.length > 0) postSysMsg(db, mg.dm_group_id, req.user.id, `Members were removed from group "${ug.name}" and have left this conversation.`);
}
}
const updated = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
res.json({ group: updated });
// Propagate to multi-group DMs
const mgDms = await query(req.schema, `
SELECT mgd.id, 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=$1
`, [ug.id]);
for (const mg of mgDms) {
if (!mg.dm_group_id) continue;
for (const uid of addedUids) await addUserSilent(req.schema, mg.dm_group_id, uid);
for (const uid of removedUids) {
const stillIn = await queryOne(req.schema, `
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=$1 AND ugm.user_id=$2
`, [mg.id, uid]);
if (!stillIn) {
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
}
}
if (addedUids.length > 0) await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `Members were added to group "${ug.name}" and have joined this conversation.`);
if (removedUids.length > 0) await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `Members were removed from group "${ug.name}" and have left this conversation.`);
}
}
const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
res.json({ group: updated });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/:id', authMiddleware, teamManagerMiddleware, (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 });
// DELETE /:id
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
if (!ug) return res.status(404).json({ error: 'Not found' });
if (ug.dm_group_id) {
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [ug.dm_group_id])).map(r => r.user_id);
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [ug.dm_group_id]);
for (const uid of members) { io.in(`user:${uid}`).socketsLeave(`group:${ug.dm_group_id}`); io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id }); }
}
await exec(req.schema, 'DELETE FROM user_groups WHERE id=$1', [ug.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;

View File

@@ -1,318 +1,264 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
const router = express.Router();
const { getDb, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
const router = express.Router();
const { query, queryOne, queryResult, exec, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
const avatarStorage = multer.diskStorage({
destination: '/app/uploads/avatars',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
}
filename: (req, file, cb) => cb(null, `avatar_${req.user.id}_${Date.now()}${path.extname(file.originalname)}`),
});
const uploadAvatar = multer({
storage: avatarStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
});
// Resolve unique name: "John Doe" exists → return "John Doe (1)", then "(2)" etc.
function resolveUniqueName(db, baseName, excludeId = null) {
const existing = db.prepare(
"SELECT name FROM users WHERE status != 'deleted' AND id != ? AND (name = ? OR name LIKE ?)"
).all(excludeId ?? -1, baseName, `${baseName} (%)`);
async function resolveUniqueName(schema, baseName, excludeId = null) {
const existing = await query(schema,
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
[excludeId ?? -1, baseName, `${baseName} (%)`]
);
if (existing.length === 0) return baseName;
let max = 0;
for (const u of existing) {
const m = u.name.match(/\((\d+)\)$/);
if (m) max = Math.max(max, parseInt(m[1]));
else max = Math.max(max, 0);
}
for (const u of existing) { const m = u.name.match(/\((\d+)\)$/); if (m) max = Math.max(max, parseInt(m[1])); else max = Math.max(max, 0); }
return `${baseName} (${max + 1})`;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
function getDefaultPassword(db) {
return process.env.USER_PASS || 'user@1234';
}
// List users (admin)
router.get('/', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const users = db.prepare(`
SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, allow_dm, created_at, last_online
FROM users WHERE status != 'deleted'
ORDER BY created_at ASC
`).all();
res.json({ users });
// List users
router.get('/', authMiddleware, adminMiddleware, async (req, res) => {
try {
const users = await query(req.schema,
"SELECT id,name,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC"
);
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Search users (public-ish for mentions/add-member)
router.get('/search', authMiddleware, (req, res) => {
// Search users
router.get('/search', authMiddleware, async (req, res) => {
const { q, groupId } = req.query;
const db = getDb();
let users;
if (groupId) {
const group = db.prepare('SELECT type, is_direct FROM groups WHERE id = ?').get(parseInt(groupId));
if (group && (group.type === 'private' || group.is_direct)) {
// Private group or direct message — only show members of this group
users = db.prepare(`
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status, u.hide_admin_tag, u.allow_dm
FROM users u
JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ?
WHERE u.status = 'active' AND u.id != ?
AND (u.name LIKE ? OR u.display_name LIKE ?)
LIMIT 10
`).all(parseInt(groupId), req.user.id, `%${q}%`, `%${q}%`);
try {
let users;
if (groupId) {
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
if (group && (group.type === 'private' || group.is_direct)) {
users = await query(req.schema,
"SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) LIMIT 10",
[parseInt(groupId), req.user.id, `%${q}%`]
);
} else {
users = await query(req.schema,
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) LIMIT 10",
[req.user.id, `%${q}%`]
);
}
} else {
// Public group — all active users
users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
WHERE status = 'active' AND id != ? AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(req.user.id, `%${q}%`, `%${q}%`);
users = await query(req.schema,
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) LIMIT 10",
[`%${q}%`]
);
}
} else {
users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(`%${q}%`, `%${q}%`);
}
res.json({ users });
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Check if a display name is already taken (excludes self)
router.get('/check-display-name', authMiddleware, (req, res) => {
// Check display name
router.get('/check-display-name', authMiddleware, async (req, res) => {
const { name } = req.query;
if (!name) return res.json({ taken: false });
const db = getDb();
const conflict = db.prepare(
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
).get(name, req.user.id);
res.json({ taken: !!conflict });
try {
const conflict = await queryOne(req.schema,
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
[name, req.user.id]
);
res.json({ taken: !!conflict });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Create user (admin) — req 3: skip duplicate email, req 4: suffix duplicate names
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
// Create user
router.post('/', authMiddleware, adminMiddleware, async (req, res) => {
const { name, email, password, role } = req.body;
if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
const db = getDb();
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = resolveUniqueName(db, name.trim());
const pw = (password || '').trim() || getDefaultPassword(db);
const hash = bcrypt.hashSync(pw, 10);
const result = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(result.lastInsertRowid);
// Admin users are automatically added to the Support group
if (role === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, result.lastInsertRowid);
try {
const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email = $1', [email]);
if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = await resolveUniqueName(req.schema, name.trim());
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
const hash = bcrypt.hashSync(pw, 10);
const r = await queryResult(req.schema,
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
[resolvedName, email, hash, role === 'admin' ? 'admin' : 'member']
);
const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId);
if (role === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
}
}
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
res.json({ user });
const user = await queryOne(req.schema, 'SELECT id,name,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Bulk create users
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
// Bulk create
router.post('/bulk', authMiddleware, adminMiddleware, async (req, res) => {
const { users } = req.body;
const db = getDb();
const results = { created: [], skipped: [] };
const seenEmails = new Set();
const defaultPw = getDefaultPassword(db);
const insertUser = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`);
for (const u of users) {
const email = (u.email || '').trim().toLowerCase();
const name = (u.name || '').trim();
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
seenEmails.add(email);
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
try {
const resolvedName = resolveUniqueName(db, name);
const pw = (u.password || '').trim() || defaultPw;
const hash = bcrypt.hashSync(pw, 10);
const newRole = u.role === 'admin' ? 'admin' : 'member';
const r = insertUser.run(resolvedName, email, hash, newRole);
addUserToPublicGroups(r.lastInsertRowid);
if (newRole === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, r.lastInsertRowid);
const defaultPw = process.env.USER_PASS || 'user@1234';
try {
for (const u of users) {
const email = (u.email || '').trim().toLowerCase();
const name = (u.name || '').trim();
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
seenEmails.add(email);
const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email=$1', [email]);
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
try {
const resolvedName = await resolveUniqueName(req.schema, name);
const pw = (u.password || '').trim() || defaultPw;
const hash = bcrypt.hashSync(pw, 10);
const newRole = u.role === 'admin' ? 'admin' : 'member';
const r = await queryResult(req.schema,
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
[resolvedName, email, hash, newRole]
);
await addUserToPublicGroups(req.schema, r.rows[0].id);
if (newRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, r.rows[0].id]);
}
}
results.created.push(email);
} catch (e) {
results.skipped.push({ email, reason: e.message });
results.created.push(email);
} catch (e) { results.skipped.push({ email, reason: e.message }); }
}
}
res.json(results);
res.json(results);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update user name (admin only — req 5)
router.patch('/:id/name', authMiddleware, adminMiddleware, (req, res) => {
// Patch name
router.patch('/:id/name', authMiddleware, adminMiddleware, async (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
// Pass the target's own id so their current name is excluded from the duplicate check
const resolvedName = resolveUniqueName(db, name.trim(), req.params.id);
db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(resolvedName, target.id);
res.json({ success: true, name: resolvedName });
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
try {
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!target) return res.status(404).json({ error: 'User not found' });
const resolvedName = await resolveUniqueName(req.schema, name.trim(), req.params.id);
await exec(req.schema, 'UPDATE users SET name=$1, updated_at=NOW() WHERE id=$2', [resolvedName, target.id]);
res.json({ success: true, name: resolvedName });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update user role (admin)
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
// Patch role
router.patch('/:id/role', authMiddleware, adminMiddleware, async (req, res) => {
const { role } = req.body;
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
// If promoted to admin, ensure they're in the Support group
if (role === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, target.id);
if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
try {
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
await exec(req.schema, 'UPDATE users SET role=$1, updated_at=NOW() WHERE id=$2', [role, target.id]);
if (role === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, target.id]);
}
}
res.json({ success: true });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Reset user password (admin)
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, (req, res) => {
// Reset password
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, async (req, res) => {
const { password } = req.body;
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
const db = getDb();
const hash = bcrypt.hashSync(password, 10);
db.prepare("UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now') WHERE id = ?").run(hash, req.params.id);
res.json({ success: true });
try {
const hash = bcrypt.hashSync(password, 10);
await exec(req.schema, 'UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE id=$2', [hash, req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Suspend user (admin)
router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
// Suspend / activate / delete
router.patch('/:id/suspend', authMiddleware, adminMiddleware, async (req, res) => {
try {
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!t) return res.status(404).json({ error: 'User not found' });
if (t.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
await exec(req.schema, "UPDATE users SET status='suspended', updated_at=NOW() WHERE id=$1", [t.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/:id/activate', authMiddleware, adminMiddleware, async (req, res) => {
try {
await exec(req.schema, "UPDATE users SET status='active', updated_at=NOW() WHERE id=$1", [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/:id', authMiddleware, adminMiddleware, async (req, res) => {
try {
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!t) return res.status(404).json({ error: 'User not found' });
if (t.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
await exec(req.schema, "UPDATE users SET status='deleted', updated_at=NOW() WHERE id=$1", [t.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Activate user (admin)
router.patch('/:id/activate', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
db.prepare("UPDATE users SET status = 'active', updated_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
});
// Delete user (admin)
router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
});
// Update own profile — display name must be unique (req 6)
router.patch('/me/profile', authMiddleware, (req, res) => {
// Update own profile
router.patch('/me/profile', authMiddleware, async (req, res) => {
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
const db = getDb();
if (displayName) {
const conflict = db.prepare(
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
).get(displayName, req.user.id);
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
}
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, allow_dm = ?, updated_at = datetime('now') WHERE id = ?")
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, allowDm === false ? 0 : 1, req.user.id);
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag, allow_dm FROM users WHERE id = ?').get(req.user.id);
res.json({ user });
try {
if (displayName) {
const conflict = await queryOne(req.schema,
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
[displayName, req.user.id]
);
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
}
await exec(req.schema,
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id]
);
const user = await queryOne(req.schema,
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm FROM users WHERE id=$1',
[req.user.id]
);
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Upload avatar — resize if needed, skip compression for files under 500 KB
// Upload avatar
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const sharp = require('sharp');
const sharp = require('sharp');
const filePath = req.file.path;
const fileSizeBytes = req.file.size;
const FIVE_HUNDRED_KB = 500 * 1024;
const MAX_DIM = 256; // max width/height in pixels
const image = sharp(filePath);
const meta = await image.metadata();
const needsResize = (meta.width > MAX_DIM || meta.height > MAX_DIM);
if (fileSizeBytes < FIVE_HUNDRED_KB && !needsResize) {
// Small enough and already correctly sized — serve as-is
} else {
// Resize (and compress only if over 500 KB)
const outPath = filePath.replace(/(\.[^.]+)$/, '_p$1');
let pipeline = sharp(filePath).resize(MAX_DIM, MAX_DIM, { fit: 'cover', withoutEnlargement: true });
if (fileSizeBytes >= FIVE_HUNDRED_KB) {
// Compress: use webp for best size/quality ratio
pipeline = pipeline.webp({ quality: 82 });
await pipeline.toFile(outPath + '.webp');
const fs = require('fs');
fs.unlinkSync(filePath);
fs.renameSync(outPath + '.webp', filePath.replace(/\.[^.]+$/, '.webp'));
const newPath = filePath.replace(/\.[^.]+$/, '.webp');
const newFilename = path.basename(newPath);
const db = getDb();
const avatarUrl = `/uploads/avatars/${newFilename}`;
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
return res.json({ avatarUrl });
} else {
// Under 500 KB but needs resize — resize only, keep original format
await pipeline.toFile(outPath);
const fs = require('fs');
fs.unlinkSync(filePath);
fs.renameSync(outPath, filePath);
}
const MAX_DIM = 256;
const image = sharp(filePath);
const meta = await image.metadata();
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
if (req.file.size >= 500 * 1024 || needsResize) {
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
const fs = require('fs');
fs.unlinkSync(filePath);
const avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
return res.json({ avatarUrl });
}
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
res.json({ avatarUrl });
} catch (err) {
console.error('Avatar processing error:', err);
// Fall back to serving unprocessed file
console.error('Avatar error:', err);
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]).catch(()=>{});
res.json({ avatarUrl });
}
});