Notificação de curtida e comentário: grave o evento antes de acender o sino
Modele notificações sociais com evento gravado, um registro por destinatário e estado de leitura no banco, e use o tempo real só como aviso extra para quem está online.
A Carla atribuiu uma tarefa urgente ao Bruno às 17h50. O sino do Bruno acendeu, mas ele já tinha fechado o notebook. Na manhã seguinte, o sino estava apagado e a lista vazia: a notificação existia só como mensagem enviada pelo SignalR para quem estivesse conectado naquele instante. A tarefa venceu sem ninguém saber.
A resposta curta: o evento gravado é o produto; o aviso em tempo real é cortesia. Primeiro grave o evento e uma notificação por destinatário, com a data de leitura, no banco. Só depois publique no canal em tempo real. A lista de "não lidas" e o contador nascem de uma consulta ao banco, e quem reconecta busca o que perdeu. Se o envio ao vivo falhar, nada se perde.
Por que o popup não pode ser o sistema
Um canal em tempo real, seja SignalR, WebSocket puro ou Server-Sent Events, entrega a mensagem para as conexões abertas naquele momento. Não há caixa de correio: se a pessoa estava offline, com a aba fechada, sem sinal no metrô ou com o celular bloqueado, a mensagem simplesmente não chega. Nenhum desses canais guarda o que não entregou.
Notificação social tem ainda outros requisitos que o canal não resolve: cada pessoa tem o seu estado de leitura, a mesma ação interessa a várias pessoas, e "Ana, Pedro e mais 3 curtiram seu comentário" é uma agregação de vários eventos. Tudo isso é dado, e dado mora no banco. O básico de configurar o hub está em notificação em tempo real com SignalR; aqui o foco é o modelo social que fica por trás.
O modelo: evento, notificação e leitura
O exemplo é um gerenciador de tarefas em ASP.NET Core com três tipos de ação: comentar, curtir e atribuir. Duas tabelas resolvem: uma registra o que aconteceu, outra registra para quem isso importa.
CREATE TABLE eventos (
id BIGINT IDENTITY PRIMARY KEY,
tipo VARCHAR(30) NOT NULL, -- comentario, curtida, atribuicao
autor_id INT NOT NULL,
alvo_tipo VARCHAR(30) NOT NULL, -- tarefa, comentario
alvo_id BIGINT NOT NULL,
criado_em DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE TABLE notificacoes (
id BIGINT IDENTITY PRIMARY KEY,
evento_id BIGINT NOT NULL REFERENCES eventos(id),
destinatario_id INT NOT NULL,
lida_em DATETIME2 NULL,
CONSTRAINT uq_notificacao UNIQUE (evento_id, destinatario_id)
);
CREATE INDEX ix_notificacoes_caixa ON notificacoes (destinatario_id, lida_em, id);
Separar evento de notificação evita duplicar o conteúdo para cada destinatário e permite marcar leitura individualmente. A restrição única impede que um reprocessamento crie duas notificações iguais para a mesma pessoa.
A ordem que não pode inverter
- Grave a ação principal (o comentário, a curtida, a atribuição).
- Grave o evento e as notificações de cada destinatário, na mesma transação, excluindo o próprio autor.
- Confirme a transação.
- Publique no canal em tempo real um aviso pequeno, com o id da notificação e um link.
- Se a publicação falhar, registre no log e siga. A notificação já está no banco e aparecerá na próxima consulta.
public class Notificador
{
private readonly AppDbContext _db;
private readonly IHubContext<NotificacoesHub> _hub;
private readonly ILogger<Notificador> _log;
public Notificador(AppDbContext db, IHubContext<NotificacoesHub> hub, ILogger<Notificador> log)
{
_db = db;
_hub = hub;
_log = log;
}
public async Task ComentarioCriadoAsync(Comentario comentario, IReadOnlyList<int> seguidores)
{
var destinatarios = seguidores.Where(id => id != comentario.AutorId).Distinct().ToList();
var evento = new Evento
{
Tipo = "comentario",
AutorId = comentario.AutorId,
AlvoTipo = "tarefa",
AlvoId = comentario.TarefaId
};
_db.Eventos.Add(evento);
foreach (var id in destinatarios)
_db.Notificacoes.Add(new Notificacao { Evento = evento, DestinatarioId = id });
await _db.SaveChangesAsync();
foreach (var id in destinatarios)
{
try
{
await _hub.Clients.User(id.ToString()).SendAsync("novaNotificacao", new
{
eventoId = evento.Id,
tipo = evento.Tipo,
link = "/tarefas/" + comentario.TarefaId
});
}
catch (Exception ex)
{
_log.LogWarning(ex, "Aviso em tempo real falhou para o usuário {Usuario}", id);
}
}
}
}
O Clients.User do SignalR usa, por padrão, a declaração de identificador do usuário autenticado para achar as conexões daquela pessoa, em todas as abas e dispositivos. Se o seu login usa outra declaração como id, configure um provedor de id de usuário compatível. Em vez de chamar o hub direto, sistemas maiores gravam o aviso numa tabela de saída e um processo separado publica, o que tira o canal do caminho da requisição.
Lista, contador e agregação saem do banco
O contador do sino é uma consulta simples sobre o índice criado acima:
SELECT COUNT(*) FROM notificacoes WHERE destinatario_id = @usuario AND lida_em IS NULL;
A agregação "Ana e mais 3 curtiram" também é feita na leitura: agrupe as notificações não lidas da pessoa por tipo e alvo, conte os autores e mostre o mais recente pelo nome. Gravar a frase pronta no banco parece prático, mas quebra quando alguém descurte ou quando a quinta curtida chega.
Marcar como lida é um UPDATE que sempre filtra pelo destinatário logado, e não só pelo id que veio do navegador. Sem isso, qualquer pessoa marca como lidas as notificações dos outros trocando o número na requisição.
UPDATE notificacoes SET lida_em = SYSUTCDATETIME()
WHERE id = @id AND destinatario_id = @usuario AND lida_em IS NULL;
Reconectar é buscar o que faltou
O cliente JavaScript do SignalR tem reconexão automática com withAutomaticReconnect() e um evento onreconnected. Use esse evento para refazer a consulta de não lidas, em vez de confiar que nada aconteceu durante a queda. O mesmo vale ao carregar a página: a lista inicial vem de uma chamada HTTP comum, e o canal ao vivo só acrescenta o que chegar depois.
Quando o aviso ao vivo chega, ele não precisa trazer o conteúdo inteiro. Um id e um link bastam; o clique abre o registro, que é carregado com as verificações de permissão de sempre. Isso evita que o texto de um documento privado apareça num popup numa tela compartilhada e que um aviso antigo mostre uma versão já editada. O raciocínio de que publicar não é entregar é o mesmo de colocar na fila não é ter entregue.
Erros comuns
- Notificação só no canal em tempo real: quem estava offline nunca fica sabendo, e tarefas atribuídas fora do horário se perdem.
- Publicar antes de gravar: o sino acende, a transação falha e a pessoa clica num registro que não existe.
- Estado de leitura único para todos: quando um destinatário lê, a notificação some para os outros.
- Marcar como lida sem filtrar pelo usuário: qualquer pessoa altera a caixa de notificações de outra trocando o id na requisição.
- Conteúdo privado inteiro no popup: trechos sigilosos aparecem em telas projetadas em reunião ou em celulares bloqueados.
- Notificar o próprio autor: a pessoa recebe aviso da ação que acabou de fazer e passa a ignorar o sino.
Checklist
- Toda ação que notifica grava evento e notificações antes de publicar no canal.
- Cada destinatário tem seu próprio registro com data de leitura.
- Uma restrição única impede notificação duplicada por evento e pessoa.
- O contador e a lista vêm de consulta ao banco.
- Falha no envio em tempo real gera log, não erro para quem fez a ação.
- Ao reconectar, o cliente busca de novo as não lidas.
- Marcar como lida filtra pelo usuário autenticado.
- O aviso ao vivo leva id e link, não o conteúdo privado.
Faça o teste do Bruno: feche o navegador, peça para alguém comentar numa tarefa sua e abra de novo. A notificação está na lista, ou ela só existia no popup que você não viu?