Analysis and Documentation: - Add ANALISIS-ALINEACION-WORKSPACE-2025-12-08.md with comprehensive gap analysis - Document SIMCO v3.2 system with 20+ directives - Identify alignment gaps between orchestration and projects New SaaS Products Structure: - Create apps/products/pos-micro/ - Ultra basic POS (~100 MXN/month) - Target: Mexican informal market (street vendors, small stores) - Features: Offline-first PWA, WhatsApp bot, minimal DB (~10 tables) - Create apps/products/erp-basico/ - Austere ERP (~300-500 MXN/month) - Target: SMBs needing full ERP without complexity - Features: Inherits from erp-core, modular pricing SaaS Layer: - Create apps/saas/ structure (billing, portal, admin, onboarding) - Add README.md and CONTEXTO-SAAS.md documentation Vertical Alignment: - Verify HERENCIA-ERP-CORE.md exists in all verticals - Add HERENCIA-SPECS-CORE.md to verticals - Update orchestration inventories Updates: - Update WORKSPACE-STATUS.md with new products and analysis - Update suite inventories with new structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
15 KiB
15 KiB
PATRÓN: VALIDACIÓN DE DATOS
Versión: 1.0.0 Fecha: 2025-12-08 Aplica a: Backend (NestJS/Express), Frontend (React) Prioridad: OBLIGATORIA
PROPÓSITO
Definir patrones estándar de validación de datos para garantizar consistencia en toda la aplicación.
PRINCIPIO FUNDAMENTAL
╔══════════════════════════════════════════════════════════════════════╗
║ VALIDACIÓN EN CAPAS ║
║ ║
║ 1. Frontend: UX inmediata (opcional pero recomendada) ║
║ 2. DTO: Validación de entrada (OBLIGATORIA) ║
║ 3. Service: Validación de negocio (cuando aplica) ║
║ 4. Database: Constraints (última línea de defensa) ║
╚══════════════════════════════════════════════════════════════════════╝
1. VALIDACIÓN EN DTO (NestJS - class-validator)
Decoradores Básicos
import {
IsString,
IsEmail,
IsNotEmpty,
IsOptional,
IsUUID,
IsInt,
IsNumber,
IsBoolean,
IsDate,
IsArray,
IsEnum,
IsUrl,
IsPhoneNumber,
} from 'class-validator';
Decoradores de Longitud
import {
Length,
MinLength,
MaxLength,
Min,
Max,
ArrayMinSize,
ArrayMaxSize,
} from 'class-validator';
Decoradores de Formato
import {
Matches,
IsAlpha,
IsAlphanumeric,
IsAscii,
Contains,
IsISO8601,
IsCreditCard,
IsHexColor,
IsJSON,
} from 'class-validator';
2. PATRONES POR TIPO DE DATO
// PATRÓN ESTÁNDAR
@ApiProperty({
description: 'Correo electrónico del usuario',
example: 'usuario@empresa.com',
})
@IsEmail({}, { message: 'El correo electrónico no es válido' })
@IsNotEmpty({ message: 'El correo electrónico es requerido' })
@MaxLength(255, { message: 'El correo no puede exceder 255 caracteres' })
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
Password
// PATRÓN ESTÁNDAR - Contraseña segura
@ApiProperty({
description: 'Contraseña (mín 8 caracteres, 1 mayúscula, 1 número, 1 especial)',
example: 'MiPassword123!',
})
@IsString()
@IsNotEmpty({ message: 'La contraseña es requerida' })
@MinLength(8, { message: 'La contraseña debe tener al menos 8 caracteres' })
@MaxLength(100, { message: 'La contraseña no puede exceder 100 caracteres' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/,
{ message: 'La contraseña debe contener mayúscula, minúscula, número y carácter especial' }
)
password: string;
UUID
// PATRÓN ESTÁNDAR
@ApiProperty({
description: 'ID único del recurso',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsUUID('4', { message: 'El ID debe ser un UUID válido' })
@IsNotEmpty({ message: 'El ID es requerido' })
id: string;
// UUID Opcional (para referencias)
@ApiPropertyOptional({
description: 'ID del usuario relacionado',
})
@IsOptional()
@IsUUID('4', { message: 'El ID de usuario debe ser un UUID válido' })
userId?: string;
Nombre/Texto Simple
// PATRÓN ESTÁNDAR
@ApiProperty({
description: 'Nombre del usuario',
example: 'Juan Pérez',
minLength: 2,
maxLength: 100,
})
@IsString({ message: 'El nombre debe ser texto' })
@IsNotEmpty({ message: 'El nombre es requerido' })
@MinLength(2, { message: 'El nombre debe tener al menos 2 caracteres' })
@MaxLength(100, { message: 'El nombre no puede exceder 100 caracteres' })
@Transform(({ value }) => value?.trim())
name: string;
Número Entero
// PATRÓN ESTÁNDAR - Entero positivo
@ApiProperty({
description: 'Cantidad de items',
example: 10,
minimum: 1,
})
@IsInt({ message: 'La cantidad debe ser un número entero' })
@Min(1, { message: 'La cantidad mínima es 1' })
@Max(10000, { message: 'La cantidad máxima es 10,000' })
quantity: number;
// Entero con valor por defecto
@ApiPropertyOptional({
description: 'Página actual',
default: 1,
})
@IsOptional()
@IsInt()
@Min(1)
@Transform(({ value }) => parseInt(value) || 1)
page?: number = 1;
Número Decimal (Dinero)
// PATRÓN ESTÁNDAR - Precio/Dinero
@ApiProperty({
description: 'Precio del producto',
example: 99.99,
minimum: 0,
})
@IsNumber(
{ maxDecimalPlaces: 2 },
{ message: 'El precio debe tener máximo 2 decimales' }
)
@Min(0, { message: 'El precio no puede ser negativo' })
@Max(999999.99, { message: 'El precio máximo es 999,999.99' })
price: number;
Boolean
// PATRÓN ESTÁNDAR
@ApiProperty({
description: 'Estado activo del usuario',
example: true,
})
@IsBoolean({ message: 'El valor debe ser verdadero o falso' })
@IsNotEmpty()
isActive: boolean;
// Boolean opcional con default
@ApiPropertyOptional({
description: 'Enviar notificación',
default: false,
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
sendNotification?: boolean = false;
Enum
// PATRÓN ESTÁNDAR
export enum UserStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
}
@ApiProperty({
description: 'Estado del usuario',
enum: UserStatus,
example: UserStatus.ACTIVE,
})
@IsEnum(UserStatus, { message: 'El estado debe ser: active, inactive o suspended' })
@IsNotEmpty()
status: UserStatus;
Fecha
// PATRÓN ESTÁNDAR - Fecha ISO
@ApiProperty({
description: 'Fecha de nacimiento',
example: '1990-05-15',
})
@IsISO8601({}, { message: 'La fecha debe estar en formato ISO 8601' })
@IsNotEmpty()
birthDate: string;
// Fecha como Date object
@ApiProperty({
description: 'Fecha de inicio',
example: '2024-01-15T10:30:00Z',
})
@IsDate({ message: 'Debe ser una fecha válida' })
@Type(() => Date)
@IsNotEmpty()
startDate: Date;
URL
// PATRÓN ESTÁNDAR
@ApiPropertyOptional({
description: 'URL del avatar',
example: 'https://example.com/avatar.jpg',
})
@IsOptional()
@IsUrl({}, { message: 'La URL no es válida' })
@MaxLength(500)
avatarUrl?: string;
Teléfono
// PATRÓN ESTÁNDAR - México
@ApiPropertyOptional({
description: 'Número de teléfono',
example: '+521234567890',
})
@IsOptional()
@IsPhoneNumber('MX', { message: 'El número de teléfono no es válido' })
phone?: string;
// Alternativa con Regex
@IsOptional()
@Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Formato de teléfono inválido' })
phone?: string;
Array
// PATRÓN ESTÁNDAR - Array de strings
@ApiProperty({
description: 'Etiquetas del producto',
example: ['electrónica', 'ofertas'],
type: [String],
})
@IsArray({ message: 'Las etiquetas deben ser un arreglo' })
@IsString({ each: true, message: 'Cada etiqueta debe ser texto' })
@ArrayMinSize(1, { message: 'Debe haber al menos una etiqueta' })
@ArrayMaxSize(10, { message: 'Máximo 10 etiquetas permitidas' })
tags: string[];
// Array de UUIDs
@ApiProperty({
description: 'IDs de categorías',
type: [String],
})
@IsArray()
@IsUUID('4', { each: true, message: 'Cada ID debe ser un UUID válido' })
@ArrayMinSize(1)
categoryIds: string[];
JSON/Object
// PATRÓN ESTÁNDAR - Objeto flexible
@ApiPropertyOptional({
description: 'Metadatos adicionales',
example: { key: 'value' },
})
@IsOptional()
@IsObject({ message: 'Los metadatos deben ser un objeto' })
metadata?: Record<string, any>;
// Objeto con estructura definida (usar class anidada)
class AddressDto {
@IsString()
@IsNotEmpty()
street: string;
@IsString()
@IsNotEmpty()
city: string;
@IsString()
@Length(5, 5)
zipCode: string;
}
@ApiProperty({ type: AddressDto })
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
3. DTOs ESTÁNDAR
CreateDto Pattern
/**
* DTO para crear usuario
*
* @example
* {
* "email": "user@example.com",
* "password": "SecurePass123!",
* "name": "Juan Pérez"
* }
*/
export class CreateUserDto {
@ApiProperty({ description: 'Email del usuario', example: 'user@example.com' })
@IsEmail()
@IsNotEmpty()
@MaxLength(255)
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
@ApiProperty({ description: 'Contraseña', example: 'SecurePass123!' })
@IsString()
@IsNotEmpty()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
password: string;
@ApiProperty({ description: 'Nombre completo', example: 'Juan Pérez' })
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
@Transform(({ value }) => value?.trim())
name: string;
@ApiPropertyOptional({ description: 'Teléfono', example: '+521234567890' })
@IsOptional()
@IsPhoneNumber('MX')
phone?: string;
}
UpdateDto Pattern
import { PartialType, OmitType } from '@nestjs/swagger';
/**
* DTO para actualizar usuario
*
* Todos los campos son opcionales.
* Email y password NO se pueden actualizar aquí.
*/
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['email', 'password'] as const)
) {}
QueryDto Pattern (Filtros y Paginación)
/**
* DTO para búsqueda y paginación de usuarios
*/
export class QueryUsersDto {
@ApiPropertyOptional({ description: 'Página', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'Items por página', default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@ApiPropertyOptional({ description: 'Buscar por nombre o email' })
@IsOptional()
@IsString()
@MaxLength(100)
search?: string;
@ApiPropertyOptional({ description: 'Filtrar por estado', enum: UserStatus })
@IsOptional()
@IsEnum(UserStatus)
status?: UserStatus;
@ApiPropertyOptional({ description: 'Ordenar por campo' })
@IsOptional()
@IsIn(['name', 'email', 'createdAt'])
sortBy?: string = 'createdAt';
@ApiPropertyOptional({ description: 'Dirección de orden', enum: ['asc', 'desc'] })
@IsOptional()
@IsIn(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
}
4. VALIDACIÓN EN SERVICE (Lógica de Negocio)
@Injectable()
export class UserService {
async create(dto: CreateUserDto): Promise<UserEntity> {
// Validación de negocio (después de DTO)
// 1. Verificar unicidad
const existing = await this.repository.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException('El email ya está registrado');
}
// 2. Validar reglas de negocio
if (await this.isEmailDomainBlocked(dto.email)) {
throw new BadRequestException('Dominio de email no permitido');
}
// 3. Validar límites
const userCount = await this.repository.count({
where: { tenantId: dto.tenantId },
});
if (userCount >= this.MAX_USERS_PER_TENANT) {
throw new ForbiddenException('Límite de usuarios alcanzado');
}
// Proceder con creación
const user = this.repository.create(dto);
return this.repository.save(user);
}
}
5. VALIDACIÓN EN FRONTEND (React)
Con React Hook Form + Zod
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// Schema de validación (espejo del DTO backend)
const createUserSchema = z.object({
email: z
.string()
.min(1, 'El email es requerido')
.email('Email inválido')
.max(255),
password: z
.string()
.min(8, 'Mínimo 8 caracteres')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
'Debe contener mayúscula, minúscula, número y carácter especial'
),
name: z
.string()
.min(2, 'Mínimo 2 caracteres')
.max(100, 'Máximo 100 caracteres'),
phone: z
.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Teléfono inválido')
.optional(),
});
type CreateUserForm = z.infer<typeof createUserSchema>;
// Uso en componente
const UserForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateUserForm>({
resolver: zodResolver(createUserSchema),
});
const onSubmit = async (data: CreateUserForm) => {
// data ya está validado
await userService.create(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
{/* ... */}
</form>
);
};
6. MENSAJES DE ERROR ESTÁNDAR
// Mensajes consistentes en español
const VALIDATION_MESSAGES = {
required: (field: string) => `${field} es requerido`,
minLength: (field: string, min: number) => `${field} debe tener al menos ${min} caracteres`,
maxLength: (field: string, max: number) => `${field} no puede exceder ${max} caracteres`,
email: 'El correo electrónico no es válido',
uuid: 'El ID no es válido',
enum: (values: string[]) => `El valor debe ser uno de: ${values.join(', ')}`,
min: (field: string, min: number) => `${field} debe ser mayor o igual a ${min}`,
max: (field: string, max: number) => `${field} debe ser menor o igual a ${max}`,
pattern: (field: string) => `${field} tiene un formato inválido`,
unique: (field: string) => `${field} ya existe`,
};
CHECKLIST DE VALIDACIÓN
DTO:
[ ] Cada campo tiene @ApiProperty o @ApiPropertyOptional
[ ] Campos requeridos tienen @IsNotEmpty
[ ] Campos opcionales tienen @IsOptional
[ ] Tipos validados (@IsString, @IsNumber, etc.)
[ ] Longitudes validadas (@MinLength, @MaxLength)
[ ] Formatos validados (@IsEmail, @IsUUID, etc.)
[ ] Transformaciones aplicadas (@Transform)
[ ] Mensajes de error en español
Service:
[ ] Validación de unicidad
[ ] Validación de existencia (referencias)
[ ] Validación de reglas de negocio
[ ] Validación de permisos
Frontend:
[ ] Schema Zod espeja DTO backend
[ ] Mensajes de error visibles
[ ] Validación en submit
[ ] Validación en blur (opcional)
Versión: 1.0.0 | Sistema: SIMCO | Tipo: Patrón de Validación