import { Controller, Get, Post, Patch, Delete, Param, Body, Query, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe, NotFoundException, ParseUUIDPipe, ParseIntPipe, DefaultValuePipe, UseGuards, ForbiddenException, ConflictException, Inject, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBadRequestResponse, ApiNotFoundResponse, ApiQuery, ApiParam, ApiBearerAuth, } from '@nestjs/swagger'; import { CreateUserDto, UpdateUserDto, UpdatePasswordDto, UserResponseDto, UserListResponseDto, } from '../dto/user.dto'; import { UserMapper } from '../mappers/user.mapper'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; import { v4 as uuidv4 } from 'uuid'; import * as argon2 from 'argon2'; import * as crypto from 'crypto'; /** * Users Controller * * Manages user CRUD operations: * - Create user / Invite user (admin/manager) * - Get user details * - Update user (admin/manager) * - Delete/deactivate user (admin) * - List users in organization * - Update own password */ @ApiTags('Users') @Controller('users') @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth() export class UsersController { private readonly logger = new Logger(UsersController.name); constructor(@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository) {} /** * Create/Invite a new user * * Admin can create users in any organization. * Manager can only create users in their own organization. */ @Post() @HttpCode(HttpStatus.CREATED) @Roles('admin', 'manager') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Create/Invite new user', description: 'Create a new user account. Admin can create in any org, manager only in their own.', }) @ApiResponse({ status: HttpStatus.CREATED, description: 'User created successfully', type: UserResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin or manager role', }) @ApiBadRequestResponse({ description: 'Invalid request parameters', }) async createUser( @Body() dto: CreateUserDto, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`); // Authorization: Managers can only create users in their own organization if (user.role === 'manager' && dto.organizationId !== user.organizationId) { throw new ForbiddenException('You can only create users in your own organization'); } // Check if user already exists const existingUser = await this.userRepository.findByEmail(dto.email); if (existingUser) { throw new ConflictException('User with this email already exists'); } // Generate temporary password if not provided const tempPassword = dto.password || this.generateTemporaryPassword(); // Hash password with Argon2id const passwordHash = await argon2.hash(tempPassword, { type: argon2.argon2id, memoryCost: 65536, // 64 MB timeCost: 3, parallelism: 4, }); // Map DTO role to Domain role const domainRole = dto.role as unknown as DomainUserRole; // Create user entity const newUser = User.create({ id: uuidv4(), organizationId: dto.organizationId, email: dto.email, passwordHash, firstName: dto.firstName, lastName: dto.lastName, role: domainRole, }); // Save to database const savedUser = await this.userRepository.save(newUser); this.logger.log(`User created successfully: ${savedUser.id}`); // TODO: Send invitation email with temporary password this.logger.warn( `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}` ); return UserMapper.toDto(savedUser); } /** * Get user by ID */ @Get(':id') @ApiOperation({ summary: 'Get user by ID', description: 'Retrieve user details. Users can view users in their org, admins can view any.', }) @ApiParam({ name: 'id', description: 'User ID (UUID)', example: '550e8400-e29b-41d4-a716-446655440000', }) @ApiResponse({ status: HttpStatus.OK, description: 'User details retrieved successfully', type: UserResponseDto, }) @ApiNotFoundResponse({ description: 'User not found', }) async getUser( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() currentUser: UserPayload ): Promise { this.logger.log(`[User: ${currentUser.email}] Fetching user: ${id}`); const user = await this.userRepository.findById(id); if (!user) { throw new NotFoundException(`User ${id} not found`); } // Authorization: Can only view users in same organization (unless admin) if (currentUser.role !== 'admin' && user.organizationId !== currentUser.organizationId) { throw new ForbiddenException('You can only view users in your organization'); } return UserMapper.toDto(user); } /** * Update user */ @Patch(':id') @Roles('admin', 'manager') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Update user', description: 'Update user details (name, role, status). Admin/manager only.', }) @ApiParam({ name: 'id', description: 'User ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'User updated successfully', type: UserResponseDto, }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin or manager role', }) @ApiNotFoundResponse({ description: 'User not found', }) async updateUser( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateUserDto, @CurrentUser() currentUser: UserPayload ): Promise { this.logger.log(`[User: ${currentUser.email}] Updating user: ${id}`); const user = await this.userRepository.findById(id); if (!user) { throw new NotFoundException(`User ${id} not found`); } // Authorization: Managers can only update users in their own organization if (currentUser.role === 'manager' && user.organizationId !== currentUser.organizationId) { throw new ForbiddenException('You can only update users in your own organization'); } // Update fields if (dto.firstName) { user.updateFirstName(dto.firstName); } if (dto.lastName) { user.updateLastName(dto.lastName); } if (dto.role) { const domainRole = dto.role as unknown as DomainUserRole; user.updateRole(domainRole); } if (dto.isActive !== undefined) { if (dto.isActive) { user.activate(); } else { user.deactivate(); } } // Save updated user const updatedUser = await this.userRepository.save(user); this.logger.log(`User updated successfully: ${updatedUser.id}`); return UserMapper.toDto(updatedUser); } /** * Delete/deactivate user */ @Delete(':id') @Roles('admin') @ApiOperation({ summary: 'Delete user', description: 'Deactivate a user account. Admin only.', }) @ApiParam({ name: 'id', description: 'User ID (UUID)', }) @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'User deactivated successfully', }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin role', }) @ApiNotFoundResponse({ description: 'User not found', }) async deleteUser( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() currentUser: UserPayload ): Promise { this.logger.log(`[Admin: ${currentUser.email}] Deactivating user: ${id}`); const user = await this.userRepository.findById(id); if (!user) { throw new NotFoundException(`User ${id} not found`); } // Deactivate user user.deactivate(); await this.userRepository.save(user); this.logger.log(`User deactivated successfully: ${id}`); } /** * List users in organization */ @Get() @ApiOperation({ summary: 'List users', description: 'Retrieve a paginated list of users in your organization. Admins can see all users.', }) @ApiQuery({ name: 'page', required: false, description: 'Page number (1-based)', example: 1, }) @ApiQuery({ name: 'pageSize', required: false, description: 'Number of items per page', example: 20, }) @ApiQuery({ name: 'role', required: false, description: 'Filter by role', enum: ['admin', 'manager', 'user', 'viewer'], }) @ApiResponse({ status: HttpStatus.OK, description: 'Users list retrieved successfully', type: UserListResponseDto, }) async listUsers( @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, @Query('role') role: string | undefined, @CurrentUser() currentUser: UserPayload ): Promise { this.logger.log( `[User: ${currentUser.email}] Listing users: page=${page}, pageSize=${pageSize}, role=${role}` ); // Fetch users by organization const users = await this.userRepository.findByOrganization(currentUser.organizationId); // Filter by role if provided const filteredUsers = role ? users.filter(u => u.role === role) : users; // Paginate const startIndex = (page - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedUsers = filteredUsers.slice(startIndex, endIndex); // Convert to DTOs const userDtos = UserMapper.toDtoArray(paginatedUsers); const totalPages = Math.ceil(filteredUsers.length / pageSize); return { users: userDtos, total: filteredUsers.length, page, pageSize, totalPages, }; } /** * Update own password */ @Patch('me/password') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Update own password', description: 'Update your own password. Requires current password.', }) @ApiResponse({ status: HttpStatus.OK, description: 'Password updated successfully', schema: { properties: { message: { type: 'string', example: 'Password updated successfully' }, }, }, }) @ApiBadRequestResponse({ description: 'Invalid current password', }) async updatePassword( @Body() dto: UpdatePasswordDto, @CurrentUser() currentUser: UserPayload ): Promise<{ message: string }> { this.logger.log(`[User: ${currentUser.email}] Updating password`); const user = await this.userRepository.findById(currentUser.id); if (!user) { throw new NotFoundException('User not found'); } // Verify current password const isPasswordValid = await argon2.verify(user.passwordHash, dto.currentPassword); if (!isPasswordValid) { throw new ForbiddenException('Current password is incorrect'); } // Hash new password const newPasswordHash = await argon2.hash(dto.newPassword, { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4, }); // Update password user.updatePassword(newPasswordHash); await this.userRepository.save(user); this.logger.log(`Password updated successfully for user: ${user.id}`); return { message: 'Password updated successfully' }; } /** * Generate a secure temporary password */ private generateTemporaryPassword(): string { const length = 16; const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; let password = ''; const randomBytes = crypto.randomBytes(length); for (let i = 0; i < length; i++) { password += charset[randomBytes[i] % charset.length]; } return password; } }