feat(integration-whatsapp): core ingest do coletor de WhatsApp#273
feat(integration-whatsapp): core ingest do coletor de WhatsApp#273Clintonrocha98 wants to merge 9 commits into
Conversation
📝 WalkthroughWalkthroughThis 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 Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
app-modules/integration-whatsapp/docs/spec.md (1)
63-78: ⚡ Quick winAdd 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 winAdd language identifiers to all unlabeled fenced blocks.
Several fenced blocks are missing a language tag; adding
text,bash, orenvas 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 winDeclare a language for the architecture fence block.
Use a language like
texton 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
⛔ Files ignored due to path filters (1)
composer.lockis excluded by!**/*.lock
📒 Files selected for processing (26)
.env.exampleapp-modules/integration-whatsapp/CONTEXT.mdapp-modules/integration-whatsapp/composer.jsonapp-modules/integration-whatsapp/config/whatsapp.phpapp-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.phpapp-modules/integration-whatsapp/database/factories/WhatsAppGroupFactory.phpapp-modules/integration-whatsapp/database/factories/WhatsAppParticipantFactory.phpapp-modules/integration-whatsapp/database/migrations/2026_05_20_120000_create_whatsapp_groups_table.phpapp-modules/integration-whatsapp/database/migrations/2026_05_20_120001_create_whatsapp_participants_table.phpapp-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.phpapp-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.mdapp-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.mdapp-modules/integration-whatsapp/docs/spec.mdapp-modules/integration-whatsapp/routes/whatsapp-routes.phpapp-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.phpapp-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.phpapp-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.phpapp-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.phpapp-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.phpapp-modules/integration-whatsapp/src/Models/WhatsAppEvent.phpapp-modules/integration-whatsapp/src/Models/WhatsAppGroup.phpapp-modules/integration-whatsapp/src/Models/WhatsAppParticipant.phpapp-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.phpapp-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.phpapp-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.phpcomposer.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
There was a problem hiding this comment.
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 liftHandler 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. IfGroupsMetadataHandler::handle()throws, its innerDB::transaction()rolls back the group/membership mutations, but the event row remains. On queue retry,firstOrCreatefinds the row,wasRecentlyCreatedisfalse, 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
wasRecentlyCreatedbranch.🛡️ 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()insideGroupsMetadataHandler::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 winConsider asserting
joined_atis preserved across rejoin.The handler uses
joined_at ??= now(), so a rejoining member keeps the originaljoined_at. Adding an assertion here (capture$first->joined_atafter the first run, thenexpect($rejoined->joined_at->equalTo($originalJoinedAt))->toBeTrue()) would lock in that semantic and prevent accidental regression if the handler is later changed to resetjoined_aton 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 winN+1 on participant upsert; collapse into a single
updateOrCreate.For each participant in the payload you issue a
SELECT+ (INSERTor nothing) viafirstOrCreate, then an unconditionalUPDATEviaforceFill(...)->save(). For a group with hundreds of members this doubles the round trips.A single
updateOrCreate(or a Postgresupsertkeyed onexternal_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 onewhereIn('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 winSplit 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 winAdd 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
📒 Files selected for processing (20)
app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.phpapp-modules/integration-whatsapp/database/factories/WhatsAppGroupParticipantFactory.phpapp-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.phpapp-modules/integration-whatsapp/database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.phpapp-modules/integration-whatsapp/docs/plans/0002-groups-metadata.mdapp-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.mdapp-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.phpapp-modules/integration-whatsapp/src/Ingest/Handlers/EventHandler.phpapp-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.phpapp-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.phpapp-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.phpapp-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.phpapp-modules/integration-whatsapp/src/Models/WhatsAppEvent.phpapp-modules/integration-whatsapp/src/Models/WhatsAppGroup.phpapp-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.phpapp-modules/integration-whatsapp/src/Models/WhatsAppParticipant.phpapp-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.phpapp-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.phpapp-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.phpapp-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
| DB::transaction(function () use ($group, $payload): void { | ||
| $group->forceFill([ | ||
| 'display_name' => $payload['subject'] ?? $group->display_name, | ||
| 'payload' => $payload, | ||
| ])->save(); |
There was a problem hiding this comment.
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.
| ['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()]); |
There was a problem hiding this comment.
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.
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.Arquitetura
O controller responde rápido (202) e enfileira o processamento no Horizon. Idempotência em duas camadas: short-circuit por
event_idno controller efirstOrCreate(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) epayload(jsonb) com o evento Baileys cru.whatsapp_groups—external_jidUNIQUE,display_name,internal_name,payload,first/last_seen_atwhatsapp_participants— global por número (external_jidUNIQUE),push_name,identity_id?(→users),payloadwhatsapp_events—event_idUNIQUE (UUIDv5, idempotência),type(indexado),group_id?,participant_id?,participant_alt?(@lid),occurred_at,occurred_at_source,received_at,payloadwhatsapp_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): oevent_idprecisa 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 doHasUuids) fragmentaria o índice da PK.uuidnativa paraevent_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).participant_alt(@lid, id interno estável do WhatsApp) eoccurred_at_source(qualidade do timestamp) eram descartados — agora são colunas de primeira classe, coerente com "data lake puro".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.left_atem 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 semparticipants).participants); o vínculo com grupo e o papel de admin vivem na pivô — a "tabela pivô futura" prevista no design inicial.typecomo string (não enum): o conjunto de eventos do Baileys é aberto e muda entre versões.collection_policydescartada nesta fase: o bot envia tudo e o Laravel salva tudo cru.Privacidade — postura consciente (fase exploratória)
Testes
24 testes Pest (84 asserts) verdes; Pint limpo; PHPStan nível 6 sem erros. Cobertura:
event_id, não-duplicação de grupoX-Event-Id→ 401;event_idnão-uuid → 400; body inválido → 422 (dataset por campo obrigatório); duplicata → 202 sem dispatch; persistência e2e do envelope;groups.metadatae2e + idempotêncialeft_at; payload semparticipantsnão altera a membershipFora 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 pelowpp-tuipara assinar o corpo.Documentação incluída
CONTEXT.md,docs/spec.md,docs/adr/0001-data-lake-approach.md,docs/plans/0001-ingest-implementation.mde — para ogroups.metadata—docs/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 agroups.metadatahandler that synchronizes group snapshots and membership via a soft-delete pivot table.References
#273by Clintonrocha98: feat/integration-whatsapp-ingest → 4.xdocs/spec.md– full integration scope and data-flow documentationdocs/adr/0001-data-lake-approach.md– design rationale for schema-on-read approachdocs/plans/0001-ingest-implementation.mdanddocs/plans/0002-groups-metadata.mddocs/specs/0001-groups-metadata-design.mdDependencies & Requirements
New Dependencies:
he4rt/integration-whatsapp: >=1to rootcomposer.jsonEnvironment Variables:
WHATSAPP_WEBHOOK_SECRET– required for HMAC-SHA256 signature validation; defined in.env.exampleConfiguration:
config/whatsapp.php– reads webhook secret from environment and registers viaIntegrationWhatsappServiceProviderContributor Summary
Changes Summary
.env.exampleWHATSAPP_WEBHOOK_SECRETtemplate variableapp-modules/integration-whatsapp/CONTEXT.mdapp-modules/integration-whatsapp/composer.jsonapp-modules/integration-whatsapp/config/whatsapp.phpapp-modules/integration-whatsapp/database/migrations/whatsapp_groups,whatsapp_participants,whatsapp_events,whatsapp_group_participantstables with UUIDs, indexes, and foreign keysapp-modules/integration-whatsapp/database/factories/app-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.mdapp-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.mdapp-modules/integration-whatsapp/docs/plans/0002-groups-metadata.mdapp-modules/integration-whatsapp/docs/spec.mdapp-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.mdapp-modules/integration-whatsapp/routes/whatsapp-routes.phpPOST /api/integrations/whatsapp/eventsapp-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.phpapp-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.phpapp-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.phpapp-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.phpapp-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.phpfirstOrCreateand handler dispatchapp-modules/integration-whatsapp/src/Ingest/Handlers/EventHandler.phpapp-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.phpgroups.metadataevents: updates group snapshot, upserts memberships, soft-removes absent membersapp-modules/integration-whatsapp/src/Models/WhatsAppGroup.phpapp-modules/integration-whatsapp/src/Models/WhatsAppParticipant.phpexternal_jidapp-modules/integration-whatsapp/src/Models/WhatsAppEvent.phpapp-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.phpapp-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.phpapp-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.phpapp-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.phpapp-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.phpapp-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.phpcomposer.json