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