Skip to content

Módulo 2 - Comunicação Omnichannel & Chat (communication)

Módulo 2: Comunicação Omnichannel & Chat (communication)

Section titled “Módulo 2: Comunicação Omnichannel & Chat (communication)”

O seletor de rota só apresenta destino confirmado após success=true e opções em formato de lista. Respostas recusadas ou incompletas mostram falha e retry, evitando confirmação indevida de Management.

Falha e carregamento do destino WhatsApp permanecem visíveis em telas pequenas e são anunciados por alert/status. O botão de nova tentativa mantém seu tamanho quando a explicação quebra em linhas.

Datas do encaminhamento usam Europe/London tanto no relógio quanto em Today/Tomorrow/Yesterday. Fora desses dias relativos, incluem o ano para distinguir histórico e agenda futura. A comparação usa dias civis, incluindo transições de horário de verão, independentemente do fuso do dispositivo.

ChatPermissionBanner.test.tsx monta React em jsdom e valida cliques simultâneos, estado ocupado, retry após rejeição, resposta tardia após remontagem e separação web/nativa. A ponte do sistema é simulada; abertura real continua pertencendo à homologação em aparelho.

O banner serializa ações de permissão/configurações, mostra processamento durante toda a operação e ignora conclusões após desmontagem. Rejeições inesperadas oferecem erro recuperável.

Feedback de permissão nativa no hook compartilhado usa instruções de configurações do dispositivo, válidas para Android e iOS, e não depende do rótulo Subscribe de uma tela específica.

Quando a permissão está bloqueada, a ação do banner abre as configurações de notificações via adaptador Android/iOS. Na web, o botão informa que verifica a permissão novamente. Falha ao abrir configurações mostra orientação manual.

O aviso de permissão bloqueada distingue o runtime: Android/iOS orientam às configurações do aplicativo no celular; navegador/PWA mantêm a orientação de permissões do site.

A filtragem de templates é importada sob demanda após a resposta válida do catálogo. A limpeza síncrona do cache da sessão não depende da execução desse módulo.

O editor de templates usa a chave composta usuário/canal do composer. Trocar qualquer uma dessas identidades remonta o editor, removendo imediatamente rascunho, seleção e estado de envio anteriores.

O catálogo exige sucesso explícito da API antes de consumir templates. Respostas recusadas não são armazenadas nem apresentadas como lista vazia confirmada; o composer oferece nova tentativa.

A limpeza de conteúdo privado da sessão invalida sincronamente o catálogo de templates antes de aguardar IndexedDB. Logout e revogação descartam sua memória; respostas em voo anteriores não a restauram.

A limpeza do catálogo de templates invalida sua geração: consultas anteriores podem concluir para seus chamadores, mas não repovoam o cache nem removem a consulta compartilhada mais recente.

Falha de carregamento do catálogo oferece “Retry loading templates” na conversa. A tentativa reutiliza a deduplicação do catálogo compartilhado e invalida a conclusão da leitura anterior, sem exigir fechar ou recarregar o aplicativo.

Durante o envio de template, variáveis e retorno à lista ficam desabilitados até a resposta. Falhas usam alertas acessíveis, carregamento usa status e o envio informa aria-busy, preservando feedback em VoiceOver/TalkBack e desktop.

Tarefas elegíveis com data inválida aparecem em “Schedule unavailable” no encaminhamento, preservando o destino sem inventar um horário. A classificação interpreta cada data uma vez e conserva os pedidos originais.

A seleção de tarefa para encaminhamento reclassifica próximas/passadas pelo relógio compartilhado de foreground. A lista acompanha a passagem do horário e o retorno do aplicativo sem exigir alteração dos pedidos ou recarregamento manual.

Encaminhar para tarefa e mover para Management possuem locks síncronos e cancelamento HTTP por usuário/conversa. A navegação ao destino revalida o contexto após atualizar os canais; trocar de conversa durante a operação impede navegação e feedback tardios. A mensagem movida exibe processamento e aria-busy. Abortar o request não desfaz uma transferência já aceita pelo servidor.

O seletor de rota distingue uma escolha vigente do modo automático: sem sessão válida, mostra Automatic · conversation context, pois o contexto humano recente pode levar ao JOB. Uma pergunta ainda sem resposta aparece como Awaiting client choice · private; Management continua selecionável para fixar explicitamente o destino privado em ambos os casos. O selo Active só aparece para uma tarefa com escolha vigente. Na retomada web/nativa, o banner oculta opções antigas até confirmar a rota no servidor; uma gravação em curso faz essa leitura ao concluir. Trocar de conversa, inclusive do mesmo cliente, invalida operações e feedback anteriores.

O módulo Communication centraliza a mensageria da empresa, unificando conversas internas com o WhatsApp oficial via Meta Cloud API. A coexistência também importa respostas feitas no aplicativo WhatsApp Business para que telefone, web, PWA, Android, iOS e desktop compartilhem o mesmo histórico.

O breakpoint md é uma decisão de renderização, não apenas de CSS. ChatInbox e ClientChatView montam exclusivamente a árvore desktop ou mobile usando o mesmo useMediaQuery fornecido ao controlador. Isso inclui a conversa, o perfil de cliente/equipe e o painel lateral da tarefa: a árvore escondida não mantém uma segunda instância, polling/WebSocket, marcação de leitura, composer, seletor de rota WhatsApp nem efeitos do dashboard ativos. Ao cruzar 768 px, o React desmonta o lifecycle anterior e monta a superfície apropriada ao novo runtime/tamanho.

O menu desktop de troca de conversa limita sua lista com dvh, acompanhando redimensionamento, split-screen e barras do navegador. A ação selecionada continua visível e rolável sem depender de uma altura vh capturada antes da mudança do viewport.

O banner de destino WhatsApp trata leitura e mudança como operações pertencentes ao cliente/conversa atual. Cada transporte recebe AbortSignal; trocar owner ou desmontar cancela fetch e retries. A mutação possui lock antes do primeiro await, promove 4xx funcional a falha e só atualiza, recarrega ou emite toast se seu epoch ainda estiver ativo.

Falhas inesperadas dos endpoints de canais usam um contrato único em channelError.ts. Listagem/criação de canais, mensagens, participantes, reações, leitura, digitação, agendamento, tradução, encaminhamento e movimentação nunca devolvem error.message do D1, Durable Objects, Gemini ou Evolution ao cliente. O aplicativo recebe uma mensagem estável com possibilidade de nova tentativa; o diagnóstico completo passa pelo sanitizador central e é gravado em system_logs com ação e tipo de entidade para investigação administrativa. Erros funcionais 400/401/403/404/423 permanecem específicos para orientar a interface igualmente no desktop, PWA, Android e iOS.

Preferências locais do módulo não expõem IDs de canais ou clientes em texto claro. Conversas fixadas usam marcadores opacos com namespace próprio e migram automaticamente chat_pinned_channels_v1 para v2; o dismiss do convite WhatsApp converte a chave antiga baseada em client.id e remove o legado. Esses marcadores servem somente à interface por dispositivo — mensagens e autorização WhatsApp continuam tendo D1 como fonte de verdade.

A velocidade das notas de voz é compartilhada entre o player da conversa e a prévia do compositor, ciclando 1x → 1.5x → 2x. A preferência usa uiPreferenceStorage: persiste normalmente por dispositivo e mantém fallback em memória quando Safari privado, sandbox ou WebView bloqueia localStorage, de modo que players montados depois na mesma sessão continuam na velocidade escolhida.

O alerta WhatsApp disparado quando um cliente escreve pelo app é um side effect posterior ao envio autoritativo do chat. Ele usa transporte autenticado com teto de 15 segundos, AbortSignal por sessão e contenção explícita da rejeição; não atrasa nem reclassifica a mensagem principal. Troca de identidade antes ou durante o relay invalida o epoch e aborta controllers ativos, impedindo trabalho tardio pertencente à conta anterior.

Anexos de imagem abrem no lightbox compartilhado com pinch, duplo toque e pan. A superfície usa o mesmo contrato dos bottom sheets: diálogo modal nomeado, scroll bloqueado, foco contido/restaurado, Escape apenas no topo da pilha e integração com back nativo. A ação de abrir/entregar o arquivo externamente bloqueia repetição e anuncia progresso.

Permissões de push também usam um contrato único. notificationPermissionProvider consulta, solicita e observa autorização; a Permissions API fica restrita ao navegador/PWA com listener removível, enquanto Android/iOS revalidam no resume e no registro APNs/FCM. O perfil recebe o mesmo estado sem manter uma implementação paralela por plataforma. notificationDeliveryProvider separa autorização do sistema e prontidão para entrega. No gate operacional, a concessão só conclui a etapa depois que a assinatura Web Push foi persistida com sucesso ou o token nativo persistido confirma um destino utilizável; falha ou timeout usa registration_failed, mostra que a permissão já está ativa e oferece nova tentativa sem classificar o problema como recusa do usuário. O perfil nativo comunica o mesmo timeout depois de devolver o controle ao botão. Ao restaurar a sessão, a checagem é somente leitura e o evento de registro nativo atualiza a etapa sem exigir reinício.

A lista My devices possui prontidão independente do seu snapshot. Antes da primeira resposta mostra verificação em andamento; somente uma resposta válida pode produzir No devices subscribed yet. Falha inicial ou de atualização mantém alerta persistente e retry, preserva qualquer lista anterior e marca o diagnóstico de registro backend como não verificável. Owner, lifecycle e passe final do single-flight continuam impedindo que uma resposta de outra sessão altere a tela em desktop, PWA, Android ou iOS.

As categorias de What sends a push usam o banco como fonte de verdade; a cópia local serve apenas para a primeira pintura. Login, retorno ao foreground e retry confirmam o snapshot com owner e sequência de request. Enquanto a leitura está pendente ou falhou, os toggles server-side permanecem bloqueados e a tela mostra status/alerta, sem confundir cache com configuração aplicada em outros dispositivos. Uma alteração otimista possui lock no mesmo frame; se o endpoint recusar, apenas disabledPushCategories volta ao último valor confirmado, o cache local acompanha o rollback e as preferências exclusivamente locais, como som e duração, são preservadas.

As APIs compartilhadas de preferências, inscrições/dispositivos, histórico/ações de notificação e banner global usam dez saídas atomicServerError e um registro específico de configuração VAPID. Falhas inesperadas de D1 ou criptografia ficam sanitizadas no painel; o cliente recebe retry estável. Estados funcionais continuam próprios: sessão/autorização, dispositivo já vinculado, VAPID ausente, nenhum dispositivo, rejeição do push e orientação de re-inscrição. A tela de diagnóstico nunca interpola a exceção que tornou o par VAPID inválido.

Na configuração do bot, os três modos de resposta são um grupo nomeado e cada cartão publica seu estado pressionado. Assim a seleção “Options menu / Keywords only / Every message” permanece compreensível por teclado, VoiceOver e TalkBack, sem criar uma interface alternativa ao toque.

Notas de voz e o checklist compacto usam o indicador quantitativo compartilhado: playback anuncia percentagem reproduzida; checklist anuncia itens concluídos sobre o total. A animação visual continua leve e decorativa, enquanto leitores de tela recebem valores estáveis.

Gravações de voz também obedecem ao lifecycle compartilhado. NativeBridge publica primecrown:app-pause e primecrown:app-resume; inclusive uma leitura inicial que encontre o shell já em segundo plano publica a pausa, sem fabricar uma retomada quando ele nasce ativo. O compositor encerra uma captura ativa ao receber a pausa nativa ou visibilitychange oculto, liberando o microfone e preservando o trecho como prévia não enviada. Os dois sinais podem ocorrer juntos sem chamar stop() duas vezes, e seus listeners são removidos no teardown. Assim, Android/iOS não deixam timer ou microfone ativos no background, enquanto PWA/desktop aplicam a mesma proteção ao ocultar a página.

Configurações do Chat usam CFInput nos editores de instruções do bot e mensagem de ausência e Button nas ações de salvar. Um lock síncrono precede a mutação e toda a superfície editável fica inert/aria-busy durante a tentativa. Instruções e mensagem são aparadas, e horário comercial ativo exige resposta não vazia. saveSettings precisa aceitar o snapshot antes de os stores compartilhados e o háptico de sucesso serem atualizados; falha mantém a edição local visível para retry sem fingir que o próximo boot preservará a mudança.

Uploads de foto, documento e voz no chat pertencem à combinação usuário+canal. O cliente multipart aceita AbortSignal; troca de conversa, conta ou desmontagem aborta o request e invalida envio, agendamento, toast e estado de loading tardios. Até uma callback de câmera mantida pela renderização anterior precisa provar que seu proprietário coincide com o epoch atual antes de iniciar o upload. A listagem de mensagens agendadas usa a mesma propriedade, impedindo dados do canal anterior após navegação rápida.

O compositor também pertence a usuário+canal. Texto digitado é mantido como rascunho separado por conversa, como em mensageiros nativos, sem reaparecer no destinatário seguinte. Trocar de canal limpa reply, menus, sugestões e prévia de voz; invalida câmera e rascunho de IA; e encerra somente a instância de gravador da tentativa anterior. Um rec.start() atrasado não fecha uma nova gravação nem publica áudio no canal seguinte, e dois starts simultâneos são bloqueados antes da resposta da permissão.

O sheet de sugestões inteligentes possui lock síncrono e token por geração. Toque duplo não cria requests concorrentes, e fechamento/troca de canal invalida a resposta antes de escrever sugestões ou loading na conversa seguinte. Quando a IA falha, retorna vazia ou ainda não há histórico, os chips prontos continuam disponíveis, mas o cabeçalho e uma região de status os identificam como Ready-made replies, sem atribuí-los falsamente à IA. A preferência pt-BR continua enviada ao endpoint para o conteúdo gerado; o fallback fixo segue o contrato de cópia inglesa da interface.

O envio textual possui lock síncrono no próprio compositor. Dois toques/cliques antes de o draft limpo voltar pelo React produzem uma única mensagem, mesmo que a API gere a chave de idempotência mais abaixo. Upload e abertura da câmera publicam aria-busy e desabilitam campo e envio; o botão tem nome explícito para VoiceOver/TalkBack. A textarea especializada permanece intencional: capitalização, correção e teclado textual são nativos, Return cria parágrafo no touch e Enter envia no desktop sem interceptar composição IME.

Mensagens aceitas sem conectividade mudam de Sending para Queued offline depois que o IndexedDB confirma a gravação. A fila guarda também o instante original e recompõe os balões ao reabrir PWA, Android, iOS ou desktop ainda offline. Gatilhos simultâneos do navegador e do plugin Network entram numa única drenagem serial; troca de conta aguarda a drenagem anterior e revalida o epoch. Falha online vira Failed com botão Retry acessível, que reaproveita o externalId original e só remove o item local após confirmação do servidor.

O painel Inbox operations trata fila e alertas WhatsApp com locks síncronos por item. Retry, dismiss, send now, cancel e decisão de reabertura não aceitam dois toques antes do render; ações em itens diferentes preservam seus próprios spinners, e o sheet não fecha no meio de uma mutação. O conteúdo publica aria-busy e emite feedback háptico somente após aceite. No ACK continua deliberadamente não reenviável — o destinatário pode ainda receber a tentativa original — e o estado vazio diz apenas que não há alertas pendentes, sem afirmar entrega que não foi comprovada.

No Worker, send_now e cancel aceitam exclusivamente registros PENDING. Um item PROCESSING já foi reivindicado atomicamente pelo runner e aparece como In flight, sem ações: recolocá-lo ou apagá-lo nesse intervalo poderia duplicar o envio ou remover um balão que chegou ao WhatsApp. A condição é repetida no SELECT e na mutação; se o runner vencer a corrida, a API responde 409 e o painel recarrega. Falha de D1 ao listar fila/alertas retorna 500 estável e mantém o último estado visível, nunca um falso success: true com lista vazia.

Retry e resolução de alertas também reconciliam a conversa aberta pelo Durable Object. Reenfileirar transmite pending; descartar uma falha cuja mensagem foi mantida limpa o estado da fila; reconhecer No ACK persiste e transmite deliveryAlertDismissedAt. Esse marcador integra o tipo compartilhado, o mapper HTTP, o merge/payload WebSocket e isUnconfirmedWhatsAppSend, portanto o alerta desaparece imediatamente e continua ausente após reload, sem exigir fechar a conversa nem sugerir reenvio.

Falha total ou parcial ao carregar Inbox operations não é apresentada como lista vazia. O último snapshot permanece visível com alerta persistente, o ícone fechado recebe marcador e nome “data may be out of date”, e um retry explícito anuncia loading sem competir com o polling serializado. Estados Nothing waiting / Queue is empty / No delivery failures só aparecem quando a leitura atual foi confiável; o polling continua silencioso em toast para não interromper o operador repetidamente.

Cada montagem de Inbox operations também delimita um ciclo de vida. Logout, troca de identidade ou desmontagem invalidam a leitura ativa e todos os resultados de ações ainda pendentes antes que possam alterar listas, busy state, toast, háptico ou callbacks do inbox anterior. O lock da leitura usa um token por request, não apenas um booleano: a remontagem de desenvolvimento do React Strict Mode pode iniciar uma leitura nova sem a resposta antiga limpar ou sobrescrever seu loading.

O pedido de reabertura do funcionário segue o mesmo ownership. Um lock por ref fecha a janela de toque duplo antes do render; Waiting for a manager, háptico e sucesso só aparecem depois de success=true. Recusa e exceção preservam a ação para retry. ownerKey=channelId remonta a notice na troca de conversa, invalidando a conclusão antes de estado, toast ou callback e impedindo que askedNow atravesse canais; somente um aceite solicita refresh da lista.

O save das configurações do chat comunica seu estado ao shell. Enquanto a persistência atômica está pendente, edição, Back interno, tabs do sidebar, troca de seção, backdrop, Escape e gestos de histórico permanecem bloqueados pelo mesmo isChatSettingsSaving. Uma desmontagem forçada, como logout, incrementa o lifecycle epoch e impede que o snapshot, toast ou háptico da identidade anterior seja publicado; a tela só destrava no finally pertencente à execução atual.

O diagnóstico Delivery Health não infere saúde da ausência de dados. Antes da primeira resposta, Risk/Evolution/Queue/Failures mostram Checking; após falha sem snapshot, mostram Unknown, nunca LOW, 0 pending ou 0 failed. Se já existe leitura confirmada, ela permanece visível com alerta de possível desatualização. Entrada automática e botão Refresh reutilizam o mesmo token/lock; saída da seção ou sessão invalida a resposta, e o botão canônico anuncia loading e bloqueia toque concorrente nas quatro plataformas.

Busca por mensagem citada, cancelamento agendado e exclusão usam o mesmo epoch. Os dois requestAnimationFrame necessários para aguardar o prepend do histórico são registrados e cancelados no teardown; cada frame revalida o canal antes de rolar ou destacar. Rollback da agenda e toasts de exclusão também são ignorados depois da troca, evitando foco e feedback pertencentes à conversa anterior.

O dashboard contextual de cliente/funcionário também pertence à identidade selecionada. A identidade tipo:id e os valores atuais de nome, e-mail e telefone invalidam save/timer anteriores e repõem o formulário, portanto uma sincronização externa da mesma pessoa não deixa o editor com dados antigos. O save usa lock síncrono, captura cliente/funcionário antes do await, aguarda a persistência com rollback da store e só então apresenta háptico/estado Saved; teardown limpa o indicador temporário. A lista de adiantamentos ordena uma cópia, de modo que abrir a aba Payroll nunca muda silenciosamente a coleção compartilhada da store.


💬 1. Arquitetura do Chat e Tipos de Canais

Section titled “💬 1. Arquitetura do Chat e Tipos de Canais”
graph TD
    subgraph UI ["Interface do Chat (src/components/chat/)"]
        Inbox["ChatInbox.tsx (Painel Geral)"]
        Sidebar["ChatSidebar.tsx (Lista de Clientes & Grupos)"]
        MessageList["ServiceMessageList.tsx (Visualizador)"]
    end

    subgraph Channels ["Tipos de Canais no D1"]
        Direct["Channel DIRECT: Suporte Direto Cliente ↔ Gerente"]
        Job["Channel JOB: Grupo Operacional do Serviço (Cliente + Equipe)"]
    end

    subgraph Infrastructure ["Infraestrutura de Mensageria"]
        DO["Durable Objects (ChatChannelDO) - Realtime WebSockets"]
        EvoAPI["Evolution API - Integração WhatsApp Webhook"]
    end

    Inbox --> Direct
    Inbox --> Job
    Direct --> DO
    Job --> DO
    DO <--> EvoAPI

⏱️ 2. Controle de SLA de Resposta (ResponseSlaBadge)

Section titled “⏱️ 2. Controle de SLA de Resposta (ResponseSlaBadge)”

O sistema monitora o tempo decorrido desde a última mensagem enviada pelo cliente sem resposta do suporte:

  • 🟢 SLA Ok (< 15 min): Atendimento dentro do tempo ideal.
  • 🟡 Atendimento Prioritário (15 - 30 min): Sinalização amarela no painel.
  • 🔴 SLA Crítico (> 30 min): Destaque vermelho e notificação de alta prioridade na inbox do gerente.

🛡️ 3. Política Anti-Ban do WhatsApp (antiBanPolicy.ts)

Section titled “🛡️ 3. Política Anti-Ban do WhatsApp (antiBanPolicy.ts)”

Para reduzir o risco de bloqueio e manter um comportamento coerente com uma equipa real, todos os envios passam pela mesma política:

  1. Jitter de envio: intervalo aleatório de 5 a 15 segundos, com máximo de 4 mensagens por minuto.
  2. Limites de volume: 150 mensagens por dia, 12 por destinatário/hora, 30 por destinatário/dia e 20 mensagens de marketing/dia.
  3. Janela operacional: mensagens não transacionais ficam em fila fora de 08:00–20:00.
  4. Warm-up: números novos ou reconectados usam uma rampa de volume durante sete dias antes de voltar ao limite normal.
  5. Fila e estabilidade: falhas usam backoff progressivo; o envio pausa quando a Evolution desconecta ou a fila fica instável.
  6. Comportamento visível: o indicador de digitação varia conforme o tamanho da mensagem; presença e confirmação de leitura só acompanham atividade real.
  7. Variação segura: apenas a saudação de mensagens de marketing pode variar. Mensagens transacionais e de suporte permanecem exatas, sem caracteres invisíveis.
  8. Baseline da Evolution: alwaysOnline, leitura automática de mensagens e leitura automática de status permanecem desligados; mensagens de grupos são ignoradas.

  • Inbox Principal: src/pages/ChatInbox.tsx
  • Hook de Mensagens: src/features/communication/hooks/useMessages.ts — WebSocket via Durable Object; poll HTTP só como fallback
  • Hook de Canais: src/features/communication/hooks/useChannels.ts — lista da inbox: load inicial + refresh ao focar a aba + reconcile lento (60s) só com tab visível (sem poll a 8s)
  • Tradução automática: só bolhas visíveis (IntersectionObserver + margem); probes lookup vão em batch (messageIds) — no máximo uma tentativa Gemini por mensagem/idioma
  • Webhook de WhatsApp: functions/api/whatsapp/evolution-webhook.ts
  • Durable Object (WebSockets): chat-do/src/index.js — relay de mensagens + cron */5 para dispatch/auto-lock/fila WA. Um segundo trigger 0 9 * * 1 executa a rotina semanal do Google Business Profile; o handler compara event.cron exatamente para que o tick de manutenção das 09:00/09:05 não duplique posts ou sincronizações de avaliações.