Skip to content

feat(integration-whatsapp): core ingest do coletor de WhatsApp#273

Open
Clintonrocha98 wants to merge 9 commits into
4.xfrom
feat/integration-whatsapp-ingest
Open

feat(integration-whatsapp): core ingest do coletor de WhatsApp#273
Clintonrocha98 wants to merge 9 commits into
4.xfrom
feat/integration-whatsapp-ingest

Conversation

@Clintonrocha98
Copy link
Copy Markdown
Member

@Clintonrocha98 Clintonrocha98 commented May 21, 2026

📊 Resumo visual (prático): abrir artefato
HTML autocontido explicando o fluxo, o modelo de dados, as decisões e os testes deste PR.


O que é

Módulo integration-whatsapp: 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, para a equipe de dados explorar depois. 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.

Esta fase é de mapeamento: capturar tudo cru e decidir o que medir depois. Sem agregação de métricas, sem dashboards — isso vem em PRs futuros.

Arquitetura

WhatsApp servers
   ↓ WebSocket (Baileys)
[ wpp-tui — repo Node separado ]  ← runtime, sessão, envia TODOS os eventos (sem filtro)
   ↓ POST /api/integrations/whatsapp/events
   ↓   X-Signature: HMAC-SHA256    X-Event-Id: UUIDv5 (determinístico → dedup)
[ integration-whatsapp — ESTE módulo ]
   ├─ VerifyWhatsAppSignature      ← valida HMAC + presença do X-Event-Id (timing-safe)
   ├─ WhatsAppWebhookController     ← valida formato do event_id + body, checa duplicata, dispatch → 202
   ├─ ProcessWhatsAppEvent (Job/Horizon)
   │     ├─ upsert whatsapp_groups / whatsapp_participants
   │     ├─ insert whatsapp_events (payload jsonb cru)
   │     └─ resolveHandler($type) → handler específico (Strategy), só se o evento for novo
   │           └─ GroupsMetadataHandler → metadata do grupo + sync de membership
   └─ Models + tabela pivô whatsapp_group_participants

O controller responde rápido (202) e enfileira o processamento no Horizon. Idempotência em duas camadas: short-circuit por event_id no controller e firstOrCreate(event_id) no Job. O processamento específico por tipo segue o padrão Strategy (resolveHandler) e só roda quando o evento é novo (wasRecentlyCreated).

Modelo de dados (data lake)

PKs em UUIDv7 (time-ordered, via trait HasVersion7Uuids) e payload (jsonb) com o evento Baileys cru.

  • whatsapp_groupsexternal_jid UNIQUE, display_name, internal_name, payload, first/last_seen_at
  • whatsapp_participantsglobal por número (external_jid UNIQUE), push_name, identity_id? (→ users), payload
  • whatsapp_eventsevent_id UNIQUE (UUIDv5, idempotência), type (indexado), group_id?, participant_id?, participant_alt? (@lid), occurred_at, occurred_at_source, received_at, payload
  • whatsapp_group_participants (pivô) — vínculo grupo↔participante: admin_role (superadmin/admin/null), joined_at, left_at (soft-delete; null = ativo), UNIQUE(group_id, participant_id)

Decisões de design

  • event_id = UUIDv5, PK = UUIDv7 (papéis distintos): o event_id precisa ser determinístico (mesmo evento → mesmo id → dedup), por isso UUIDv5 gerado pelo bot a partir do conteúdo. As PKs internas usam UUIDv7 (time-ordered) para inserts sequenciais no índice — ideal para data lake de alto volume. O UUIDv4 aleatório (padrão do HasUuids) fragmentaria o índice da PK.
  • Coluna uuid nativa para event_id: 16 bytes vs ~64 de um hash hex — o índice unique, consultado a cada request, fica ~4× menor. O controller valida o formato (Str::isUuid) e responde 400 em vez de deixar o INSERT estourar 500 (e o bot reenviar em loop).
  • Persistir o envelope completo: participant_alt (@lid, id interno estável do WhatsApp) e occurred_at_source (qualidade do timestamp) eram descartados — agora são colunas de primeira classe, coerente com "data lake puro".
  • Handler por tipo (Strategy): resolveHandler($type) mantém o Job enxuto e deixa o encaixe pronto para novos tipos. groups.metadata é o primeiro handler dedicado; tipos sem handler seguem o fluxo genérico.
  • Membership com soft-delete: a pivô usa left_at em vez de deletar — preserva o histórico de quem esteve no grupo. O sync de cada snapshot roda em transação e tem guarda contra esvaziar o grupo por engano (payload sem participants).
  • Participante global + pivô: identidade é por número (participants); o vínculo com grupo e o papel de admin vivem na pivô — a "tabela pivô futura" prevista no design inicial.
  • type como string (não enum): o conjunto de eventos do Baileys é aberto e muda entre versões.
  • collection_policy descartada nesta fase: o bot envia tudo e o Laravel salva tudo cru.

Privacidade — postura consciente (fase exploratória)

⚠️ 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 (hashing, minimização, retenção). Os payloads de exemplo nos docs/testes usam apenas dados sintéticos.

Testes

24 testes Pest (84 asserts) verdes; Pint limpo; PHPStan nível 6 sem erros. Cobertura:

  • Models: UUID, casts jsonb, FKs nuláveis, vínculo via pivô (admin_role)
  • Job: upsert grupo+participante, evento sem grupo/participante, envelope nulo, idempotência por event_id, não-duplicação de grupo
  • Webhook: HMAC válido → 202 + dispatch; assinatura inválida → 401; sem X-Event-Id → 401; event_id não-uuid → 400; body inválido → 422 (dataset por campo obrigatório); duplicata → 202 sem dispatch; persistência e2e do envelope; groups.metadata e2e + idempotência
  • GroupsMetadataHandler: popula grupo + membership com roles; soft-remove de quem saiu + update de role; reentrada zera left_at; payload sem participants não altera a membership

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

Fora de escopo (PRs futuros)

Handlers real-time (group-participants.update, groups.update), recurso Filament para administração, flow de vínculo de identidade (@lid/número → membro He4rt), e operação (deploy, observabilidade, retenção, onboarding/consentimento).

Config

Requer WHATSAPP_WEBHOOK_SECRET (no .env.example) — o mesmo segredo usado pelo wpp-tui para assinar o corpo.

Documentação incluída

CONTEXT.md, docs/spec.md, docs/adr/0001-data-lake-approach.md, docs/plans/0001-ingest-implementation.md e — para o groups.metadatadocs/specs/0001-groups-metadata-design.md + docs/plans/0002-groups-metadata.md.

Description

Implements the WhatsApp integration module for Laravel (integration-whatsapp), featuring an HMAC-SHA256-authenticated webhook ingest layer that receives raw events from the Baileys-based Node collector (wpp-tui). The module persists event envelopes into a data-lake model with three core tables (whatsapp_groups, whatsapp_participants, whatsapp_events), enforces idempotency via UUIDv5 event IDs, and dispatches an async job that upserts groups/participants and routes events through type-specific handlers. Includes a groups.metadata handler that synchronizes group snapshots and membership via a soft-delete pivot table.

References

  • PR #273 by Clintonrocha98: feat/integration-whatsapp-ingest → 4.x
  • Related specification: docs/spec.md – full integration scope and data-flow documentation
  • Related ADR-0001: docs/adr/0001-data-lake-approach.md – design rationale for schema-on-read approach
  • Implementation plans: docs/plans/0001-ingest-implementation.md and docs/plans/0002-groups-metadata.md
  • Design spec for groups metadata: docs/specs/0001-groups-metadata-design.md
  • Repository: he4rt/heartdevs.com – follows established patterns from integration-discord, integration-twitch, and integration-devto modules

Dependencies & Requirements

New Dependencies:

  • Added he4rt/integration-whatsapp: >=1 to root composer.json

Environment Variables:

  • WHATSAPP_WEBHOOK_SECRET – required for HMAC-SHA256 signature validation; defined in .env.example

Configuration:

  • New config file: config/whatsapp.php – reads webhook secret from environment and registers via IntegrationWhatsappServiceProvider

Contributor Summary

Contributor Lines Added Lines Removed Files Changed
Clintonrocha98 4,458 0 35

Changes Summary

File Path Change Description
.env.example Added WHATSAPP_WEBHOOK_SECRET template variable
app-modules/integration-whatsapp/CONTEXT.md Module scope, architecture, and glossary documentation
app-modules/integration-whatsapp/composer.json Package manifest with PSR-4 autoload and Laravel service provider
app-modules/integration-whatsapp/config/whatsapp.php Configuration array sourcing webhook secret from environment
app-modules/integration-whatsapp/database/migrations/ Four migrations creating whatsapp_groups, whatsapp_participants, whatsapp_events, whatsapp_group_participants tables with UUIDs, indexes, and foreign keys
app-modules/integration-whatsapp/database/factories/ Four factories for seeding WhatsApp models (Group, Participant, Event, GroupParticipant)
app-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.md Architecture decision record explaining schema-on-read, raw payload storage, and privacy stance
app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md Implementation roadmap for ingest core layer with test outlines
app-modules/integration-whatsapp/docs/plans/0002-groups-metadata.md Implementation roadmap for groups.metadata handler with member sync and soft-delete behavior
app-modules/integration-whatsapp/docs/spec.md Full specification covering webhook flow, data model, and deferred features
app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md Design specification for group metadata sync and membership soft-deletion strategy
app-modules/integration-whatsapp/routes/whatsapp-routes.php Route definition for webhook endpoint POST /api/integrations/whatsapp/events
app-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.php Trait overriding UUID generation to use UUIDv7 for deterministic IDs
app-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.php HMAC-SHA256 signature validation middleware with error handling for missing/invalid signatures
app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php Webhook controller validating event ID format, checking duplicates, dispatching job, returning 202 Accepted
app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php FormRequest validating type, occurred_at, payload, and optional JID and participant fields
app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php Queued job upserting groups/participants and persisting events with idempotent firstOrCreate and handler dispatch
app-modules/integration-whatsapp/src/Ingest/Handlers/EventHandler.php Interface contract for type-specific event handlers
app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php Handles groups.metadata events: updates group snapshot, upserts memberships, soft-removes absent members
app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php Eloquent model with relationships to events, participants, tenant, and group memberships
app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php Eloquent model representing global WhatsApp participant keyed by external_jid
app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php Eloquent model storing raw event envelope with jsonb payload and materialized fields
app-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.php Pivot model managing group membership with admin role and soft-delete timestamps
app-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.php Laravel service provider registering module configuration
app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php Feature tests (24 tests) for webhook acceptance, signature validation, duplicate handling, and e2e groups.metadata
app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php Tests verifying job idempotency, foreign key resolution, and payload persistence
app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php Tests for group snapshot sync, membership upsert/soft-removal, and rejoin behavior
app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php Tests verifying model relationships and pivot linkage
composer.json Added dependency on new integration module

@Clintonrocha98 Clintonrocha98 marked this pull request as draft May 21, 2026 04:14
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

📝 Walkthrough

Walkthrough

This PR introduces a complete WhatsApp event ingestion module for the Laravel monolith. The implementation follows a data-lake schema-on-read approach with HMAC-authenticated webhook endpoints, idempotency checks via X-Event-Id, and asynchronous Horizon jobs that persist raw event payloads into three tables: whatsapp_groups, whatsapp_participants, and whatsapp_events. The module includes migrations, Eloquent models with UUID support, test factories, request validation, signature verification middleware, and comprehensive feature tests validating the full request-to-persistence flow.

Suggested reviewers

  • danielhe4rt
  • gvieira18
  • 1pride
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(integration-whatsapp): core ingest do coletor de WhatsApp' is highly specific and directly describes the main feature addition: the WhatsApp integration's core ingest layer for the external collector.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
app-modules/integration-whatsapp/docs/spec.md (1)

63-78: ⚡ Quick win

Add language tags to fenced diagrams to satisfy markdown linting.

Both fenced blocks should declare a language (e.g., text) to avoid MD040 warnings and keep docs CI clean.

Suggested diff
-```
+```text
 WhatsApp servers
    ↓ WebSocket (Baileys)
 [ wpp-tui ]  ← repo Node separado · runtime · sessão · envia todos os eventos (sem filtro)
@@
-```
+```

-```
+```text
 whatsapp_groups
 ├─ id                uuid PK
@@
-```
+```

Also applies to: 109-141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/integration-whatsapp/docs/spec.md` around lines 63 - 78, The two
fenced diagram blocks in the spec (the block starting with "WhatsApp servers ↓
WebSocket (Baileys) [ wpp-tui ]" and the block listing "whatsapp_groups ├─ id
uuid PK ...") are missing language tags; update their opening ``` fences to
include a language (e.g., ```text) so both diagrams and the other affected block
range (lines around the second diagram referenced) are annotated and satisfy
markdown linting (MD040).
app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md (1)

32-54: ⚡ Quick win

Add language identifiers to all unlabeled fenced blocks.

Several fenced blocks are missing a language tag; adding text, bash, or env as appropriate will clear MD040 warnings and improve readability.

Also applies to: 58-69, 73-86, 90-96, 102-130, 226-228

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md`
around lines 32 - 54, The Markdown file contains multiple unlabeled fenced code
blocks (e.g., the architecture diagram and payload examples surrounding symbols
like VerifyWhatsAppSignature, WhatsAppWebhookController, ProcessWhatsAppEvent,
and the POST body schema) which trigger MD040; add appropriate language
identifiers to each fence — use "text" for the ASCII diagram block, "json" for
the request body example ({ type, group_jid, ... }), "bash" for any CLI
snippets, and "env" for environment samples — so every fenced block has a
language tag to clear the lint warnings and improve readability.
app-modules/integration-whatsapp/CONTEXT.md (1)

25-38: ⚡ Quick win

Declare a language for the architecture fence block.

Use a language like text on this fenced diagram to satisfy markdownlint MD040.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/integration-whatsapp/CONTEXT.md` around lines 25 - 38, The fenced
architecture diagram at the top of the file is missing a language tag which
triggers markdownlint MD040; update the opening triple-backtick of that diagram
(the top-level architecture fence block) to include a language identifier such
as text so the fence becomes ```text, ensuring the markdown linter recognizes it
as a code block with an explicit language.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.md`:
- Around line 70-77: Update the ADR text to replace the vague “revisit when the
exploration phase ends” with a concrete privacy review trigger: add a specific
calendar deadline (e.g., "Review by YYYY-MM-DD") and an accountable owner (e.g.,
"Owner: Data Privacy Lead / <name or role>"). Also document where the review
will evaluate the listed items (aggregating signals, TTL for whatsapp_events,
phone hashing/content minimization, and presence.update) so the owner and date
are clearly tied to those symbols (`whatsapp_events`, `presence.update`) in the
same paragraph.

---

Nitpick comments:
In `@app-modules/integration-whatsapp/CONTEXT.md`:
- Around line 25-38: The fenced architecture diagram at the top of the file is
missing a language tag which triggers markdownlint MD040; update the opening
triple-backtick of that diagram (the top-level architecture fence block) to
include a language identifier such as text so the fence becomes ```text,
ensuring the markdown linter recognizes it as a code block with an explicit
language.

In `@app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md`:
- Around line 32-54: The Markdown file contains multiple unlabeled fenced code
blocks (e.g., the architecture diagram and payload examples surrounding symbols
like VerifyWhatsAppSignature, WhatsAppWebhookController, ProcessWhatsAppEvent,
and the POST body schema) which trigger MD040; add appropriate language
identifiers to each fence — use "text" for the ASCII diagram block, "json" for
the request body example ({ type, group_jid, ... }), "bash" for any CLI
snippets, and "env" for environment samples — so every fenced block has a
language tag to clear the lint warnings and improve readability.

In `@app-modules/integration-whatsapp/docs/spec.md`:
- Around line 63-78: The two fenced diagram blocks in the spec (the block
starting with "WhatsApp servers ↓ WebSocket (Baileys) [ wpp-tui ]" and the block
listing "whatsapp_groups ├─ id uuid PK ...") are missing language tags; update
their opening ``` fences to include a language (e.g., ```text) so both diagrams
and the other affected block range (lines around the second diagram referenced)
are annotated and satisfy markdown linting (MD040).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro

Run ID: b6fb1b2d-3870-4d40-9845-c6d4061b98d8

📥 Commits

Reviewing files that changed from the base of the PR and between efd05ff and c264605.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (26)
  • .env.example
  • app-modules/integration-whatsapp/CONTEXT.md
  • app-modules/integration-whatsapp/composer.json
  • app-modules/integration-whatsapp/config/whatsapp.php
  • app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php
  • app-modules/integration-whatsapp/database/factories/WhatsAppGroupFactory.php
  • app-modules/integration-whatsapp/database/factories/WhatsAppParticipantFactory.php
  • app-modules/integration-whatsapp/database/migrations/2026_05_20_120000_create_whatsapp_groups_table.php
  • app-modules/integration-whatsapp/database/migrations/2026_05_20_120001_create_whatsapp_participants_table.php
  • app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php
  • app-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.md
  • app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md
  • app-modules/integration-whatsapp/docs/spec.md
  • app-modules/integration-whatsapp/routes/whatsapp-routes.php
  • app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php
  • app-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.php
  • app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php
  • app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php
  • app-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.php
  • app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php
  • app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php
  • app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php
  • app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php
  • app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php
  • app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php
  • composer.json

- event_id passa a UUIDv5 deterministico (validacao de formato no controller -> 400 em vez de 500); PKs usam UUIDv7 time-ordered via trait HasVersion7Uuids

- persiste campos do envelope antes descartados: participant_alt (@lid) e occurred_at_source

- handler groups.metadata via Strategy (resolveHandler): popula display_name/payload do grupo e sincroniza membership

- nova tabela pivo whatsapp_group_participants (admin_role + soft-delete left_at)

- testes: substitui reflection por asserts end-to-end, datasets de validacao, cobertura de handler/membership
@Clintonrocha98 Clintonrocha98 marked this pull request as ready for review May 21, 2026 14:27
@Clintonrocha98 Clintonrocha98 requested a review from a team May 21, 2026 14:27
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php (1)

38-60: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Handler failures are not recoverable on retry — wrap event creation + handler dispatch in a single transaction.

WhatsAppEvent::firstOrCreate(...) commits the event row before the handler runs. If GroupsMetadataHandler::handle() throws, its inner DB::transaction() rolls back the group/membership mutations, but the event row remains. On queue retry, firstOrCreate finds the row, wasRecentlyCreated is false, and the handler is skipped permanently — leaving the event persisted with no downstream side effects and no signal that reconciliation is needed.

Wrap the event persistence and handler dispatch in one transaction so a handler failure also rolls back the event row, allowing the retry to re-enter the wasRecentlyCreated branch.

🛡️ Proposed fix
+use Illuminate\Support\Facades\DB;
+
     public function handle(): void
     {
         $group = $this->resolveGroup();
         $participant = $this->resolveParticipant();

-        $event = WhatsAppEvent::query()->firstOrCreate(
-            ['event_id' => $this->eventId],
-            [
-                'type' => $this->type,
-                'group_id' => $group?->id,
-                'participant_id' => $participant?->id,
-                'participant_alt' => $this->participantAlt,
-                'occurred_at' => $this->occurredAt,
-                'occurred_at_source' => $this->occurredAtSource,
-                'received_at' => now(),
-                'payload' => $this->payload,
-            ],
-        );
-
-        if ($event->wasRecentlyCreated) {
-            $this->resolveHandler()?->handle($event);
-        }
+        DB::transaction(function () use ($group, $participant): void {
+            $event = WhatsAppEvent::query()->firstOrCreate(
+                ['event_id' => $this->eventId],
+                [
+                    'type' => $this->type,
+                    'group_id' => $group?->id,
+                    'participant_id' => $participant?->id,
+                    'participant_alt' => $this->participantAlt,
+                    'occurred_at' => $this->occurredAt,
+                    'occurred_at_source' => $this->occurredAtSource,
+                    'received_at' => now(),
+                    'payload' => $this->payload,
+                ],
+            );
+
+            if ($event->wasRecentlyCreated) {
+                $this->resolveHandler()?->handle($event);
+            }
+        });
     }

Note: if you keep this pattern, consider removing the inner DB::transaction() inside GroupsMetadataHandler::handle() since the outer transaction will already cover its writes (nested transactions in Laravel use savepoints, which is fine but slightly more overhead).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php`
around lines 38 - 60, The event is persisted with WhatsAppEvent::firstOrCreate
before calling the handler, so if the handler (e.g.,
GroupsMetadataHandler::handle) throws the event row remains and retries skip the
handler; fix by wrapping the creation and handler dispatch in a single DB
transaction inside ProcessWhatsAppEvent::handle (use DB::transaction around the
firstOrCreate + resolveHandler()->handle($event) call) so a handler exception
rolls back the event row and allows queue retries to re-run the handler; also
note you can remove or simplify the inner DB::transaction in
GroupsMetadataHandler::handle to avoid redundant nested transactions.
🧹 Nitpick comments (4)
app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php (1)

114-148: ⚡ Quick win

Consider asserting joined_at is preserved across rejoin.

The handler uses joined_at ??= now(), so a rejoining member keeps the original joined_at. Adding an assertion here (capture $first->joined_at after the first run, then expect($rejoined->joined_at->equalTo($originalJoinedAt))->toBeTrue()) would lock in that semantic and prevent accidental regression if the handler is later changed to reset joined_at on rejoin.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php`
around lines 114 - 148, After the first GroupsMetadataHandler::handle($first)
run, capture the participant's original joined_at (via
WhatsAppGroupParticipant::query()->whereRelation('participant','external_jid','100000000000002@lid')->firstOrFail()->joined_at)
and after the rejoin assert that the rejoined record's joined_at equals the
original (e.g.,
expect($rejoined->joined_at->equalTo($originalJoinedAt))->toBeTrue()), ensuring
joined_at is preserved across rejoin.
app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php (1)

49-54: ⚡ Quick win

N+1 on participant upsert; collapse into a single updateOrCreate.

For each participant in the payload you issue a SELECT + (INSERT or nothing) via firstOrCreate, then an unconditional UPDATE via forceFill(...)->save(). For a group with hundreds of members this doubles the round trips.

A single updateOrCreate (or a Postgres upsert keyed on external_jid) does both in one statement and returns the model for the membership step:

♻️ Recommended refactor
-                $participant = WhatsAppParticipant::query()->firstOrCreate(
-                    ['external_jid' => $jid],
-                    ['first_seen_at' => now()],
-                );
-
-                $participant->forceFill(['last_seen_at' => now()])->save();
+                $participant = WhatsAppParticipant::query()->updateOrCreate(
+                    ['external_jid' => $jid],
+                    [
+                        'first_seen_at' => DB::raw('COALESCE(first_seen_at, NOW())'),
+                        'last_seen_at'  => now(),
+                    ],
+                );

For very large group snapshots, an even cheaper option is a single WhatsAppParticipant::upsert(...) followed by one whereIn('external_jid', $jids)->get(['id','external_jid']) to build the id map for the membership upsert below.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php`
around lines 49 - 54, The current logic uses
WhatsAppParticipant::firstOrCreate(...) followed by
$participant->forceFill(['last_seen_at'=>now()])->save(), causing N+1 queries;
replace this with a single updateOrCreate call on WhatsAppParticipant keyed by
'external_jid' that sets 'first_seen_at' and 'last_seen_at' in one statement (or
for very large payloads, perform a bulk WhatsAppParticipant::upsert(...) for all
jids then fetch the created/updated rows with
whereIn('external_jid')->get(['id','external_jid']) to build the id map used
later), removing the firstOrCreate + forceFill pattern.
app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php (1)

32-32: ⚡ Quick win

Split the multi-argument dispatch(new ProcessWhatsAppEvent(...)) call across multiple lines.

The single-line dispatch with eight named arguments is hard to read and diff. Reformatting improves maintainability and is consistent with the named-arg style already adopted.

♻️ Proposed formatting
-        dispatch(new ProcessWhatsAppEvent(eventId: $eventId, type: $validated['type'], groupJid: $validated['group_jid'] ?? null, participantJid: $validated['participant_jid'] ?? null, participantAlt: $validated['participant_alt'] ?? null, occurredAt: Date::parse($validated['occurred_at']), occurredAtSource: $validated['occurred_at_source'] ?? null, payload: $validated['payload']));
+        dispatch(new ProcessWhatsAppEvent(
+            eventId: $eventId,
+            type: $validated['type'],
+            groupJid: $validated['group_jid'] ?? null,
+            participantJid: $validated['participant_jid'] ?? null,
+            participantAlt: $validated['participant_alt'] ?? null,
+            occurredAt: Date::parse($validated['occurred_at']),
+            occurredAtSource: $validated['occurred_at_source'] ?? null,
+            payload: $validated['payload'],
+        ));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php`
at line 32, The single-line dispatch call is hard to read; reformat the
dispatch(new ProcessWhatsAppEvent(...)) invocation so each named argument is on
its own line for clarity and easier diffs. Locate the dispatch(...) call in
WhatsAppWebhookController and break the ProcessWhatsAppEvent constructor
arguments (eventId, type, groupJid, participantJid, participantAlt, occurredAt
using Date::parse($validated['occurred_at']), occurredAtSource, payload) onto
separate lines, keeping the new ProcessWhatsAppEvent(...) and dispatch(...)
parentheses aligned and preserving the same named-argument order and
null-coalescing expressions.
app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md (1)

67-67: ⚡ Quick win

Add language identifiers to fenced code blocks (MD040).

Lines 67, 97, 118, and 133 use fenced blocks without a language tag, which triggers markdownlint warnings.

💡 Suggested markdown fix
-```
+```text
   ┌─────────────────────────────┐
   │ ProcessWhatsAppEvent (job)  │  fluxo base p/ TODO tipo
   │  resolveGroup()             │
   │  resolveParticipant()       │
@@
-```
+```

-```
+```text
 id              uuid (v7) PK            -- via HasVersion7Uuids
 group_id        uuid FK → whatsapp_groups (NOT NULL, cascadeOnDelete)
 participant_id  uuid FK → whatsapp_participants (NOT NULL, cascadeOnDelete)
@@
-```
+```

-```
+```text
 payload.participants[]                  vínculos ativos atuais do grupo
   100..001@lid superadmin ──┐
   100..002@lid admin        ├─► upsert: admin_role=X, left_at=NULL, joined_at=coalesce(atual, now)
@@
-```
+```

-```
+```text
           snapshot inclui o membro
  [novo] ───────────────────────────► [ativo]  (left_at=null, admin_role=X)
                                          │  ▲
@@
-```
+```

Also applies to: 97-97, 118-118, 133-133

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md`
at line 67, The markdown has four fenced code blocks missing language tags
(MD040); update each opening fence to include a language identifier (use "text")
for the blocks that start with the diagram line
"┌─────────────────────────────┐", the table starting "id              uuid (v7)
PK", the participants payload diagram starting "payload.participants[]", and the
snapshot diagram starting "snapshot inclui o membro" — replace their opening ```
with ```text so each fenced block is tagged and the markdownlint MD040 warnings
are resolved.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md`:
- Line 4: Update the status line in the specs markdown (the current string
"Aprovado (brainstorming) — aguardando plano de implementação") to reflect that
the feature is implemented and shipped—replace it with a clear current state
such as "Implementado" or "Finalizado" (or the repo's canonical status term),
and ensure any adjacent metadata or badges in the same header are consistent
with the new status.

In
`@app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php`:
- Around line 24-28: The handler currently unconditionally overwrites
$group->display_name and $group->payload inside DB::transaction which can
regress newer data when out-of-order events are processed; change the update
logic in the transaction used by GroupsMetadataHandler (the block that
references $group and $payload) to compare the incoming event timestamp
(payload['occurred_at'] or similar) against a stored high-water-mark on the
model (e.g., $group->metadata_updated_at) and only set display_name/payload and
persist metadata_updated_at when the incoming occurred_at is greater than the
stored timestamp (or when the stored value is null); alternatively, if you
intend last-write-wins by processing order, add a clear comment in the
DB::transaction block noting that ingest ordering is not enforced.
- Around line 51-74: Replace uses of now() when setting membership timestamps
with the source event timestamp $event->occurred_at: set first_seen_at,
joined_at, left_at and last_seen_at from $event->occurred_at instead of now() in
the block that creates/updates WhatsAppGroupParticipant (and the
participant->forceFill(['last_seen_at'=>...]) call); ensure you don't move
last_seen_at backwards by using a max/GREATEST-like update (e.g., last_seen_at =
max(existing last_seen_at, $event->occurred_at)) when saving the participant or
updating the membership, and apply the same $event->occurred_at substitution in
the final whereNotIn(...)->update(['left_at'=>...]) call so left_at is sourced
from the event timestamp.

---

Outside diff comments:
In `@app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php`:
- Around line 38-60: The event is persisted with WhatsAppEvent::firstOrCreate
before calling the handler, so if the handler (e.g.,
GroupsMetadataHandler::handle) throws the event row remains and retries skip the
handler; fix by wrapping the creation and handler dispatch in a single DB
transaction inside ProcessWhatsAppEvent::handle (use DB::transaction around the
firstOrCreate + resolveHandler()->handle($event) call) so a handler exception
rolls back the event row and allows queue retries to re-run the handler; also
note you can remove or simplify the inner DB::transaction in
GroupsMetadataHandler::handle to avoid redundant nested transactions.

---

Nitpick comments:
In `@app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md`:
- Line 67: The markdown has four fenced code blocks missing language tags
(MD040); update each opening fence to include a language identifier (use "text")
for the blocks that start with the diagram line
"┌─────────────────────────────┐", the table starting "id              uuid (v7)
PK", the participants payload diagram starting "payload.participants[]", and the
snapshot diagram starting "snapshot inclui o membro" — replace their opening ```
with ```text so each fenced block is tagged and the markdownlint MD040 warnings
are resolved.

In
`@app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php`:
- Around line 49-54: The current logic uses
WhatsAppParticipant::firstOrCreate(...) followed by
$participant->forceFill(['last_seen_at'=>now()])->save(), causing N+1 queries;
replace this with a single updateOrCreate call on WhatsAppParticipant keyed by
'external_jid' that sets 'first_seen_at' and 'last_seen_at' in one statement (or
for very large payloads, perform a bulk WhatsAppParticipant::upsert(...) for all
jids then fetch the created/updated rows with
whereIn('external_jid')->get(['id','external_jid']) to build the id map used
later), removing the firstOrCreate + forceFill pattern.

In
`@app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php`:
- Line 32: The single-line dispatch call is hard to read; reformat the
dispatch(new ProcessWhatsAppEvent(...)) invocation so each named argument is on
its own line for clarity and easier diffs. Locate the dispatch(...) call in
WhatsAppWebhookController and break the ProcessWhatsAppEvent constructor
arguments (eventId, type, groupJid, participantJid, participantAlt, occurredAt
using Date::parse($validated['occurred_at']), occurredAtSource, payload) onto
separate lines, keeping the new ProcessWhatsAppEvent(...) and dispatch(...)
parentheses aligned and preserving the same named-argument order and
null-coalescing expressions.

In
`@app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php`:
- Around line 114-148: After the first GroupsMetadataHandler::handle($first)
run, capture the participant's original joined_at (via
WhatsAppGroupParticipant::query()->whereRelation('participant','external_jid','100000000000002@lid')->firstOrFail()->joined_at)
and after the rejoin assert that the rejoined record's joined_at equals the
original (e.g.,
expect($rejoined->joined_at->equalTo($originalJoinedAt))->toBeTrue()), ensuring
joined_at is preserved across rejoin.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 0378382d-d600-46f0-b56b-7a3e834da70c

📥 Commits

Reviewing files that changed from the base of the PR and between c264605 and 8677ff6.

📒 Files selected for processing (20)
  • app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php
  • app-modules/integration-whatsapp/database/factories/WhatsAppGroupParticipantFactory.php
  • app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php
  • app-modules/integration-whatsapp/database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php
  • app-modules/integration-whatsapp/docs/plans/0002-groups-metadata.md
  • app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md
  • app-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.php
  • app-modules/integration-whatsapp/src/Ingest/Handlers/EventHandler.php
  • app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php
  • app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php
  • app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php
  • app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php
  • app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php
  • app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php
  • app-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.php
  • app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php
  • app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php
  • app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php
  • app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php
  • app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php
✅ Files skipped from review due to trivial changes (3)
  • app-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.php
  • app-modules/integration-whatsapp/database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php
  • app-modules/integration-whatsapp/src/Ingest/Handlers/EventHandler.php

Comment on lines +24 to +28
DB::transaction(function () use ($group, $payload): void {
$group->forceFill([
'display_name' => $payload['subject'] ?? $group->display_name,
'payload' => $payload,
])->save();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Group metadata can regress when events arrive out of order.

display_name and payload are unconditionally overwritten with the current event's data. Because ProcessWhatsAppEvent is queued and groups.metadata events for the same group can be dispatched out of order (or backfilled), an older snapshot processed after a newer one will silently replace the freshest data on the group row.

Consider guarding the update with the event's occurred_at against a metadata_updated_at (or similar high-water-mark) on whatsapp_groups, e.g.:

🛡️ Sketch
-            $group->forceFill([
-                'display_name' => $payload['subject'] ?? $group->display_name,
-                'payload' => $payload,
-            ])->save();
+            if ($group->metadata_updated_at === null
+                || $event->occurred_at->greaterThanOrEqualTo($group->metadata_updated_at)) {
+                $group->forceFill([
+                    'display_name' => $payload['subject'] ?? $group->display_name,
+                    'payload' => $payload,
+                    'metadata_updated_at' => $event->occurred_at,
+                ])->save();
+            }

If the design intent is genuinely "last-write-wins by processing order", a brief comment here would help readers understand that ingest ordering is not enforced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php`
around lines 24 - 28, The handler currently unconditionally overwrites
$group->display_name and $group->payload inside DB::transaction which can
regress newer data when out-of-order events are processed; change the update
logic in the transaction used by GroupsMetadataHandler (the block that
references $group and $payload) to compare the incoming event timestamp
(payload['occurred_at'] or similar) against a stored high-water-mark on the
model (e.g., $group->metadata_updated_at) and only set display_name/payload and
persist metadata_updated_at when the incoming occurred_at is greater than the
stored timestamp (or when the stored value is null); alternatively, if you
intend last-write-wins by processing order, add a clear comment in the
DB::transaction block noting that ingest ordering is not enforced.

Comment on lines +51 to +74
['first_seen_at' => now()],
);

$participant->forceFill(['last_seen_at' => now()])->save();

$membership = WhatsAppGroupParticipant::query()->firstOrNew([
'group_id' => $group->id,
'participant_id' => $participant->id,
]);

$membership->admin_role = $row['admin'] ?? null;
$membership->left_at = null;
$membership->joined_at ??= now();
$membership->save();

$seenParticipantIds[] = $participant->id;
}

if ($seenParticipantIds !== []) {
WhatsAppGroupParticipant::query()
->where('group_id', $group->id)
->whereNull('left_at')
->whereNotIn('participant_id', $seenParticipantIds)
->update(['left_at' => now()]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prefer $event->occurred_at over now() for membership timestamps.

joined_at, left_at, last_seen_at, and first_seen_at are all set to now() (i.e., the job processing wall time). For a queued ingest, that is usually close to reality, but it breaks for:

  • backfills / replays of historical events,
  • bursts where jobs are delayed in the queue,
  • out-of-order delivery (an older snapshot processed last would set left_at = now() for participants who are in fact still members today).

Using $event->occurred_at ties the recorded timestamps to the source event and keeps the data lake reproducible regardless of when jobs run.

🛡️ Proposed diff
-                $participant = WhatsAppParticipant::query()->firstOrCreate(
-                    ['external_jid' => $jid],
-                    ['first_seen_at' => now()],
-                );
-
-                $participant->forceFill(['last_seen_at' => now()])->save();
+                $participant = WhatsAppParticipant::query()->firstOrCreate(
+                    ['external_jid' => $jid],
+                    ['first_seen_at' => $event->occurred_at],
+                );
+
+                $participant->forceFill(['last_seen_at' => $event->occurred_at])->save();
@@
                 $membership->admin_role = $row['admin'] ?? null;
                 $membership->left_at = null;
-                $membership->joined_at ??= now();
+                $membership->joined_at ??= $event->occurred_at;
                 $membership->save();
@@
                     ->whereNotIn('participant_id', $seenParticipantIds)
-                    ->update(['left_at' => now()]);
+                    ->update(['left_at' => $event->occurred_at]);

Note: combined with out-of-order handling on the group row (see other comment), you'll also want to avoid moving last_seen_at backwards — e.g. last_seen_at = GREATEST(last_seen_at, $event->occurred_at).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php`
around lines 51 - 74, Replace uses of now() when setting membership timestamps
with the source event timestamp $event->occurred_at: set first_seen_at,
joined_at, left_at and last_seen_at from $event->occurred_at instead of now() in
the block that creates/updates WhatsAppGroupParticipant (and the
participant->forceFill(['last_seen_at'=>...]) call); ensure you don't move
last_seen_at backwards by using a max/GREATEST-like update (e.g., last_seen_at =
max(existing last_seen_at, $event->occurred_at)) when saving the participant or
updating the membership, and apply the same $event->occurred_at substitution in
the final whereNotIn(...)->update(['left_at'=>...]) call so left_at is sourced
from the event timestamp.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant