Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m53s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Has been cancelled
- Replace all ../../domain/ imports with @domain/ across 67 files - Configure NestJS to use tsconfig.build.json with rootDir - Add tsc-alias to resolve path aliases after build - This fixes 'Cannot find module' TypeScript compilation errors Fixed files: - 30 files in application layer - 37 files in infrastructure layer
452 lines
12 KiB
TypeScript
452 lines
12 KiB
TypeScript
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<UserResponseDto> {
|
|
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<UserResponseDto> {
|
|
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<UserResponseDto> {
|
|
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<void> {
|
|
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<UserListResponseDto> {
|
|
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;
|
|
}
|
|
}
|