Tema
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
| Arquivo | Classe | Responsabilidade |
|---|---|---|
redirect.service.ts | RedirectService | Lógica principal: valida token, resolve instância WhatsApp, envia mensagens e retorna URL de redirecionamento |
redirect.controller.ts | RedirectController | Endpoints públicos /go/:token e /go/unsubscribe/:token |
Endpoints (públicos — sem autenticação Clerk)
| Método | Path | Descrição |
|---|---|---|
GET | /go/:token | Resolve redirect e retorna wa.me URL da loja |
GET | /go/unsubscribe/:token | Processa 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 campanhafixedStoreId?: 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
- Busca
CampaignRedirectTokenpelotoken(busca por campo único). - Se não encontrado → loga warning, retorna
null. - Se
expiresAt < new Date()→ loga warning "expired", retornanull. Tokens expiram em 30 dias. - Busca
Customerpelorecord.customerIdcom{ preferredStoreId, organizationId, phone }. - 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 encontradaRetorna:
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:
- Busca nome da loja (
tradeNamepreferencial,namecomo fallback). - Formata o número para exibição com
PhoneUtils.format(evo.number). - Registra clique:
recordClick(record.id, evo.number). - WABA envia confirmação para o cliente no canal da campanha:
sendWabaConfirmation(record.campaignId, customerPhone, confirmationTemplate, storeName, displayNumber) - Evolution envia saudação diretamente da loja para o cliente:
evolutionApi.sendText(evo.evolutionInstanceName, customerPhone, greetingMessage, apiKey)- A
apiKeyda Evolution é decriptada viaEncryptionService.decrypt()antes do uso.
- A
- 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 encontradaFluxo WABA Oficial (resolveWabaPath)
- Chama
resolveWabaStore()para encontrar a melhor loja. - Busca o número WABA (sender):
StoreWhatsappNumbercomphoneNumberId NOT NULLeaccessToken NOT NULL. - Retorna
nullse nenhum número WABA configurado. - Decripta
accessTokenviaEncryptionService.decrypt(). - Registra clique:
recordClick(record.id, wabaNumber.number ?? wabaNumber.phoneNumberId). - WABA envia mensagem de conexão (síncrono, best-effort):Enviada via
Template: settings.connectingMessage || 'Conectando você com a {{loja}}, aguarde um instante... ⏳'MetaGraphService.sendMessage(phoneNumberId, accessToken, { type: 'text', text: { body } }). Se falhar → loga warning e continua (não bloqueia o redirect). - Salva preferência de loja no cliente:
Customer.preferredStoreId = wabaStore.storeId - Configura conversa Chatwoot em background (não-bloqueante, via
.catch()):Este passo cria a conversa no Chatwoot, envia nota de contexto e atribui agente — tudo assíncrono.wabaRouter.setupWabaConversationAsync(orgId, chatwootInboxId, customerPhone, isFallback, { phoneNumberId, accessToken, storeName }) - 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:
- Busca
Campaign.whatsappNumberIdpelo ID da campanha. - Busca
StoreWhatsappNumbercomphoneNumberIdeaccessTokenpelowhatsappNumberId. - Decripta
accessToken. - Substitui variáveis no template:
→ nome da loja→ número formatado da loja Evolution
- 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:
- Gera hash SHA-256 do token:typescript
const tokenHash = createHash('sha256').update(token).digest('hex'); - Busca cliente com
WHERE whatsappUnsubscribeToken = tokenHash. - Se não encontrado → retorna
'notfound'. - Se
customer.whatsappOptOut = true→ retorna'already'(idempotente). - Atualiza cliente:typescript
{ whatsappOptOut: true, whatsappOptOutAt: new Date() } - Atribui o opt-out à última campanha enviada para o cliente:typescriptUsa os últimos 8 dígitos do telefone para match resiliente (independente de código de país).
WhatsappMessage.findFirst({ where: { direction: 'OUTBOUND', campaignId: NOT NULL, recipientPhone: { endsWith: phone.slice(-8) } }, orderBy: { sentAt: 'desc' } }) - Se encontrou campanha →
CampaignMetric.upsert({ unsubscribes: { increment: 1 } }). - Retorna
'ok'. - 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 clienteO 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
| Regra | Comportamento |
|---|---|
| Token expirado (> 30 dias) | Retorna null, controller responde com página de erro |
| Token não encontrado | Retorna null, controller responde com página de erro |
| Cliente não encontrado | Retorna null |
| Falha no envio da confirmação WABA | Loga warning, redirect continua normalmente |
| Falha no envio da saudação Evolution | Loga warning, redirect continua normalmente |
| Falha no setup do Chatwoot | Loga warning (.catch()), redirect já foi retornado |
| Opt-out já registrado | Retorna 'already' — não duplica o registro |
| Token SHA-256 não encontrado | Retorna 'notfound' |
| Erro inesperado no unsubscribe | Propaga a exceção → controller retorna 500 |
| Nenhuma loja WABA com inbox | Retorna 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
| Aspecto | Path A: Evolution | Path B: WABA Oficial |
|---|---|---|
| Número do wa.me | Número da instância Evolution (loja) | Número WABA compartilhado |
| Confirmação | WABA (campanha) → cliente | WABA → "Conectando você com a loja..." |
| Saudação | Evolution (loja) → cliente, imediato | Chatwoot (agente atribuído), assíncrono |
| Conversa no Chatwoot | Criada pelo webhook da Evolution ao receber resposta | Criada proativamente e assincronamente |
| Preferência de loja salva | Não (já existe preferência) | Sim — Customer.preferredStoreId = wabaStore.storeId |
| Loja com fallback | E-commerce se preferred sem Evolution | E-commerce → qualquer loja com inbox |
| isFallback | Não se aplica | Indica ao setupWabaConversationAsync se deve mostrar menu de lojas |
Integração com Outros Módulos
| Módulo | Uso |
|---|---|
EvolutionApiService | Envia mensagem de saudação no Path A |
MetaGraphService | Envia mensagem de confirmação/conexão via WABA |
WhatsappRouterService | setupWabaConversationAsync — cria conversa Chatwoot, atribui agente, envia saudação |
ChatwootConfigService | Consultado para resolver configurações de modo WABA (indiretamente via WhatsappRouterService) |
EncryptionService | Decripta evolutionApiKey e accessToken WABA |
PhoneUtils | normalizeBrazilian() — normaliza telefone do cliente para envio; format() — formata número para exibição |
PrismaService | Leitura de CampaignRedirectToken, Customer, Organization, Store, StoreWhatsappNumber, Campaign, WhatsappMessage, CampaignMetric |
Tabelas do Banco
| Tabela | Campos lidos | Campos escritos |
|---|---|---|
CampaignRedirectToken | token, expiresAt, customerId, organizationId, campaignId | clickedAt, redirectedTo |
Customer | preferredStoreId, organizationId, phone, whatsappOptOut, whatsappUnsubscribeToken | whatsappOptOut, whatsappOptOutAt, preferredStoreId |
Organization | campaignSettings (JSONB) | — |
Store | tradeName, name, isEcommerce, isActive | — |
StoreWhatsappNumber | number, evolutionInstanceName, evolutionApiKey, provider, chatwootInboxId, phoneNumberId, accessToken, storeId | — |
Campaign | whatsappNumberId | — |
WhatsappMessage | direction, campaignId, recipientPhone, sentAt | — |
CampaignMetric | — | unsubscribes (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/catchinterno, loga warning.evolutionApi.sendText()no Path A → semawaitexplí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.