import { Router, Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import multer from 'multer'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { prisma, bigintToNumber } from '../lib/prisma'; import { authMiddleware } from '../lib/auth'; const router = Router(); router.use(authMiddleware); const str = (v: string | string[] | undefined): string => Array.isArray(v) ? v[0] ?? '' : (v ?? ''); // GET /api/users — list all users (admin only, with search) router.get('/', async (req: Request, res: Response) => { try { if (req.user!.globalRole !== 'ADMIN') { res.status(403).json({ error: 'Forbidden — admin only' }); return; } const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; // storageUsed = sum of fileSize of all assets in projects this user owns const users = await prisma.user.findMany({ where: search ? { OR: [ { name: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, ], } : {}, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, storageQuota: true, createdAt: true, // total storage used = sum of assets in projects this user owns projects: { select: { id: true, assets: { select: { fileSize: true }, }, }, }, _count: { select: { memberships: true, comments: true, }, }, }, orderBy: { createdAt: 'desc' }, }); // Compute storageUsed and ownedProjects from owned projects const usersWithStorage = users.map(u => ({ ...u, storageUsed: u.projects.reduce( (sum, p) => sum + p.assets.reduce((s, a) => s + Number(a.fileSize), 0), 0 ), ownedProjects: u.projects.length, })); res.json({ users: usersWithStorage.map(bigintToNumber) }); } catch (err) { console.error('List users error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // GET /api/users/me — get current user profile router.get('/me', async (req: Request, res: Response) => { try { const user = await prisma.user.findUnique({ where: { id: req.user!.userId }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, createdAt: true, _count: { select: { memberships: true, comments: true, }, }, }, }); if (!user) { res.status(404).json({ error: 'User not found' }); return; } res.json({ user }); } catch (err) { console.error('Get me error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // PUT /api/users/:id/quota — update storage quota (admin only) router.put('/:id/quota', async (req: Request, res: Response) => { try { if (req.user!.globalRole !== 'ADMIN') { res.status(403).json({ error: 'Forbidden — admin only' }); return; } const { storageQuota } = req.body; if (typeof storageQuota !== 'number' || !Number.isInteger(storageQuota) || storageQuota < 0) { res.status(400).json({ error: 'storageQuota must be a non-negative integer (bytes)' }); return; } // Allow max 1 TB const MAX_QUOTA = 1024 * 1024 * 1024 * 1024; if (storageQuota > MAX_QUOTA) { res.status(400).json({ error: `storageQuota cannot exceed ${MAX_QUOTA} bytes (1 TB)` }); return; } const user = await prisma.user.findUnique({ where: { id: str(req.params.id) }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, storageUsed: true }, }); if (!user) { res.status(404).json({ error: 'User not found' }); return; } await prisma.user.update({ where: { id: str(req.params.id) }, data: { storageQuota }, }); res.json({ user: bigintToNumber({ ...user, storageQuota, storageUsed: user.storageUsed ?? BigInt(0) }), }); } catch (err) { console.error('Update quota error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // PUT /api/users/me — update current user profile router.put('/me', async (req: Request, res: Response) => { try { const { name, avatarUrl, currentPassword, newPassword } = req.body; if (newPassword) { if (!currentPassword) { res.status(400).json({ error: 'currentPassword is required to change password' }); return; } if (newPassword.length < 6) { res.status(400).json({ error: 'New password must be at least 6 characters' }); return; } const user = await prisma.user.findUnique({ where: { id: req.user!.userId } }); if (!user) { res.status(404).json({ error: 'User not found' }); return; } const valid = await bcrypt.compare(currentPassword, user.password); if (!valid) { res.status(401).json({ error: 'Current password is incorrect' }); return; } const hashed = await bcrypt.hash(newPassword, 12); const updated = await prisma.user.update({ where: { id: req.user!.userId }, data: { name: name?.trim() || undefined, avatarUrl: avatarUrl ?? undefined, password: hashed, }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, }, }); res.json({ user: updated }); return; } const updated = await prisma.user.update({ where: { id: req.user!.userId }, data: { name: name?.trim() || undefined, avatarUrl: avatarUrl ?? undefined, }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, }, }); res.json({ user: updated }); } catch (err) { console.error('Update me error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // ── Avatar upload ──────────────────────────────────────────────────────────── const avatarStorage = multer.diskStorage({ destination: (_req, _file, cb) => { cb(null, path.resolve(__dirname, '../../uploads/avatars')); }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; cb(null, `${uuidv4()}${ext}`); }, }); const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB fileFilter: (_req, file, cb) => { if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Only JPEG, PNG, WebP, or GIF images are allowed')); } }, }); // POST /api/users/me/avatar — upload avatar image router.post('/me/avatar', avatarUpload.single('avatar'), async (req: Request, res: Response) => { try { if (!req.file) { res.status(400).json({ error: 'No file uploaded' }); return; } const avatarUrl = `/uploads/avatars/${req.file!.filename}`; const updated = await prisma.user.update({ where: { id: req.user!.userId }, data: { avatarUrl }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true }, }); res.json({ user: updated, avatarUrl }); } catch (err) { console.error('Avatar upload error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // PUT /api/users/:id/role — change user globalRole (admin only) router.put('/:id/role', async (req: Request, res: Response) => { try { if (req.user!.globalRole !== 'ADMIN') { res.status(403).json({ error: 'Forbidden — admin only' }); return; } const { role } = req.body; const validRoles = ['ADMIN', 'MEMBER', 'PROJECT_USER']; if (!validRoles.includes(role)) { res.status(400).json({ error: 'Invalid globalRole. Must be ADMIN, MEMBER, or PROJECT_USER' }); return; } const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } }); if (!user) { res.status(404).json({ error: 'User not found' }); return; } // Prevent last admin from being demoted if (role !== 'ADMIN') { const adminCount = await prisma.user.count({ where: { globalRole: 'ADMIN', active: true } }); if (adminCount <= 1 && user.globalRole === 'ADMIN') { res.status(400).json({ error: 'Cannot demote the last system admin' }); return; } } const updated = await prisma.user.update({ where: { id: str(req.params.id) }, data: { globalRole: role as any }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, createdAt: true, }, }); res.json({ user: updated }); } catch (err) { console.error('Update globalRole error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // PUT /api/users/:id/active — activate/deactivate user (admin only) router.put('/:id/active', async (req: Request, res: Response) => { try { if (req.user!.globalRole !== 'ADMIN') { res.status(403).json({ error: 'Forbidden — admin only' }); return; } const { active } = req.body; if (typeof active !== 'boolean') { res.status(400).json({ error: 'active must be a boolean' }); return; } const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } }); if (!user) { res.status(404).json({ error: 'User not found' }); return; } if (user.id === req.user!.userId) { res.status(400).json({ error: 'Cannot deactivate your own account' }); return; } if (!active && user.globalRole === 'ADMIN') { const adminCount = await prisma.user.count({ where: { globalRole: 'ADMIN', active: true } }); if (adminCount <= 1) { res.status(400).json({ error: 'Cannot deactivate the last admin' }); return; } } const updated = await prisma.user.update({ where: { id: str(req.params.id) }, data: { active }, select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true, createdAt: true, }, }); res.json({ user: updated }); } catch (err) { console.error('Update active error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // DELETE /api/users/:id — delete user (admin only) router.delete('/:id', async (req: Request, res: Response) => { try { if (req.user!.globalRole !== 'ADMIN') { res.status(403).json({ error: 'Forbidden — admin only' }); return; } const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } }); if (!user) { res.status(404).json({ error: 'User not found' }); return; } if (user.id === req.user!.userId) { res.status(400).json({ error: 'Cannot delete your own account' }); return; } await prisma.user.delete({ where: { id: str(req.params.id) } }); res.json({ message: 'User deleted' }); } catch (err) { console.error('Delete user error:', err); res.status(500).json({ error: 'Internal server error' }); } }); export default router;