import { Router, Request, Response, NextFunction } from 'express'; import bcrypt from 'bcryptjs'; import { z } from 'zod'; import prisma from '../lib/prisma'; import { authenticate, authorize } from '../middleware/auth'; import { Role } from '@prisma/client'; const router = Router(); const createUserSchema = z.object({ email: z.string().email('Invalid email format'), password: z.string().min(6, 'Password must be at least 6 characters'), firstName: z.string().min(1, 'First name is required').max(100), lastName: z.string().min(1, 'Last name is required').max(100), role: z.nativeEnum(Role), tenantId: z.string().uuid().optional().nullable(), active: z.boolean().optional(), }); const updateUserSchema = z.object({ email: z.string().email().optional(), firstName: z.string().min(1).max(100).optional(), lastName: z.string().min(1).max(100).optional(), role: z.nativeEnum(Role).optional(), tenantId: z.string().uuid().optional().nullable(), active: z.boolean().optional(), }); const changePasswordSchema = z.object({ currentPassword: z.string().min(1, 'Current password is required'), newPassword: z.string().min(6, 'New password must be at least 6 characters'), }); router.use(authenticate); // GET /api/users router.get('/', async (req: Request, res: Response, next: NextFunction) => { try { const user = req.user!; let where: any = {}; if (user.role === 'CUSTOMER_ADMIN') { // Customer admins see users in their tenant only where = { tenantId: user.tenantId }; } else if (user.role === 'CUSTOMER_USER') { // Customer users can only see themselves where = { id: user.id }; } // SYSTEM_ADMIN and TECHNICIAN see all users // Optional tenant filter via query param if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && req.query.tenantId) { where.tenantId = req.query.tenantId as string; } const users = await prisma.user.findMany({ where, select: { id: true, email: true, firstName: true, lastName: true, role: true, tenantId: true, active: true, createdAt: true, updatedAt: true, tenant: { select: { id: true, name: true, slug: true }, }, }, orderBy: { lastName: 'asc' }, }); res.json({ data: users }); } catch (err) { next(err); } }); // GET /api/users/:id router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { try { const currentUser = req.user!; const id = req.params.id as string; const targetUser = await prisma.user.findUnique({ where: { id }, select: { id: true, email: true, firstName: true, lastName: true, role: true, tenantId: true, active: true, createdAt: true, updatedAt: true, tenant: { select: { id: true, name: true, slug: true }, }, }, }); if (!targetUser) { res.status(404).json({ error: 'User not found' }); return; } // Access control if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) { res.status(403).json({ error: 'Forbidden', message: 'You can only view users in your tenant' }); return; } if (currentUser.role === 'CUSTOMER_USER' && targetUser.id !== currentUser.id) { res.status(403).json({ error: 'Forbidden', message: 'You can only view your own profile' }); return; } res.json({ data: targetUser }); } catch (err) { next(err); } }); // POST /api/users router.post('/', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { const currentUser = req.user!; const parsed = createUserSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); return; } const data = parsed.data; // CUSTOMER_ADMIN can only create users in their own tenant with limited roles if (currentUser.role === 'CUSTOMER_ADMIN') { if (data.role === 'SYSTEM_ADMIN' || data.role === 'TECHNICIAN') { res.status(403).json({ error: 'Forbidden', message: 'You cannot create users with this role' }); return; } data.tenantId = currentUser.tenantId; } // Validate that tenant-scoped roles have a tenantId if ((data.role === 'CUSTOMER_ADMIN' || data.role === 'CUSTOMER_USER') && !data.tenantId) { res.status(400).json({ error: 'Validation error', message: 'Customer roles require a tenantId' }); return; } // Check email uniqueness const existing = await prisma.user.findUnique({ where: { email: data.email } }); if (existing) { res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' }); return; } const passwordHash = await bcrypt.hash(data.password, 12); const user = await prisma.user.create({ data: { email: data.email, passwordHash, firstName: data.firstName, lastName: data.lastName, role: data.role, tenantId: data.tenantId ?? null, active: data.active ?? true, }, select: { id: true, email: true, firstName: true, lastName: true, role: true, tenantId: true, active: true, createdAt: true, }, }); res.status(201).json({ data: user }); } catch (err) { next(err); } }); // PUT /api/users/:id router.put('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { const currentUser = req.user!; const id = req.params.id as string; const parsed = updateUserSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); return; } const targetUser = await prisma.user.findUnique({ where: { id } }); if (!targetUser) { res.status(404).json({ error: 'User not found' }); return; } // CUSTOMER_ADMIN can only edit users in their tenant if (currentUser.role === 'CUSTOMER_ADMIN') { if (targetUser.tenantId !== currentUser.tenantId) { res.status(403).json({ error: 'Forbidden', message: 'You can only edit users in your tenant' }); return; } // Cannot change role to system-level roles if (parsed.data.role && (parsed.data.role === 'SYSTEM_ADMIN' || parsed.data.role === 'TECHNICIAN')) { res.status(403).json({ error: 'Forbidden', message: 'You cannot assign this role' }); return; } // Cannot change tenantId delete parsed.data.tenantId; } // Check email uniqueness if changing if (parsed.data.email && parsed.data.email !== targetUser.email) { const existing = await prisma.user.findUnique({ where: { email: parsed.data.email } }); if (existing) { res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' }); return; } } const updated = await prisma.user.update({ where: { id }, data: parsed.data, select: { id: true, email: true, firstName: true, lastName: true, role: true, tenantId: true, active: true, createdAt: true, updatedAt: true, }, }); res.json({ data: updated }); } catch (err) { next(err); } }); // DELETE /api/users/:id router.delete('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { try { const currentUser = req.user!; const id = req.params.id as string; if (id === currentUser.id) { res.status(400).json({ error: 'Bad request', message: 'You cannot delete your own account' }); return; } const targetUser = await prisma.user.findUnique({ where: { id } }); if (!targetUser) { res.status(404).json({ error: 'User not found' }); return; } if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) { res.status(403).json({ error: 'Forbidden', message: 'You can only delete users in your tenant' }); return; } // Soft delete await prisma.user.update({ where: { id }, data: { active: false }, }); res.json({ message: 'User deactivated successfully' }); } catch (err) { next(err); } }); // PATCH /api/users/:id/password router.patch('/:id/password', async (req: Request, res: Response, next: NextFunction) => { try { const currentUser = req.user!; const id = req.params.id as string; // Users can only change their own password (admins can reset via update) if (id !== currentUser.id && currentUser.role !== 'SYSTEM_ADMIN') { res.status(403).json({ error: 'Forbidden', message: 'You can only change your own password' }); return; } const parsed = changePasswordSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); return; } const user = await prisma.user.findUnique({ where: { id } }); if (!user) { res.status(404).json({ error: 'User not found' }); return; } // Verify current password (skip for SYSTEM_ADMIN resetting another user's password) if (id === currentUser.id) { const validPassword = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash); if (!validPassword) { res.status(400).json({ error: 'Invalid current password' }); return; } } const passwordHash = await bcrypt.hash(parsed.data.newPassword, 12); await prisma.user.update({ where: { id }, data: { passwordHash }, }); res.json({ message: 'Password changed successfully' }); } catch (err) { next(err); } }); export default router;