# Especificacion Tecnica Backend - MGN-002 Users ## Identificacion | Campo | Valor | |-------|-------| | **Modulo** | MGN-002 | | **Nombre** | Users - Gestion de Usuarios | | **Version** | 1.0 | | **Framework** | NestJS | | **Estado** | En Diseño | | **Autor** | System | | **Fecha** | 2025-12-05 | --- ## Estructura del Modulo ``` src/modules/users/ ├── users.module.ts ├── controllers/ │ ├── users.controller.ts │ └── profile.controller.ts ├── services/ │ ├── users.service.ts │ ├── profile.service.ts │ ├── avatar.service.ts │ └── preferences.service.ts ├── dto/ │ ├── create-user.dto.ts │ ├── update-user.dto.ts │ ├── user-response.dto.ts │ ├── user-list-query.dto.ts │ ├── update-profile.dto.ts │ ├── profile-response.dto.ts │ ├── change-password.dto.ts │ ├── request-email-change.dto.ts │ └── update-preferences.dto.ts ├── entities/ │ ├── user.entity.ts │ ├── user-preference.entity.ts │ ├── user-avatar.entity.ts │ ├── email-change-request.entity.ts │ └── user-activation-token.entity.ts ├── interfaces/ │ ├── user-status.enum.ts │ └── user-preferences.interface.ts └── guards/ └── user-owner.guard.ts ``` --- ## Entidades ### User Entity ```typescript // entities/user.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, OneToOne, CreateDateColumn, UpdateDateColumn, Index, DeleteDateColumn } from 'typeorm'; import { Tenant } from '../../tenants/entities/tenant.entity'; import { UserPreference } from './user-preference.entity'; import { UserAvatar } from './user-avatar.entity'; import { UserRole } from '../../roles/entities/user-role.entity'; import { UserStatus } from '../interfaces/user-status.enum'; @Entity({ schema: 'core_users', name: 'users' }) @Index(['tenantId', 'email'], { unique: true, where: '"deleted_at" IS NULL' }) export class User { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) tenant: Tenant; @Column({ type: 'varchar', length: 255 }) email: string; @Column({ name: 'password_hash', type: 'varchar', length: 255, select: false }) passwordHash: string; @Column({ name: 'first_name', type: 'varchar', length: 100 }) firstName: string; @Column({ name: 'last_name', type: 'varchar', length: 100 }) lastName: string; @Column({ type: 'varchar', length: 20, nullable: true }) phone: string | null; @Column({ name: 'avatar_url', type: 'varchar', length: 500, nullable: true }) avatarUrl: string | null; @Column({ name: 'avatar_thumbnail_url', type: 'varchar', length: 500, nullable: true }) avatarThumbnailUrl: string | null; @Column({ type: 'enum', enum: UserStatus, default: UserStatus.PENDING_ACTIVATION }) status: UserStatus; @Column({ name: 'is_active', type: 'boolean', default: false }) isActive: boolean; @Column({ name: 'email_verified_at', type: 'timestamptz', nullable: true }) emailVerifiedAt: Date | null; @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) lastLoginAt: Date | null; @Column({ name: 'failed_login_attempts', type: 'integer', default: 0 }) failedLoginAttempts: number; @Column({ name: 'locked_until', type: 'timestamptz', nullable: true }) lockedUntil: Date | null; @Column({ type: 'jsonb', default: {} }) metadata: Record; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ name: 'created_by', type: 'uuid', nullable: true }) createdBy: string | null; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; @Column({ name: 'updated_by', type: 'uuid', nullable: true }) updatedBy: string | null; @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz' }) deletedAt: Date | null; @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) deletedBy: string | null; // Relations @OneToOne(() => UserPreference, (pref) => pref.user) preferences: UserPreference; @OneToMany(() => UserAvatar, (avatar) => avatar.user) avatars: UserAvatar[]; @OneToMany(() => UserRole, (userRole) => userRole.user) userRoles: UserRole[]; // Virtual property get fullName(): string { return `${this.firstName} ${this.lastName}`; } } ``` ### UserPreference Entity ```typescript // entities/user-preference.entity.ts @Entity({ schema: 'core_users', name: 'user_preferences' }) export class UserPreference { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'user_id', type: 'uuid' }) userId: string; @OneToOne(() => User, (user) => user.preferences, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user: User; @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; @Column({ type: 'varchar', length: 5, default: 'es' }) language: string; @Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' }) timezone: string; @Column({ name: 'date_format', type: 'varchar', length: 20, default: 'DD/MM/YYYY' }) dateFormat: string; @Column({ name: 'time_format', type: 'varchar', length: 5, default: '24h' }) timeFormat: string; @Column({ type: 'varchar', length: 3, default: 'MXN' }) currency: string; @Column({ name: 'number_format', type: 'varchar', length: 10, default: 'es-MX' }) numberFormat: string; @Column({ type: 'varchar', length: 10, default: 'system' }) theme: string; @Column({ name: 'sidebar_collapsed', type: 'boolean', default: false }) sidebarCollapsed: boolean; @Column({ name: 'compact_mode', type: 'boolean', default: false }) compactMode: boolean; @Column({ name: 'font_size', type: 'varchar', length: 10, default: 'medium' }) fontSize: string; @Column({ type: 'jsonb', default: {} }) notifications: NotificationPreferences; @Column({ type: 'jsonb', default: {} }) dashboard: DashboardPreferences; @Column({ type: 'jsonb', default: {} }) metadata: Record; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; } ``` ### UserStatus Enum ```typescript // interfaces/user-status.enum.ts export enum UserStatus { PENDING_ACTIVATION = 'pending_activation', ACTIVE = 'active', INACTIVE = 'inactive', LOCKED = 'locked', } ``` --- ## DTOs ### CreateUserDto ```typescript // dto/create-user.dto.ts import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsArray, IsUUID, Matches } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateUserDto { @ApiProperty({ example: 'john.doe@example.com' }) @IsEmail({}, { message: 'Email invalido' }) email: string; @ApiProperty({ example: 'Juan', minLength: 2, maxLength: 100 }) @IsString() @MinLength(2, { message: 'Nombre debe tener al menos 2 caracteres' }) @MaxLength(100, { message: 'Nombre no puede exceder 100 caracteres' }) firstName: string; @ApiProperty({ example: 'Perez', minLength: 2, maxLength: 100 }) @IsString() @MinLength(2, { message: 'Apellido debe tener al menos 2 caracteres' }) @MaxLength(100, { message: 'Apellido no puede exceder 100 caracteres' }) lastName: string; @ApiPropertyOptional({ example: '+521234567890' }) @IsOptional() @Matches(/^\+[0-9]{10,15}$/, { message: 'Telefono debe estar en formato E.164' }) phone?: string; @ApiPropertyOptional({ type: [String], example: ['role-uuid-1'] }) @IsOptional() @IsArray() @IsUUID('4', { each: true }) roleIds?: string[]; @ApiPropertyOptional() @IsOptional() metadata?: Record; } ``` ### UpdateUserDto ```typescript // dto/update-user.dto.ts import { PartialType, OmitType } from '@nestjs/swagger'; import { IsEnum, IsBoolean, IsOptional } from 'class-validator'; import { CreateUserDto } from './create-user.dto'; import { UserStatus } from '../interfaces/user-status.enum'; export class UpdateUserDto extends PartialType( OmitType(CreateUserDto, ['email'] as const), ) { @IsOptional() @IsEnum(UserStatus) status?: UserStatus; @IsOptional() @IsBoolean() isActive?: boolean; } ``` ### UserResponseDto ```typescript // dto/user-response.dto.ts import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { UserStatus } from '../interfaces/user-status.enum'; export class UserResponseDto { @ApiProperty() id: string; @ApiProperty() email: string; @ApiProperty() firstName: string; @ApiProperty() lastName: string; @ApiProperty() fullName: string; @ApiPropertyOptional() phone?: string; @ApiPropertyOptional() avatarUrl?: string; @ApiPropertyOptional() avatarThumbnailUrl?: string; @ApiProperty({ enum: UserStatus }) status: UserStatus; @ApiProperty() isActive: boolean; @ApiPropertyOptional() emailVerifiedAt?: Date; @ApiPropertyOptional() lastLoginAt?: Date; @ApiProperty() createdAt: Date; @ApiProperty() updatedAt: Date; @ApiPropertyOptional() roles?: { id: string; name: string }[]; } ``` ### UserListQueryDto ```typescript // dto/user-list-query.dto.ts import { IsOptional, IsString, IsEnum, IsInt, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { UserStatus } from '../interfaces/user-status.enum'; export class UserListQueryDto { @ApiPropertyOptional({ default: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; @ApiPropertyOptional({ default: 20, maximum: 100 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit?: number = 20; @ApiPropertyOptional() @IsOptional() @IsString() search?: string; @ApiPropertyOptional({ enum: UserStatus }) @IsOptional() @IsEnum(UserStatus) status?: UserStatus; @ApiPropertyOptional() @IsOptional() @IsString() roleId?: string; @ApiPropertyOptional({ enum: ['createdAt', 'firstName', 'lastName', 'email'] }) @IsOptional() @IsString() sortBy?: string = 'createdAt'; @ApiPropertyOptional({ enum: ['ASC', 'DESC'] }) @IsOptional() @IsString() sortOrder?: 'ASC' | 'DESC' = 'DESC'; } ``` ### UpdateProfileDto ```typescript // dto/update-profile.dto.ts import { IsString, MinLength, MaxLength, IsOptional, Matches } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; export class UpdateProfileDto { @ApiPropertyOptional({ example: 'Juan' }) @IsOptional() @IsString() @MinLength(2) @MaxLength(100) firstName?: string; @ApiPropertyOptional({ example: 'Perez' }) @IsOptional() @IsString() @MinLength(2) @MaxLength(100) lastName?: string; @ApiPropertyOptional({ example: '+521234567890' }) @IsOptional() @Matches(/^\+[0-9]{10,15}$/, { message: 'Telefono debe estar en formato E.164' }) phone?: string; } ``` ### ChangePasswordDto ```typescript // dto/change-password.dto.ts import { IsString, MinLength, MaxLength, Matches, IsBoolean, IsOptional } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class ChangePasswordDto { @ApiProperty() @IsString() currentPassword: string; @ApiProperty({ minLength: 8 }) @IsString() @MinLength(8, { message: 'Password debe tener al menos 8 caracteres' }) @MaxLength(128) @Matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { message: 'Password debe incluir mayuscula, minuscula, numero y caracter especial' }, ) newPassword: string; @ApiProperty() @IsString() confirmPassword: string; @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() logoutOtherSessions?: boolean = false; } ``` ### RequestEmailChangeDto ```typescript // dto/request-email-change.dto.ts import { IsEmail, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class RequestEmailChangeDto { @ApiProperty({ example: 'newemail@example.com' }) @IsEmail({}, { message: 'Email invalido' }) newEmail: string; @ApiProperty() @IsString() currentPassword: string; } ``` ### UpdatePreferencesDto ```typescript // dto/update-preferences.dto.ts import { IsString, IsBoolean, IsOptional, IsIn, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; class NotificationEmailDto { @IsOptional() @IsBoolean() enabled?: boolean; @IsOptional() @IsIn(['instant', 'daily', 'weekly']) digest?: string; @IsOptional() @IsBoolean() marketing?: boolean; @IsOptional() @IsBoolean() security?: boolean; @IsOptional() @IsBoolean() updates?: boolean; } class NotificationsDto { @IsOptional() @ValidateNested() @Type(() => NotificationEmailDto) email?: NotificationEmailDto; @IsOptional() push?: { enabled?: boolean; sound?: boolean }; @IsOptional() inApp?: { enabled?: boolean; desktop?: boolean }; } export class UpdatePreferencesDto { @ApiPropertyOptional({ enum: ['es', 'en', 'pt'] }) @IsOptional() @IsIn(['es', 'en', 'pt']) language?: string; @ApiPropertyOptional() @IsOptional() @IsString() timezone?: string; @ApiPropertyOptional({ enum: ['DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD'] }) @IsOptional() @IsIn(['DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD']) dateFormat?: string; @ApiPropertyOptional({ enum: ['12h', '24h'] }) @IsOptional() @IsIn(['12h', '24h']) timeFormat?: string; @ApiPropertyOptional({ enum: ['light', 'dark', 'system'] }) @IsOptional() @IsIn(['light', 'dark', 'system']) theme?: string; @ApiPropertyOptional() @IsOptional() @IsBoolean() sidebarCollapsed?: boolean; @ApiPropertyOptional() @IsOptional() @IsBoolean() compactMode?: boolean; @ApiPropertyOptional({ enum: ['small', 'medium', 'large'] }) @IsOptional() @IsIn(['small', 'medium', 'large']) fontSize?: string; @ApiPropertyOptional() @IsOptional() @ValidateNested() @Type(() => NotificationsDto) notifications?: NotificationsDto; @ApiPropertyOptional() @IsOptional() dashboard?: { defaultView?: string; widgets?: string[] }; } ``` --- ## Endpoints ### Resumen de Endpoints #### Gestion de Usuarios (Admin) | Metodo | Ruta | Descripcion | Permisos | |--------|------|-------------|----------| | POST | `/api/v1/users` | Crear usuario | users:create | | GET | `/api/v1/users` | Listar usuarios | users:read | | GET | `/api/v1/users/:id` | Obtener usuario | users:read | | PATCH | `/api/v1/users/:id` | Actualizar usuario | users:update | | DELETE | `/api/v1/users/:id` | Eliminar usuario | users:delete | | POST | `/api/v1/users/:id/activate` | Activar usuario | users:update | | POST | `/api/v1/users/:id/deactivate` | Desactivar usuario | users:update | | POST | `/api/v1/users/:id/resend-invitation` | Reenviar invitacion | users:update | #### Perfil Personal (Self-service) | Metodo | Ruta | Descripcion | |--------|------|-------------| | GET | `/api/v1/users/me` | Obtener mi perfil | | PATCH | `/api/v1/users/me` | Actualizar mi perfil | | POST | `/api/v1/users/me/avatar` | Subir avatar | | DELETE | `/api/v1/users/me/avatar` | Eliminar avatar | | POST | `/api/v1/users/me/password` | Cambiar password | | POST | `/api/v1/users/me/email/request-change` | Solicitar cambio email | | GET | `/api/v1/users/email/verify-change` | Verificar cambio email | | GET | `/api/v1/users/me/preferences` | Obtener preferencias | | PATCH | `/api/v1/users/me/preferences` | Actualizar preferencias | | POST | `/api/v1/users/me/preferences/reset` | Reset preferencias | --- ## Controllers ### UsersController (Admin) ```typescript // controllers/users.controller.ts @ApiTags('Users') @Controller('api/v1/users') @UseGuards(JwtAuthGuard, RolesGuard) export class UsersController { constructor(private readonly usersService: UsersService) {} @Post() @Permissions('users:create') @ApiOperation({ summary: 'Crear nuevo usuario' }) @ApiResponse({ status: 201, type: UserResponseDto }) async create( @Body() dto: CreateUserDto, @CurrentUser() currentUser: JwtPayload, ): Promise { return this.usersService.create(dto, currentUser); } @Get() @Permissions('users:read') @ApiOperation({ summary: 'Listar usuarios' }) @ApiResponse({ status: 200, type: PaginatedResponse }) async findAll( @Query() query: UserListQueryDto, @CurrentUser() currentUser: JwtPayload, ): Promise> { return this.usersService.findAll(query, currentUser.tid); } @Get(':id') @Permissions('users:read') @ApiOperation({ summary: 'Obtener usuario por ID' }) @ApiResponse({ status: 200, type: UserResponseDto }) async findOne( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() currentUser: JwtPayload, ): Promise { return this.usersService.findOne(id, currentUser.tid); } @Patch(':id') @Permissions('users:update') @ApiOperation({ summary: 'Actualizar usuario' }) @ApiResponse({ status: 200, type: UserResponseDto }) async update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateUserDto, @CurrentUser() currentUser: JwtPayload, ): Promise { return this.usersService.update(id, dto, currentUser); } @Delete(':id') @Permissions('users:delete') @ApiOperation({ summary: 'Eliminar usuario (soft delete)' }) @ApiResponse({ status: 200 }) async remove( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() currentUser: JwtPayload, ): Promise<{ message: string }> { return this.usersService.remove(id, currentUser); } @Post(':id/activate') @Permissions('users:update') @ApiOperation({ summary: 'Activar usuario' }) async activate( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() currentUser: JwtPayload, ): Promise { return this.usersService.activate(id, currentUser); } @Post(':id/deactivate') @Permissions('users:update') @ApiOperation({ summary: 'Desactivar usuario' }) async deactivate( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() currentUser: JwtPayload, ): Promise { return this.usersService.deactivate(id, currentUser); } } ``` ### ProfileController (Self-service) ```typescript // controllers/profile.controller.ts @ApiTags('Profile') @Controller('api/v1/users') @UseGuards(JwtAuthGuard) export class ProfileController { constructor( private readonly profileService: ProfileService, private readonly avatarService: AvatarService, private readonly preferencesService: PreferencesService, ) {} @Get('me') @ApiOperation({ summary: 'Obtener mi perfil' }) @ApiResponse({ status: 200, type: ProfileResponseDto }) async getProfile(@CurrentUser() user: JwtPayload): Promise { return this.profileService.getProfile(user.sub); } @Patch('me') @ApiOperation({ summary: 'Actualizar mi perfil' }) @ApiResponse({ status: 200, type: ProfileResponseDto }) async updateProfile( @Body() dto: UpdateProfileDto, @CurrentUser() user: JwtPayload, ): Promise { return this.profileService.updateProfile(user.sub, dto); } @Post('me/avatar') @UseInterceptors(FileInterceptor('avatar', avatarUploadOptions)) @ApiOperation({ summary: 'Subir avatar' }) @ApiConsumes('multipart/form-data') async uploadAvatar( @UploadedFile() file: Express.Multer.File, @CurrentUser() user: JwtPayload, ): Promise<{ avatarUrl: string; avatarThumbnailUrl: string }> { return this.avatarService.upload(user.sub, file); } @Delete('me/avatar') @ApiOperation({ summary: 'Eliminar avatar' }) async deleteAvatar(@CurrentUser() user: JwtPayload): Promise<{ message: string }> { await this.avatarService.delete(user.sub); return { message: 'Avatar eliminado' }; } @Post('me/password') @ApiOperation({ summary: 'Cambiar password' }) async changePassword( @Body() dto: ChangePasswordDto, @CurrentUser() user: JwtPayload, ): Promise<{ message: string; sessionsInvalidated?: number }> { return this.profileService.changePassword(user.sub, dto); } @Post('me/email/request-change') @ApiOperation({ summary: 'Solicitar cambio de email' }) async requestEmailChange( @Body() dto: RequestEmailChangeDto, @CurrentUser() user: JwtPayload, ): Promise<{ message: string; expiresAt: Date }> { return this.profileService.requestEmailChange(user.sub, dto); } @Get('email/verify-change') @Public() @ApiOperation({ summary: 'Verificar cambio de email' }) async verifyEmailChange( @Query('token') token: string, @Res() res: Response, ): Promise { await this.profileService.verifyEmailChange(token); res.redirect('/login?emailChanged=true'); } @Get('me/preferences') @ApiOperation({ summary: 'Obtener preferencias' }) async getPreferences(@CurrentUser() user: JwtPayload): Promise { return this.preferencesService.getPreferences(user.sub); } @Patch('me/preferences') @ApiOperation({ summary: 'Actualizar preferencias' }) async updatePreferences( @Body() dto: UpdatePreferencesDto, @CurrentUser() user: JwtPayload, ): Promise { return this.preferencesService.updatePreferences(user.sub, dto); } @Post('me/preferences/reset') @ApiOperation({ summary: 'Resetear preferencias a valores por defecto' }) async resetPreferences(@CurrentUser() user: JwtPayload): Promise { return this.preferencesService.resetPreferences(user.sub); } } ``` --- ## Services ### UsersService ```typescript // services/users.service.ts @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly userRepository: Repository, @InjectRepository(UserRole) private readonly userRoleRepository: Repository, private readonly emailService: EmailService, private readonly activationService: ActivationService, ) {} async create(dto: CreateUserDto, currentUser: JwtPayload): Promise { const tenantId = currentUser.tid; // Verificar email unico const existing = await this.userRepository.findOne({ where: { tenantId, email: dto.email.toLowerCase() }, }); if (existing) { throw new ConflictException('El email ya esta registrado'); } // Crear usuario const user = this.userRepository.create({ ...dto, email: dto.email.toLowerCase(), tenantId, passwordHash: await this.generateTemporaryPassword(), status: UserStatus.PENDING_ACTIVATION, isActive: false, createdBy: currentUser.sub, }); await this.userRepository.save(user); // Asignar roles if (dto.roleIds?.length) { await this.assignRoles(user.id, dto.roleIds, tenantId, currentUser.sub); } // Generar token de activacion y enviar email const activationToken = await this.activationService.generateToken(user.id, tenantId); await this.emailService.sendInvitationEmail(user.email, activationToken, user.firstName); return this.toResponseDto(user); } async findAll(query: UserListQueryDto, tenantId: string): Promise> { const { page, limit, search, status, roleId, sortBy, sortOrder } = query; const qb = this.userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.userRoles', 'userRole') .leftJoinAndSelect('userRole.role', 'role') .where('user.tenantId = :tenantId', { tenantId }) .andWhere('user.deletedAt IS NULL'); // Filtros if (search) { qb.andWhere( '(user.email ILIKE :search OR user.firstName ILIKE :search OR user.lastName ILIKE :search)', { search: `%${search}%` }, ); } if (status) { qb.andWhere('user.status = :status', { status }); } if (roleId) { qb.andWhere('userRole.roleId = :roleId', { roleId }); } // Ordenamiento qb.orderBy(`user.${sortBy}`, sortOrder); // Paginacion const total = await qb.getCount(); const users = await qb .skip((page - 1) * limit) .take(limit) .getMany(); return { data: users.map(this.toResponseDto), meta: { total, page, limit, totalPages: Math.ceil(total / limit), hasNext: page * limit < total, hasPrev: page > 1, }, }; } async findOne(id: string, tenantId: string): Promise { const user = await this.userRepository.findOne({ where: { id, tenantId }, relations: ['userRoles', 'userRoles.role'], }); if (!user) { throw new NotFoundException('Usuario no encontrado'); } return this.toResponseDto(user); } async update(id: string, dto: UpdateUserDto, currentUser: JwtPayload): Promise { const user = await this.userRepository.findOne({ where: { id, tenantId: currentUser.tid }, }); if (!user) { throw new NotFoundException('Usuario no encontrado'); } Object.assign(user, { ...dto, updatedBy: currentUser.sub, }); await this.userRepository.save(user); // Actualizar roles si se proporcionan if (dto.roleIds !== undefined) { await this.userRoleRepository.delete({ userId: id }); if (dto.roleIds.length) { await this.assignRoles(id, dto.roleIds, currentUser.tid, currentUser.sub); } } return this.findOne(id, currentUser.tid); } async remove(id: string, currentUser: JwtPayload): Promise<{ message: string }> { if (id === currentUser.sub) { throw new BadRequestException('No puedes eliminarte a ti mismo'); } const user = await this.userRepository.findOne({ where: { id, tenantId: currentUser.tid }, }); if (!user) { throw new NotFoundException('Usuario no encontrado'); } // Soft delete user.deletedAt = new Date(); user.deletedBy = currentUser.sub; user.isActive = false; user.status = UserStatus.INACTIVE; await this.userRepository.save(user); return { message: 'Usuario eliminado exitosamente' }; } async activate(id: string, currentUser: JwtPayload): Promise { const user = await this.userRepository.findOne({ where: { id, tenantId: currentUser.tid }, }); if (!user) { throw new NotFoundException('Usuario no encontrado'); } user.status = UserStatus.ACTIVE; user.isActive = true; user.updatedBy = currentUser.sub; await this.userRepository.save(user); return this.toResponseDto(user); } async deactivate(id: string, currentUser: JwtPayload): Promise { if (id === currentUser.sub) { throw new BadRequestException('No puedes desactivarte a ti mismo'); } const user = await this.userRepository.findOne({ where: { id, tenantId: currentUser.tid }, }); if (!user) { throw new NotFoundException('Usuario no encontrado'); } user.status = UserStatus.INACTIVE; user.isActive = false; user.updatedBy = currentUser.sub; await this.userRepository.save(user); return this.toResponseDto(user); } private async assignRoles( userId: string, roleIds: string[], tenantId: string, createdBy: string, ): Promise { const userRoles = roleIds.map((roleId) => ({ userId, roleId, tenantId, createdBy, })); await this.userRoleRepository.save(userRoles); } private toResponseDto(user: User): UserResponseDto { return { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, fullName: `${user.firstName} ${user.lastName}`, phone: user.phone, avatarUrl: user.avatarUrl, avatarThumbnailUrl: user.avatarThumbnailUrl, status: user.status, isActive: user.isActive, emailVerifiedAt: user.emailVerifiedAt, lastLoginAt: user.lastLoginAt, createdAt: user.createdAt, updatedAt: user.updatedAt, roles: user.userRoles?.map((ur) => ({ id: ur.role.id, name: ur.role.name, })), }; } } ``` ### AvatarService ```typescript // services/avatar.service.ts @Injectable() export class AvatarService { constructor( @InjectRepository(User) private readonly userRepository: Repository, @InjectRepository(UserAvatar) private readonly avatarRepository: Repository, private readonly storageService: StorageService, ) {} async upload(userId: string, file: Express.Multer.File): Promise { // Validar archivo this.validateFile(file); const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException('Usuario no encontrado'); } const timestamp = Date.now(); const basePath = `avatars/${userId}`; // Procesar imagenes const mainBuffer = await sharp(file.buffer) .resize(200, 200, { fit: 'cover' }) .jpeg({ quality: 85 }) .toBuffer(); const thumbBuffer = await sharp(file.buffer) .resize(50, 50, { fit: 'cover' }) .jpeg({ quality: 80 }) .toBuffer(); // Subir a storage const originalUrl = await this.storageService.upload( `${basePath}/${timestamp}-original.${this.getExtension(file.mimetype)}`, file.buffer, ); const mainUrl = await this.storageService.upload( `${basePath}/${timestamp}-200.jpg`, mainBuffer, ); const thumbUrl = await this.storageService.upload( `${basePath}/${timestamp}-50.jpg`, thumbBuffer, ); // Marcar avatares anteriores como no actuales await this.avatarRepository.update( { userId, isCurrent: true }, { isCurrent: false }, ); // Guardar registro await this.avatarRepository.save({ userId, tenantId: user.tenantId, originalUrl, mainUrl, thumbnailUrl: thumbUrl, mimeType: file.mimetype, fileSize: file.size, isCurrent: true, }); // Actualizar usuario await this.userRepository.update(userId, { avatarUrl: mainUrl, avatarThumbnailUrl: thumbUrl, }); return { avatarUrl: mainUrl, avatarThumbnailUrl: thumbUrl }; } async delete(userId: string): Promise { await this.avatarRepository.update( { userId, isCurrent: true }, { isCurrent: false }, ); await this.userRepository.update(userId, { avatarUrl: null, avatarThumbnailUrl: null, }); } private validateFile(file: Express.Multer.File): void { const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; const maxSize = 10 * 1024 * 1024; // 10MB if (!allowedMimeTypes.includes(file.mimetype)) { throw new BadRequestException('Formato de imagen no permitido'); } if (file.size > maxSize) { throw new BadRequestException('Imagen excede tamaño maximo (10MB)'); } } private getExtension(mimeType: string): string { const map: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', }; return map[mimeType] || 'jpg'; } } ``` --- ## Module Configuration ```typescript // users.module.ts @Module({ imports: [ TypeOrmModule.forFeature([ User, UserPreference, UserAvatar, EmailChangeRequest, UserActivationToken, UserRole, ]), AuthModule, RolesModule, EmailModule, StorageModule, ], controllers: [UsersController, ProfileController], providers: [ UsersService, ProfileService, AvatarService, PreferencesService, ActivationService, ], exports: [UsersService], }) export class UsersModule {} ``` --- ## Manejo de Errores | Codigo | Constante | Descripcion | |--------|-----------|-------------| | USER001 | EMAIL_EXISTS | Email ya registrado | | USER002 | USER_NOT_FOUND | Usuario no encontrado | | USER003 | CANNOT_DELETE_SELF | No puede eliminarse a si mismo | | USER004 | CANNOT_DEACTIVATE_SELF | No puede desactivarse a si mismo | | USER005 | INVALID_FILE_TYPE | Tipo de archivo no permitido | | USER006 | FILE_TOO_LARGE | Archivo excede limite | | USER007 | PASSWORD_INCORRECT | Password actual incorrecto | | USER008 | PASSWORD_MISMATCH | Passwords no coinciden | | USER009 | PASSWORD_REUSED | Password ya usado anteriormente | | USER010 | EMAIL_CHANGE_PENDING | Ya hay solicitud de cambio pendiente | | USER011 | TOKEN_EXPIRED | Token expirado | | USER012 | EMAIL_NOT_AVAILABLE | Nuevo email ya existe | --- ## Historial de Cambios | Version | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-12-05 | System | Creacion inicial | --- ## Aprobaciones | Rol | Nombre | Fecha | Firma | |-----|--------|-------|-------| | Tech Lead | - | - | [ ] | | Backend Lead | - | - | [ ] |