Skip to content

Módulo Redirecionamento

Localização: backend/src/modules/redirect/

Responsável por resolver tokens de links de campanha WhatsApp e processar opt-outs. Quando uma campanha inclui botão de URL rastreado, o cliente clica em um link curto (/go/{token}) que este módulo resolve, registra o clique e redireciona o cliente para o WhatsApp da loja correta.


Arquivos e Responsabilidades

ArquivoClasseResponsabilidade
redirect.service.tsRedirectServiceLógica principal: valida token, resolve instância WhatsApp, envia mensagens e retorna URL de redirecionamento
redirect.controller.tsRedirectControllerEndpoints públicos /go/:token e /go/unsubscribe/:token

Endpoints (públicos — sem autenticação Clerk)

MétodoPathDescrição
GET/go/:tokenResolve redirect e retorna wa.me URL da loja
GET/go/unsubscribe/:tokenProcessa opt-out WhatsApp permanente

Ambos os endpoints são marcados com @Public() — acessíveis diretamente pelo navegador do cliente sem sessão.


RedirectService

Classe: RedirectServiceLocalização: backend/src/modules/redirect/redirect.service.ts

Interface de Retorno

typescript
export interface RedirectResult {
  waLink: string;  // URL do tipo "https://wa.me/{numero}" aberta no browser do cliente
}

resolve(token, fixedStoreId?): Promise<RedirectResult | null>

Ponto de entrada principal. Valida o token, identifica o modo de operação (Evolution ou WABA oficial) e executa o fluxo correspondente.

Parâmetros:

  • token: string — UUID do token de redirect, incluído na URL da campanha
  • fixedStoreId?: string — override de loja fixado no botão da campanha

Retorna: RedirectResult com a URL wa.me, ou null se o token for inválido ou expirado.

Validação do Token

  1. Busca CampaignRedirectToken pelo token (busca por campo único).
  2. Se não encontrado → loga warning, retorna null.
  3. Se expiresAt < new Date() → loga warning "expired", retorna null. Tokens expiram em 30 dias.
  4. Busca Customer pelo record.customerId com { preferredStoreId, organizationId, phone }.
  5. Se cliente não encontrado → retorna null.

Carregamento de Configurações da Campanha

Busca Organization.campaignSettings (JSONB) para obter os templates de mensagem configurados pelo administrador:

typescript
const settings = org?.campaignSettings ?? {};

// Templates com fallback padrão:
confirmationTemplate = settings.redirectConfirmation ||
  'Obrigado! A loja {{storeName}} entrará em contato pelo número {{storeNumber}} em breve. 😊';

greetingMessage = settings.redirectGreeting ||
  'Olá! Vi que você demonstrou interesse. Como posso te ajudar? 😊';

Path A: Evolution API (modo por loja)

Executado quando resolveEvolutionInstance() retorna uma instância Evolution.

resolveEvolutionInstance(orgId, preferredStoreId, fixedStoreId): Promise<EvoInstance | null>

Resolve a instância Evolution na ordem de prioridade:

Prioridade 0 — Loja fixada no botão (fixedStoreId override)
  WHERE storeId = fixedStoreId AND organizationId = orgId AND provider = 'evolution'
        AND evolutionInstanceName IS NOT NULL

Prioridade 1 — Loja preferida do cliente (customer.preferredStoreId)
  WHERE storeId = preferredStoreId AND organizationId = orgId AND provider = 'evolution'
        AND evolutionInstanceName IS NOT NULL

Prioridade 2 — Loja e-commerce (isEcommerce = true AND isActive = true)
  Busca a loja e-commerce → busca número WhatsApp com evolutionInstanceName

Retorna null se nenhuma instância Evolution encontrada

Retorna:

typescript
{
  number: string;                   // número WhatsApp da loja
  evolutionInstanceName: string | null;
  evolutionApiKey: string | null;   // criptografado AES-256-GCM
  storeId: string | null;
}

Fluxo Evolution (resolve — continuação)

Quando evo é encontrado:

  1. Busca nome da loja (tradeName preferencial, name como fallback).
  2. Formata o número para exibição com PhoneUtils.format(evo.number).
  3. Registra clique: recordClick(record.id, evo.number).
  4. WABA envia confirmação para o cliente no canal da campanha:
    sendWabaConfirmation(record.campaignId, customerPhone, confirmationTemplate, storeName, displayNumber)
  5. Evolution envia saudação diretamente da loja para o cliente:
    evolutionApi.sendText(evo.evolutionInstanceName, customerPhone, greetingMessage, apiKey)
    • A apiKey da Evolution é decriptada via EncryptionService.decrypt() antes do uso.
  6. Retorna { waLink: "https://wa.me/{numero}" }.

Mensagens são best-effort: se qualquer envio falhar, o redirect ainda retorna a URL wa.me.


Path B: WABA Oficial (sem Evolution)

Executado quando nenhuma instância Evolution é encontrada — resolveEvolutionInstance() retorna null.

resolveWabaStore(orgId, preferredStoreId): Promise<{ chatwootInboxId, storeId, isFallback } | null>

Encontra a melhor loja WABA com inbox Chatwoot configurada:

Prioridade 1 — Loja preferida (preferredStoreId)
  WHERE storeId = preferredStoreId AND organizationId = orgId
        AND chatwootInboxId IS NOT NULL
  → { isFallback: false }

Prioridade 2 — Loja e-commerce (isEcommerce = true AND isActive = true)
  → { isFallback: true }

Prioridade 3 — Qualquer loja com chatwootInboxId
  → { isFallback: true }

Retorna null se nenhuma loja WABA encontrada

Fluxo WABA Oficial (resolveWabaPath)

  1. Chama resolveWabaStore() para encontrar a melhor loja.
  2. Busca o número WABA (sender): StoreWhatsappNumber com phoneNumberId NOT NULL e accessToken NOT NULL.
  3. Retorna null se nenhum número WABA configurado.
  4. Decripta accessToken via EncryptionService.decrypt().
  5. Registra clique: recordClick(record.id, wabaNumber.number ?? wabaNumber.phoneNumberId).
  6. WABA envia mensagem de conexão (síncrono, best-effort):
    Template: settings.connectingMessage || 'Conectando você com a {{loja}}, aguarde um instante... ⏳'
    Enviada via MetaGraphService.sendMessage(phoneNumberId, accessToken, { type: 'text', text: { body } }). Se falhar → loga warning e continua (não bloqueia o redirect).
  7. Salva preferência de loja no cliente:
    Customer.preferredStoreId = wabaStore.storeId
  8. Configura conversa Chatwoot em background (não-bloqueante, via .catch()):
    wabaRouter.setupWabaConversationAsync(orgId, chatwootInboxId, customerPhone, isFallback, { phoneNumberId, accessToken, storeName })
    Este passo cria a conversa no Chatwoot, envia nota de contexto e atribui agente — tudo assíncrono.
  9. Retorna { waLink: "https://wa.me/{wabaRawNumber}" }.

Mensagem de Confirmação WABA (Evolution mode)

sendWabaConfirmation(campaignId, customerPhone, template, storeName, storeNumber): Promise<void>

Envia confirmação de redirect para o cliente via o número WABA da campanha original.

Fluxo:

  1. Busca Campaign.whatsappNumberId pelo ID da campanha.
  2. Busca StoreWhatsappNumber com phoneNumberId e accessToken pelo whatsappNumberId.
  3. Decripta accessToken.
  4. Substitui variáveis no template:
    • → nome da loja
    • → número formatado da loja Evolution
  5. Envia via MetaGraphService.sendMessage().

Totalmente best-effort: erros são capturados com try/catch e logados. Nunca propaga exceção.


Registro de Clique

recordClick(tokenId, number): Promise<void>

Atualiza o CampaignRedirectToken com o timestamp e o número para onde o cliente foi redirecionado.

typescript
// Campos atualizados
{
  clickedAt: new Date(),
  redirectedTo: number  // número da instância Evolution ou número WABA
}

Best-effort: erros capturados e logados como warning. O redirect não é afetado.


Fluxo de Opt-out (Unsubscribe WhatsApp)

unsubscribe(token): Promise<'ok' | 'already' | 'notfound'>

Processa o opt-out permanente de um cliente para campanhas WhatsApp.

Token permanente: diferente do redirect token (que expira em 30 dias), o unsubscribe token nunca expira e é único por cliente (Customer.whatsappUnsubscribeToken).

Fluxo:

  1. Gera hash SHA-256 do token:
    typescript
    const tokenHash = createHash('sha256').update(token).digest('hex');
  2. Busca cliente com WHERE whatsappUnsubscribeToken = tokenHash.
  3. Se não encontrado → retorna 'notfound'.
  4. Se customer.whatsappOptOut = true → retorna 'already' (idempotente).
  5. Atualiza cliente:
    typescript
    { whatsappOptOut: true, whatsappOptOutAt: new Date() }
  6. Atribui o opt-out à última campanha enviada para o cliente:
    typescript
    WhatsappMessage.findFirst({
      where: { direction: 'OUTBOUND', campaignId: NOT NULL, recipientPhone: { endsWith: phone.slice(-8) } },
      orderBy: { sentAt: 'desc' }
    })
    Usa os últimos 8 dígitos do telefone para match resiliente (independente de código de país).
  7. Se encontrou campanha → CampaignMetric.upsert({ unsubscribes: { increment: 1 } }).
  8. Retorna 'ok'.
  9. Se qualquer erro inesperado → propaga a exceção (controller retorna 500).

Por que SHA-256? O token em si nunca é armazenado no banco — apenas seu hash. O cliente que clica no botão de opt-out envia o token na URL, e o sistema o procura pelo hash. Isso protege os tokens de exposição em caso de vazamento do banco.


Geração de Tokens

Redirect Token (por envio de campanha)

Gerado pelo WhatsappSendProcessor ao enfileirar o envio de cada cliente da campanha.

typescript
// Estrutura da tabela CampaignRedirectToken
{
  id: string;           // UUID gerado pelo backend
  token: string;        // UUID aleatório — incluído na URL do botão
  campaignId: string;
  customerId: string;
  organizationId: string;
  preferredStoreId: string | null;  // loja preferida no momento do envio
  expiresAt: Date;      // now() + 30 dias
  clickedAt: Date | null;
  redirectedTo: string | null;      // número para onde foi redirecionado
}

URL de campanha gerada: https://apicrm.galdix.com.br/go/{token}

Unsubscribe Token (permanente por cliente)

Gerado uma única vez e armazenado em Customer.whatsappUnsubscribeToken (como hash SHA-256).

Incluído como botão de "Parar de receber" nos templates de campanha WhatsApp.

URL de opt-out gerada: https://apicrm.galdix.com.br/go/unsubscribe/{token_plaintext}


Decisão de Roteamento

A decisão entre Path A (Evolution) e Path B (WABA oficial) é feita automaticamente com base na existência de instâncias Evolution configuradas:

resolve(token, fixedStoreId?)

  ├─ resolveEvolutionInstance() → encontrou?
  │   ├─ SIM → Path A (Evolution mode)
  │   │         WABA confirma + Evolution saúda + wa.me loja
  │   │
  │   └─ NÃO → Path B (WABA oficial)
  │             WABA conecta + Chatwoot async + wa.me WABA

  └─ waLink retornado ao controller → 302 redirect para o cliente

O fixedStoreId pode ser passado pelo controller se o botão da campanha tiver uma loja específica fixada (campo storeOverride no template do botão).


Regras de Negócio

RegraComportamento
Token expirado (> 30 dias)Retorna null, controller responde com página de erro
Token não encontradoRetorna null, controller responde com página de erro
Cliente não encontradoRetorna null
Falha no envio da confirmação WABALoga warning, redirect continua normalmente
Falha no envio da saudação EvolutionLoga warning, redirect continua normalmente
Falha no setup do ChatwootLoga warning (.catch()), redirect já foi retornado
Opt-out já registradoRetorna 'already' — não duplica o registro
Token SHA-256 não encontradoRetorna 'notfound'
Erro inesperado no unsubscribePropaga a exceção → controller retorna 500
Nenhuma loja WABA com inboxRetorna null no Path B

Princípio central: o redirect para wa.me sempre acontece se o token for válido. Mensagens de confirmação, saudação e setup do Chatwoot são todas best-effort e nunca bloqueiam o redirect.


Comparação dos Dois Paths

AspectoPath A: EvolutionPath B: WABA Oficial
Número do wa.meNúmero da instância Evolution (loja)Número WABA compartilhado
ConfirmaçãoWABA (campanha) → clienteWABA → "Conectando você com a loja..."
SaudaçãoEvolution (loja) → cliente, imediatoChatwoot (agente atribuído), assíncrono
Conversa no ChatwootCriada pelo webhook da Evolution ao receber respostaCriada proativamente e assincronamente
Preferência de loja salvaNão (já existe preferência)Sim — Customer.preferredStoreId = wabaStore.storeId
Loja com fallbackE-commerce se preferred sem EvolutionE-commerce → qualquer loja com inbox
isFallbackNão se aplicaIndica ao setupWabaConversationAsync se deve mostrar menu de lojas

Integração com Outros Módulos

MóduloUso
EvolutionApiServiceEnvia mensagem de saudação no Path A
MetaGraphServiceEnvia mensagem de confirmação/conexão via WABA
WhatsappRouterServicesetupWabaConversationAsync — cria conversa Chatwoot, atribui agente, envia saudação
ChatwootConfigServiceConsultado para resolver configurações de modo WABA (indiretamente via WhatsappRouterService)
EncryptionServiceDecripta evolutionApiKey e accessToken WABA
PhoneUtilsnormalizeBrazilian() — normaliza telefone do cliente para envio; format() — formata número para exibição
PrismaServiceLeitura de CampaignRedirectToken, Customer, Organization, Store, StoreWhatsappNumber, Campaign, WhatsappMessage, CampaignMetric

Tabelas do Banco

TabelaCampos lidosCampos escritos
CampaignRedirectTokentoken, expiresAt, customerId, organizationId, campaignIdclickedAt, redirectedTo
CustomerpreferredStoreId, organizationId, phone, whatsappOptOut, whatsappUnsubscribeTokenwhatsappOptOut, whatsappOptOutAt, preferredStoreId
OrganizationcampaignSettings (JSONB)
StoretradeName, name, isEcommerce, isActive
StoreWhatsappNumbernumber, evolutionInstanceName, evolutionApiKey, provider, chatwootInboxId, phoneNumberId, accessToken, storeId
CampaignwhatsappNumberId
WhatsappMessagedirection, campaignId, recipientPhone, sentAt
CampaignMetricunsubscribes (increment)

Tratamento de Erros

Todos os erros são capturados via try/catch dentro dos métodos internos. O controller principal nunca recebe exceções dos helpers:

  • recordClick()try/catch, loga warning, não propaga.
  • sendWabaConfirmation()try/catch interno, loga warning.
  • evolutionApi.sendText() no Path A → sem await explícito de erro (best-effort na camada da Evolution).
  • setupWabaConversationAsync().catch() com log de warning.
  • unsubscribe() → propaga erros inesperados (controller retorna 500).

Logging estruturado: todos os warnings incluem campos estruturados (orgId, customerPhone, storeId, phoneNumberId) para facilitar correlação em ferramentas de observabilidade.

Documentação interna — Galdix CRM