This commit is contained in:
2026-03-09 14:36:19 -04:00
parent f37fe0086f
commit 42ad779750
40 changed files with 1928 additions and 593 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "teamchat-backend",
"version": "1.0.0",
"name": "jama-backend",
"version": "0.3.0",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -32,9 +32,10 @@ app.use('/uploads', express.static('/app/uploads'));
// API Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/users', require('./routes/users'));
app.use('/api/groups', require('./routes/groups'));
app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about'));
app.use('/api/push', pushRouter);
// Link preview proxy
@@ -128,14 +129,27 @@ io.on('connection', (socket) => {
// Broadcast online status
io.emit('user:online', { userId });
// Join personal room for direct notifications
socket.join(`user:${userId}`);
// Join rooms for all user's groups
const db = getDb();
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
for (const g of publicGroups) socket.join(`group:${g.id}`);
const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId);
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
// When a new group is created and pushed to this socket, join its room
socket.on('group:join-room', ({ groupId }) => {
socket.join(`group:${groupId}`);
});
// When a user leaves a group, remove them from the socket room
socket.on('group:leave-room', ({ groupId }) => {
socket.leave(`group:${groupId}`);
});
// Handle new message
socket.on('message:send', async (data) => {
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
@@ -271,12 +285,31 @@ io.on('connection', (socket) => {
socket.on('message:delete', (data) => {
const { messageId } = data;
const db = getDb();
const message = db.prepare('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 = ?').get(messageId);
const message = db.prepare(`
SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_direct
FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?
`).get(messageId);
if (!message) return;
const canDelete = message.user_id === userId ||
(socket.user.role === 'admin' && message.group_type === 'public') ||
(message.group_type === 'private' && message.group_owner_id === userId);
const isAdmin = socket.user.role === 'admin';
const isOwner = message.group_owner_id === userId;
const isAuthor = message.user_id === userId;
// Rules:
// 1. Author can always delete their own message
// 2. Admin can delete in any public group or any group they're a member of
// 3. Group owner can delete any message in their group
// 4. In direct messages: author + owner rules apply (no blanket block)
let canDelete = isAuthor || isOwner;
if (!canDelete && isAdmin) {
if (message.group_type === 'public') {
canDelete = true;
} else {
// Admin can delete in private/direct groups they're a member of
const membership = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(message.group_id, userId);
if (membership) canDelete = true;
}
}
if (!canDelete) return;

View File

@@ -166,6 +166,22 @@ function initDb() {
}
} catch (e) { console.error('[DB] active_sessions migration error:', e.message); }
// Migration: add is_direct for user-to-user direct messages
try {
db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added is_direct column');
} catch (e) { /* column already exists */ }
// Migration: store both peer IDs so direct-message names survive member leave
try {
db.exec("ALTER TABLE groups ADD COLUMN direct_peer1_id INTEGER");
console.log('[DB] Migration: added direct_peer1_id column');
} catch (e) { /* column already exists */ }
try {
db.exec("ALTER TABLE groups ADD COLUMN direct_peer2_id INTEGER");
console.log('[DB] Migration: added direct_peer2_id column');
} catch (e) { /* column already exists */ }
console.log('[DB] Schema initialized');
return db;
}

View File

@@ -0,0 +1,40 @@
const express = require('express');
const router = express.Router();
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',
developer: 'Ricky Stretch',
license: 'AGPL 3.0',
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',
description: 'Self-hosted, privacy-first team messaging.',
};
// GET /api/about — public, no auth required
router.get('/', (req, res) => {
let overrides = {};
try {
if (fs.existsSync(ABOUT_FILE)) {
const raw = fs.readFileSync(ABOUT_FILE, 'utf8');
overrides = JSON.parse(raw);
}
} catch (e) {
console.warn('about.json parse error:', e.message);
}
// Version always comes from the runtime env (same source as Settings window)
const about = {
...DEFAULTS,
...overrides,
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
};
// Never expose docker_image — removed from UI
delete about.docker_image;
res.json({ about });
});
module.exports = router;

View File

@@ -3,14 +3,52 @@ const router = express.Router();
const { getDb } = 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);
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 });
}
}
}
// 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);
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 });
}
}
// 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;
// Public groups (all users are members)
const publicGroups = db.prepare(`
SELECT g.*,
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
@@ -19,9 +57,9 @@ router.get('/', authMiddleware, (req, res) => {
ORDER BY g.is_default DESC, g.name ASC
`).all();
// Private groups (user is a member)
const privateGroups = db.prepare(`
SELECT g.*,
// 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,
@@ -33,34 +71,98 @@ router.get('/', authMiddleware, (req, res) => {
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 FROM users WHERE id = ?').get(otherUserId);
if (other) g.name = other.display_name || other.name;
}
}
return g;
});
res.json({ publicGroups, privateGroups });
});
// Create group
router.post('/', authMiddleware, (req, res) => {
const { name, type, memberIds, isReadonly } = req.body;
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];
const userId = req.user.id;
// 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);
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) });
}
// 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 });
}
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_readonly)
VALUES (?, ?, ?, ?)
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') {
// Add all users to public group
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 {
// Add creator
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id);
// Add other members
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);
@@ -68,6 +170,10 @@ router.post('/', authMiddleware, (req, res) => {
}
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
// Notify all members via socket
emitGroupNew(io, groupId);
res.json({ group });
});
@@ -77,14 +183,14 @@ router.patch('/:id/rename', 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 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 });
});
@@ -108,15 +214,37 @@ router.post('/:id/members', authMiddleware, (req, res) => {
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 });
});
// Remove a member from a private group (owner or admin only)
// 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);
@@ -126,9 +254,7 @@ router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
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' });
}
if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId);
res.json({ success: true });
});
@@ -140,11 +266,43 @@ router.delete('/:id/leave', authMiddleware, (req, res) => {
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' });
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, req.user.id);
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);
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);
}
// Tell the leaver's socket to leave the group room and remove from sidebar
io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id });
}
res.json({ success: true });
});
// Admin take ownership of private group
// Admin take ownership
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
@@ -152,7 +310,7 @@ router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) =
res.json({ success: true });
});
// Delete group (admin or private group owner)
// Delete group
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
@@ -163,8 +321,21 @@ router.delete('/:id', authMiddleware, (req, res) => {
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); });
}
db.prepare('DELETE FROM groups WHERE id = ?').run(group.id);
// Notify all affected users
emitGroupDeleted(io, group.id, members);
res.json({ success: true });
});
module.exports = router;
return router;
};

View File

@@ -39,7 +39,8 @@ router.get('/', (req, res) => {
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.TEAMCHAT_VERSION || 'dev';
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 });
});

View File

@@ -2,7 +2,6 @@ const express = require('express');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const router = express.Router();
const { getDb, addUserToPublicGroups } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
@@ -14,8 +13,8 @@ const avatarStorage = multer.diskStorage({
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
}
});
const uploadAvatar = multer({
storage: avatarStorage,
const uploadAvatar = multer({
storage: avatarStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
@@ -23,6 +22,29 @@ const uploadAvatar = multer({
}
});
// 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} (%)`);
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);
}
return `${baseName} (${max + 1})`;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function getDefaultPassword(db) {
return process.env.USER_PASS || 'user@1234';
}
// List users (admin)
router.get('/', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
@@ -34,75 +56,102 @@ router.get('/', authMiddleware, adminMiddleware, (req, res) => {
res.json({ users });
});
// Get single user profile (public-ish for mentions)
// Search users (public-ish for mentions/add-member)
router.get('/search', authMiddleware, (req, res) => {
const { q } = req.query;
const db = getDb();
const users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users
SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(`%${q}%`, `%${q}%`);
res.json({ users });
});
// Create user (admin)
// Check if a display name is already taken (excludes self)
router.get('/check-display-name', authMiddleware, (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 });
});
// Create user (admin) — req 3: skip duplicate email, req 4: suffix duplicate names
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
const { name, email, password, role } = req.body;
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, password required' });
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 hash = bcrypt.hashSync(password, 10);
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(name, email, hash, role === 'admin' ? 'admin' : 'member');
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(result.lastInsertRowid);
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 });
});
// Bulk create users via CSV data
// Bulk create users
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
const { users } = req.body; // array of {name, email, password, role}
const { users } = req.body;
const db = getDb();
const results = { created: [], errors: [] };
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)
`);
const transaction = db.transaction((users) => {
for (const u of users) {
if (!u.name || !u.email || !u.password) {
results.errors.push({ email: u.email, error: 'Missing required fields' });
continue;
}
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(u.email);
if (exists) {
results.errors.push({ email: u.email, error: 'Email already exists' });
continue;
}
try {
const hash = bcrypt.hashSync(u.password, 10);
const r = insertUser.run(u.name, u.email, hash, u.role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(r.lastInsertRowid);
results.created.push(u.email);
} catch (e) {
results.errors.push({ email: u.email, error: e.message });
}
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 r = insertUser.run(resolvedName, email, hash, u.role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(r.lastInsertRowid);
results.created.push(email);
} catch (e) {
results.skipped.push({ email, reason: e.message });
}
});
}
transaction(users);
res.json(results);
});
// Update user name (admin only — req 5)
router.patch('/:id/name', authMiddleware, adminMiddleware, (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 });
});
// Update user role (admin)
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
const { role } = req.body;
@@ -111,7 +160,6 @@ router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
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);
res.json({ success: true });
});
@@ -132,7 +180,6 @@ router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
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 });
});
@@ -150,28 +197,80 @@ router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
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
// Update own profile — display name must be unique (req 6)
router.patch('/me/profile', authMiddleware, (req, res) => {
const { displayName, aboutMe, hideAdminTag } = 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 = ?, updated_at = datetime('now') WHERE id = ?")
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, req.user.id);
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag FROM users WHERE id = ?').get(req.user.id);
res.json({ user });
});
// Upload avatar
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), (req, res) => {
// Upload avatar — resize if needed, skip compression for files under 500 KB
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
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);
res.json({ avatarUrl });
try {
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 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);
res.json({ avatarUrl });
} catch (err) {
console.error('Avatar processing error:', err);
// Fall back to serving unprocessed file
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);
res.json({ avatarUrl });
}
});
module.exports = router;