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. Trazkind,subtype(golpes, só pra adultos),severity(critical,warningouinfo) esignalsde 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 gravaopened,finding_addedeseverity_raised; o notificador,notified; o responsável,excerpt_viewed,resolved,dismissed,reopenedefollow_up("ainda estou preocupado"); a retenção,purged.sharedespera 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
| Perfil | Tipos (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 (
resolvedoudismissed) 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_harmesexual_contentnunca 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}/excerptdevolve de 1 a 3 mensagens, gravaexcerpt_viewedcom quem viu e publicaalert.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 workerexcerpt-purgerapaga trecho, justificativa e notas e gravapurged; depois disso a rota responde 410EXCERPT_PURGED. Se a vaga perdeu a chave (parada pela pessoa), também é 410.
Ler e agir
| Rota | O que faz |
|---|---|
GET /accounts/{uid}/alerts | lista 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}/reopen | até 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
type | Payload |
|---|---|
alert.opened | { alertUid, seatUid, kind, subtype, severity } |
alert.updated | o 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.
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):
| Tipo | Quando | Canais | Horário de silêncio |
|---|---|---|---|
risk.critical | "Agir agora", e toda autolesão, qualquer que seja a severidade | app, e-mail (template risk-alert), push, WhatsApp (pulado: sem template) | fura |
risk.warning | "Vale conversar hoje" | app, e-mail, push | espera 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.
risksno app é fixo (locked), e o e-mail ou o push precisa ficar ligado (422CATEGORY_LOCKED,RISKS_CHANNEL_REQUIRED). Riscos e resumo sãoinstantouoff(422MODE_NOT_OFFERED).- Caixa de entrada:
GET /notifications?category=&cursor=&limit=comunreadByCategoryealertUid. - Horário de silêncio:
GETePUT /notifications/quiet-hours(from,to,timezoneIANA). O que espera sai no fim da janela.
Em aberto
- Contraparte "salvo ou não salvo":
contacts.savedainda não é preenchido pelo ingest, então ficanull. - 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.tsbackend/src/domains/analysis/vocabulary.ts,use-cases/analyze.ts,excerpt.ts,mask.tsbackend/migrations/0044_risk_alerts.ts,backend/apps/workers/analysis-scheduler.tsbackend/src/domains/notifications/catalog.ts,use-cases/raise.ts,use-cases/dispatch.tsbackend/src/domains/protection/ports/alerts.tsbackend/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)
Pausar e parar
Pausa feita pela família e parada feita pela própria pessoa - quem pode, o que para, o que é apagado, como volta e quais eventos e notificações saem.
Tempo real (SSE)
Os dois streams de Server-Sent Events (o da conta e o da pessoa protegida), o ticket de 60 segundos, a retomada por Last-Event-ID, os heartbeats e o catálogo de eventos.
