PR #273 · integration-whatsapp base: 4.x

Core Ingest do
Coletor de WhatsApp

Um webhook autenticado por HMAC transforma todo evento dos grupos de WhatsApp em uma linha crua num data lake — pronto para a equipe de dados explorar depois.

Em uma frase

Este PR entrega o lado Laravel do ingest: recebe, valida e persiste eventos crus do Baileys. Sem agregação, sem dashboard — esta é a fase de mapeamento: capturar tudo, decidir o que medir depois.

01

O fluxo, ponta a ponta

Cada evento percorre quatro estações. O caminho é fino e rápido na borda (resposta < 50 ms) e o trabalho pesado vai para a fila do Horizon.

1

wpp-tui (Node/Baileys · repo separado)

fora deste PR

Segura a sessão do WhatsApp e envia todos os eventos, sem filtro, para o webhook.

2

VerifyWhatsAppSignature middleware

Confere o HMAC-SHA256 do corpo (timing-safe) e exige X-Event-Id. Assinatura ruim → 401.

POST /api/integrations/whatsapp/events
X-Signature: hmac_sha256(body, secret)
X-Event-Id: <uuid>
3

WhatsAppWebhookController

Valida o corpo (FormRequest), checa duplicata por event_id e despacha o Job. Responde 202 accepted (ou 202 duplicate).

4

ProcessWhatsAppEvent Job · Horizon

Fora do request: upsert do grupo e do participante, e firstOrCreate(event_id) do evento. Idempotente mesmo sob retry/corrida.

→ whatsapp_groups → whatsapp_participants → whatsapp_events
🛡️

Idempotência em 2 camadas: short-circuit por event_id no controller + firstOrCreate no Job. Reenvio nunca duplica.

02

O modelo de dados

Estratégia data lake (schema-on-read): o payload jsonb guarda o evento Baileys cru. Só type, FKs e timestamps viram coluna — para indexar.

whatsapp_groups

  • id : uuid
  • external_jid UQ
  • tenant_id?
  • display/internal_name
  • payload : jsonb
  • first/last_seen_at

whatsapp_events

  • id : uuid
  • event_id UQ
  • type idx
  • group_id? · participant_id?
  • occurred/received_at
  • payload : jsonb (cru)

whatsapp_participants

  • id : uuid
  • external_jid UQ
  • push_name
  • identity_id? → users
  • payload : jsonb
  • first/last_seen_at

Participante é global — 1 linha por número (external_jid UNIQUE), não 1 por grupo. O vínculo com cada grupo vive nos eventos; "em quais grupos a pessoa está" = DISTINCT group_id nos eventos dela. Isso resolve uma contradição interna do spec e mantém identidade ≠ relacionamento.

03

Decisões que valem o olhar

🔑

UUIDv7, não bigint

O monolito já usa uuid em users. UUIDv7 é ordenado por tempo → sem fragmentação de índice no insert da tabela de alto volume.

🔐

HMAC-SHA256 no webhook

Integridade + anti-replay (X-Event-Id). Middleware novo — não havia precedente de HMAC no repo.

✂️

collection_policy descartada

Bot envia tudo, Laravel salva tudo cru. Some o subsistema de polling/cache e fica mais aderente ao data lake. Revisitar no endurecimento.

🔤

type é string, não enum

O conjunto de eventos do Baileys é aberto e muda entre versões. Um enum rejeitaria tipos novos e quebraria o mapeamento.

⚠️

Privacidade — postura consciente

Telefone real (sem hash), conteúdo cru e sem TTL. É a postura mais exposta possível sob LGPD — uma decisão deliberada e temporária da fase de mapeamento, registrada no docs/adr/0001.

Débito a revisitar ao fim da exploração: hashing, minimização de conteúdo e política de retenção. Não é descuido — é escopo.

04

Cobertura

✓ 12 testes 37 asserts Pest 4 Pint ✓

MODELS · 3

UUID, casts jsonb, FKs nuláveis.

JOB · 4

Upsert grupo+participante, evento sem grupo, idempotência, não-duplicação.

WEBHOOK · 5

HMAC ✓→202, assinatura inválida→401, sem event-id→401, body→422, duplicata→202.

Fora de escopo (PRs futuros)

Admin Filament Vínculo de identidade Webhook no wpp-tui Deploy / observabilidade Retenção / consentimento Agregação de métricas