Documentação / Number Pool — Balanceamento de Envios por API Key

Number Pool — Balanceamento de Envios por API Key

Entrar

Number Pool — Balanceamento de Envios por API Key

Status: Proposta
Depende de: Plano: Múltiplos Provedores + Múltiplas Instâncias por Número
Estimativa: 3-5 dias após conclusão do plano principal


1. Objetivo

Permitir que uma API Key envie mensagens por múltiplos números WhatsApp com:

  1. Round-robin sequencial — cada nova mensagem usa o próximo número do pool
  2. Afinidade de destinatário — se um destinatário já recebeu de um número, tenta usar o mesmo número
  3. Fallback automático — se o número com afinidade está sem conexão ativa, usa outro número do pool

2. Conceito

API Key
├── defaultNumber: "5511999990001"   (fallback, usada quando pool está desativado)
├── numberSelectionMode: "pool"      ("default" | "pool")
└── numberPool: [                    (N números vinculados)
      { number: "5511999990001", priority: 0 }
      { number: "5511999990002", priority: 1 }
    ]

Envio para +55 11 88888-0001:
  1. Já enviei para esse destinatário antes? → Usa o mesmo número (afinidade)
  2. Número com afinidade está conectado? → Usa ele
  3. Número com afinidade desconectado? → Usa próximo do round-robin
  4. Nenhum número conectado? → Erro

3. O que o plano principal já resolve

O plano multiple-instances-per-number.plan.md implementa mudanças que são pré-requisitos diretos para o pool:

3.1 defaultNumber no ApiKey (Seção 4.6 do plano)

model ApiKey {
  whatsappInstanceId  String?   @map("whatsapp_instance_id")   // DEPRECATED
  defaultNumber       String?   @map("default_number")          // NOVO
}

Hoje a API key aponta para uma instância. Com defaultNumber, aponta para um número (WhatsAppNumber), que pode ter N instâncias. Essa mudança de granularidade (instância → número) é o primeiro passo para suportar múltiplos números.

3.2 sourceNumber na Message (Seção 4.7 do plano)

model Message {
  sourceNumber  String?  @map("source_number")   // NOVO
}

Permite saber "de qual número foi enviado". Essa é a base da afinidade de destinatário — a query busca qual sourceNumber foi usado na última mensagem enviada para aquele destinationNumber.

3.3 Fallback entre instâncias (Seções 5.3 e 11 do plano)

O worker já terá lógica de: dado um número, buscar instâncias conectadas e tentar em ordem de priority. O pool expande essa lógica de "instâncias de um número" para "números de uma API key".


4. Schema — Mudanças Novas

4.1 Campo no ApiKey

model ApiKey {
  // ... campos existentes (incluindo defaultNumber do plano) ...

  /// Modo de seleção de número para envio:
  /// "default" — usa apenas o defaultNumber
  /// "pool" — round-robin entre os números do pool
  numberSelectionMode  String  @default("default") @map("number_selection_mode")
}

4.2 Tabela de relacionamento N-N

/// Relação entre API Key e números WhatsApp no pool.
model ApiKeyNumberPool {
  id                String   @id @default(cuid())
  apiKeyId          String   @map("api_key_id")
  whatsappNumberId  String   @map("whatsapp_number_id")

  /// Prioridade dentro do pool (menor = tentado primeiro no round-robin)
  priority          Int      @default(0) @map("priority")

  createdAt         DateTime @default(now()) @map("created_at")

  apiKey            ApiKey          @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
  whatsappNumber    WhatsAppNumber  @relation(fields: [whatsappNumberId], references: [id], onDelete: Cascade)

  @@unique([apiKeyId, whatsappNumberId])
  @@index([apiKeyId, priority])
  @@map("api_key_number_pool")
}

4.3 Relações atualizadas

model ApiKey {
  // ... existing ...
  numberPool  ApiKeyNumberPool[]
}

model WhatsAppNumber {
  // ... existing ...
  apiKeyPools  ApiKeyNumberPool[]
}

5. Worker — Lógica de Resolução

5.1 Fluxo de decisão

processMessage(job)
  │
  ├── apiKey.numberSelectionMode == "default" || pool vazio?
  │   └── Usa defaultNumber (fluxo atual do plano)
  │
  └── apiKey.numberSelectionMode == "pool"
      │
      ├── 1. Affinity: destinationNumber já recebeu de algum número do pool?
      │   ├── Sim, e o número está conectado → Usa esse número
      │   └── Sim, mas número desconectado → Fallback para round-robin
      │
      ├── 2. Round-robin: próximo número do pool (Redis counter)
      │   └── Tenta cada número em ordem, pulando desconectados
      │
      └── 3. Nenhum número conectado → Erro

5.2 Código

async function resolveSourceNumberForSend(params: {
  apiKey: {
    id: string;
    defaultNumber: string | null;
    numberSelectionMode: string;
  };
  destinationNumber: string;
  tenantId: string;
}): Promise<{
  whatsappNumber: WhatsAppNumber;
  instance?: WhatsAppInstance;
}> {
  const { apiKey, destinationNumber, tenantId } = params;

  // ── Default mode (sem pool) ──
  if (
    apiKey.numberSelectionMode !== "pool" ||
    !apiKey.defaultNumber
  ) {
    return resolveByDefaultNumber(apiKey.defaultNumber, tenantId);
  }

  // ── Pool mode: busca números vinculados ──
  const pool = await prisma.apiKeyNumberPool.findMany({
    where: { apiKeyId: apiKey.id },
    include: {
      whatsappNumber: {
        include: {
          instances: {
            where: { state: "CONNECTED" },
            include: { providerConfig: true },
            orderBy: [
              { providerConfig: { priority: "asc" } },
              { priority: "asc" },
            ],
          },
        },
      },
    },
    orderBy: { priority: "asc" },
  });

  if (pool.length === 0) {
    return resolveByDefaultNumber(apiKey.defaultNumber, tenantId);
  }

  // ── 1. Affinity: destinatário já recebeu de algum número do pool? ──
  const lastMessage = await prisma.message.findFirst({
    where: {
      tenantId,
      destinationNumber,
      sourceNumber: { not: null },
      status: { in: ["SENT", "DELIVERED", "READ"] },
    },
    orderBy: { sentAt: "desc" },
    select: { sourceNumber: true },
  });

  if (lastMessage?.sourceNumber) {
    const affinityEntry = pool.find(
      (p) => p.whatsappNumber.number === lastMessage.sourceNumber
    );
    if (
      affinityEntry &&
      affinityEntry.whatsappNumber.instances.length > 0
    ) {
      return {
        whatsappNumber: affinityEntry.whatsappNumber,
        instance: affinityEntry.whatsappNumber.instances[0],
      };
    }
    // Affinity number disconnected → fallback to round-robin
  }

  // ── 2. Round-robin: próximo número do pool ──
  const rrIndex = await getAndIncrementRoundRobin(apiKey.id, pool.length);
  for (let i = 0; i < pool.length; i++) {
    const idx = (rrIndex + i) % pool.length;
    const entry = pool[idx];

    // Meta numbers (sem instâncias web)
    if (entry.whatsappNumber.metaPhoneId) {
      return { whatsappNumber: entry.whatsappNumber, instance: undefined };
    }

    // Web-based: precisa de instância conectada
    if (entry.whatsappNumber.instances.length > 0) {
      return {
        whatsappNumber: entry.whatsappNumber,
        instance: entry.whatsappNumber.instances[0],
      };
    }
  }

  throw new Error("No number available in pool");
}

5.3 Round-robin com Redis

async function getAndIncrementRoundRobin(
  apiKeyId: string,
  poolSize: number,
): Promise<number> {
  const redis = getWorkerRedis();
  const key = `pool:round-robin:${apiKeyId}`;
  const index = await redis.incr(key);
  // TTL para auto-cleanup (chaves de APIs inativas somem)
  if (index === 1) {
    await redis.expire(key, 86400); // 24h
  }
  return (index - 1) % poolSize;
}

5.4 Interação com o fluxo de fallback do plano

O plano principal define que, para cada número, o worker faz fallback entre instâncias (Seção 5.3). O pool adiciona uma camada acima dessa lógica:

Pool (round-robin entre números)
  └── Número A
      └── Fallback entre instâncias (plano principal, Seção 5.3)
          ├── Instância A1 (EVO GO, priority 0)
          └── Instância A2 (EVO V2, priority 1)
  └── Número B
      └── Fallback entre instâncias
          └── Instância B1 (EVO GO, priority 0)

O pool escolhe o número, e o mecanismo de instâncias escolhe a conexão dentro desse número.


6. Backend API

6.1 Endpoints

| Método | Endpoint | Descrição | |---|---|---| | PUT | /api/api-keys/:id/selection-mode | Atualiza numberSelectionMode ("default" / "pool") | | GET | /api/api-keys/:id/pool | Lista números do pool da API key | | POST | /api/api-keys/:id/pool | Adiciona número ao pool | | DELETE | /api/api-keys/:id/pool/:numberId | Remove número do pool | | PUT | /api/api-keys/:id/pool/reorder | Reordena prioridade dos números |

6.2 Exemplo: Adicionar número ao pool

// POST /api/api-keys/clx.../pool
{
  "whatsappNumberId": "clx-number-abc"
}

// Response
{
  "id": "clx-pool-xyz",
  "apiKeyId": "clx...",
  "whatsappNumberId": "clx-number-abc",
  "priority": 2,
  "whatsappNumber": {
    "id": "clx-number-abc",
    "number": "5511999990002",
    "displayName": "Vendas"
  }
}

7. UI — ApiKeys.tsx

7.1 Componentes novos

Na tela de edição da API key, adicionar:

| Componente | Descrição | |---|---| | Toggle "Balanceamento de envios" | Liga/desliga o modo pool (numberSelectionMode) | | Multi-select de números | Lista números conectados do tenant para adicionar ao pool | | Lista ordenada do pool | Drag-and-drop para reordenar prioridade | | Badge por número | Status: conectado / desconectado | | Botão "Remover do pool" | Remove número do pool |

7.2 Wireframe

┌──────────────────────────────────────────────────────┐
│  Editar API Key: "Produção"                          │
│                                                       │
│  Nome: Produção                                      │
│  Ambiente: LIVE                                      │
│  Número padrão: +55 11 99999-0001                    │
│                                                       │
│  ┌── Balanceamento de Envios ──────────────────────┐ │
│  │                                                  │ │
│  │  [Toggle] Ativar balanceamento de envios        │ │
│  │                                                  │ │
│  │  Números no pool:                               │ │
│  │  ┌──────────────────────────────────────────┐   │ │
│  │  │ ☰ 1. +55 11 99999-0001 (Vendas)  🟢     │ × │ │
│  │  │ ☰ 2. +55 11 99999-0002 (Suporte) 🟢     │ × │ │
│  │  │ ☰ 3. +55 11 99999-0003 (Demo)    🔴     │ × │ │
│  │  └──────────────────────────────────────────┘   │ │
│  │                                                  │ │
│  │  [+ Adicionar número ao pool]                   │ │
│  │                                                  │ │
│  │  ℹ️ Mensagens são enviadas em sequência         │ │
│  │  entre os números. Destinatários que já         │ │
│  │  receberam de um número continuarão             │ │
│  │  recebendo do mesmo quando possível.            │ │
│  └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘

8. Testes

| Camada | Cenário | Descrição | |---|---|---| | Unit | resolveSourceNumberForSend — default mode | Usa defaultNumber, ignora pool | | Unit | resolveSourceNumberForSend — pool round-robin | Alterna entre números em sequência | | Unit | resolveSourceNumberForSend — affinity | Destinatário já recebeu do número A → usa A | | Unit | resolveSourceNumberForSend — affinity fallback | Número A desconectado → usa próximo do pool | | Unit | resolveSourceNumberForSend — pool vazio | Fallback para defaultNumber | | Unit | resolveSourceNumberForSend — todos desconectados | Erro: "No number available in pool" | | Unit | getAndIncrementRoundRobin | Contador atômico wraps corretamente | | Integration | API pool CRUD | Adicionar, remover, reordenar números no pool | | Integration | Envio com pool | Mensagem é enviada pelo número correto do pool | | Fullstack | Affinity end-to-end | 2 mensagens para mesmo destinatário → mesmo número | | Fullstack | Fallback end-to-end | Número primário cai → envia pelo secundário |


9. Considerações

9.1 Limits

Definir um limite máximo de números por pool (ex: 10) para evitar queries pesadas. Validar no backend.

9.2 Métricas

Adicionar tracing no messageTrace para registrar qual número do pool foi usado e se houve fallback de afinidade:

await messageTrace.appendEvent({
  messageId,
  tenantId,
  type: MessageTraceEventType.POOL_RESOLVED,
  stage: "pool.number.resolved",
  data: {
    selectionMode: "pool",
    affinityNumber: lastMessage?.sourceNumber ?? null,
    resolvedNumber: entry.whatsappNumber.number,
    roundRobinIndex: rrIndex,
    poolSize: pool.length,
  },
});

9.3 Webhook

O webhook de message.sent (e message.failed) já retorna o messageId do provedor. Adicionar o sourceNumber no payload do webhook para que o cliente saiba qual número foi usado:

{
  "event": "message.sent",
  "data": {
    "messageId": "...",
    "sourceNumber": "5511999990001",
    "destinationNumber": "5511988880001",
    "status": "SENT",
    "sentAt": "..."
  }
}

9.4 Rollout

O campo numberSelectionMode tem default "default", então a funcionalidade é opt-in. Não quebra nada existente. Rollout pode ser gradual por tenant.