v0.9.88 major change sqlite to postgres
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
312
backend/src/routes/host.js
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user