Na Vetra, a distribuidora fictícia que mantenho como demonstração do portfólio, uma conta grande ficou trinta e dois dias sem recompra e ninguém percebeu. O dado estava lá, num campo de risco que o Salesforce calculava sozinho, mas ele só aparecia para quem abrisse aquela conta específica. O gerente da carteira não abriu, porque não tinha motivo para abrir uma conta que, na cabeça dele, estava bem. A informação existia e mesmo assim o cliente esfriou. O problema não era falta de dado, era o dado esperando alguém ir até ele.
Para enviar uma notificação proativa no Salesforce, você usa Messaging.CustomNotification
no Apex: define título, corpo, o CustomNotificationType e o setTargetId que leva o
clique ao registro, e chama send() com o conjunto de destinatários. Dispare só na transição de
estado para não virar spam, e agende o resumo periódico com uma classe Schedulable. A org deixa de
esperar e passa a avisar a pessoa certa no momento certo.
Por que o dado certo no lugar errado não muda decisão
O Salesforce é ótimo em calcular. Um campo de fórmula, um Flow agendado ou uma classe Apex derivam o status de risco de uma conta, o SLA estourado de um caso, a oportunidade que passou do prazo de fechamento, e gravam isso num campo limpo e correto. O buraco não é o cálculo, é a entrega. Esse campo vive dentro do registro, e o registro só se abre quando alguém tem motivo para abri-lo. Quem tem motivo para abrir uma conta que parece saudável? Ninguém, até ela deixar de estar saudável, e aí já passou o momento de agir.
A saída preguiçosa é montar um relatório com todas as contas em risco e torcer para alguém olhar toda manhã. Não olham. Relatório é consulta ativa: exige que a pessoa lembre de ir lá, filtre, interprete. A automação proativa inverte a direção. Em vez de esperar a pessoa ir ao dado, o dado vai à pessoa, no canal que ela já olha o dia inteiro, com um clique que abre o registro que precisa de atenção.
O mínimo que dispara o alerta: Messaging.CustomNotification
A ferramenta nativa para isso é a Custom Notification, a mesma que alimenta o sino no canto superior do Lightning e a notificação push no app mobile. Do Apex, ela cabe em poucas linhas. No rebuy-radar, o projeto de onde tirei este exemplo, a classe que avisa o dono da conta quando ela entra em risco crítico é esta:
public with sharing class RebuyRiskNotifier {
public static void avisar(Account conta, Id donoId) {
Messaging.CustomNotification n = new Messaging.CustomNotification();
n.setTitle('Conta em risco crítico: ' + conta.Name);
n.setBody('A recompra prevista venceu há mais de 30 dias. Abra e acione o cliente.');
n.setNotificationTypeId(tipoId()); // o CustomNotificationType
n.setSenderId(UserInfo.getUserId());
n.setTargetId(conta.Id); // o clique abre ESTA conta
n.send(new Set<String>{ donoId }); // destinatário: o dono da carteira
}
}
Cada linha carrega uma decisão. O setTitle e o setBody são o que a pessoa lê antes de
decidir se clica, então precisam dizer o que houve e o que fazer, não um genérico "atualização na conta". O
setTargetId é o detalhe que separa um alerta útil de um alerta irritante: ele define o registro
que abre no clique. Sem ele, a pessoa recebe o aviso, entende que algo aconteceu e ainda precisa caçar qual
conta era. Com ele, um toque já a coloca na tela certa. O send() recebe um Set de
Ids, e é bom que seja um conjunto: notificar em lote é o caso comum, e passar o Set inteiro de uma vez respeita
os limites de governança em vez de chamar send() num laço.
O CustomNotificationType é metadado, não configuração de tela
O setNotificationTypeId pede o Id de um CustomNotificationType, e aqui mora uma
escolha de arquitetura que muita gente erra. Dá para criar esse tipo pela interface, em Setup, apontando e
clicando. O problema é que aí ele vira uma configuração órfã que existe só naquela org: você faz o deploy da
classe Apex, ela procura o tipo pelo nome, não encontra na sandbox de destino e estoura em produção. O tipo de
notificação é metadado como qualquer outro, com o sufixo .notiftype-meta.xml, e o lugar dele é o
repositório, versionado junto com a classe que o usa.
No código, você nunca chumba o Id do tipo, porque ele muda de org para org. Busca pelo
DeveloperName, que é estável, e guarda o resultado numa variável estática para não repetir a query
a cada chamada dentro da mesma transação:
private static Id tipoCache;
private static Id tipoId() {
if (tipoCache == null) {
tipoCache = [SELECT Id FROM CustomNotificationType
WHERE DeveloperName = 'Rebuy_Risk_Alert' LIMIT 1].Id;
}
return tipoCache;
}
Esse cuidado de tratar a configuração como código, e não como clique numa tela, é o mesmo princípio que defendo no post sobre versionar relatório e dashboard no Git: o que sustenta uma automação de produção precisa de histórico, diff e deploy repetível, senão você descobre que quebrou só quando o cliente reclama.
Anti-fadiga: notificar na transição, não a cada salvamento
Aqui está o erro que transforma uma boa ideia num filtro de e-mail que todo mundo cria para mandar o alerta direto para a lixeira. Se você dispara a notificação sempre que o registro está na condição crítica, um mesmo caso que continua crítico por cinco dias gera cinco alertas, um a cada vez que qualquer campo da conta é salvo. No terceiro, a pessoa já parou de ler. O alerta que grita toda hora tem exatamente o mesmo efeito de não ter alerta nenhum.
A regra é notificar no evento, não no estado. O evento é a transição: o instante em que a conta passa de saudável para crítica, não o fato de ela estar crítica. Num trigger, isso é comparar o valor novo com o antigo e agir só quando a fronteira foi cruzada:
// trigger after update em Account
for (Account nova : Trigger.new) {
Account antiga = Trigger.oldMap.get(nova.Id);
Boolean entrouEmRisco = nova.Rebuy_Status__c == 'Critico'
&& antiga.Rebuy_Status__c != 'Critico';
if (entrouEmRisco) {
RebuyRiskNotifier.avisar(nova, nova.OwnerId);
}
}
A conta que já estava crítica e continua crítica não passa no entrouEmRisco, então não gera nada.
Só o cruzamento da fronteira dispara. Esse mesmo raciocínio de agir na mudança de status, e não na leitura
repetida, é o que uso para medir tempo em cada etapa no post sobre
como medir SLA e tempo em cada status: transição é o
evento que importa, o resto é ruído.
O digest semanal: quando o resumo vale mais que o alerta
Nem toda informação merece interromper. O dono da conta precisa saber na hora que ela virou crítica, porque cada dia conta. O gerente que supervisiona doze vendedores não precisa de doze interrupções ao longo da semana; precisa de uma foto no início da segunda-feira: quantas contas da equipe dele estão em risco agora e para quem cobrar. Esse é o trabalho de uma classe Schedulable, que roda no horário que você agenda e monta um resumo em vez de reagir a eventos soltos.
public with sharing class RebuyManagerDigest implements Schedulable {
public void execute(SchedulableContext sc) {
Map<Id, Integer> porGestor = new Map<Id, Integer>();
for (AggregateResult ar : [
SELECT Owner.ManagerId gestor, COUNT(Id) qtd
FROM Account
WHERE Rebuy_Status__c = 'Critico' AND Owner.ManagerId != null
GROUP BY Owner.ManagerId
]) {
porGestor.put((Id) ar.get('gestor'), (Integer) ar.get('qtd'));
}
for (Id gestorId : porGestor.keySet()) {
Messaging.CustomNotification n = new Messaging.CustomNotification();
n.setTitle('Resumo de risco: ' + porGestor.get(gestorId) + ' contas críticas');
n.setBody('Sua equipe tem contas paradas na recompra. Abra o painel e priorize a semana.');
n.setNotificationTypeId(tipoId());
n.setTargetId(gestorId);
n.send(new Set<String>{ gestorId });
}
}
}
O agendamento é uma linha, uma vez, no Developer Console ou num script de setup, com a expressão cron que define segunda às oito da manhã:
System.schedule('Rebuy Digest Semanal', '0 0 8 ? * MON', new RebuyManagerDigest());
Repare no contraste que faz a coisa funcionar: o dono recebe um alerta pontual, na transição, porque a urgência
é individual e imediata; o gestor recebe um agregado semanal, porque a decisão dele é de alocação, não de
pronto-atendimento. A mesma Custom Notification serve os dois, o que muda é a cadência. Errar isso, mandando
para o gestor cada transição de cada conta, é a receita mais rápida de treinar todo mundo a ignorar o sino. E
como o digest roda numa classe Schedulable que varre a org inteira, vale conhecer os
governor limits do Apex antes de soltar: um
AggregateResult bem escrito resolve em uma query o que um laço ingênuo estouraria. Se o volume
da varredura crescer a ponto de não caber numa transação, o Schedulable deixa de fazer o trabalho e passa a
apenas disparar um Batch, um dos encaixes que detalho em
Queueable, Batch ou Schedulable: qual usar em cada caso.
Testar o que o send() não deixa consultar
Chega o momento de escrever o teste e você descobre o problema: o send() não grava um registro que
você possa buscar por SOQL depois. Não existe um objeto "Notificação Enviada" para você fazer
SELECT e afirmar "saiu para o dono certo". Em contexto de teste a chamada simplesmente não entrega
nada, e você fica sem como provar que a lógica está correta olhando a org. O instinto errado é desistir da
cobertura ou testar só que o método não estoura, o que não garante nada sobre quem seria notificado.
A saída é abrir um seam no código: uma variável estática marcada com @TestVisible que guarda o
conjunto de destinatários calculado logo antes de chamar send(). Em produção ela não faz
diferença; no teste, ela é o que você inspeciona.
public with sharing class RebuyRiskNotifier {
@TestVisible private static Set<String> ultimoEnvio;
public static void avisar(Account conta, Id donoId) {
Set<String> destinatarios = new Set<String>{ donoId };
ultimoEnvio = destinatarios; // seam: o teste lê isto
Messaging.CustomNotification n = new Messaging.CustomNotification();
n.setTitle('Conta em risco crítico: ' + conta.Name);
n.setNotificationTypeId(tipoId());
n.setTargetId(conta.Id);
n.send(destinatarios);
}
}
O teste então dispara a condição real, uma conta que cruza para crítica, e valida o que importa: que o dono certo entrou no conjunto e que uma conta que já estava crítica não gerou envio nenhum.
@IsTest
static void avisaSomenteNaTransicaoParaCritico() {
Account c = new Account(Name = 'Cliente Teste', Rebuy_Status__c = 'Saudavel');
insert c;
Test.startTest();
c.Rebuy_Status__c = 'Critico'; // cruza a fronteira
update c;
Test.stopTest();
System.assertEquals(1, RebuyRiskNotifier.ultimoEnvio.size(), 'deveria notificar 1 destinatario');
System.assert(RebuyRiskNotifier.ultimoEnvio.contains(c.OwnerId), 'deveria notificar o dono');
}
O seam não é uma gambiarra, é a forma honesta de testar um efeito colateral que a plataforma não expõe. O mesmo padrão aparece sempre que a API grava algo que você não pode ler de volta, e deixa o teste afirmando a regra de negócio real, não um detalhe de implementação.
Custom Notification ou Platform Event: qual mecanismo
Custom Notification não é o único jeito de a org avisar sobre algo, e escolher errado gera retrabalho. A pergunta que decide é simples: você quer avisar uma pessoa, ou desacoplar quem gera o fato de quem reage a ele?
| Você quer | Custom Notification | Platform Event |
|---|---|---|
| Alvo | uma pessoa (sino, mobile) | qualquer assinante |
| Clique abre registro | sim, via setTargetId | não por si só |
| Atualiza tela ao vivo | não | sim, LWC com empApi |
| Integra com sistema externo | não | sim, é um barramento |
| Esforço para o caso simples | baixo | médio |
A regra prática que sigo: se o objetivo é um humano tomar uma ação sobre um registro, Custom Notification resolve com menos peça móvel. Se o objetivo é um painel que reage sozinho, ou um segundo sistema que escuta o mesmo fato, o caminho é o dos alertas em tempo real com Platform Events, que desacoplam a geração da reação. Os dois convivem bem: no rebuy-radar, a transição para risco publica um Platform Event que acende o heatmap ao vivo no cockpit e, no mesmo ponto, dispara a Custom Notification que bate no sino do dono. Um serve à tela, o outro serve à pessoa.
Quando não vale automatizar o aviso
Honestidade antes de vender complexidade: nem todo dado pede notificação, e encher o sino de alertas de baixa relevância mata a confiança em todos eles, inclusive nos que importam. Se um fato não muda o que a pessoa faria nos próximos dias, ele não merece interromper. Uma conta que virou crítica e alguém precisa ligar hoje, merece. Uma mudança de campo que só interessa no fechamento do mês, não; essa vive melhor num relatório que a pessoa consulta quando for a hora, sem toque no sino.
Vale também medir o volume antes de montar tudo. Se a sua operação gera duas ou três situações críticas por semana, você não precisa de trigger, Schedulable e tipo de notificação versionado; um alerta de e-mail simples já resolve. A engenharia deste post se paga quando o volume é alto e a lógica de quando notificar é sutil, com transição, anti-fadiga e destinatário dinâmico. Comece pela dor real: qual é o aviso que, se chegasse na hora, teria evitado um problema no mês passado? Automatize esse primeiro e só então expanda. O objetivo não é notificar tudo, é fazer a org falar exatamente quando calar sairia caro.
Perguntas frequentes
Como enviar uma notificação proativa no Salesforce com Apex?
Você instancia Messaging.CustomNotification no Apex, define título e corpo com
setTitle e setBody, aponta o tipo com setNotificationTypeId (o Id de um
CustomNotificationType), define o registro de destino com setTargetId e chama
send() passando um Set com os Ids dos destinatários. A notificação aparece no sino do
Lightning e no app mobile, e o clique abre o registro que você marcou como alvo.
Qual a diferença entre Custom Notification e Platform Event no Salesforce?
Custom Notification entrega uma mensagem direta para pessoas específicas no sino e no mobile, com um alvo clicável. Platform Event é um barramento de mensagens entre sistemas e telas: você publica um evento e qualquer assinante reage, inclusive um LWC ao vivo. Use Custom Notification quando o objetivo é avisar um humano; use Platform Event quando quer desacoplar quem gera de quem reage, sem saber quem escuta.
Como testar uma Custom Notification se o send() não é consultável?
O send() não grava nada que você possa buscar por SOQL, então o teste não consegue afirmar que a
notificação saiu olhando a org. A saída é criar um seam: uma variável estática marcada com
@TestVisible que guarda o conjunto de destinatários calculado logo antes do send(). O
teste chama o método, lê essa variável e valida quem seria notificado e em qual condição, sem depender do envio
real.
Como evitar que a notificação proativa vire spam para o usuário?
Dispare no evento, não no estado. Notifique apenas na transição para a condição crítica, comparando
Trigger.new com Trigger.oldMap, para que um mesmo registro que continua em risco não
gere alerta a cada salvamento. Para o acompanhamento contínuo, troque o alerta repetido por um resumo periódico
com uma classe Schedulable, uma notificação por gestor por semana em vez de uma por registro por dia.
