erp-core/docs/01-fase-foundation/MGN-002-users/especificaciones/ET-users-backend.md

32 KiB

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

// 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<string, any>;

  @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

// 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<string, any>;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt: Date;
}

UserStatus Enum

// interfaces/user-status.enum.ts
export enum UserStatus {
  PENDING_ACTIVATION = 'pending_activation',
  ACTIVE = 'active',
  INACTIVE = 'inactive',
  LOCKED = 'locked',
}

DTOs

CreateUserDto

// 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<string, any>;
}

UpdateUserDto

// 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

// 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

// 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

// 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

// 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

// 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

// 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)

// 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<UserResponseDto> {
    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<PaginatedResponse<UserResponseDto>> {
    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<UserResponseDto> {
    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<UserResponseDto> {
    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<UserResponseDto> {
    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<UserResponseDto> {
    return this.usersService.deactivate(id, currentUser);
  }
}

ProfileController (Self-service)

// 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<ProfileResponseDto> {
    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<ProfileResponseDto> {
    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<void> {
    await this.profileService.verifyEmailChange(token);
    res.redirect('/login?emailChanged=true');
  }

  @Get('me/preferences')
  @ApiOperation({ summary: 'Obtener preferencias' })
  async getPreferences(@CurrentUser() user: JwtPayload): Promise<PreferencesResponseDto> {
    return this.preferencesService.getPreferences(user.sub);
  }

  @Patch('me/preferences')
  @ApiOperation({ summary: 'Actualizar preferencias' })
  async updatePreferences(
    @Body() dto: UpdatePreferencesDto,
    @CurrentUser() user: JwtPayload,
  ): Promise<PreferencesResponseDto> {
    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<PreferencesResponseDto> {
    return this.preferencesService.resetPreferences(user.sub);
  }
}

Services

UsersService

// services/users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(UserRole)
    private readonly userRoleRepository: Repository<UserRole>,
    private readonly emailService: EmailService,
    private readonly activationService: ActivationService,
  ) {}

  async create(dto: CreateUserDto, currentUser: JwtPayload): Promise<UserResponseDto> {
    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<PaginatedResponse<UserResponseDto>> {
    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<UserResponseDto> {
    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<UserResponseDto> {
    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<UserResponseDto> {
    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<UserResponseDto> {
    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<void> {
    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

// services/avatar.service.ts
@Injectable()
export class AvatarService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(UserAvatar)
    private readonly avatarRepository: Repository<UserAvatar>,
    private readonly storageService: StorageService,
  ) {}

  async upload(userId: string, file: Express.Multer.File): Promise<AvatarUrls> {
    // 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<void> {
    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<string, string> = {
      'image/jpeg': 'jpg',
      'image/png': 'png',
      'image/webp': 'webp',
    };
    return map[mimeType] || 'jpg';
  }
}

Module Configuration

// 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 - - [ ]