PR #273 · he4rt/he4rt-bot-api app-modules/integration-whatsapp base 4.x

Coletor de WhatsApp:
Ingest + groups.metadata

Um data lake idempotente, autenticado e extensível para os eventos dos grupos — com escolhas de identificador (UUIDv5 vs UUIDv7) e modelagem de membership que equilibram deduplicação, performance de índice e histórico.

24testes Pest
84assertions
4tabelas (data lake)
0erros PHPStan (nv.6)

01

O que é

A camada de ingest (lado Laravel) do coletor de métricas dos grupos de WhatsApp da He4rt. Um webhook autenticado por HMAC recebe os eventos crus emitidos pelo coletor externo (wpp-tui, repo Node/Baileys separado) e os persiste num data lake. Este PR inclui o tratamento do snapshot de grupo groups.metadata, que popula nome/metadata do grupo e o vínculo participante↔grupo com papel de admin.

Fase de mapeamento: capturar tudo cru e decidir o que medir depois. Sem agregação de métricas nem dashboards — isso vem em PRs futuros.
02

Fluxo de ponta a ponta

📡

wpp-tui (Node / Baileys — repo separado)

Mantém a sessão do WhatsApp e envia todos os eventos crus, sem filtro.

POST /api/integrations/whatsapp/events  ·  X-Signature: HMAC-SHA256  ·  X-Event-Id: UUIDv5
🔐

VerifyWhatsAppSignature

Valida o HMAC (comparação timing-safe) e a presença do X-Event-Id.

🎛️

WhatsAppWebhookController

Valida o formato do event_id (→ 400 se inválido), checa duplicata e despacha o Job. Responde 202 rápido.

⚙️

ProcessWhatsAppEvent (Job · Horizon)

upsert de grupo/participante · firstOrCreate(event_id) · roteia por tipo.

resolveHandler($type) // padrão Strategy — só roda se o evento é novo
🧩

GroupsMetadataHandler novo

Popula display_name/payload do grupo e sincroniza a membership (membros + admins) em transação.

idempotência

Duas camadas: short-circuit por event_id no controller + firstOrCreate no Job (seguro sob corrida/retry).

extensibilidade

Novos tipos = novo handler + um case no resolveHandler. O Job permanece enxuto.

03

Modelo de dados (data lake)

PKs em UUIDv7 (time-ordered) e a coluna payload (jsonb) com o evento Baileys cru. Só type, FKs e timestamps viram colunas materializadas.

whatsapp_groups

  • external_jid : string unique
  • display_name · internal_name : string?
  • payload : jsonb · first/last_seen_at

whatsapp_participants global por número

  • external_jid : string unique
  • push_name : string?
  • identity_id : uuid? → users (vínculo futuro)

whatsapp_events

  • event_id : uuid unique · UUIDv5
  • type : string indexed
  • group_id? · participant_id? : uuid
  • participant_alt? : string @lid
  • occurred_at · occurred_at_source? · received_at
  • payload : jsonb

whatsapp_group_participants novo · pivô

  • group_id · participant_id : uuid unique(par)
  • admin_role : superadmin | admin | null
  • joined_at : ts?
  • left_at : ts? null = ativo (soft-delete)
04

Decisões & motivações

Dois UUIDs, dois papéis

event_id → UUIDv5 dedup

Precisa ser determinístico: o mesmo evento gera o mesmo id, então reenvios são deduplicados. Gerado pelo bot a partir do conteúdo.

PK (id) → UUIDv7 índice

Time-ordered: inserts caem no fim do índice B-tree, sem fragmentação. Ideal para tabela de alto volume. (UUIDv4 do HasUuids seria aleatório.)

Coluna uuid nativa + validação na borda

O tipo uuid nativo ocupa 16 bytes contra ~64 de um hash hex — o índice unique, consultado a cada request, fica ~4× menor. O controller valida o formato e falha cedo, em vez de deixar o banco estourar.

antes

SHA-256 no event_id → 500 (invalid uuid syntax) → bot reenvia em loop

depois

Str::isUuid() → 400 claro, sem tocar o banco

Persistir o envelope completo

participant_alt (@lid) descartado coluna (id estável)
occurred_at_source descartado coluna (qualidade do ts)

Coerente com "data lake puro": não jogar fora dado que o envelope já entrega.

Membership com soft-delete (preserva histórico)

novo — snapshot inclui —→ ativo (left_at = null) — some do snapshot —→ inativo (left_at = ts)

A reentrada zera o left_at. O sync roda em DB::transaction e tem guarda contra esvaziar o grupo por engano (snapshot sem participants).

05

Privacidade — postura consciente

⚠️

Telefone real (sem hash), conteúdo cru e sem TTL — decisão deliberada e temporária para a fase de mapeamento, registrada no docs/adr/0001.

Passivo LGPD a revisitar no endurecimento (hashing, minimização, retenção). Os payloads de exemplo em docs/testes usam apenas dados sintéticos.

06

Qualidade & testes

24 testes Pest · 84 asserts
L6 PHPStan · 0 erros
Pint limpo
webhook

HMAC válido → 202+dispatch · assinatura inválida → 401 · sem event-id → 401 · não-uuid → 400 · body inválido → 422 (dataset) · duplicata → 202 · persistência e2e do envelope · groups.metadata e2e + idempotência

handler & job & models

grupo + membership com roles · soft-remove + update de role · reentrada zera left_at · payload sem participants não altera membership · upserts, envelope nulo e idempotência do job · vínculo via pivô

Testes verificam comportamento observável (estado no banco), sem reflection/mocks frágeis; decisões críticas têm prova por mutação.