1248 lines
32 KiB
Markdown
1248 lines
32 KiB
Markdown
# 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<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
|
|
|
|
```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<string, any>;
|
|
|
|
@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<string, any>;
|
|
}
|
|
```
|
|
|
|
### 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<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)
|
|
|
|
```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<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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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 | - | - | [ ] |
|