Programação Por Equipe DevBras

Capa do artigo: Notificação de curtida e comentário: grave o evento antes de acender o sino

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

  1. Grave a ação principal (o comentário, a curtida, a atribuição).
  2. Grave o evento e as notificações de cada destinatário, na mesma transação, excluindo o próprio autor.
  3. Confirme a transação.
  4. Publique no canal em tempo real um aviso pequeno, com o id da notificação e um link.
  5. 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?

Avalie este artigo

Referência: https://learn.microsoft.com/pt-br/archive/msdn-magazine/2018/august/cutting-edge-social-style-notifications-with-asp-net-core-signalr