Workflow/backend/src/routes/user.routes.ts

334 lines
9.9 KiB
TypeScript

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;