SuperdindaDocs

Alertas

Do achado da análise ao alerta e à notificação - o que já existe na main, as regras de agrupamento e privacidade, e o que ainda falta.

A ideia do produto: quando a análise percebe um risco numa conversa, o responsável recebe um alerta com o trecho mínimo e um caminho do que fazer. Ele não navega pelas conversas, e push, e-mail e SSE nunca levam o trecho (regras P1 a P4 do api-map.md).

O ciclo completo está na main: a análise abre o alerta, o responsável é avisado por severidade, lê o alerta, vê o trecho só quando pede e resolve, dispensa ou reabre. A tela Alertas do app chega com a onda 4; hoje o alerta aparece em Início (GET /accounts/{uid}/home) e no sino.

Do achado ao alerta

flowchart TD
  msgs[("Mensagens guardadas<br/>cifradas por vaga")] --> win["Janela de análise fecha<br/>analysis-scheduler, a cada 30 s"]
  win --> model["Modelo lê e cita mensagens<br/>m1…mN"]
  model --> tx{{"Uma transação"}}
  tx --> findings[("risk_findings<br/>imutável")]
  tx --> alerts[("risk_alerts")]
  tx --> history[("risk_alert_events")]
  tx --> events[("events<br/>alert.opened / alert.updated")]
  tx -->|depois do commit| sse["Stream da conta<br/>SSE pro app"]
  tx -->|depois do commit| notifier["Notificador de risco<br/>risk.critical · warning · info"]
  notifier --> dispatch["notification-dispatcher<br/>app, e-mail, push"]
  sse --> guardian(["Responsável"])
  dispatch --> guardian
  guardian -->|"lê, pede o trecho, resolve,<br/>dispensa ou reabre"| act["POST /alerts/{uid}/…"]
  act --> history
  act --> sse
  act -.->|"resolve ou dispensa:<br/>trecho apagado em 30 dias"| purger["excerpt-purger"]
  • Achado (risk_findings): a saída do modelo, imutável. Cita de 1 a 3 mensagens guardadas (message_refs) em vez de escrever uma citação, então não existe trecho inventado. Traz kind, subtype (golpes, só pra adultos), severity (critical, warning ou info) e signals de um vocabulário fechado.
  • Alerta (risk_alerts): o que o responsável vê. Agrupa achados da mesma vaga, mesma contraparte (ou conversa) e mesmo tipo: group_key = seat:(contraparte ou conversa):kind.
  • Histórico (risk_alert_events): append-only. A análise grava opened, finding_added e severity_raised; o notificador, notified; o responsável, excerpt_viewed, resolved, dismissed, reopened e follow_up ("ainda estou preocupado"); a retenção, purged. shared espera o G-30.

Se qualquer passo falhar, tudo volta (nem achado, nem alerta, nem evento) e a tarefa tenta de novo.

O que a análise procura

PerfilTipos (kind)
minor (menor de idade)grooming, sexual_content, bullying, self_harm, drugs, scam, stranger_contact
adult (adulto sob cuidado)self_harm, scam (com subtipo: fake_relative_pix, fake_bank_call, fake_authority, fake_prize, romance, link_payment), stranger_contact

O perfil vem de dependents.kind.

Regras de agrupamento

  • Um achado entra no alerta aberto do grupo se aconteceu até ALERT_GROUP_WINDOW_HOURS (padrão 72 h) do intervalo do alerta. Passada a janela, o alerta antigo é marcado como substituído (superseded_at, continua aberto pro responsável) e um novo abre, apontando pro anterior.
  • Existe um alerta coletando por grupo (índice único parcial).
  • Um alerta fechado (resolved ou dismissed) nunca é reaberto por um achado novo. Abre-se outro.
  • A severidade só sobe enquanto o alerta está aberto.
  • Pausa e parada: vaga pausada não é analisada. Uma pausa no meio da chamada ao modelo descarta os achados e devolve a tarefa. Parada pela pessoa ou vaga arquivada: a tarefa é pulada e nada é guardado. Veja Pausar e parar.
  • Há um gancho de contato confiável (silencedByTrust), que hoje sempre responde "não". self_harm e sexual_content nunca seriam silenciados.

O trecho

O trecho vem das mensagens guardadas, montado no servidor e mascarado: telefones, CPF/CNPJ, cartões e chaves Pix viram •••• 1234, e e-mails viram •••• @domínio.

  • GET /alerts/{uid} não traz o trecho, só excerptAvailable.
  • POST /alerts/{uid}/excerpt devolve de 1 a 3 mensagens, grava excerpt_viewed com quem viu e publica alert.updated (change: excerpt_viewed). Limite de 30 por minuto por responsável (429).
  • Resolver ou dispensar agenda o apagamento pra 30 dias depois (ALERT_EXCERPT_RETENTION_DAYS). O worker excerpt-purger apaga trecho, justificativa e notas e grava purged; depois disso a rota responde 410 EXCERPT_PURGED. Se a vaga perdeu a chave (parada pela pessoa), também é 410.

Ler e agir

RotaO que faz
GET /accounts/{uid}/alertslista por status, seat, kind, severity, since, com cursor e counts por status
GET /alerts/{uid}detalhe: título escrito pelo servidor, contraparte, sinais, roteiro, quem recebeu e viu, histórico
POST /alerts/{uid}/resolve{ outcome, note? }; o desfecho depende do tipo. still_worried (autolesão) mantém aberto
POST /alerts/{uid}/dismiss"Não é risco": { reason, note? }, motivo vai pra analysis_feedback
POST /alerts/{uid}/reopenaté 7 dias depois de fechar; se um alerta mais novo do grupo já coleta, este volta aberto mas substituído

Qualquer membro da família lê e age; quem é de fora recebe 404 ALERT_NOT_FOUND. Fechar avisa os outros responsáveis no app (alert.resolved_by_other).

Eventos

typePayload
alert.opened{ alertUid, seatUid, kind, subtype, severity }
alert.updatedo mesmo + status, change, byMemberUid. change: severity_raised ou grouped (análise); resolved, dismissed, reopened, follow_up ou excerpt_viewed (responsável)

Um evento por alerta por lote, e o mais forte vence: opened, depois severity_raised, depois grouped.

Reagindo a um alerta novo no app
source.addEventListener("event", (e) => {
  const ev = JSON.parse(e.data);
  if (ev.type === "alert.opened" && ev.payload.severity === "critical") {
    showBanner({ seatUid: ev.seatUid, kind: ev.payload.kind }); // sem trecho: ele só vem pela rota autenticada
  }
});

Notificações

raise() grava a notificação e enfileira uma entrega por responsável por canal; o worker notification-dispatcher envia, e uma dedupeKey evita repetição.

Risco (G-06). O notificador é instalado no boot da api e dos workers (installRiskNotifier):

TipoQuandoCanaisHorário de silêncio
risk.critical"Agir agora", e toda autolesão, qualquer que seja a severidadeapp, e-mail (template risk-alert), push, WhatsApp (pulado: sem template)fura
risk.warning"Vale conversar hoje"app, e-mail, pushespera o fim
risk.info"Para saber"só no app—

O payload é fechado: { alertUid, seatUid, kind, subtype, severity, firstName }, e qualquer outra chave é recusada. Nunca o trecho nem a contraparte. A deduplicação é risk:{alertUid}:{severity}: se piorar, avisa de novo. O e-mail de autolesão traz o CVV 188.

Categorias (G-07): risks, protection, billing e summary.

  • risks no app é fixo (locked), e o e-mail ou o push precisa ficar ligado (422 CATEGORY_LOCKED, RISKS_CHANNEL_REQUIRED). Riscos e resumo são instant ou off (422 MODE_NOT_OFFERED).
  • Caixa de entrada: GET /notifications?category=&cursor=&limit= com unreadByCategory e alertUid.
  • Horário de silêncio: GET e PUT /notifications/quiet-hours (from, to, timezone IANA). O que espera sai no fim da janela.

Em aberto

  • Contraparte "salvo ou não salvo": contacts.saved ainda não é preenchido pelo ingest, então fica null.
  • Achados do histórico importado abrem alertas como os ao vivo. Falta decidir se a tela marca esses como "encontrado no histórico".
  • Escopo por pessoa (G-27): hoje todo membro vivo lê e age em todos os alertas.

Fontes

  • backend/src/domains/alerts/** (open-or-attach.ts, act-on-alert.ts, reveal-excerpt.ts, notify-risk.ts, alerts-reader.ts, playbooks/pt-BR.ts)
  • backend/src/http/routes/alerts/*.route.ts, backend/migrations/0047_alert_actions.ts
  • backend/src/domains/analysis/vocabulary.ts, use-cases/analyze.ts, excerpt.ts, mask.ts
  • backend/migrations/0044_risk_alerts.ts, backend/apps/workers/analysis-scheduler.ts
  • backend/src/domains/notifications/catalog.ts, use-cases/raise.ts, use-cases/dispatch.ts
  • backend/src/domains/protection/ports/alerts.ts
  • backend/src/http/routes/notifications/*.route.ts
  • Notas das frentes k-alert-core e k-alert-api
  • .specs/features/redesign/api-gaps.md (G-02 a G-07), api-map.md (§0.2, §7)

On this page