Skip to main content

Arquitectura y diseño

Principios de diseño

1. Separación de responsabilidades

// ❌ Incorrecto - Todo en una función
async function handleMessage(message) {
  // Validar mensaje
  if (!message.text) return;
  
  // Procesar mensaje
  const response = await processMessage(message.text);
  
  // Enviar respuesta
  await sendMessage(message.from, response);
  
  // Guardar en base de datos
  await saveToDatabase(message);
  
  // Enviar notificación
  await sendNotification(message);
}

// ✅ Correcto - Separado por responsabilidades
class MessageHandler {
  constructor(messageValidator, messageProcessor, messageSender, messageRepository, notificationService) {
    this.validator = messageValidator;
    this.processor = messageProcessor;
    this.sender = messageSender;
    this.repository = messageRepository;
    this.notificationService = notificationService;
  }
  
  async handleMessage(message) {
    if (!this.validator.isValid(message)) {
      throw new ValidationError('Invalid message');
    }
    
    const response = await this.processor.process(message);
    await this.sender.send(message.from, response);
    await this.repository.save(message);
    await this.notificationService.notify(message);
  }
}

2. Manejo de errores robusto

class NotMetaClient {
  async sendMessage(messageData) {
    try {
      return await this.makeRequest('/messages', 'POST', messageData);
    } catch (error) {
      if (error.status === 429) {
        throw new RateLimitError('Too many requests', error.retryAfter);
      } else if (error.status === 401) {
        throw new AuthenticationError('Invalid credentials');
      } else if (error.status >= 500) {
        throw new ServerError('NotMeta service unavailable');
      } else {
        throw new NotMetaError(error.message, error.status);
      }
    }
  }
}

3. Configuración centralizada

// config/notmeta.js
module.exports = {
  apiUrl: process.env.NOTMETA_API_URL || 'https://api.notmeta.com',
  apiToken: process.env.NOTMETA_API_TOKEN,
  webhookSecret: process.env.NOTMETA_WEBHOOK_SECRET,
  timeout: parseInt(process.env.NOTMETA_TIMEOUT) || 30000,
  retries: parseInt(process.env.NOTMETA_RETRIES) || 3,
  rateLimit: {
    requests: 100,
    window: 60000 // 1 minuto
  }
};

Gestión de datos

Caché inteligente

class ConversationCache {
  constructor(redisClient, ttl = 300) { // 5 minutos
    this.redis = redisClient;
    this.ttl = ttl;
  }
  
  async getConversation(conversationId) {
    const cacheKey = `conversation:${conversationId}`;
    const cached = await this.redis.get(cacheKey);
    
    if (cached) {
      return JSON.parse(cached);
    }
    
    // Obtener de la API
    const conversation = await this.notMetaClient.getConversation(conversationId);
    
    // Guardar en caché
    await this.redis.setex(cacheKey, this.ttl, JSON.stringify(conversation));
    
    return conversation;
  }
  
  async invalidateConversation(conversationId) {
    const cacheKey = `conversation:${conversationId}`;
    await this.redis.del(cacheKey);
  }
}

Validación de datos

const Joi = require('joi');

const messageSchema = Joi.object({
  to: Joi.string().pattern(/^\d+$/).required(),
  type: Joi.string().valid('text', 'image', 'document', 'audio', 'video').required(),
  text: Joi.string().when('type', {
    is: 'text',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  }),
  media: Joi.object().when('type', {
    is: Joi.string().valid('image', 'document', 'audio', 'video'),
    then: Joi.required(),
    otherwise: Joi.forbidden()
  })
});

function validateMessage(messageData) {
  const { error, value } = messageSchema.validate(messageData);
  if (error) {
    throw new ValidationError(error.details[0].message);
  }
  return value;
}

Paginación eficiente

class ConversationService {
  async getConversations(options = {}) {
    const params = {
      limit: Math.min(options.limit || 50, 100),
      cursor: options.cursor,
      status: options.status,
      assigned_to: options.assignedTo
    };
    
    const response = await this.notMetaClient.get('/conversations', { params });
    
    return {
      conversations: response.data,
      pagination: {
        nextCursor: response.pagination?.next_cursor,
        hasMore: response.pagination?.has_more
      }
    };
  }
  
  async getAllConversations(options = {}) {
    const allConversations = [];
    let cursor = null;
    
    do {
      const result = await this.getConversations({
        ...options,
        cursor
      });
      
      allConversations.push(...result.conversations);
      cursor = result.pagination.nextCursor;
    } while (cursor);
    
    return allConversations;
  }
}

Performance y escalabilidad

Rate limiting

class RateLimiter {
  constructor(requests, windowMs) {
    this.requests = requests;
    this.windowMs = windowMs;
    this.requests = new Map();
  }
  
  async isAllowed(key) {
    const now = Date.now();
    const windowStart = now - this.windowMs;
    
    // Limpiar requests antiguos
    for (const [timestamp] of this.requests.entries()) {
      if (timestamp < windowStart) {
        this.requests.delete(timestamp);
      }
    }
    
    // Verificar límite
    if (this.requests.size >= this.requests) {
      return false;
    }
    
    // Registrar request
    this.requests.set(now, key);
    return true;
  }
}

class NotMetaClientWithRateLimit {
  constructor(rateLimiter) {
    this.rateLimiter = rateLimiter;
  }
  
  async makeRequest(url, method, data) {
    if (!await this.rateLimiter.isAllowed('api')) {
      throw new RateLimitError('Rate limit exceeded');
    }
    
    return await this.httpClient.request({ url, method, data });
  }
}

Procesamiento asíncrono

const Queue = require('bull');

class MessageQueue {
  constructor() {
    this.queue = new Queue('message processing', {
      redis: {
        host: process.env.REDIS_HOST,
        port: process.env.REDIS_PORT
      }
    });
    
    this.setupProcessors();
  }
  
  setupProcessors() {
    this.queue.process('send-message', 10, async (job) => {
      const { messageData } = job.data;
      return await this.notMetaClient.sendMessage(messageData);
    });
    
    this.queue.process('process-webhook', 20, async (job) => {
      const { event } = job.data;
      return await this.handleWebhookEvent(event);
    });
  }
  
  async addSendMessage(messageData, options = {}) {
    return await this.queue.add('send-message', { messageData }, {
      attempts: 3,
      backoff: 'exponential',
      ...options
    });
  }
}

Circuit breaker

class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.threshold = threshold;
    this.timeout = timeout;
    this.failureCount = 0;
    this.lastFailureTime = null;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
  }
  
  async execute(operation) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new CircuitBreakerOpenError('Circuit breaker is open');
      }
    }
    
    try {
      const result = await operation();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  
  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }
  
  onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    
    if (this.failureCount >= this.threshold) {
      this.state = 'OPEN';
    }
  }
}

Seguridad

Validación de webhooks

class WebhookValidator {
  constructor(secret) {
    this.secret = secret;
  }
  
  validate(payload, signature) {
    const expectedSignature = this.calculateSignature(payload);
    return this.secureCompare(signature, expectedSignature);
  }
  
  calculateSignature(payload) {
    const crypto = require('crypto');
    return 'sha256=' + crypto
      .createHmac('sha256', this.secret)
      .update(payload)
      .digest('hex');
  }
  
  secureCompare(a, b) {
    if (a.length !== b.length) {
      return false;
    }
    
    let result = 0;
    for (let i = 0; i < a.length; i++) {
      result |= a.charCodeAt(i) ^ b.charCodeAt(i);
    }
    
    return result === 0;
  }
}

Sanitización de datos

class DataSanitizer {
  static sanitizePhoneNumber(phone) {
    // Remover caracteres no numéricos
    return phone.replace(/\D/g, '');
  }
  
  static sanitizeMessage(message) {
    // Remover caracteres de control y limitar longitud
    return message
      .replace(/[\x00-\x1F\x7F]/g, '')
      .substring(0, 4096);
  }
  
  static sanitizeFileName(filename) {
    // Remover caracteres peligrosos
    return filename.replace(/[^a-zA-Z0-9._-]/g, '_');
  }
}

Logging seguro

class SecureLogger {
  static log(message, data = {}) {
    const sanitizedData = this.sanitizeLogData(data);
    
    console.log(JSON.stringify({
      timestamp: new Date().toISOString(),
      message,
      data: sanitizedData
    }));
  }
  
  static sanitizeLogData(data) {
    const sensitiveFields = ['token', 'secret', 'password', 'api_key'];
    const sanitized = { ...data };
    
    for (const field of sensitiveFields) {
      if (sanitized[field]) {
        sanitized[field] = '[REDACTED]';
      }
    }
    
    return sanitized;
  }
}

Monitoreo y observabilidad

Métricas personalizadas

class MetricsCollector {
  constructor() {
    this.metrics = new Map();
  }
  
  increment(counter, tags = {}) {
    const key = this.getKey(counter, tags);
    this.metrics.set(key, (this.metrics.get(key) || 0) + 1);
  }
  
  timing(timer, duration, tags = {}) {
    const key = this.getKey(timer, tags);
    const timings = this.metrics.get(key) || [];
    timings.push(duration);
    this.metrics.set(key, timings);
  }
  
  getKey(name, tags) {
    const sortedTags = Object.keys(tags)
      .sort()
      .map(key => `${key}:${tags[key]}`)
      .join(',');
    
    return sortedTags ? `${name}{${sortedTags}}` : name;
  }
}

// Uso
const metrics = new MetricsCollector();

metrics.increment('messages.sent', { status: 'success' });
metrics.timing('api.response_time', 150, { endpoint: '/messages' });

Health checks

class HealthChecker {
  constructor(notMetaClient) {
    this.notMetaClient = notMetaClient;
  }
  
  async checkHealth() {
    const checks = {
      database: await this.checkDatabase(),
      notmeta: await this.checkNotMeta(),
      redis: await this.checkRedis()
    };
    
    const isHealthy = Object.values(checks).every(check => check.status === 'healthy');
    
    return {
      status: isHealthy ? 'healthy' : 'unhealthy',
      checks,
      timestamp: new Date().toISOString()
    };
  }
  
  async checkNotMeta() {
    try {
      await this.notMetaClient.get('/health');
      return { status: 'healthy' };
    } catch (error) {
      return { 
        status: 'unhealthy', 
        error: error.message 
      };
    }
  }
}

Testing

Tests unitarios

describe('MessageHandler', () => {
  let messageHandler;
  let mockNotMetaClient;
  
  beforeEach(() => {
    mockNotMetaClient = {
      sendMessage: jest.fn(),
      getConversation: jest.fn()
    };
    
    messageHandler = new MessageHandler(mockNotMetaClient);
  });
  
  test('should send message successfully', async () => {
    const messageData = {
      to: '1234567890',
      type: 'text',
      text: 'Hello'
    };
    
    mockNotMetaClient.sendMessage.mockResolvedValue({ id: 'msg_123' });
    
    const result = await messageHandler.sendMessage(messageData);
    
    expect(mockNotMetaClient.sendMessage).toHaveBeenCalledWith(messageData);
    expect(result.id).toBe('msg_123');
  });
  
  test('should handle API errors', async () => {
    const messageData = {
      to: '1234567890',
      type: 'text',
      text: 'Hello'
    };
    
    mockNotMetaClient.sendMessage.mockRejectedValue(
      new Error('API Error')
    );
    
    await expect(messageHandler.sendMessage(messageData))
      .rejects.toThrow('API Error');
  });
});

Tests de integración

describe('Webhook Integration', () => {
  let app;
  let server;
  
  beforeAll(async () => {
    app = createApp();
    server = app.listen(0);
  });
  
  afterAll(async () => {
    await server.close();
  });
  
  test('should handle webhook events', async () => {
    const event = {
      id: 'evt_123',
      type: 'message.received',
      data: {
        message: {
          id: 'msg_123',
          from: '1234567890',
          text: 'Hello'
        }
      }
    };
    
    const signature = calculateWebhookSignature(event);
    
    const response = await request(app)
      .post('/webhook')
      .set('X-NotMeta-Signature', signature)
      .send(event);
    
    expect(response.status).toBe(200);
    expect(response.text).toBe('OK');
  });
});

Recursos adicionales

Introducción para desarrolladores

Conceptos básicos de la API de NotMeta

API Reference

Documentación completa de la API