workspace/core/orchestration/patrones/PATRON-VALIDACION.md
rckrdmrd 2781837d9e feat: Add SaaS products architecture and alignment analysis
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>
2025-12-08 11:34:35 -06:00

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

Email

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