From 8397b1b90c9e423e32ac34c98d9187e3c31c8e25 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 01:08:32 -0300 Subject: [PATCH 1/9] feat(integration-whatsapp): scaffold module --- .env.example | 2 + .../integration-whatsapp/composer.json | 28 +++++++++++++ .../integration-whatsapp/config/whatsapp.php | 7 ++++ .../IntegrationWhatsappServiceProvider.php | 15 +++++++ composer.json | 1 + composer.lock | 40 ++++++++++++++++++- 6 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 app-modules/integration-whatsapp/composer.json create mode 100644 app-modules/integration-whatsapp/config/whatsapp.php create mode 100644 app-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.php diff --git a/.env.example b/.env.example index 792fe0262..1c3c885a7 100644 --- a/.env.example +++ b/.env.example @@ -105,3 +105,5 @@ BACKUP_VPS_PASSWORD= BACKUP_VPS_KEY_PATH= BACKUP_VPS_PORT=22 BACKUP_VPS_ROOT=/backups + +WHATSAPP_WEBHOOK_SECRET= diff --git a/app-modules/integration-whatsapp/composer.json b/app-modules/integration-whatsapp/composer.json new file mode 100644 index 000000000..36d273d55 --- /dev/null +++ b/app-modules/integration-whatsapp/composer.json @@ -0,0 +1,28 @@ +{ + "name": "he4rt/integration-whatsapp", + "description": "", + "type": "library", + "version": "1.0", + "license": "proprietary", + "require": {}, + "autoload": { + "psr-4": { + "He4rt\\IntegrationWhatsapp\\": "src/", + "He4rt\\IntegrationWhatsapp\\Database\\Factories\\": "database/factories/", + "He4rt\\IntegrationWhatsapp\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "He4rt\\IntegrationWhatsapp\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationWhatsapp\\IntegrationWhatsappServiceProvider" + ] + } + } +} diff --git a/app-modules/integration-whatsapp/config/whatsapp.php b/app-modules/integration-whatsapp/config/whatsapp.php new file mode 100644 index 000000000..620e98999 --- /dev/null +++ b/app-modules/integration-whatsapp/config/whatsapp.php @@ -0,0 +1,7 @@ + env('WHATSAPP_WEBHOOK_SECRET', ''), +]; diff --git a/app-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.php b/app-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.php new file mode 100644 index 000000000..f261627d0 --- /dev/null +++ b/app-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.php @@ -0,0 +1,15 @@ +mergeConfigFrom(__DIR__.'/../config/whatsapp.php', 'whatsapp'); + } +} diff --git a/composer.json b/composer.json index 9c7c91f3b..66df0b074 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "he4rt/integration-devto": ">=1", "he4rt/integration-discord": ">=1", "he4rt/integration-twitch": ">=1", + "he4rt/integration-whatsapp": ">=1", "he4rt/moderation": "^1.0", "he4rt/panel-admin": ">=1", "he4rt/panel-app": ">=1", diff --git a/composer.lock b/composer.lock index a62182af2..40ec6827f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4cda5177e73cfcd5a61098bfe216f7d4", + "content-hash": "c39fd42abeada33c4b345d0d9468fc6f", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -3490,6 +3490,42 @@ "relative": true } }, + { + "name": "he4rt/integration-whatsapp", + "version": "1.0", + "dist": { + "type": "path", + "url": "app-modules/integration-whatsapp", + "reference": "6165e9aaca275dc1cb7ea94bb46f4e1bc0eac4f6" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationWhatsapp\\IntegrationWhatsappServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "He4rt\\IntegrationWhatsapp\\": "src/", + "He4rt\\IntegrationWhatsapp\\Database\\Factories\\": "database/factories/", + "He4rt\\IntegrationWhatsapp\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "He4rt\\IntegrationWhatsapp\\Tests\\": "tests/" + } + }, + "license": [ + "proprietary" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "he4rt/moderation", "version": "1.0", @@ -18196,5 +18232,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From f75506b21ef4c858998c0951e444618f8948520a Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 01:08:32 -0300 Subject: [PATCH 2/9] feat(integration-whatsapp): add data-lake migrations --- ...20_120000_create_whatsapp_groups_table.php | 30 ++++++++++++++++ ...001_create_whatsapp_participants_table.php | 29 +++++++++++++++ ...20_120002_create_whatsapp_events_table.php | 35 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 app-modules/integration-whatsapp/database/migrations/2026_05_20_120000_create_whatsapp_groups_table.php create mode 100644 app-modules/integration-whatsapp/database/migrations/2026_05_20_120001_create_whatsapp_participants_table.php create mode 100644 app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php diff --git a/app-modules/integration-whatsapp/database/migrations/2026_05_20_120000_create_whatsapp_groups_table.php b/app-modules/integration-whatsapp/database/migrations/2026_05_20_120000_create_whatsapp_groups_table.php new file mode 100644 index 000000000..713f34fce --- /dev/null +++ b/app-modules/integration-whatsapp/database/migrations/2026_05_20_120000_create_whatsapp_groups_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); + $table->string('external_jid')->unique(); + $table->string('display_name')->nullable(); + $table->string('internal_name')->nullable(); + $table->jsonb('payload')->nullable(); + $table->timestamp('first_seen_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_groups'); + } +}; diff --git a/app-modules/integration-whatsapp/database/migrations/2026_05_20_120001_create_whatsapp_participants_table.php b/app-modules/integration-whatsapp/database/migrations/2026_05_20_120001_create_whatsapp_participants_table.php new file mode 100644 index 000000000..ab4b7bff3 --- /dev/null +++ b/app-modules/integration-whatsapp/database/migrations/2026_05_20_120001_create_whatsapp_participants_table.php @@ -0,0 +1,29 @@ +uuid('id')->primary(); + $table->string('external_jid')->unique(); + $table->string('push_name')->nullable(); + $table->jsonb('payload')->nullable(); + $table->foreignUuid('identity_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('first_seen_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_participants'); + } +}; diff --git a/app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php b/app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php new file mode 100644 index 000000000..c1965f070 --- /dev/null +++ b/app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php @@ -0,0 +1,35 @@ +uuid('id')->primary(); + $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); + $table->uuid('event_id')->unique(); + $table->string('type')->index(); + $table->foreignUuid('group_id')->nullable()->constrained('whatsapp_groups')->nullOnDelete(); + $table->foreignUuid('participant_id')->nullable()->constrained('whatsapp_participants')->nullOnDelete(); + $table->timestamp('occurred_at')->index(); + $table->timestamp('received_at')->nullable(); + $table->jsonb('payload'); + $table->timestamps(); + + $table->index(['type', 'occurred_at']); + $table->index(['group_id', 'occurred_at']); + $table->index(['participant_id', 'occurred_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_events'); + } +}; From e1354b46fdd1cd1fe49a680b050396c6212557a2 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 01:08:32 -0300 Subject: [PATCH 3/9] feat(integration-whatsapp): add models and factories --- .../factories/WhatsAppEventFactory.php | 34 +++++++ .../factories/WhatsAppGroupFactory.php | 28 ++++++ .../factories/WhatsAppParticipantFactory.php | 28 ++++++ .../src/Models/WhatsAppEvent.php | 90 +++++++++++++++++++ .../src/Models/WhatsAppGroup.php | 81 +++++++++++++++++ .../src/Models/WhatsAppParticipant.php | 79 ++++++++++++++++ .../Feature/Models/WhatsAppModelsTest.php | 36 ++++++++ 7 files changed, 376 insertions(+) create mode 100644 app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php create mode 100644 app-modules/integration-whatsapp/database/factories/WhatsAppGroupFactory.php create mode 100644 app-modules/integration-whatsapp/database/factories/WhatsAppParticipantFactory.php create mode 100644 app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php create mode 100644 app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php create mode 100644 app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php create mode 100644 app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php diff --git a/app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php b/app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php new file mode 100644 index 000000000..cc8e2e6c2 --- /dev/null +++ b/app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php @@ -0,0 +1,34 @@ + + */ +final class WhatsAppEventFactory extends Factory +{ + protected $model = WhatsAppEvent::class; + + public function definition(): array + { + return [ + 'event_id' => fake()->uuid(), + 'type' => 'messages.upsert', + 'group_id' => WhatsAppGroup::factory(), + 'participant_id' => WhatsAppParticipant::factory(), + 'occurred_at' => now(), + 'received_at' => now(), + 'payload' => [ + 'key' => ['id' => fake()->uuid()], + 'message' => ['conversation' => fake()->sentence()], + ], + ]; + } +} diff --git a/app-modules/integration-whatsapp/database/factories/WhatsAppGroupFactory.php b/app-modules/integration-whatsapp/database/factories/WhatsAppGroupFactory.php new file mode 100644 index 000000000..e5bdc147f --- /dev/null +++ b/app-modules/integration-whatsapp/database/factories/WhatsAppGroupFactory.php @@ -0,0 +1,28 @@ + + */ +final class WhatsAppGroupFactory extends Factory +{ + protected $model = WhatsAppGroup::class; + + public function definition(): array + { + return [ + 'external_jid' => fake()->unique()->numerify('1203#########').'@g.us', + 'display_name' => fake()->words(2, true), + 'internal_name' => fake()->randomElement(['geral', 'delas', 'vagas']), + 'payload' => [], + 'first_seen_at' => now(), + 'last_seen_at' => now(), + ]; + } +} diff --git a/app-modules/integration-whatsapp/database/factories/WhatsAppParticipantFactory.php b/app-modules/integration-whatsapp/database/factories/WhatsAppParticipantFactory.php new file mode 100644 index 000000000..cebb64bbb --- /dev/null +++ b/app-modules/integration-whatsapp/database/factories/WhatsAppParticipantFactory.php @@ -0,0 +1,28 @@ + + */ +final class WhatsAppParticipantFactory extends Factory +{ + protected $model = WhatsAppParticipant::class; + + public function definition(): array + { + return [ + 'external_jid' => fake()->unique()->numerify('5511#########').'@s.whatsapp.net', + 'push_name' => fake()->name(), + 'payload' => [], + 'identity_id' => null, + 'first_seen_at' => now(), + 'last_seen_at' => now(), + ]; + } +} diff --git a/app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php b/app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php new file mode 100644 index 000000000..9388574c7 --- /dev/null +++ b/app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php @@ -0,0 +1,90 @@ + $payload + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read Tenant|null $tenant + * @property-read WhatsAppGroup|null $group + * @property-read WhatsAppParticipant|null $participant + */ +final class WhatsAppEvent extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $table = 'whatsapp_events'; + + protected $fillable = [ + 'tenant_id', + 'event_id', + 'type', + 'group_id', + 'participant_id', + 'occurred_at', + 'received_at', + 'payload', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function group(): BelongsTo + { + return $this->belongsTo(WhatsAppGroup::class, 'group_id'); + } + + /** + * @return BelongsTo + */ + public function participant(): BelongsTo + { + return $this->belongsTo(WhatsAppParticipant::class, 'participant_id'); + } + + protected static function newFactory(): WhatsAppEventFactory + { + return WhatsAppEventFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload' => 'array', + 'occurred_at' => 'datetime', + 'received_at' => 'datetime', + ]; + } +} diff --git a/app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php b/app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php new file mode 100644 index 000000000..10b14cefb --- /dev/null +++ b/app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php @@ -0,0 +1,81 @@ +|null $payload + * @property Carbon|null $first_seen_at + * @property Carbon|null $last_seen_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read Tenant|null $tenant + * @property-read Collection $events + */ +final class WhatsAppGroup extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $table = 'whatsapp_groups'; + + protected $fillable = [ + 'tenant_id', + 'external_jid', + 'display_name', + 'internal_name', + 'payload', + 'first_seen_at', + 'last_seen_at', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return HasMany + */ + public function events(): HasMany + { + return $this->hasMany(WhatsAppEvent::class, 'group_id'); + } + + protected static function newFactory(): WhatsAppGroupFactory + { + return WhatsAppGroupFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload' => 'array', + 'first_seen_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + } +} diff --git a/app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php b/app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php new file mode 100644 index 000000000..af20d0d01 --- /dev/null +++ b/app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php @@ -0,0 +1,79 @@ +|null $payload + * @property string|null $identity_id + * @property Carbon|null $first_seen_at + * @property Carbon|null $last_seen_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read User|null $identity + * @property-read Collection $events + */ +final class WhatsAppParticipant extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $table = 'whatsapp_participants'; + + protected $fillable = [ + 'external_jid', + 'push_name', + 'payload', + 'identity_id', + 'first_seen_at', + 'last_seen_at', + ]; + + /** + * @return BelongsTo + */ + public function identity(): BelongsTo + { + return $this->belongsTo(User::class, 'identity_id'); + } + + /** + * @return HasMany + */ + public function events(): HasMany + { + return $this->hasMany(WhatsAppEvent::class, 'participant_id'); + } + + protected static function newFactory(): WhatsAppParticipantFactory + { + return WhatsAppParticipantFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload' => 'array', + 'first_seen_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + } +} diff --git a/app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php b/app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php new file mode 100644 index 000000000..ab7089a1d --- /dev/null +++ b/app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php @@ -0,0 +1,36 @@ +create(); + + expect(Str::isUuid($group->id))->toBeTrue() + ->and($group->payload)->toBeArray(); +}); + +test('event accepts null group and participant', function (): void { + $event = WhatsAppEvent::factory()->create([ + 'group_id' => null, + 'participant_id' => null, + ]); + + expect($event->group_id)->toBeNull() + ->and($event->participant_id)->toBeNull() + ->and($event->payload)->toBeArray(); +}); + +test('participant is global with unique external_jid', function (): void { + $participant = WhatsAppParticipant::factory()->create(['external_jid' => '5511999999999@s.whatsapp.net']); + + expect(Str::isUuid($participant->id))->toBeTrue() + ->and($participant->external_jid)->toBe('5511999999999@s.whatsapp.net'); +}); From 80c374d0eb36ae5a1d354e95a48b938517506a8a Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 01:08:32 -0300 Subject: [PATCH 4/9] feat(integration-whatsapp): add HMAC signature middleware --- .../Middleware/VerifyWhatsAppSignature.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 app-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.php diff --git a/app-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.php b/app-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.php new file mode 100644 index 000000000..9c96f2d09 --- /dev/null +++ b/app-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.php @@ -0,0 +1,45 @@ +string('whatsapp.webhook_secret'); + + if ($secret === '') { + return response()->json( + ['error' => 'Webhook secret not configured'], + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + $signature = $request->header('X-Signature'); + $eventId = $request->header('X-Event-Id'); + + if (!is_string($signature) || !is_string($eventId) || $eventId === '') { + return response()->json( + ['error' => 'Missing signature or event id'], + Response::HTTP_UNAUTHORIZED, + ); + } + + $expected = hash_hmac('sha256', $request->getContent(), $secret); + + if (!hash_equals($expected, $signature)) { + return response()->json( + ['error' => 'Invalid signature'], + Response::HTTP_UNAUTHORIZED, + ); + } + + return $next($request); + } +} From 6fb889725c01f200f160b41ca1f4d50b0bae3d0e Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 01:08:32 -0300 Subject: [PATCH 5/9] feat(integration-whatsapp): add ProcessWhatsAppEvent job --- .../src/Ingest/Jobs/ProcessWhatsAppEvent.php | 97 +++++++++++++++++++ .../Ingest/ProcessWhatsAppEventTest.php | 83 ++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php create mode 100644 app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php diff --git a/app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php b/app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php new file mode 100644 index 000000000..664af8824 --- /dev/null +++ b/app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php @@ -0,0 +1,97 @@ + $payload + */ + public function __construct( + private readonly string $eventId, + private readonly string $type, + private readonly ?string $groupJid, + private readonly ?string $participantJid, + private readonly CarbonInterface $occurredAt, + private readonly array $payload, + ) {} + + public function handle(): void + { + $group = $this->resolveGroup(); + $participant = $this->resolveParticipant(); + + WhatsAppEvent::query()->firstOrCreate( + ['event_id' => $this->eventId], + [ + 'type' => $this->type, + 'group_id' => $group?->id, + 'participant_id' => $participant?->id, + 'occurred_at' => $this->occurredAt, + 'received_at' => now(), + 'payload' => $this->payload, + ], + ); + } + + private function resolveGroup(): ?WhatsAppGroup + { + if ($this->groupJid === null || $this->groupJid === '') { + return null; + } + + $group = WhatsAppGroup::query()->firstOrCreate( + ['external_jid' => $this->groupJid], + [ + 'display_name' => $this->payload['subject'] ?? null, + 'first_seen_at' => now(), + ], + ); + + $group->forceFill([ + 'display_name' => $this->payload['subject'] ?? $group->display_name, + 'last_seen_at' => now(), + ])->save(); + + return $group; + } + + private function resolveParticipant(): ?WhatsAppParticipant + { + if ($this->participantJid === null || $this->participantJid === '') { + return null; + } + + $pushName = $this->payload['pushName'] ?? null; + + $participant = WhatsAppParticipant::query()->firstOrCreate( + ['external_jid' => $this->participantJid], + [ + 'push_name' => $pushName, + 'first_seen_at' => now(), + ], + ); + + $participant->forceFill([ + 'push_name' => $pushName ?? $participant->push_name, + 'last_seen_at' => now(), + ])->save(); + + return $participant; + } +} diff --git a/app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php b/app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php new file mode 100644 index 000000000..5528489ee --- /dev/null +++ b/app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php @@ -0,0 +1,83 @@ + (string) Str::uuid(), + 'type' => 'messages.upsert', + 'groupJid' => '120363000000000000@g.us', + 'participantJid' => '5511999999999@s.whatsapp.net', + 'occurredAt' => Carbon::parse('2026-05-20 12:00:00'), + 'payload' => ['subject' => 'He4rt Geral', 'pushName' => 'Maria', 'message' => ['conversation' => 'oi']], + ]; + + $data = array_merge($defaults, $overrides); + + (new ProcessWhatsAppEvent( + eventId: $data['eventId'], + type: $data['type'], + groupJid: $data['groupJid'], + participantJid: $data['participantJid'], + occurredAt: $data['occurredAt'], + payload: $data['payload'], + ))->handle(); +} + +test('upserts group, participant and inserts event', function (): void { + runJob(); + + expect(WhatsAppGroup::query()->count())->toBe(1) + ->and(WhatsAppParticipant::query()->count())->toBe(1) + ->and(WhatsAppEvent::query()->count())->toBe(1); + + $event = WhatsAppEvent::query()->first(); + expect($event->type)->toBe('messages.upsert') + ->and($event->group_id)->not->toBeNull() + ->and($event->participant_id)->not->toBeNull() + ->and($event->payload['message']['conversation'])->toBe('oi'); + + expect(WhatsAppParticipant::query()->first()->push_name)->toBe('Maria') + ->and(WhatsAppGroup::query()->first()->display_name)->toBe('He4rt Geral'); +}); + +test('handles null group and participant', function (): void { + runJob(['groupJid' => null, 'participantJid' => null]); + + expect(WhatsAppGroup::query()->count())->toBe(0) + ->and(WhatsAppParticipant::query()->count())->toBe(0) + ->and(WhatsAppEvent::query()->count())->toBe(1); + + $event = WhatsAppEvent::query()->first(); + expect($event->group_id)->toBeNull() + ->and($event->participant_id)->toBeNull(); +}); + +test('is idempotent on event_id', function (): void { + $eventId = (string) Str::uuid(); + + runJob(['eventId' => $eventId]); + runJob(['eventId' => $eventId]); + + expect(WhatsAppEvent::query()->where('event_id', $eventId)->count())->toBe(1); +}); + +test('does not duplicate group on second event', function (): void { + runJob(['eventId' => (string) Str::uuid()]); + runJob(['eventId' => (string) Str::uuid()]); + + expect(WhatsAppGroup::query()->count())->toBe(1) + ->and(WhatsAppParticipant::query()->count())->toBe(1) + ->and(WhatsAppEvent::query()->count())->toBe(2); +}); From f6ff5a21d7961585e2a93f5aad2b60d75bf2b966 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 01:08:32 -0300 Subject: [PATCH 6/9] feat(integration-whatsapp): add webhook ingest endpoint --- .../routes/whatsapp-routes.php | 13 +++ .../Controllers/WhatsAppWebhookController.php | 37 ++++++ .../Http/Requests/IngestEventRequest.php | 29 +++++ .../Feature/Ingest/WebhookIngestTest.php | 107 ++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 app-modules/integration-whatsapp/routes/whatsapp-routes.php create mode 100644 app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php create mode 100644 app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php create mode 100644 app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php diff --git a/app-modules/integration-whatsapp/routes/whatsapp-routes.php b/app-modules/integration-whatsapp/routes/whatsapp-routes.php new file mode 100644 index 000000000..7c16025d6 --- /dev/null +++ b/app-modules/integration-whatsapp/routes/whatsapp-routes.php @@ -0,0 +1,13 @@ +middleware('api')->group(function (): void { + Route::post('events', [WhatsAppWebhookController::class, 'store']) + ->middleware(VerifyWhatsAppSignature::class) + ->name('whatsapp.events.store'); +}); diff --git a/app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php b/app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php new file mode 100644 index 000000000..4ea24ee40 --- /dev/null +++ b/app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php @@ -0,0 +1,37 @@ +header('X-Event-Id'); + $validated = $request->validated(); + + if (WhatsAppEvent::query()->where('event_id', $eventId)->exists()) { + return response()->json(['status' => 'duplicate'], Response::HTTP_ACCEPTED); + } + + ProcessWhatsAppEvent::dispatch( + eventId: $eventId, + type: $validated['type'], + groupJid: $validated['group_jid'] ?? null, + participantJid: $validated['participant_jid'] ?? null, + occurredAt: Carbon::parse($validated['occurred_at']), + payload: $validated['payload'], + ); + + return response()->json(['status' => 'accepted'], Response::HTTP_ACCEPTED); + } +} diff --git a/app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php b/app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php new file mode 100644 index 000000000..85e93d440 --- /dev/null +++ b/app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php @@ -0,0 +1,29 @@ +> + */ + public function rules(): array + { + return [ + 'type' => ['required', 'string', 'max:100'], + 'group_jid' => ['nullable', 'string', 'max:255'], + 'participant_jid' => ['nullable', 'string', 'max:255'], + 'occurred_at' => ['required', 'date'], + 'payload' => ['required', 'array'], + ]; + } +} diff --git a/app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php b/app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php new file mode 100644 index 000000000..105aefb52 --- /dev/null +++ b/app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php @@ -0,0 +1,107 @@ +set('whatsapp.webhook_secret', 'test-secret'); +}); + +/** + * @param array $payload + */ +function postEvent(array $payload, ?string $signature = null, ?string $eventId = null): Illuminate\Testing\TestResponse +{ + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $eventId ??= (string) Str::uuid(); + $signature ??= hash_hmac('sha256', $body, 'test-secret'); + + return test()->call( + method: 'POST', + uri: '/api/integrations/whatsapp/events', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_SIGNATURE' => $signature, + 'HTTP_X_EVENT_ID' => $eventId, + ], + content: $body, + ); +} + +function validBody(): array +{ + return [ + 'type' => 'messages.upsert', + 'group_jid' => '120363000000000000@g.us', + 'participant_jid' => '5511999999999@s.whatsapp.net', + 'occurred_at' => '2026-05-20T12:00:00+00:00', + 'payload' => ['subject' => 'He4rt Geral', 'pushName' => 'Maria'], + ]; +} + +test('accepts a valid signed event and dispatches the job', function (): void { + Bus::fake(); + + $response = postEvent(validBody()); + + $response->assertStatus(202)->assertJson(['status' => 'accepted']); + Bus::assertDispatched(ProcessWhatsAppEvent::class); +}); + +test('rejects an event with an invalid signature', function (): void { + Bus::fake(); + + $response = postEvent(validBody(), signature: 'deadbeef'); + + $response->assertStatus(401); + Bus::assertNothingDispatched(); +}); + +test('rejects an event missing the X-Event-Id header', function (): void { + Bus::fake(); + + $body = json_encode(validBody(), JSON_THROW_ON_ERROR); + + $response = test()->call( + method: 'POST', + uri: '/api/integrations/whatsapp/events', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_SIGNATURE' => hash_hmac('sha256', $body, 'test-secret'), + ], + content: $body, + ); + + $response->assertStatus(401); + Bus::assertNothingDispatched(); +}); + +test('returns 422 when body is invalid', function (): void { + Bus::fake(); + + $response = postEvent(['type' => 'messages.upsert']); // sem occurred_at e payload + + $response->assertStatus(422); + Bus::assertNothingDispatched(); +}); + +test('acks duplicate event_id without dispatching', function (): void { + Bus::fake(); + + $eventId = (string) Str::uuid(); + WhatsAppEvent::factory()->create(['event_id' => $eventId]); + + $response = postEvent(validBody(), eventId: $eventId); + + $response->assertStatus(202)->assertJson(['status' => 'duplicate']); + Bus::assertNothingDispatched(); +}); From c264605a7b545aa70b828c9c260fb1fa3f543a54 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 01:08:32 -0300 Subject: [PATCH 7/9] docs(integration-whatsapp): add context, spec, ADR and implementation plan --- app-modules/integration-whatsapp/CONTEXT.md | 69 + .../docs/adr/0001-data-lake-approach.md | 77 + .../docs/plans/0001-ingest-implementation.md | 1458 +++++++++++++++++ app-modules/integration-whatsapp/docs/spec.md | 225 +++ 4 files changed, 1829 insertions(+) create mode 100644 app-modules/integration-whatsapp/CONTEXT.md create mode 100644 app-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.md create mode 100644 app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md create mode 100644 app-modules/integration-whatsapp/docs/spec.md diff --git a/app-modules/integration-whatsapp/CONTEXT.md b/app-modules/integration-whatsapp/CONTEXT.md new file mode 100644 index 000000000..2c72f1255 --- /dev/null +++ b/app-modules/integration-whatsapp/CONTEXT.md @@ -0,0 +1,69 @@ +# Integration WhatsApp Context + +Ingest and integration layer for the WhatsApp platform. Receives normalized events from an +external collector service (`wpp-tui`, a separate Node/Baileys repo) via authenticated webhook, +and persists them as a raw event store (data lake) for later analysis. + +> **Status:** Core ingest implemented (migrations, models, HMAC webhook, raw event store + Job). +> The Collector sends every event and Laravel stores everything raw — there is **no collection policy / +> filtering** in this phase (the per-group `collection_policy` idea was dropped; revisit if/when a +> hardening phase needs it). Design in `docs/spec.md`; data-modeling decision in +> `docs/adr/0001-data-lake-approach.md`; implementation plan in `docs/plans/0001-ingest-implementation.md`. +> Also deferred: Filament admin, identity-link flow, `wpp-tui` webhook wiring, deploy/retention/onboarding. + +## Glossary + +| Term | Definition | Not to be confused with | +| ---------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| **Collector** | The external Node/Baileys service (`wpp-tui` repo) that holds the WhatsApp WebSocket session and emits events. | This module (which is the Laravel-side ingest, not the WhatsApp runtime). | +| **Webhook ingest** | The HTTP endpoint that receives events from the Collector, validates HMAC, and enqueues a Job. | The Collector's own optional `WEBHOOK_URL` proxy (raw forwarding). | +| **Event store** | The `whatsapp_events` table holding raw Baileys payloads (jsonb), one row per event. | Aggregated metrics (which do not exist yet — deferred until data team). | +| **Identity link** | The future flow that associates a `whatsapp_participants` row with an `identity.users` record. | OAuth (Discord uses OAuth; WhatsApp uses a DM verification code flow). | + +## Architecture + +``` +WhatsApp servers + ↓ WebSocket (Baileys) +[ wpp-tui — external Node repo ] ← runtime, holds session, sends every event (no filtering) + ↓ POST /api/integrations/whatsapp/events (HMAC + X-Event-Id) +[ integration-whatsapp — THIS module ] + ├─ Ingest/Http/Middleware/VerifyWhatsAppSignature ← validate HMAC + X-Event-Id + ├─ Ingest/Http/Controllers/WhatsAppWebhookController ← idempotency check, dispatch Job → 202 + ├─ Ingest/Jobs/ProcessWhatsAppEvent ← upsert group/participant, insert raw event + └─ Models/{WhatsAppGroup, WhatsAppParticipant, WhatsAppEvent} + ↓ (future) +[ identity ] ← resolve participant → user (DM verification flow) +[ activity ] ← (future) aggregate engagement once metrics are defined +``` + +## Module Boundaries + +### This module owns: + +- The webhook ingest endpoint and its HMAC/idempotency validation +- The raw event store (`whatsapp_groups`, `whatsapp_participants`, `whatsapp_events`) +- Upsert logic for groups and participants from incoming events +- (Future) the identity-link verification endpoint + +### This module does NOT own: + +- The WhatsApp WebSocket connection / Baileys runtime (lives in the `wpp-tui` repo) +- Metric aggregation / dashboards (deferred — will likely live in `activity` + `panel-admin`) +- Identity/user records (belongs to `identity`) + +## Dependencies + +- **Identity** — participant ↔ user linking (future) +- **No dependency on** `bot-discord`, `moderation`, `community`, etc. + +## Key decisions (see docs/adr/0001) + +- **Data lake first**: store raw Baileys payloads in `whatsapp_events.payload` (jsonb). Decide what to + measure later, once the data team has material to work with. +- **No phone hashing / no encryption (for now)**: `participants.external_jid` stores the real WhatsApp + JID (real phone number). Conscious decision for the exploration phase. +- **No TTL (for now)**: events are retained indefinitely until the exploration phase ends and a + retention policy is defined. +- **Collect all event types**: including `presence.update`. Volume is accepted as the cost of mapping. +- **Only `type` is materialized top-level** on `whatsapp_events`; everything else lives in `payload`. diff --git a/app-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.md b/app-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.md new file mode 100644 index 000000000..579c423a8 --- /dev/null +++ b/app-modules/integration-whatsapp/docs/adr/0001-data-lake-approach.md @@ -0,0 +1,77 @@ +# ADR-0001: Data-lake ingest for WhatsApp events (raw payload, no anonymization, no TTL) + +**Status:** Accepted +**Date:** 2026-05-20 +**Deciders:** Clinton (Clintonrocha98) + +> **Update (2026-05-21):** the per-group `collection_policy` mechanism mentioned in this ADR was +> **dropped** during implementation. The Collector sends every event and the Laravel side stores +> everything raw — no filtering, no `collection_policy` column, no `GET /policies` endpoint. The rest +> of this decision (data lake, no hashing, no TTL, collect all event types) stands. Revisit +> policy/filtering only if a hardening phase requires it. + +## Context + +The He4rt community runs three WhatsApp groups (Geral, He4rt Delas, Vagas) and wants to capture +member activity to eventually cross-reference with the unified He4rt profile. A separate Node/Baileys +service (`wpp-tui`) already holds the WhatsApp session and emits every event. + +The open question was **how the Laravel monolith should model and store these events**. Two tensions +shaped the decision: + +1. **We don't yet know which metrics matter.** The team has not defined what engagement signals to + compute. Designing a normalized, purpose-built schema now risks modeling the wrong things. +2. **Privacy posture vs. exploration freedom.** A privacy-first design (hash phone numbers, drop + message content, short TTL) was considered, but it constrains what the data team can later explore. + +An earlier draft of the design proposed four normalized tables, phone hashing with a shared pepper, +content discarded at the Node, and a 30–60 day TTL. During the brainstorming this was deliberately +reversed in favor of maximum exploration freedom for an initial mapping phase. + +## Decision + +Adopt a **data-lake (schema-on-read) ingest** with three tables, storing raw Baileys payloads: + +- `whatsapp_groups`, `whatsapp_participants`, `whatsapp_events` (see `docs/spec.md` for full schema). +- `whatsapp_events.payload` (jsonb) holds the **complete, raw** Baileys event. Only `type`, the FKs, + and timestamps are materialized as top-level columns for indexing. + +Three sub-decisions, all conscious and scoped to the **exploration phase**: + +1. **No phone hashing / no encryption.** `whatsapp_participants.external_jid` stores the real WhatsApp + JID (real phone number). The payload also contains real numbers and message content. +2. **No TTL.** Events are retained indefinitely until a retention policy is defined. +3. **Collect all event types**, including high-volume noise like `presence.update`. The data team + gets the broadest possible material to work with. + +## Consequences + +### Positive + +- **Maximum exploration freedom.** The data team can derive any metric retroactively from raw payloads + without re-instrumenting the collector. +- **Simple ingest.** The Laravel side is a thin webhook → Job → insert. No transformation logic to + maintain while requirements are unknown. +- **Simple identity linking.** With real numbers stored, linking is a direct `external_jid` lookup — + no shared pepper between Node and Laravel needed (for now). + +### Negative / risks (must be revisited) + +- **LGPD exposure is maximal.** Real phone numbers + raw message content + indefinite retention is + the most exposed posture possible. This is acceptable only for a **short, well-communicated** + exploration phase. +- **Volume grows unbounded.** No TTL + all events (incl. `presence.update`) means `whatsapp_events` + will grow quickly. Partitioning by `type` and/or by date may become necessary. +- **Deferred privacy debt.** Hashing, content scrubbing, and retention were designed but not + implemented. Re-introducing them later is more costly than building them in now. + +### Review trigger + +This decision **must be revisited when the exploration phase ends** — concretely, once the data team +has defined the metrics worth keeping. At that point, evaluate: + +- Aggregating useful signals into a persistent metrics table and **dropping or truncating raw payloads**. +- Introducing a **retention TTL** for `whatsapp_events`. +- Re-introducing **phone hashing / content minimization** (with a shared pepper if hashing, to keep + identity linking working). +- Whether `presence.update` is still worth collecting at volume. diff --git a/app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md b/app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md new file mode 100644 index 000000000..72dcb9349 --- /dev/null +++ b/app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md @@ -0,0 +1,1458 @@ +# Integration WhatsApp — Core Ingest Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the Laravel-side ingest of the `integration-whatsapp` module: an HMAC-authenticated webhook that persists raw Baileys events into a data-lake (3 tables). The Collector sends every event; Laravel stores everything raw (pure schema-on-read). + +**Architecture:** A new `internachi/modular` module under `app-modules/integration-whatsapp`. A signature-verifying middleware guards `POST /api/integrations/whatsapp/events`; the controller validates the body, short-circuits duplicates, and dispatches `ProcessWhatsAppEvent` to Horizon. The Job upserts group/participant and inserts the raw event. No filtering: every event received is stored. + +**Tech Stack:** Laravel 11, `internachi/modular` v3 (auto-discovers routes + migrations), Pest 4, PostgreSQL (jsonb), UUIDv7 PKs via `HasUuids`, HMAC-SHA256. + +--- + +## Decisões fechadas (resolvem ambiguidades do spec) + +| # | Decisão | Razão | +|---|---------|-------| +| D1 | **UUID PK** (`HasUuids` → `Str::uuid7()`) nas 3 tabelas | Preferência do usuário; `users` (core) já usa uuid; UUIDv7 é ordenado por tempo → sem fragmentação de insert | +| D2 | **HMAC-SHA256** + `X-Event-Id` no `POST /events` | Segue o spec; integridade + anti-replay | +| D3 | `tenant_id` nullable em `whatsapp_groups` e `whatsapp_events` | Consistência multi-tenant (como `discord_guilds`). Participant é pessoa global → sem `tenant_id` | +| D4 | `whatsapp_participants` = **Modelo A global** (1 linha por número, `external_jid` UNIQUE, sem `group_id`) | Identidade é por número; vínculo com grupo vive em `whatsapp_events`. Resolve a contradição interna do spec | +| D5 | **`collection_policy` removida nesta fase** — bot envia tudo, Laravel salva tudo cru | Elimina `GET /policies` + polling/cache/refresh do contrato com o bot. Alinha com o data-lake ("store raw, decide depois"). Sem coluna `collection_policy`/`enabled`. Revisitar no endurecimento (ADR review trigger) | +| D6 | Escopo: **ingest puro** | Filament, identity-link e a policy ficam para depois | + +> **Nota:** a `collection_policy` foi **descartada nesta fase** (não cancelada — revisitar no endurecimento). `CONTEXT.md`, `spec.md` e `docs/adr/0001` já foram atualizados em 2026-05-21 para refletir a remoção. + +--- + +## Diagramas + +### Arquitetura + +``` + WhatsApp servers + │ WebSocket (Baileys) + ▼ + ┌────────────────────────────┐ + │ wpp-tui (Node, repo sep.) │ ────► POST /api/integrations/whatsapp/events + │ runtime · sessão │ Headers: X-Signature (HMAC-SHA256), X-Event-Id (uuid) + └────────────────────────────┘ Body: { type, group_jid, participant_jid, occurred_at, payload } + │ (envia TODOS os eventos, sem filtro) + ▼ + ┌──────────────────────────────────────────────────────────────────┐ + │ integration-whatsapp (Laravel · ESTE módulo) │ + │ │ + │ VerifyWhatsAppSignature ──► WhatsAppWebhookController ──► 202 │ + │ (HMAC + X-Event-Id) (valida body, checa dup, dispatch) │ + │ │ │ + │ ▼ (Horizon/Redis) │ + │ ProcessWhatsAppEvent (Job) │ + │ ├─ firstOrCreate whatsapp_groups │ + │ ├─ firstOrCreate whatsapp_participants│ + │ └─ firstOrCreate whatsapp_events │ + └──────────────────────────────────────────────────────────────────┘ +``` + +### Fluxo de dados (ingest de um evento) + +``` + [Webhook] [Middleware] [Controller] [Job/Horizon] [DB] + │ │ │ │ │ + POST /events ──────► verifica HMAC ─────► valida body (FormRequest) ─► dispatch ──┐ │ + X-Signature X-Event-Id event_id já existe? │ │ + X-Event-Id ✓/401 ├─ sim ─► 202 {duplicate} │ │ + raw body └─ não ─► 202 {accepted} ───────────┘ │ + │ │ + resolveGroup() ──────────┼──► upsert whatsapp_groups + resolveParticipant() ────┼──► upsert whatsapp_participants + firstOrCreate(event) ────┴──► insert whatsapp_events (payload jsonb cru) +``` + +### Modelo de dados (Modelo A — participante global) + +``` + whatsapp_groups whatsapp_events whatsapp_participants + ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ + │ id uuid │◄──────────│ group_id uuid?│ │ id uuid │ + │ tenant_id fk? │ │ participant_id ──┼──────────►│ external_jid UNIQUE │ + │ external_jid UQ │ │ tenant_id fk? │ │ push_name │ + │ display_name │ │ event_id UQ │ │ identity_id fk?→users│ + │ internal_name │ │ type idx │ │ payload jsonb │ + │ payload jsonb│ │ occurred_at idx │ │ first/last_seen_at │ + │ first/last_seen │ │ received_at │ └──────────────────────┘ + └──────────────────┘ │ payload jsonb│ + └──────────────────┘ + Grupo da Maria ──► eventos da Maria ──► Maria (1 linha). "Em quais grupos?" = DISTINCT group_id nos eventos. +``` + +### Ciclo de vida de um `event_id` (idempotência) + +``` + [novo] ──HMAC ✓ + body ✓──► [aceito 202] ──dispatch──► [processando] + │ │ │ + HMAC ✗ event_id já no DB firstOrCreate(event_id) + ▼ ▼ ├─ inédito ──► [persistido] + [401] [202 duplicate] └─ corrida ──► [persistido] (1 linha só) +``` + +--- + +## File Structure + +``` +app-modules/integration-whatsapp/ +├─ composer.json (criar — pacote he4rt/integration-whatsapp) +├─ config/whatsapp.php (criar — só webhook_secret) +├─ routes/whatsapp-routes.php (criar — auto-descoberto pelo modular) +├─ src/ +│ ├─ IntegrationWhatsappServiceProvider.php (criar — mergeConfigFrom) +│ ├─ Models/WhatsAppGroup.php (criar) +│ ├─ Models/WhatsAppParticipant.php (criar) +│ ├─ Models/WhatsAppEvent.php (criar) +│ └─ Ingest/ +│ ├─ Http/Middleware/VerifyWhatsAppSignature.php (criar) +│ ├─ Http/Requests/IngestEventRequest.php (criar) +│ ├─ Http/Controllers/WhatsAppWebhookController.php (criar) +│ └─ Jobs/ProcessWhatsAppEvent.php (criar) +├─ database/ +│ ├─ migrations/2026_05_20_120000_create_whatsapp_groups_table.php (criar) +│ ├─ migrations/2026_05_20_120001_create_whatsapp_participants_table.php (criar) +│ ├─ migrations/2026_05_20_120002_create_whatsapp_events_table.php (criar) +│ └─ factories/{WhatsAppGroupFactory,WhatsAppParticipantFactory,WhatsAppEventFactory}.php (criar) +└─ tests/Feature/ + ├─ Models/WhatsAppModelsTest.php (criar) + ├─ Ingest/WebhookIngestTest.php (criar) + └─ Ingest/ProcessWhatsAppEventTest.php (criar) + +Raiz: +├─ composer.json (modificar — add require he4rt/integration-whatsapp) +└─ .env.example (modificar — add WHATSAPP_WEBHOOK_SECRET) +``` + +--- + +### Task 0: Scaffold do módulo + +**Contexto:** O diretório `app-modules/integration-whatsapp/` só tem docs. Para o `internachi/modular` reconhecer o módulo, são necessários `composer.json` (com namespace + provider) e a entrada no `require` do composer raiz. Sem isso, nada de `He4rt\IntegrationWhatsapp\` é autoloaded. + +**Comportamento esperado (BDD):** +- **Given** o módulo registrado, **When** rodo `php artisan modules:list`, **Then** `integration-whatsapp` aparece. +- **Given** o config publicado, **When** leio `config('whatsapp.webhook_secret')`, **Then** retorna o valor de `WHATSAPP_WEBHOOK_SECRET` (ou `''`). + +**Files:** +- Create: `app-modules/integration-whatsapp/composer.json` +- Create: `app-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.php` +- Create: `app-modules/integration-whatsapp/config/whatsapp.php` +- Modify: `composer.json` (raiz, bloco `require`) +- Modify: `.env.example` + +- [ ] **Step 1: Criar `composer.json` do módulo** + +```json +{ + "name": "he4rt/integration-whatsapp", + "description": "", + "type": "library", + "version": "1.0", + "license": "proprietary", + "require": {}, + "autoload": { + "psr-4": { + "He4rt\\IntegrationWhatsapp\\": "src/", + "He4rt\\IntegrationWhatsapp\\Database\\Factories\\": "database/factories/", + "He4rt\\IntegrationWhatsapp\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "He4rt\\IntegrationWhatsapp\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "He4rt\\IntegrationWhatsapp\\IntegrationWhatsappServiceProvider" + ] + } + } +} +``` + +- [ ] **Step 2: Criar o ServiceProvider** + +```php +mergeConfigFrom(__DIR__.'/../config/whatsapp.php', 'whatsapp'); + } +} +``` + +- [ ] **Step 3: Criar `config/whatsapp.php`** + +```php + env('WHATSAPP_WEBHOOK_SECRET', ''), +]; +``` + +- [ ] **Step 4: Adicionar o módulo ao `require` do composer raiz** + +Em `composer.json` (raiz), no bloco `require`, logo após `"he4rt/integration-twitch": ">=1",` adicione: + +```json + "he4rt/integration-whatsapp": ">=1", +``` + +- [ ] **Step 5: Adicionar a env de exemplo** + +Em `.env.example`, adicione ao final: + +``` +WHATSAPP_WEBHOOK_SECRET= +``` + +- [ ] **Step 6: Instalar o pacote e disparar o package discovery** + +Run: `composer update he4rt/integration-whatsapp` +Expected: instala o symlink do path repo e roda `package:discover` listando `he4rt/integration-whatsapp`. + +- [ ] **Step 7: Verificar registro do módulo** + +Run: `php artisan modules:list` +Expected: linha com `integration-whatsapp` (enabled). + +- [ ] **Step 8: Commit** + +```bash +git add app-modules/integration-whatsapp/composer.json \ + app-modules/integration-whatsapp/src/IntegrationWhatsappServiceProvider.php \ + app-modules/integration-whatsapp/config/whatsapp.php \ + composer.json composer.lock .env.example +git commit -m "feat(integration-whatsapp): scaffold module" +``` + +--- + +### Task 1: Migrações das 3 tabelas + +**Contexto:** A estratégia é data-lake (schema-on-read). Só `type`, FKs e timestamps são materializados; o resto vive em `payload` jsonb. PKs em UUID (D1), `tenant_id` em groups/events (D3), participante global (D4). Sem `collection_policy`/`enabled` (D5). O `internachi/modular` auto-descobre migrações; não é preciso `loadMigrationsFrom`. + +**Comportamento esperado (BDD):** +- **Given** migrações rodadas, **When** inspeciono o schema, **Then** existem `whatsapp_groups`, `whatsapp_participants`, `whatsapp_events` com `id uuid PK`. +- **Edge:** inserir dois eventos com o mesmo `event_id` viola a unique e falha (idempotência garantida no banco). +- **Compat:** FK `tenant_id` → `tenants` (bigint), `identity_id` → `users` (uuid), ambos `nullOnDelete`. + +**Files:** +- Create: `app-modules/integration-whatsapp/database/migrations/2026_05_20_120000_create_whatsapp_groups_table.php` +- Create: `app-modules/integration-whatsapp/database/migrations/2026_05_20_120001_create_whatsapp_participants_table.php` +- Create: `app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php` + +- [ ] **Step 1: Migração `whatsapp_groups`** + +```php +uuid('id')->primary(); + $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); + $table->string('external_jid')->unique(); + $table->string('display_name')->nullable(); + $table->string('internal_name')->nullable(); + $table->jsonb('payload')->nullable(); + $table->timestamp('first_seen_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_groups'); + } +}; +``` + +- [ ] **Step 2: Migração `whatsapp_participants`** + +```php +uuid('id')->primary(); + $table->string('external_jid')->unique(); + $table->string('push_name')->nullable(); + $table->jsonb('payload')->nullable(); + $table->foreignUuid('identity_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('first_seen_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_participants'); + } +}; +``` + +- [ ] **Step 3: Migração `whatsapp_events`** + +```php +uuid('id')->primary(); + $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); + $table->uuid('event_id')->unique(); + $table->string('type')->index(); + $table->foreignUuid('group_id')->nullable()->constrained('whatsapp_groups')->nullOnDelete(); + $table->foreignUuid('participant_id')->nullable()->constrained('whatsapp_participants')->nullOnDelete(); + $table->timestamp('occurred_at')->index(); + $table->timestamp('received_at')->nullable(); + $table->jsonb('payload'); + $table->timestamps(); + + $table->index(['type', 'occurred_at']); + $table->index(['group_id', 'occurred_at']); + $table->index(['participant_id', 'occurred_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_events'); + } +}; +``` + +- [ ] **Step 4: Rodar as migrações** + +Run: `php artisan migrate` +Expected: cria `whatsapp_groups`, `whatsapp_participants`, `whatsapp_events` sem erro. + +- [ ] **Step 5: Commit** + +```bash +git add app-modules/integration-whatsapp/database/migrations/ +git commit -m "feat(integration-whatsapp): add data-lake migrations" +``` + +--- + +### Task 2: Models e Factories + +**Contexto:** Models `final`, `strict_types`, PHPDoc de propriedades, `casts()` e `HasFactory` seguem o padrão de `DiscordGuild`. A diferença é `HasUuids` (D1) — que gera UUIDv7 e ajusta `keyType`/`incrementing`. Factories seguem `DiscordGuildFactory`. + +**Comportamento esperado (BDD):** +- **Given** `WhatsAppGroup::factory()->create()`, **Then** o `id` é um UUID válido e `payload` volta como array. +- **Given** um `WhatsAppEvent` com `payload` jsonb, **When** recarrego, **Then** `payload` é array. +- **Edge:** `group_id`/`participant_id` nulos são aceitos no model de evento. + +**Files:** +- Create: `app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php` +- Create: `app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php` +- Create: `app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php` +- Create: `app-modules/integration-whatsapp/database/factories/WhatsAppGroupFactory.php` +- Create: `app-modules/integration-whatsapp/database/factories/WhatsAppParticipantFactory.php` +- Create: `app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php` +- Test: `app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php` + +- [ ] **Step 1: Escrever teste de model (falha)** + +Criar `app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php`: + +```php +create(); + + expect(Str::isUuid($group->id))->toBeTrue() + ->and($group->payload)->toBeArray(); +}); + +test('event accepts null group and participant', function (): void { + $event = WhatsAppEvent::factory()->create([ + 'group_id' => null, + 'participant_id' => null, + ]); + + expect($event->group_id)->toBeNull() + ->and($event->participant_id)->toBeNull() + ->and($event->payload)->toBeArray(); +}); + +test('participant is global with unique external_jid', function (): void { + $participant = WhatsAppParticipant::factory()->create(['external_jid' => '5511999999999@s.whatsapp.net']); + + expect(Str::isUuid($participant->id))->toBeTrue() + ->and($participant->external_jid)->toBe('5511999999999@s.whatsapp.net'); +}); +``` + +- [ ] **Step 2: Rodar o teste (deve falhar)** + +Run: `php artisan test --filter=WhatsAppModelsTest` +Expected: FAIL — classes `WhatsAppGroup`/`WhatsAppEvent`/`WhatsAppParticipant` não existem. + +- [ ] **Step 3: Criar `WhatsAppGroup`** + +```php +|null $payload + * @property Carbon|null $first_seen_at + * @property Carbon|null $last_seen_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read Tenant|null $tenant + * @property-read Collection $events + */ +final class WhatsAppGroup extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $table = 'whatsapp_groups'; + + protected $fillable = [ + 'tenant_id', + 'external_jid', + 'display_name', + 'internal_name', + 'payload', + 'first_seen_at', + 'last_seen_at', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return HasMany + */ + public function events(): HasMany + { + return $this->hasMany(WhatsAppEvent::class, 'group_id'); + } + + protected static function newFactory(): WhatsAppGroupFactory + { + return WhatsAppGroupFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload' => 'array', + 'first_seen_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + } +} +``` + +- [ ] **Step 4: Criar `WhatsAppParticipant`** + +```php +|null $payload + * @property string|null $identity_id + * @property Carbon|null $first_seen_at + * @property Carbon|null $last_seen_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read User|null $identity + * @property-read Collection $events + */ +final class WhatsAppParticipant extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $table = 'whatsapp_participants'; + + protected $fillable = [ + 'external_jid', + 'push_name', + 'payload', + 'identity_id', + 'first_seen_at', + 'last_seen_at', + ]; + + /** + * @return BelongsTo + */ + public function identity(): BelongsTo + { + return $this->belongsTo(User::class, 'identity_id'); + } + + /** + * @return HasMany + */ + public function events(): HasMany + { + return $this->hasMany(WhatsAppEvent::class, 'participant_id'); + } + + protected static function newFactory(): WhatsAppParticipantFactory + { + return WhatsAppParticipantFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload' => 'array', + 'first_seen_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + } +} +``` + +- [ ] **Step 5: Criar `WhatsAppEvent`** + +```php + $payload + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read Tenant|null $tenant + * @property-read WhatsAppGroup|null $group + * @property-read WhatsAppParticipant|null $participant + */ +final class WhatsAppEvent extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $table = 'whatsapp_events'; + + protected $fillable = [ + 'tenant_id', + 'event_id', + 'type', + 'group_id', + 'participant_id', + 'occurred_at', + 'received_at', + 'payload', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function group(): BelongsTo + { + return $this->belongsTo(WhatsAppGroup::class, 'group_id'); + } + + /** + * @return BelongsTo + */ + public function participant(): BelongsTo + { + return $this->belongsTo(WhatsAppParticipant::class, 'participant_id'); + } + + protected static function newFactory(): WhatsAppEventFactory + { + return WhatsAppEventFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload' => 'array', + 'occurred_at' => 'datetime', + 'received_at' => 'datetime', + ]; + } +} +``` + +- [ ] **Step 6: Criar `WhatsAppGroupFactory`** + +```php + + */ +final class WhatsAppGroupFactory extends Factory +{ + protected $model = WhatsAppGroup::class; + + public function definition(): array + { + return [ + 'external_jid' => fake()->unique()->numerify('1203#########').'@g.us', + 'display_name' => fake()->words(2, true), + 'internal_name' => fake()->randomElement(['geral', 'delas', 'vagas']), + 'payload' => [], + 'first_seen_at' => now(), + 'last_seen_at' => now(), + ]; + } +} +``` + +- [ ] **Step 7: Criar `WhatsAppParticipantFactory`** + +```php + + */ +final class WhatsAppParticipantFactory extends Factory +{ + protected $model = WhatsAppParticipant::class; + + public function definition(): array + { + return [ + 'external_jid' => fake()->unique()->numerify('5511#########').'@s.whatsapp.net', + 'push_name' => fake()->name(), + 'payload' => [], + 'identity_id' => null, + 'first_seen_at' => now(), + 'last_seen_at' => now(), + ]; + } +} +``` + +- [ ] **Step 8: Criar `WhatsAppEventFactory`** + +```php + + */ +final class WhatsAppEventFactory extends Factory +{ + protected $model = WhatsAppEvent::class; + + public function definition(): array + { + return [ + 'event_id' => fake()->uuid(), + 'type' => 'messages.upsert', + 'group_id' => WhatsAppGroup::factory(), + 'participant_id' => WhatsAppParticipant::factory(), + 'occurred_at' => now(), + 'received_at' => now(), + 'payload' => [ + 'key' => ['id' => fake()->uuid()], + 'message' => ['conversation' => fake()->sentence()], + ], + ]; + } +} +``` + +- [ ] **Step 9: Rodar os testes de model (devem passar)** + +Run: `php artisan test --filter=WhatsAppModelsTest` +Expected: PASS (3 testes). + +- [ ] **Step 10: Commit** + +```bash +git add app-modules/integration-whatsapp/src/Models/ \ + app-modules/integration-whatsapp/database/factories/ \ + app-modules/integration-whatsapp/tests/Feature/Models/ +git commit -m "feat(integration-whatsapp): add models and factories" +``` + +--- + +### Task 3: Middleware de verificação HMAC + +**Contexto:** O `POST /events` precisa garantir integridade e autenticidade (D2). Não há precedente de HMAC no repo, então criamos `VerifyWhatsAppSignature`, que compara `X-Signature` com `hash_hmac('sha256', rawBody, secret)` via `hash_equals` (timing-safe) e exige `X-Event-Id`. O segredo vem de `config('whatsapp.webhook_secret')`. + +**Comportamento esperado (BDD):** +- **Happy:** **Given** body assinado corretamente + `X-Event-Id`, **Then** segue (`next`). +- **Edge:** sem `X-Signature` ou sem `X-Event-Id` → 401. +- **Edge:** assinatura incorreta → 401. +- **Edge:** segredo não configurado (`''`) → 500 (falha explícita, não passa direto). + +**Files:** +- Create: `app-modules/integration-whatsapp/src/Ingest/Http/Middleware/VerifyWhatsAppSignature.php` +- Test: coberto pelo `WebhookIngestTest` (Task 5). Middleware é exercitado via rota. + +- [ ] **Step 1: Criar o middleware** + +```php +string('whatsapp.webhook_secret'); + + if ($secret === '') { + return response()->json( + ['error' => 'Webhook secret not configured'], + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + $signature = $request->header('X-Signature'); + $eventId = $request->header('X-Event-Id'); + + if (!is_string($signature) || !is_string($eventId) || $eventId === '') { + return response()->json( + ['error' => 'Missing signature or event id'], + Response::HTTP_UNAUTHORIZED, + ); + } + + $expected = hash_hmac('sha256', $request->getContent(), $secret); + + if (!hash_equals($expected, $signature)) { + return response()->json( + ['error' => 'Invalid signature'], + Response::HTTP_UNAUTHORIZED, + ); + } + + return $next($request); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app-modules/integration-whatsapp/src/Ingest/Http/Middleware/ +git commit -m "feat(integration-whatsapp): add HMAC signature middleware" +``` + +--- + +### Task 4: Job `ProcessWhatsAppEvent` + +**Contexto:** O processamento pesado roda fora do request (Horizon/Redis), seguindo o padrão de `He4rt\Moderation\Classification\Jobs\IngestContent` (`implements ShouldQueue`, `InteractsWithQueue`, `Queueable`, construtor com promoção). O Job faz upsert do grupo e do participante (Modelo A — global), e insere o evento com `firstOrCreate` em `event_id` (idempotência mesmo sob corrida). Sem qualquer filtro: todo evento recebido é persistido. `Model::unguard()` é global no projeto. + +**Antes/depois:** não há código anterior — é criação. O ponto de atenção é a idempotência: a inserção do evento usa `firstOrCreate(['event_id' => ...], [...])`, não `create(...)`, para que reprocessamento (retry de fila) não duplique. + +**Comportamento esperado (BDD):** +- **Happy:** **Given** evento com `group_jid` e `participant_jid`, **When** o Job roda, **Then** existe 1 grupo, 1 participante e 1 evento ligando os dois; `last_seen_at` atualizado. +- **Edge:** `group_jid`/`participant_jid` nulos → evento criado com `group_id`/`participant_id` nulos; nenhum grupo/participante criado. +- **Edge (idempotência):** rodar o Job 2x com o mesmo `event_id` → 1 evento só. +- **Edge:** segundo evento do mesmo grupo → não duplica o grupo (firstOrCreate por `external_jid`), só atualiza `last_seen_at`. + +**Files:** +- Create: `app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php` +- Test: `app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php` + +- [ ] **Step 1: Escrever os testes do Job (falham)** + +Criar `app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php`: + +```php + (string) Str::uuid(), + 'type' => 'messages.upsert', + 'groupJid' => '120363000000000000@g.us', + 'participantJid' => '5511999999999@s.whatsapp.net', + 'occurredAt' => Carbon::parse('2026-05-20 12:00:00'), + 'payload' => ['subject' => 'He4rt Geral', 'pushName' => 'Maria', 'message' => ['conversation' => 'oi']], + ]; + + $data = array_merge($defaults, $overrides); + + (new ProcessWhatsAppEvent( + eventId: $data['eventId'], + type: $data['type'], + groupJid: $data['groupJid'], + participantJid: $data['participantJid'], + occurredAt: $data['occurredAt'], + payload: $data['payload'], + ))->handle(); +} + +test('upserts group, participant and inserts event', function (): void { + runJob(); + + expect(WhatsAppGroup::query()->count())->toBe(1) + ->and(WhatsAppParticipant::query()->count())->toBe(1) + ->and(WhatsAppEvent::query()->count())->toBe(1); + + $event = WhatsAppEvent::query()->first(); + expect($event->type)->toBe('messages.upsert') + ->and($event->group_id)->not->toBeNull() + ->and($event->participant_id)->not->toBeNull() + ->and($event->payload['message']['conversation'])->toBe('oi'); + + expect(WhatsAppParticipant::query()->first()->push_name)->toBe('Maria') + ->and(WhatsAppGroup::query()->first()->display_name)->toBe('He4rt Geral'); +}); + +test('handles null group and participant', function (): void { + runJob(['groupJid' => null, 'participantJid' => null]); + + expect(WhatsAppGroup::query()->count())->toBe(0) + ->and(WhatsAppParticipant::query()->count())->toBe(0) + ->and(WhatsAppEvent::query()->count())->toBe(1); + + $event = WhatsAppEvent::query()->first(); + expect($event->group_id)->toBeNull() + ->and($event->participant_id)->toBeNull(); +}); + +test('is idempotent on event_id', function (): void { + $eventId = (string) Str::uuid(); + + runJob(['eventId' => $eventId]); + runJob(['eventId' => $eventId]); + + expect(WhatsAppEvent::query()->where('event_id', $eventId)->count())->toBe(1); +}); + +test('does not duplicate group on second event', function (): void { + runJob(['eventId' => (string) Str::uuid()]); + runJob(['eventId' => (string) Str::uuid()]); + + expect(WhatsAppGroup::query()->count())->toBe(1) + ->and(WhatsAppParticipant::query()->count())->toBe(1) + ->and(WhatsAppEvent::query()->count())->toBe(2); +}); +``` + +- [ ] **Step 2: Rodar (deve falhar)** + +Run: `php artisan test --filter=ProcessWhatsAppEventTest` +Expected: FAIL — classe `ProcessWhatsAppEvent` não existe. + +- [ ] **Step 3: Criar o Job** + +```php + $payload + */ + public function __construct( + private readonly string $eventId, + private readonly string $type, + private readonly ?string $groupJid, + private readonly ?string $participantJid, + private readonly CarbonInterface $occurredAt, + private readonly array $payload, + ) {} + + public function handle(): void + { + $group = $this->resolveGroup(); + $participant = $this->resolveParticipant(); + + WhatsAppEvent::query()->firstOrCreate( + ['event_id' => $this->eventId], + [ + 'type' => $this->type, + 'group_id' => $group?->id, + 'participant_id' => $participant?->id, + 'occurred_at' => $this->occurredAt, + 'received_at' => now(), + 'payload' => $this->payload, + ], + ); + } + + private function resolveGroup(): ?WhatsAppGroup + { + if ($this->groupJid === null || $this->groupJid === '') { + return null; + } + + $group = WhatsAppGroup::query()->firstOrCreate( + ['external_jid' => $this->groupJid], + [ + 'display_name' => $this->payload['subject'] ?? null, + 'first_seen_at' => now(), + ], + ); + + $group->forceFill([ + 'display_name' => $this->payload['subject'] ?? $group->display_name, + 'last_seen_at' => now(), + ])->save(); + + return $group; + } + + private function resolveParticipant(): ?WhatsAppParticipant + { + if ($this->participantJid === null || $this->participantJid === '') { + return null; + } + + $pushName = $this->payload['pushName'] ?? null; + + $participant = WhatsAppParticipant::query()->firstOrCreate( + ['external_jid' => $this->participantJid], + [ + 'push_name' => $pushName, + 'first_seen_at' => now(), + ], + ); + + $participant->forceFill([ + 'push_name' => $pushName ?? $participant->push_name, + 'last_seen_at' => now(), + ])->save(); + + return $participant; + } +} +``` + +- [ ] **Step 4: Rodar (deve passar)** + +Run: `php artisan test --filter=ProcessWhatsAppEventTest` +Expected: PASS (4 testes). + +- [ ] **Step 5: Commit** + +```bash +git add app-modules/integration-whatsapp/src/Ingest/Jobs/ \ + app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php +git commit -m "feat(integration-whatsapp): add ProcessWhatsAppEvent job" +``` + +--- + +### Task 5: FormRequest + Webhook Controller + rota + +**Contexto:** O controller é fino (resposta < 50ms): valida o corpo via `IngestEventRequest`, checa duplicata por `event_id` (ack idempotente) e despacha o Job, retornando 202. A rota `POST /api/integrations/whatsapp/events` recebe o middleware `VerifyWhatsAppSignature`. O arquivo de rota é auto-descoberto pelo `RoutesPlugin` do modular, mas precisa declarar `->middleware('api')` explicitamente (o modular não envelopa em grupo, igual aos `*-routes.php` do `identity`). + +**Comportamento esperado (BDD):** +- **Happy:** **Given** body válido assinado, **Then** 202 `{status: accepted}` e o Job é despachado. +- **Edge:** assinatura inválida → 401, Job não despachado. +- **Edge:** body sem `type`/`payload` → 422. +- **Edge (idempotência na borda):** `event_id` já persistido → 202 `{status: duplicate}` sem despachar o Job. + +**Files:** +- Create: `app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php` +- Create: `app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php` +- Create: `app-modules/integration-whatsapp/routes/whatsapp-routes.php` +- Test: `app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php` + +- [ ] **Step 1: Escrever os testes do webhook (falham)** + +Criar `app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php`: + +```php +set('whatsapp.webhook_secret', 'test-secret'); +}); + +/** + * @param array $payload + */ +function postEvent(array $payload, ?string $signature = null, ?string $eventId = null): \Illuminate\Testing\TestResponse +{ + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $eventId ??= (string) Str::uuid(); + $signature ??= hash_hmac('sha256', $body, 'test-secret'); + + return test()->call( + method: 'POST', + uri: '/api/integrations/whatsapp/events', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_SIGNATURE' => $signature, + 'HTTP_X_EVENT_ID' => $eventId, + ], + content: $body, + ); +} + +function validBody(): array +{ + return [ + 'type' => 'messages.upsert', + 'group_jid' => '120363000000000000@g.us', + 'participant_jid' => '5511999999999@s.whatsapp.net', + 'occurred_at' => '2026-05-20T12:00:00+00:00', + 'payload' => ['subject' => 'He4rt Geral', 'pushName' => 'Maria'], + ]; +} + +test('accepts a valid signed event and dispatches the job', function (): void { + Bus::fake(); + + $response = postEvent(validBody()); + + $response->assertStatus(202)->assertJson(['status' => 'accepted']); + Bus::assertDispatched(ProcessWhatsAppEvent::class); +}); + +test('rejects an event with an invalid signature', function (): void { + Bus::fake(); + + $response = postEvent(validBody(), signature: 'deadbeef'); + + $response->assertStatus(401); + Bus::assertNothingDispatched(); +}); + +test('rejects an event missing the X-Event-Id header', function (): void { + Bus::fake(); + + $body = json_encode(validBody(), JSON_THROW_ON_ERROR); + + $response = test()->call( + method: 'POST', + uri: '/api/integrations/whatsapp/events', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_SIGNATURE' => hash_hmac('sha256', $body, 'test-secret'), + ], + content: $body, + ); + + $response->assertStatus(401); + Bus::assertNothingDispatched(); +}); + +test('returns 422 when body is invalid', function (): void { + Bus::fake(); + + $response = postEvent(['type' => 'messages.upsert']); // sem occurred_at e payload + + $response->assertStatus(422); + Bus::assertNothingDispatched(); +}); + +test('acks duplicate event_id without dispatching', function (): void { + Bus::fake(); + + $eventId = (string) Str::uuid(); + WhatsAppEvent::factory()->create(['event_id' => $eventId]); + + $response = postEvent(validBody(), eventId: $eventId); + + $response->assertStatus(202)->assertJson(['status' => 'duplicate']); + Bus::assertNothingDispatched(); +}); +``` + +- [ ] **Step 2: Rodar (deve falhar)** + +Run: `php artisan test --filter=WebhookIngestTest` +Expected: FAIL — rota/controller inexistentes (404/erro de classe). + +- [ ] **Step 3: Criar `IngestEventRequest`** + +```php +> + */ + public function rules(): array + { + return [ + 'type' => ['required', 'string', 'max:100'], + 'group_jid' => ['nullable', 'string', 'max:255'], + 'participant_jid' => ['nullable', 'string', 'max:255'], + 'occurred_at' => ['required', 'date'], + 'payload' => ['required', 'array'], + ]; + } +} +``` + +- [ ] **Step 4: Criar `WhatsAppWebhookController`** + +```php +header('X-Event-Id'); + $validated = $request->validated(); + + if (WhatsAppEvent::query()->where('event_id', $eventId)->exists()) { + return response()->json(['status' => 'duplicate'], Response::HTTP_ACCEPTED); + } + + ProcessWhatsAppEvent::dispatch( + eventId: $eventId, + type: $validated['type'], + groupJid: $validated['group_jid'] ?? null, + participantJid: $validated['participant_jid'] ?? null, + occurredAt: Carbon::parse($validated['occurred_at']), + payload: $validated['payload'], + ); + + return response()->json(['status' => 'accepted'], Response::HTTP_ACCEPTED); + } +} +``` + +- [ ] **Step 5: Criar a rota do webhook** + +Criar `app-modules/integration-whatsapp/routes/whatsapp-routes.php`: + +```php +middleware('api')->group(function (): void { + Route::post('events', [WhatsAppWebhookController::class, 'store']) + ->middleware(VerifyWhatsAppSignature::class) + ->name('whatsapp.events.store'); +}); +``` + +- [ ] **Step 6: Rodar (deve passar)** + +Run: `php artisan test --filter=WebhookIngestTest` +Expected: PASS (5 testes). + +- [ ] **Step 7: Commit** + +```bash +git add app-modules/integration-whatsapp/src/Ingest/Http/Requests/ \ + app-modules/integration-whatsapp/src/Ingest/Http/Controllers/ \ + app-modules/integration-whatsapp/routes/whatsapp-routes.php \ + app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php +git commit -m "feat(integration-whatsapp): add webhook ingest endpoint" +``` + +--- + +### Task 6: Verificação final + atualizar status do CONTEXT + +**Contexto:** Fechar o ciclo: rodar a suíte do módulo inteira, conferir a rota registrada e atualizar o status no `CONTEXT.md` (de "Planned" para core implementado), registrando que a `collection_policy` foi adiada nesta fase. + +**Comportamento esperado (BDD):** +- **Given** o módulo completo, **When** rodo a suíte do módulo, **Then** todos os testes passam. +- **Given** `php artisan route:list`, **Then** aparece `whatsapp.events.store`. + +**Files:** +- Modify: `app-modules/integration-whatsapp/CONTEXT.md` (bloco de Status) + +- [ ] **Step 1: Rodar a suíte do módulo** + +Run: `php artisan test --filter=WhatsApp` +Expected: PASS em todos (models, job, webhook). + +- [ ] **Step 2: Conferir a rota** + +Run: `php artisan route:list | grep whatsapp` +Expected: `POST api/integrations/whatsapp/events`. + +- [ ] **Step 3: Atualizar o Status no `CONTEXT.md`** + +Substituir o bloco de status atual: + +```markdown +> **Status:** Planned — module does not have an implementation yet. This document and `docs/spec.md` +> capture the design agreed during the 2026-05-20 brainstorming. See `docs/adr/0001-data-lake-approach.md` +> for the central data-modeling decision. +``` + +por: + +```markdown +> **Status:** Core ingest implemented (migrations, models, HMAC webhook, raw event store + Job). +> The Collector sends every event and Laravel stores everything raw. The `collection_policy` / +> `GET /policies` mechanism described below was **deferred** for the exploration phase (revisit at the +> ADR review trigger). Design in `docs/spec.md`; data-modeling decision in +> `docs/adr/0001-data-lake-approach.md`; implementation plan in `docs/plans/0001-ingest-implementation.md`. +> Also deferred: Filament admin, identity-link flow, `wpp-tui` webhook wiring, deploy/retention/onboarding. +``` + +- [ ] **Step 4: Rodar o linter/static analysis (se existir no projeto)** + +Run: `composer run-script lint 2>/dev/null || ./vendor/bin/pint app-modules/integration-whatsapp 2>/dev/null || true` +Expected: sem violações de estilo (ou comando ausente — seguir o que o projeto usa em CI). + +- [ ] **Step 5: Commit** + +```bash +git add app-modules/integration-whatsapp/CONTEXT.md +git commit -m "docs(integration-whatsapp): mark core ingest as implemented" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Webhook HMAC + idempotência → Task 3 (middleware) + Task 5 (controller, dup-check) + Task 4 (firstOrCreate). ✓ +- Fila interna Horizon → Task 4 (`ShouldQueue`). ✓ +- 3 tabelas data-lake, `payload` cru, só `type` materializado → Task 1. ✓ +- UUID PK → D1, Task 1/2. ✓ +- Telefone cru / sem hash / sem TTL → respeitado (sem hashing, sem job de limpeza). ✓ +- Todos os tipos de evento (inclui `presence.update`) → `type` é string livre; Laravel salva tudo, sem filtro. ✓ +- Módulo segue padrão `integration-*` → Task 0 (composer/provider) + auto-discovery modular. ✓ +- Multiplicidade de grupos → resolvido por Modelo A (D4). ✓ + +**Removido/adiado nesta fase (D5/D6):** `collection_policy` + `GET /policies` (enforcement no Node), `enabled`, Filament para editar policy, identity-link, deploy/observabilidade/retention/onboarding do `wpp-tui`. São os "pontos adiados" do spec (seções 6 e 8) mais a policy. + +**Type consistency:** `ProcessWhatsAppEvent` é despachado em Task 5 com os mesmos parâmetros nomeados definidos em Task 4 (`eventId`, `type`, `groupJid`, `participantJid`, `occurredAt`, `payload`). Models `WhatsAppGroup/Participant/Event` usados consistentemente. A rota `whatsapp-routes.php` tem só o endpoint de eventos. diff --git a/app-modules/integration-whatsapp/docs/spec.md b/app-modules/integration-whatsapp/docs/spec.md new file mode 100644 index 000000000..70340418b --- /dev/null +++ b/app-modules/integration-whatsapp/docs/spec.md @@ -0,0 +1,225 @@ +# Spec — Coletor de Métricas do WhatsApp + +**Data:** 2026-05-20 (core ingest implementado em 2026-05-21) +**Status:** Core ingest implementado +**Origem:** Brainstorming entre Clinton e Claude (Opus 4.7) + +> Este documento captura o desenho acordado para a integração WhatsApp da plataforma He4rt. +> A camada de runtime (sessão WhatsApp) vive no repositório separado `wpp-tui` (Node + Baileys). +> Este módulo (`integration-whatsapp`) é a camada de ingest no monolito Laravel. +> +> **Atualização (2026-05-21):** a `collection_policy` (filtro por grupo) foi **descartada** nesta fase. +> O bot envia todos os eventos e o Laravel salva tudo cru — sem filtro, sem endpoint `GET /policies`. +> As referências a policy abaixo foram removidas. Revisitar só se uma fase de endurecimento exigir. + +--- + +## 1. Contexto e escopo + +A He4rt mapeia engajamento de membros fora do Discord. A comunidade tem **três grupos no +WhatsApp**: Geral, He4rt Delas, Vagas. O objetivo é capturar atividade desses grupos e, +futuramente, cruzar com o perfil unificado do membro. + +O projeto começou como ideia de coletor headless e, na prática, virou o **`wpp-tui`** — um +cliente WhatsApp TUI (Ink + React) que, além da interface de terminal, registra todos os +eventos e tem pipeline de extração. Este spec trata da **integração com o monolito**, não do +TUI em si. + +### Escopo atual (fase de mapeamento) + +| | | +| --- | --- | +| ✓ | 1 chip dedicado em N grupos (3 hoje) | +| ✓ | Ingest de eventos crus para um data lake | +| ✓ | Todos os tipos de evento (inclusive `presence.update`) | +| ✓ | Vinculação opcional com o perfil He4rt (flow futuro) | +| ✗ | Sem agregação de métricas ainda (dados decide depois) | +| ✗ | Sem hash/criptografia de telefone | +| ✗ | Sem TTL/expiração de dados | + +--- + +## 2. Decisão de tecnologia — Baileys (não Evolution API) + +Não existe API oficial do WhatsApp para grupos comuns. As opções práticas são clientes +não-oficiais (Baileys) ou APIs empacotadas em cima deles (Evolution API). + +**Escolhido: Baileys direto** (já materializado no `wpp-tui`). + +Razões: + +1. Caso de uso pequeno e específico (1 chip, 3 grupos) — não justifica a infra completa da Evolution. +2. Controle total sobre eventos, parsing e payload. +3. Evolution traria Postgres/Redis próprios + CRUD genérico não usado. +4. O time já mantém runtime persistente (`bot-discord` com Laracord) — um equivalente em Node não muda dramaticamente a topologia. +5. Repo enxuto e dedicado é mais fácil de auditar/atualizar do que um container caixa-preta. + +> Reversibilidade: se o escopo crescer muito (10+ grupos, multi-tenant), Evolution API volta a ser defensável. + +--- + +## 3. Arquitetura + +``` +WhatsApp servers + ↓ WebSocket (Baileys) +[ wpp-tui ] ← repo Node separado · runtime · sessão · envia todos os eventos (sem filtro) + ↓ POST /api/integrations/whatsapp/events + │ Headers: X-Signature (HMAC-SHA256), X-Event-Id (UUID idempotência) + │ Body: { type, group_jid, participant_jid, occurred_at, payload } + ↓ +[ integration-whatsapp ] (Laravel) + ├─ WhatsAppWebhookController → valida HMAC, checa event_id, dispatch Job → 202 + ├─ ProcessWhatsAppEvent (Job/Horizon) + │ ├─ upsert whatsapp_groups + │ ├─ upsert whatsapp_participants + │ └─ insert whatsapp_events + └─ (futuro) emite eventos de domínio → activity agrega +``` + +### Por que serviço Node separado + +WhatsApp exige uma conexão WebSocket persistente e autenticada (Baileys). Laravel é +request-response. É a mesma situação que o Laracord resolve para o Discord — runtime persistente, +processo separado. O `wpp-tui` é "o Laracord do WhatsApp". + +### Comunicação Node → Laravel + +Webhook HTTP autenticado por HMAC + `X-Event-Id` para idempotência. O Controller só valida e +enfileira (resposta < 50ms); o processamento pesado vai para um Job no Horizon (Redis). + +**Webhook na borda + fila interna via Horizon.** A real fragilidade está entre WhatsApp e Node +(eventos perdidos durante quedas do coletor), não entre Node e Laravel. Migrar para uma fila +externa (Redis Streams) é trivial se necessário no futuro, mas não é prioridade. + +### Módulo no monolito + +Segue o padrão dos `integration-*` existentes. Depende de `identity` (vinculação futura), +emite eventos consumidos por `activity` (futuro). Não importa de outros módulos de domínio. + +--- + +## 4. Dados — abordagem data lake + +A intenção atual é **mapear** o que o WhatsApp entrega, não medir métricas específicas (ainda não +definidas). Por isso a estratégia é **schema-on-read**: capturar tudo cru, decidir depois. + +### Modelagem — 3 tabelas + +``` +whatsapp_groups +├─ id uuid PK +├─ tenant_id bigint? FK → tenants +├─ external_jid string UNIQUE "120363xxx@g.us" +├─ display_name string? +├─ internal_name string? "geral" | "delas" | "vagas" +├─ payload jsonb metadata cru atual do grupo (Baileys) +├─ first_seen_at timestamp +└─ last_seen_at timestamp + +whatsapp_participants +├─ id uuid PK +├─ external_jid string UNIQUE "5511999999999@s.whatsapp.net" (número real, sem hash) +├─ push_name string nome visível atual +├─ payload jsonb metadata adicional do contact, se houver +├─ identity_id uuid? FK → identity.users NULL até vincular +├─ first_seen_at timestamp +└─ last_seen_at timestamp + +whatsapp_events +├─ id uuid PK +├─ event_id uuid UNIQUE idempotência (vem do Node) +├─ type string INDEX "messages.upsert", "messages.reaction", "presence.update", ... +├─ group_id uuid? FK NULL se evento não tem grupo (DM, presença global) +├─ participant_id uuid? FK NULL se evento não tem emissor identificável +├─ occurred_at timestamp INDEX timestamp real do WhatsApp +├─ received_at timestamp quando chegou no Laravel +└─ payload jsonb evento Baileys cru completo + INDEX (type, occurred_at) + INDEX (group_id, occurred_at) + INDEX (participant_id, occurred_at) +``` + +**Princípio:** apenas `type` é materializado como coluna top-level (além das FKs e timestamps, +úteis para indexação). Todo o resto fica no `payload` jsonb, para a equipe de dados explorar. + +### Multiplicidade dos grupos + +Uma pessoa pode estar em N grupos. `whatsapp_participants` é **global por número** (`external_jid` +UNIQUE) — uma única linha por pessoa, independente de quantos grupos. O vínculo com cada grupo vive +em `whatsapp_events` (via `group_id`); "em quais grupos a pessoa está" = `DISTINCT group_id` nos +eventos dela. A vinculação de identidade (quando feita) seta `identity_id` nessa única linha. Se um +dia for preciso modelar associação explícita membro↔grupo (papel, `joined_at`), o lugar é uma tabela +pivô `whatsapp_group_memberships`, não inchar `participants`. + +--- + +## 5. Privacidade — postura atual + +> **Atenção:** a postura atual é a mais permissiva possível. Foi uma decisão consciente para a +> fase de mapeamento, registrada no ADR-0001, e deve ser revisitada ao fim dessa fase. + +- **Telefone:** armazenado cru no `external_jid` (número real). Sem hash, sem criptografia. +- **Conteúdo:** o payload cru inclui texto das mensagens. +- **Retenção:** sem TTL — dados persistem indefinidamente até nova decisão. +- **Eventos:** todos coletados, inclusive `presence.update`. + +### Implicação LGPD + +Número real + conteúdo cru + sem TTL = tratamento de dado pessoal sem anonimização e sem limite +temporal. É aceitável para uma fase curta de exploração com aviso aos membros, mas é um passivo +que cresce com o tempo. **Revisitar quando a exploração terminar** (ver ADR-0001, seção +Consequences). + +### Vinculação de identidade (flow futuro) + +Quando implementada, a ideia é: + +1. Usuário informa o número no perfil web (`/perfil/integracoes`). +2. Laravel normaliza e busca `whatsapp_participants WHERE external_jid LIKE '@%'`. +3. Gera código `HE4RT-VERIFY-XXXXX` (TTL curto). +4. Usuário envia o código em DM ao bot (via `wa.me/?text=...`, sem poluir grupo). +5. O Node detecta o prefixo localmente e chama um endpoint de verificação. +6. Laravel valida e seta `identity_id` em todas as participations daquele número. + +> Nota: como não há hash, a busca é por número direto. Se um dia o telefone for hasheado, o `PEPPER` +> precisará ser compartilhado entre Node e Laravel para a vinculação continuar funcionando. + +--- + +## 6. Pontos adiados (próxima rodada de planejamento) + +Estes não foram esquecidos — foram conscientemente reservados para a fase de execução: + +- **Hospedagem e deploy do `wpp-tui`** — onde roda, como sobe, atualização de Baileys, re-pareamento. +- **Resiliência e gaps de eventos** — Baileys só captura conectado; quedas geram lacunas. +- **Observabilidade** — healthcheck, alerta de desconexão, detecção de risco de ban, uptime. +- **Política de retenção formal** — TTL, job de limpeza, revisão jurídica. +- **Onboarding dos membros** — aviso fixado nos grupos, página de privacidade, canal de opt-out. + +--- + +## 7. Decisões registradas + +| # | Decisão | Razão | +|---|---------|-------| +| 01 | Baileys direto, não Evolution API | Caso pequeno; controle; repo enxuto | +| 02 | Coletor (`wpp-tui`) em repo separado | Stack diferente; ciclo de vida descolado; blast radius isolado | +| 03 | Webhook HMAC + idempotência, fila interna Horizon | Padrão já usado; sem infra nova | +| 04 | Módulo `integration-whatsapp` no monolito | Segue padrão `integration-*` | +| 05 | Data lake: 3 tabelas com `payload` cru | Fase de mapeamento; schema-on-read | +| 06 | Telefone cru, sem hash/criptografia | Decisão consciente da fase exploratória | +| 07 | Sem TTL | Idem — revisitar ao fim da exploração | +| 08 | Todos os eventos (inclui presence) | Dados decide depois com o que tiver | +| 09 | Só `type` materializado top-level | Resto no payload para exploração | +| 10 | Sem `collection_policy` nesta fase — bot envia tudo, Laravel salva tudo cru | Mais aderente ao data lake; remove endpoint/polling. Revisitar no endurecimento | + +--- + +## 8. Próximos passos + +1. Modelagem inicial no `integration-whatsapp` (migrações das 3 tabelas, models, webhook controller, Job). +2. Ajustes no `wpp-tui` para emitir o webhook no formato acordado (type + payload + event_id + HMAC). +3. Detalhamento dos pontos adiados (operação, deploy, retenção, onboarding). +4. Termo de consentimento + página de privacidade. +5. Anúncio público nos 3 grupos. From 3955aa11f9e77d00e4ad9555475e4ccb331eb4ee Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 11:14:33 -0300 Subject: [PATCH 8/9] feat(integration-whatsapp): groups.metadata handler and ingest hardening - 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 --- .../factories/WhatsAppEventFactory.php | 2 + .../WhatsAppGroupParticipantFactory.php | 29 +++ ...20_120002_create_whatsapp_events_table.php | 2 + ...eate_whatsapp_group_participants_table.php | 31 ++++ .../src/Concerns/HasVersion7Uuids.php | 21 +++ .../src/Ingest/Handlers/EventHandler.php | 12 ++ .../Ingest/Handlers/GroupsMetadataHandler.php | 78 ++++++++ .../Controllers/WhatsAppWebhookController.php | 17 +- .../Http/Requests/IngestEventRequest.php | 2 + .../src/Ingest/Jobs/ProcessWhatsAppEvent.php | 20 ++- .../src/Models/WhatsAppEvent.php | 8 +- .../src/Models/WhatsAppGroup.php | 26 ++- .../src/Models/WhatsAppGroupParticipant.php | 73 ++++++++ .../src/Models/WhatsAppParticipant.php | 26 ++- .../Ingest/GroupsMetadataHandlerTest.php | 170 ++++++++++++++++++ .../Ingest/ProcessWhatsAppEventTest.php | 22 ++- .../Feature/Ingest/WebhookIngestTest.php | 83 ++++++++- .../Feature/Models/WhatsAppModelsTest.php | 19 ++ 18 files changed, 615 insertions(+), 26 deletions(-) create mode 100644 app-modules/integration-whatsapp/database/factories/WhatsAppGroupParticipantFactory.php create mode 100644 app-modules/integration-whatsapp/database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php create mode 100644 app-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.php create mode 100644 app-modules/integration-whatsapp/src/Ingest/Handlers/EventHandler.php create mode 100644 app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php create mode 100644 app-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.php create mode 100644 app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php diff --git a/app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php b/app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php index cc8e2e6c2..22b720359 100644 --- a/app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php +++ b/app-modules/integration-whatsapp/database/factories/WhatsAppEventFactory.php @@ -23,7 +23,9 @@ public function definition(): array 'type' => 'messages.upsert', 'group_id' => WhatsAppGroup::factory(), 'participant_id' => WhatsAppParticipant::factory(), + 'participant_alt' => fake()->numerify('################').'@lid', 'occurred_at' => now(), + 'occurred_at_source' => 'whatsapp', 'received_at' => now(), 'payload' => [ 'key' => ['id' => fake()->uuid()], diff --git a/app-modules/integration-whatsapp/database/factories/WhatsAppGroupParticipantFactory.php b/app-modules/integration-whatsapp/database/factories/WhatsAppGroupParticipantFactory.php new file mode 100644 index 000000000..599e4ceaa --- /dev/null +++ b/app-modules/integration-whatsapp/database/factories/WhatsAppGroupParticipantFactory.php @@ -0,0 +1,29 @@ + + */ +final class WhatsAppGroupParticipantFactory extends Factory +{ + protected $model = WhatsAppGroupParticipant::class; + + public function definition(): array + { + return [ + 'group_id' => WhatsAppGroup::factory(), + 'participant_id' => WhatsAppParticipant::factory(), + 'admin_role' => null, + 'joined_at' => now(), + 'left_at' => null, + ]; + } +} diff --git a/app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php b/app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php index c1965f070..b111ed29d 100644 --- a/app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php +++ b/app-modules/integration-whatsapp/database/migrations/2026_05_20_120002_create_whatsapp_events_table.php @@ -17,7 +17,9 @@ public function up(): void $table->string('type')->index(); $table->foreignUuid('group_id')->nullable()->constrained('whatsapp_groups')->nullOnDelete(); $table->foreignUuid('participant_id')->nullable()->constrained('whatsapp_participants')->nullOnDelete(); + $table->string('participant_alt')->nullable(); $table->timestamp('occurred_at')->index(); + $table->string('occurred_at_source')->nullable(); $table->timestamp('received_at')->nullable(); $table->jsonb('payload'); $table->timestamps(); diff --git a/app-modules/integration-whatsapp/database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php b/app-modules/integration-whatsapp/database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php new file mode 100644 index 000000000..026a28e7f --- /dev/null +++ b/app-modules/integration-whatsapp/database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->foreignUuid('group_id')->constrained('whatsapp_groups')->cascadeOnDelete(); + $table->foreignUuid('participant_id')->constrained('whatsapp_participants')->cascadeOnDelete(); + $table->string('admin_role')->nullable(); + $table->timestamp('joined_at')->nullable(); + $table->timestamp('left_at')->nullable(); + $table->timestamps(); + + $table->unique(['group_id', 'participant_id']); + $table->index(['group_id', 'left_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_group_participants'); + } +}; diff --git a/app-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.php b/app-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.php new file mode 100644 index 000000000..f97fd5f62 --- /dev/null +++ b/app-modules/integration-whatsapp/src/Concerns/HasVersion7Uuids.php @@ -0,0 +1,21 @@ +group; + + if ($group === null) { + return; + } + + $payload = $event->payload; + + DB::transaction(function () use ($group, $payload): void { + $group->forceFill([ + 'display_name' => $payload['subject'] ?? $group->display_name, + 'payload' => $payload, + ])->save(); + + $participants = $payload['participants'] ?? null; + + if (!is_array($participants) || $participants === []) { + return; + } + + $seenParticipantIds = []; + + foreach ($participants as $row) { + if (!is_array($row)) { + continue; + } + + $jid = $row['id'] ?? null; + + if (!is_string($jid) || $jid === '') { + continue; + } + + $participant = WhatsAppParticipant::query()->firstOrCreate( + ['external_jid' => $jid], + ['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()]); + } + }); + } +} diff --git a/app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php b/app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php index 4ea24ee40..f6cd7a61a 100644 --- a/app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php +++ b/app-modules/integration-whatsapp/src/Ingest/Http/Controllers/WhatsAppWebhookController.php @@ -9,7 +9,8 @@ use He4rt\IntegrationWhatsapp\Ingest\Jobs\ProcessWhatsAppEvent; use He4rt\IntegrationWhatsapp\Models\WhatsAppEvent; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Date; +use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; final class WhatsAppWebhookController extends Controller @@ -17,20 +18,18 @@ final class WhatsAppWebhookController extends Controller public function store(IngestEventRequest $request): JsonResponse { $eventId = (string) $request->header('X-Event-Id'); + + if (!Str::isUuid($eventId)) { + return response()->json(['error' => 'Invalid event id'], Response::HTTP_BAD_REQUEST); + } + $validated = $request->validated(); if (WhatsAppEvent::query()->where('event_id', $eventId)->exists()) { return response()->json(['status' => 'duplicate'], Response::HTTP_ACCEPTED); } - ProcessWhatsAppEvent::dispatch( - eventId: $eventId, - type: $validated['type'], - groupJid: $validated['group_jid'] ?? null, - participantJid: $validated['participant_jid'] ?? null, - occurredAt: Carbon::parse($validated['occurred_at']), - 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'])); return response()->json(['status' => 'accepted'], Response::HTTP_ACCEPTED); } diff --git a/app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php b/app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php index 85e93d440..159741856 100644 --- a/app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php +++ b/app-modules/integration-whatsapp/src/Ingest/Http/Requests/IngestEventRequest.php @@ -22,7 +22,9 @@ public function rules(): array 'type' => ['required', 'string', 'max:100'], 'group_jid' => ['nullable', 'string', 'max:255'], 'participant_jid' => ['nullable', 'string', 'max:255'], + 'participant_alt' => ['nullable', 'string', 'max:255'], 'occurred_at' => ['required', 'date'], + 'occurred_at_source' => ['nullable', 'string', 'max:50'], 'payload' => ['required', 'array'], ]; } diff --git a/app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php b/app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php index 664af8824..c7d2913e8 100644 --- a/app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php +++ b/app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php @@ -5,6 +5,8 @@ namespace He4rt\IntegrationWhatsapp\Ingest\Jobs; use Carbon\CarbonInterface; +use He4rt\IntegrationWhatsapp\Ingest\Handlers\EventHandler; +use He4rt\IntegrationWhatsapp\Ingest\Handlers\GroupsMetadataHandler; use He4rt\IntegrationWhatsapp\Models\WhatsAppEvent; use He4rt\IntegrationWhatsapp\Models\WhatsAppGroup; use He4rt\IntegrationWhatsapp\Models\WhatsAppParticipant; @@ -27,7 +29,9 @@ public function __construct( private readonly string $type, private readonly ?string $groupJid, private readonly ?string $participantJid, + private readonly ?string $participantAlt, private readonly CarbonInterface $occurredAt, + private readonly ?string $occurredAtSource, private readonly array $payload, ) {} @@ -36,17 +40,31 @@ public function handle(): void $group = $this->resolveGroup(); $participant = $this->resolveParticipant(); - WhatsAppEvent::query()->firstOrCreate( + $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); + } + } + + private function resolveHandler(): ?EventHandler + { + return match ($this->type) { + 'groups.metadata' => resolve(GroupsMetadataHandler::class), + default => null, + }; } private function resolveGroup(): ?WhatsAppGroup diff --git a/app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php b/app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php index 9388574c7..7d671cf1a 100644 --- a/app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php +++ b/app-modules/integration-whatsapp/src/Models/WhatsAppEvent.php @@ -6,8 +6,8 @@ use Carbon\Carbon; use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\IntegrationWhatsapp\Concerns\HasVersion7Uuids; use He4rt\IntegrationWhatsapp\Database\Factories\WhatsAppEventFactory; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -19,7 +19,9 @@ * @property string $type * @property string|null $group_id * @property string|null $participant_id + * @property string|null $participant_alt * @property Carbon $occurred_at + * @property string|null $occurred_at_source * @property Carbon|null $received_at * @property array $payload * @property Carbon|null $created_at @@ -32,7 +34,7 @@ final class WhatsAppEvent extends Model { /** @use HasFactory */ use HasFactory; - use HasUuids; + use HasVersion7Uuids; protected $table = 'whatsapp_events'; @@ -42,7 +44,9 @@ final class WhatsAppEvent extends Model 'type', 'group_id', 'participant_id', + 'participant_alt', 'occurred_at', + 'occurred_at_source', 'received_at', 'payload', ]; diff --git a/app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php b/app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php index 10b14cefb..7b91708ca 100644 --- a/app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php +++ b/app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php @@ -6,12 +6,13 @@ use Carbon\Carbon; use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\IntegrationWhatsapp\Concerns\HasVersion7Uuids; use He4rt\IntegrationWhatsapp\Database\Factories\WhatsAppGroupFactory; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; /** @@ -32,7 +33,7 @@ final class WhatsAppGroup extends Model { /** @use HasFactory */ use HasFactory; - use HasUuids; + use HasVersion7Uuids; protected $table = 'whatsapp_groups'; @@ -62,6 +63,27 @@ public function events(): HasMany return $this->hasMany(WhatsAppEvent::class, 'group_id'); } + /** + * @return HasMany + */ + public function groupParticipants(): HasMany + { + return $this->hasMany(WhatsAppGroupParticipant::class, 'group_id'); + } + + /** + * @return BelongsToMany + */ + public function participants(): BelongsToMany + { + return $this->belongsToMany( + WhatsAppParticipant::class, + 'whatsapp_group_participants', + 'group_id', + 'participant_id', + )->withPivot(['admin_role', 'joined_at', 'left_at'])->withTimestamps(); + } + protected static function newFactory(): WhatsAppGroupFactory { return WhatsAppGroupFactory::new(); diff --git a/app-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.php b/app-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.php new file mode 100644 index 000000000..26c6b3215 --- /dev/null +++ b/app-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.php @@ -0,0 +1,73 @@ + */ + use HasFactory; + use HasVersion7Uuids; + + protected $table = 'whatsapp_group_participants'; + + protected $fillable = [ + 'group_id', + 'participant_id', + 'admin_role', + 'joined_at', + 'left_at', + ]; + + /** + * @return BelongsTo + */ + public function group(): BelongsTo + { + return $this->belongsTo(WhatsAppGroup::class, 'group_id'); + } + + /** + * @return BelongsTo + */ + public function participant(): BelongsTo + { + return $this->belongsTo(WhatsAppParticipant::class, 'participant_id'); + } + + protected static function newFactory(): WhatsAppGroupParticipantFactory + { + return WhatsAppGroupParticipantFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'joined_at' => 'datetime', + 'left_at' => 'datetime', + ]; + } +} diff --git a/app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php b/app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php index af20d0d01..2cffc025b 100644 --- a/app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php +++ b/app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php @@ -6,12 +6,13 @@ use Carbon\Carbon; use He4rt\Identity\User\Models\User; +use He4rt\IntegrationWhatsapp\Concerns\HasVersion7Uuids; use He4rt\IntegrationWhatsapp\Database\Factories\WhatsAppParticipantFactory; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; /** @@ -31,7 +32,7 @@ final class WhatsAppParticipant extends Model { /** @use HasFactory */ use HasFactory; - use HasUuids; + use HasVersion7Uuids; protected $table = 'whatsapp_participants'; @@ -60,6 +61,27 @@ public function events(): HasMany return $this->hasMany(WhatsAppEvent::class, 'participant_id'); } + /** + * @return HasMany + */ + public function groupParticipants(): HasMany + { + return $this->hasMany(WhatsAppGroupParticipant::class, 'participant_id'); + } + + /** + * @return BelongsToMany + */ + public function groups(): BelongsToMany + { + return $this->belongsToMany( + WhatsAppGroup::class, + 'whatsapp_group_participants', + 'participant_id', + 'group_id', + )->withPivot(['admin_role', 'joined_at', 'left_at'])->withTimestamps(); + } + protected static function newFactory(): WhatsAppParticipantFactory { return WhatsAppParticipantFactory::new(); diff --git a/app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php b/app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php new file mode 100644 index 000000000..66b2b654a --- /dev/null +++ b/app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php @@ -0,0 +1,170 @@ + $participants + * @param array $payloadOverrides + */ +function metadataEvent(array $participants, array $payloadOverrides = []): WhatsAppEvent +{ + $group = WhatsAppGroup::factory()->create([ + 'external_jid' => '120363000000000000@g.us', + 'display_name' => null, + 'internal_name' => 'apelido-interno', + 'payload' => null, + ]); + + $payload = array_merge([ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs', + 'desc' => 'Comunidade dev', + 'owner' => '100000000000001@lid', + 'participants' => $participants, + ], $payloadOverrides); + + return WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $group->id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => $payload, + ]); +} + +test('populates group metadata and creates membership with admin roles', function (): void { + $event = metadataEvent([ + ['id' => '100000000000001@lid', 'admin' => 'superadmin'], + ['id' => '100000000000002@lid', 'admin' => 'admin'], + ['id' => '100000000000003@lid', 'admin' => null], + ]); + + resolve(GroupsMetadataHandler::class)->handle($event); + + $group = WhatsAppGroup::query()->findOrFail($event->group_id); + expect($group->display_name)->toBe('He4rt Devs') + ->and($group->internal_name)->toBe('apelido-interno') + ->and($group->payload['desc'])->toBe('Comunidade dev') + ->and($group->payload['owner'])->toBe('100000000000001@lid') + ->and(WhatsAppParticipant::query()->count())->toBe(3) + ->and(WhatsAppGroupParticipant::query()->count())->toBe(3); + + $super = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000001@lid') + ->firstOrFail(); + expect($super->admin_role)->toBe('superadmin') + ->and($super->left_at)->toBeNull() + ->and($super->joined_at)->not->toBeNull(); + + $member = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000003@lid') + ->firstOrFail(); + expect($member->admin_role)->toBeNull(); +}); + +test('soft-removes members absent from a newer snapshot and updates roles', function (): void { + $first = metadataEvent([ + ['id' => '100000000000001@lid', 'admin' => 'superadmin'], + ['id' => '100000000000002@lid', 'admin' => null], + ]); + resolve(GroupsMetadataHandler::class)->handle($first); + + $second = WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $first->group_id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs', + 'participants' => [ + ['id' => '100000000000001@lid', 'admin' => 'admin'], + ['id' => '100000000000003@lid', 'admin' => null], + ], + ], + ]); + resolve(GroupsMetadataHandler::class)->handle($second); + + $gone = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000002@lid') + ->firstOrFail(); + $stayed = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000001@lid') + ->firstOrFail(); + + expect($gone->left_at)->not->toBeNull() + ->and($stayed->left_at)->toBeNull() + ->and($stayed->admin_role)->toBe('admin') + ->and(WhatsAppGroupParticipant::query()->whereNull('left_at')->count())->toBe(2); +}); + +test('rejoining a member clears left_at', function (): void { + $first = metadataEvent([['id' => '100000000000002@lid', 'admin' => null]]); + resolve(GroupsMetadataHandler::class)->handle($first); + + $second = WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $first->group_id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'participants' => [['id' => '100000000000001@lid', 'admin' => null]], + ], + ]); + resolve(GroupsMetadataHandler::class)->handle($second); + + $third = WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $first->group_id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'participants' => [['id' => '100000000000002@lid', 'admin' => null]], + ], + ]); + resolve(GroupsMetadataHandler::class)->handle($third); + + $rejoined = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000002@lid') + ->firstOrFail(); + expect($rejoined->left_at)->toBeNull(); +}); + +test('does not touch membership when payload has no participants', function (): void { + $first = metadataEvent([['id' => '100000000000001@lid', 'admin' => null]]); + resolve(GroupsMetadataHandler::class)->handle($first); + + $second = WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $first->group_id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs (renomeado)', + ], + ]); + resolve(GroupsMetadataHandler::class)->handle($second); + + $group = WhatsAppGroup::query()->findOrFail($first->group_id); + expect($group->display_name)->toBe('He4rt Devs (renomeado)') + ->and(WhatsAppGroupParticipant::query()->whereNull('left_at')->count())->toBe(1); +}); diff --git a/app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php b/app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php index 5528489ee..fee4e033a 100644 --- a/app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php +++ b/app-modules/integration-whatsapp/tests/Feature/Ingest/ProcessWhatsAppEventTest.php @@ -7,7 +7,7 @@ use He4rt\IntegrationWhatsapp\Models\WhatsAppGroup; use He4rt\IntegrationWhatsapp\Models\WhatsAppParticipant; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; uses(RefreshDatabase::class); @@ -19,20 +19,24 @@ function runJob(array $overrides = []): void 'type' => 'messages.upsert', 'groupJid' => '120363000000000000@g.us', 'participantJid' => '5511999999999@s.whatsapp.net', - 'occurredAt' => Carbon::parse('2026-05-20 12:00:00'), + 'participantAlt' => '100000000000001@lid', + 'occurredAt' => Date::parse('2026-05-20 12:00:00'), + 'occurredAtSource' => 'whatsapp', 'payload' => ['subject' => 'He4rt Geral', 'pushName' => 'Maria', 'message' => ['conversation' => 'oi']], ]; $data = array_merge($defaults, $overrides); - (new ProcessWhatsAppEvent( + new ProcessWhatsAppEvent( eventId: $data['eventId'], type: $data['type'], groupJid: $data['groupJid'], participantJid: $data['participantJid'], + participantAlt: $data['participantAlt'], occurredAt: $data['occurredAt'], + occurredAtSource: $data['occurredAtSource'], payload: $data['payload'], - ))->handle(); + )->handle(); } test('upserts group, participant and inserts event', function (): void { @@ -46,6 +50,8 @@ function runJob(array $overrides = []): void expect($event->type)->toBe('messages.upsert') ->and($event->group_id)->not->toBeNull() ->and($event->participant_id)->not->toBeNull() + ->and($event->participant_alt)->toBe('100000000000001@lid') + ->and($event->occurred_at_source)->toBe('whatsapp') ->and($event->payload['message']['conversation'])->toBe('oi'); expect(WhatsAppParticipant::query()->first()->push_name)->toBe('Maria') @@ -64,6 +70,14 @@ function runJob(array $overrides = []): void ->and($event->participant_id)->toBeNull(); }); +test('stores null envelope extras when absent', function (): void { + runJob(['participantAlt' => null, 'occurredAtSource' => null]); + + $event = WhatsAppEvent::query()->sole(); + expect($event->participant_alt)->toBeNull() + ->and($event->occurred_at_source)->toBeNull(); +}); + test('is idempotent on event_id', function (): void { $eventId = (string) Str::uuid(); diff --git a/app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php b/app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php index 105aefb52..c307769f8 100644 --- a/app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php +++ b/app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php @@ -4,9 +4,13 @@ use He4rt\IntegrationWhatsapp\Ingest\Jobs\ProcessWhatsAppEvent; use He4rt\IntegrationWhatsapp\Models\WhatsAppEvent; +use He4rt\IntegrationWhatsapp\Models\WhatsAppGroup; +use He4rt\IntegrationWhatsapp\Models\WhatsAppGroupParticipant; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Str; +use Illuminate\Testing\TestResponse; uses(RefreshDatabase::class); @@ -17,7 +21,7 @@ /** * @param array $payload */ -function postEvent(array $payload, ?string $signature = null, ?string $eventId = null): Illuminate\Testing\TestResponse +function postEvent(array $payload, ?string $signature = null, ?string $eventId = null): TestResponse { $body = json_encode($payload, JSON_THROW_ON_ERROR); $eventId ??= (string) Str::uuid(); @@ -42,7 +46,9 @@ function validBody(): array 'type' => 'messages.upsert', 'group_jid' => '120363000000000000@g.us', 'participant_jid' => '5511999999999@s.whatsapp.net', + 'participant_alt' => '100000000000001@lid', 'occurred_at' => '2026-05-20T12:00:00+00:00', + 'occurred_at_source' => 'whatsapp', 'payload' => ['subject' => 'He4rt Geral', 'pushName' => 'Maria'], ]; } @@ -56,12 +62,33 @@ function validBody(): array Bus::assertDispatched(ProcessWhatsAppEvent::class); }); +test('persists the full envelope end-to-end', function (): void { + postEvent(validBody()); + + $event = WhatsAppEvent::query()->sole(); + + expect($event->type)->toBe('messages.upsert') + ->and($event->participant_alt)->toBe('100000000000001@lid') + ->and($event->occurred_at_source)->toBe('whatsapp') + ->and($event->group?->external_jid)->toBe('120363000000000000@g.us') + ->and($event->participant?->external_jid)->toBe('5511999999999@s.whatsapp.net'); +}); + +test('rejects an event with a non-uuid event id', function (): void { + Bus::fake(); + + $response = postEvent(validBody(), eventId: 'not-a-uuid'); + + $response->assertStatus(400); + Bus::assertNothingDispatched(); +}); + test('rejects an event with an invalid signature', function (): void { Bus::fake(); $response = postEvent(validBody(), signature: 'deadbeef'); - $response->assertStatus(401); + $response->assertUnauthorized(); Bus::assertNothingDispatched(); }); @@ -85,14 +112,13 @@ function validBody(): array Bus::assertNothingDispatched(); }); -test('returns 422 when body is invalid', function (): void { +test('returns 422 when a required field is missing', function (string $missingField): void { Bus::fake(); - $response = postEvent(['type' => 'messages.upsert']); // sem occurred_at e payload + postEvent(Arr::except(validBody(), $missingField))->assertUnprocessable(); - $response->assertStatus(422); Bus::assertNothingDispatched(); -}); +})->with(['type', 'occurred_at', 'payload']); test('acks duplicate event_id without dispatching', function (): void { Bus::fake(); @@ -105,3 +131,48 @@ function validBody(): array $response->assertStatus(202)->assertJson(['status' => 'duplicate']); Bus::assertNothingDispatched(); }); + +/** + * @return array + */ +function groupsMetadataBody(): array +{ + return [ + 'type' => 'groups.metadata', + 'group_jid' => '120363000000000000@g.us', + 'participant_jid' => null, + 'participant_alt' => null, + 'occurred_at' => '2026-05-21T12:43:45+00:00', + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs', + 'desc' => 'Comunidade dev', + 'owner' => '100000000000001@lid', + 'participants' => [ + ['id' => '100000000000001@lid', 'admin' => 'superadmin'], + ['id' => '100000000000002@lid', 'admin' => 'admin'], + ['id' => '100000000000003@lid', 'admin' => null], + ], + ], + ]; +} + +test('groups.metadata populates group and membership end-to-end', function (): void { + postEvent(groupsMetadataBody())->assertStatus(202); + + $group = WhatsAppGroup::query()->where('external_jid', '120363000000000000@g.us')->sole(); + expect($group->display_name)->toBe('He4rt Devs') + ->and($group->payload['desc'])->toBe('Comunidade dev') + ->and($group->groupParticipants()->whereNull('left_at')->count())->toBe(3); +}); + +test('groups.metadata resend with same event_id does not reprocess', function (): void { + $eventId = (string) Str::uuid(); + + postEvent(groupsMetadataBody(), eventId: $eventId)->assertStatus(202)->assertJson(['status' => 'accepted']); + postEvent(groupsMetadataBody(), eventId: $eventId)->assertStatus(202)->assertJson(['status' => 'duplicate']); + + expect(WhatsAppEvent::query()->where('event_id', $eventId)->count())->toBe(1) + ->and(WhatsAppGroupParticipant::query()->count())->toBe(3); +}); diff --git a/app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php b/app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php index ab7089a1d..c2c4fc147 100644 --- a/app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php +++ b/app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php @@ -4,6 +4,7 @@ use He4rt\IntegrationWhatsapp\Models\WhatsAppEvent; use He4rt\IntegrationWhatsapp\Models\WhatsAppGroup; +use He4rt\IntegrationWhatsapp\Models\WhatsAppGroupParticipant; use He4rt\IntegrationWhatsapp\Models\WhatsAppParticipant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; @@ -34,3 +35,21 @@ expect(Str::isUuid($participant->id))->toBeTrue() ->and($participant->external_jid)->toBe('5511999999999@s.whatsapp.net'); }); + +test('group and participant are linked through the membership pivot', function (): void { + $group = WhatsAppGroup::factory()->create(); + $participant = WhatsAppParticipant::factory()->create(); + + $membership = WhatsAppGroupParticipant::factory()->create([ + 'group_id' => $group->id, + 'participant_id' => $participant->id, + 'admin_role' => 'admin', + 'left_at' => null, + ]); + + expect($membership->group->is($group))->toBeTrue() + ->and($membership->participant->is($participant))->toBeTrue() + ->and($group->participants()->first()->id)->toBe($participant->id) + ->and($group->participants()->first()->pivot->admin_role)->toBe('admin') + ->and($participant->groups()->first()->id)->toBe($group->id); +}); From 8677ff6caebaca85fd808400a2dc16e6698277bd Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Thu, 21 May 2026 11:15:12 -0300 Subject: [PATCH 9/9] docs(integration-whatsapp): groups.metadata design spec and implementation plan --- .../docs/plans/0002-groups-metadata.md | 900 ++++++++++++++++++ .../docs/specs/0001-groups-metadata-design.md | 206 ++++ 2 files changed, 1106 insertions(+) create mode 100644 app-modules/integration-whatsapp/docs/plans/0002-groups-metadata.md create mode 100644 app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md diff --git a/app-modules/integration-whatsapp/docs/plans/0002-groups-metadata.md b/app-modules/integration-whatsapp/docs/plans/0002-groups-metadata.md new file mode 100644 index 000000000..40e9a2d17 --- /dev/null +++ b/app-modules/integration-whatsapp/docs/plans/0002-groups-metadata.md @@ -0,0 +1,900 @@ +# groups.metadata Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tratar o evento `groups.metadata` do webhook para popular a metadata do grupo (`display_name`, `payload`) e sincronizar o vínculo participante↔grupo com papel de admin. + +**Architecture:** O job `ProcessWhatsAppEvent` mantém o fluxo base e delega, via Strategy (`resolveHandler($type)`), para um `GroupsMetadataHandler` quando o evento é novo. A membership vive numa tabela pivô `whatsapp_group_participants` (soft-delete via `left_at`). Tudo dentro de `DB::transaction()`. + +**Tech Stack:** Laravel 13, PostgreSQL (jsonb + uuid), Pest, PHPStan nível 6, Pint. UUIDv7 nas PKs (trait `HasVersion7Uuids`), UUIDv5 no `event_id`. + +**Design de referência:** [`docs/specs/0001-groups-metadata-design.md`](../specs/0001-groups-metadata-design.md) + +--- + +## Estrutura de arquivos + +**Criar:** +- `database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php` — tabela pivô. +- `src/Models/WhatsAppGroupParticipant.php` — model do vínculo (pivô estendido). +- `database/factories/WhatsAppGroupParticipantFactory.php` — factory do vínculo. +- `src/Ingest/Handlers/EventHandler.php` — interface do Strategy. +- `src/Ingest/Handlers/GroupsMetadataHandler.php` — lógica do snapshot. +- `tests/Feature/Ingest/GroupsMetadataHandlerTest.php` — testes do handler (isolado). + +**Modificar:** +- `src/Models/WhatsAppGroup.php` — relações `groupParticipants()` / `participants()`. +- `src/Models/WhatsAppParticipant.php` — relações `groupParticipants()` / `groups()`. +- `src/Ingest/Jobs/ProcessWhatsAppEvent.php` — `resolveHandler()` + chamada condicional. +- `tests/Feature/Ingest/WebhookIngestTest.php` — caso e2e de `groups.metadata`. + +**Comandos de verificação (usados em vários passos):** +- Testes do módulo: `php artisan test app-modules/integration-whatsapp/tests` +- Pint: `./vendor/bin/pint app-modules/integration-whatsapp` +- PHPStan (como o CI): `./vendor/bin/phpstan analyse --no-progress --memory-limit=2G` + +> **Nota sobre commits:** Clinton commita manualmente. Os passos de commit abaixo são sugeridos (Conventional Commits, **sem** linha de co-autoria, conforme regra do projeto). Pode agrupar/adiar conforme preferir. + +--- + +## Task 1: Tabela pivô `whatsapp_group_participants` (migration + model + factory + relações) + +**Files:** +- Create: `app-modules/integration-whatsapp/database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php` +- Create: `app-modules/integration-whatsapp/src/Models/WhatsAppGroupParticipant.php` +- Create: `app-modules/integration-whatsapp/database/factories/WhatsAppGroupParticipantFactory.php` +- Modify: `app-modules/integration-whatsapp/src/Models/WhatsAppGroup.php` +- Modify: `app-modules/integration-whatsapp/src/Models/WhatsAppParticipant.php` +- Test: `app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php` + +- [ ] **Step 1: Write the failing test** + +Adicionar ao final de `tests/Feature/Models/WhatsAppModelsTest.php`: + +```php +test('group and participant are linked through the membership pivot', function (): void { + $group = WhatsAppGroup::factory()->create(); + $participant = WhatsAppParticipant::factory()->create(); + + $membership = WhatsAppGroupParticipant::factory()->create([ + 'group_id' => $group->id, + 'participant_id' => $participant->id, + 'admin_role' => 'admin', + 'left_at' => null, + ]); + + expect($membership->group->is($group))->toBeTrue() + ->and($membership->participant->is($participant))->toBeTrue() + ->and($group->participants()->first()->id)->toBe($participant->id) + ->and($group->participants()->first()->pivot->admin_role)->toBe('admin') + ->and($participant->groups()->first()->id)->toBe($group->id); +}); +``` + +Garanta o import no topo do arquivo (se ainda não houver): + +```php +use He4rt\IntegrationWhatsapp\Models\WhatsAppGroupParticipant; +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php` +Expected: FAIL — `Class "He4rt\IntegrationWhatsapp\Models\WhatsAppGroupParticipant" not found`. + +- [ ] **Step 3: Create the migration** + +`database/migrations/2026_05_21_120003_create_whatsapp_group_participants_table.php`: + +```php +uuid('id')->primary(); + $table->foreignUuid('group_id')->constrained('whatsapp_groups')->cascadeOnDelete(); + $table->foreignUuid('participant_id')->constrained('whatsapp_participants')->cascadeOnDelete(); + // admin_role: 'superadmin' | 'admin' | null (membro comum). + $table->string('admin_role')->nullable(); + $table->timestamp('joined_at')->nullable(); + // left_at: null = vínculo ativo; preenchido = saiu do grupo (soft remove). + $table->timestamp('left_at')->nullable(); + $table->timestamps(); + + $table->unique(['group_id', 'participant_id']); + $table->index(['group_id', 'left_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_group_participants'); + } +}; +``` + +- [ ] **Step 4: Create the model** + +`src/Models/WhatsAppGroupParticipant.php`: + +```php + */ + use HasFactory; + use HasVersion7Uuids; + + protected $table = 'whatsapp_group_participants'; + + protected $fillable = [ + 'group_id', + 'participant_id', + 'admin_role', + 'joined_at', + 'left_at', + ]; + + /** + * @return BelongsTo + */ + public function group(): BelongsTo + { + return $this->belongsTo(WhatsAppGroup::class, 'group_id'); + } + + /** + * @return BelongsTo + */ + public function participant(): BelongsTo + { + return $this->belongsTo(WhatsAppParticipant::class, 'participant_id'); + } + + protected static function newFactory(): WhatsAppGroupParticipantFactory + { + return WhatsAppGroupParticipantFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'joined_at' => 'datetime', + 'left_at' => 'datetime', + ]; + } +} +``` + +- [ ] **Step 5: Create the factory** + +`database/factories/WhatsAppGroupParticipantFactory.php`: + +```php + + */ +final class WhatsAppGroupParticipantFactory extends Factory +{ + protected $model = WhatsAppGroupParticipant::class; + + public function definition(): array + { + return [ + 'group_id' => WhatsAppGroup::factory(), + 'participant_id' => WhatsAppParticipant::factory(), + 'admin_role' => null, + 'joined_at' => now(), + 'left_at' => null, + ]; + } +} +``` + +- [ ] **Step 6: Add relations to `WhatsAppGroup`** + +Em `src/Models/WhatsAppGroup.php`, adicionar o import: + +```php +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +``` + +E adicionar estes métodos logo após `events(): HasMany`: + +```php + /** + * @return HasMany + */ + public function groupParticipants(): HasMany + { + return $this->hasMany(WhatsAppGroupParticipant::class, 'group_id'); + } + + /** + * @return BelongsToMany + */ + public function participants(): BelongsToMany + { + return $this->belongsToMany( + WhatsAppParticipant::class, + 'whatsapp_group_participants', + 'group_id', + 'participant_id', + )->withPivot(['admin_role', 'joined_at', 'left_at'])->withTimestamps(); + } +``` + +- [ ] **Step 7: Add relations to `WhatsAppParticipant`** + +Em `src/Models/WhatsAppParticipant.php`, adicionar o import: + +```php +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +``` + +E adicionar estes métodos logo após `events(): HasMany`: + +```php + /** + * @return HasMany + */ + public function groupParticipants(): HasMany + { + return $this->hasMany(WhatsAppGroupParticipant::class, 'participant_id'); + } + + /** + * @return BelongsToMany + */ + public function groups(): BelongsToMany + { + return $this->belongsToMany( + WhatsAppGroup::class, + 'whatsapp_group_participants', + 'participant_id', + 'group_id', + )->withPivot(['admin_role', 'joined_at', 'left_at'])->withTimestamps(); + } +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `php artisan test app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php` +Expected: PASS. + +- [ ] **Step 9: Lint + static analysis** + +Run: `./vendor/bin/pint app-modules/integration-whatsapp && ./vendor/bin/phpstan analyse --no-progress --memory-limit=2G` +Expected: Pint `passed`, PHPStan `0 errors`. + +- [ ] **Step 10: Commit** + +```bash +git add app-modules/integration-whatsapp/database/migrations app-modules/integration-whatsapp/src/Models app-modules/integration-whatsapp/database/factories app-modules/integration-whatsapp/tests/Feature/Models/WhatsAppModelsTest.php +git commit -m "feat(integration-whatsapp): add group-participant membership pivot" +``` + +--- + +## Task 2: `GroupsMetadataHandler` — happy path (popula grupo + cria membership) + +**Files:** +- Create: `app-modules/integration-whatsapp/src/Ingest/Handlers/EventHandler.php` +- Create: `app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php` +- Test: `app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php` + +- [ ] **Step 1: Write the failing test** + +Criar `tests/Feature/Ingest/GroupsMetadataHandlerTest.php`: + +```php + $participants + * @param array $payloadOverrides + */ +function metadataEvent(array $participants, array $payloadOverrides = []): WhatsAppEvent +{ + $group = WhatsAppGroup::factory()->create([ + 'external_jid' => '120363000000000000@g.us', + 'display_name' => null, + 'internal_name' => 'apelido-interno', + 'payload' => null, + ]); + + $payload = array_merge([ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs', + 'desc' => 'Comunidade dev', + 'owner' => '100000000000001@lid', + 'participants' => $participants, + ], $payloadOverrides); + + return WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $group->id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => $payload, + ]); +} + +test('populates group metadata and creates membership with admin roles', function (): void { + $event = metadataEvent([ + ['id' => '100000000000001@lid', 'admin' => 'superadmin'], + ['id' => '100000000000002@lid', 'admin' => 'admin'], + ['id' => '100000000000003@lid', 'admin' => null], + ]); + + app(GroupsMetadataHandler::class)->handle($event); + + $group = WhatsAppGroup::query()->findOrFail($event->group_id); + expect($group->display_name)->toBe('He4rt Devs') + ->and($group->internal_name)->toBe('apelido-interno') // NÃO sobrescrito + ->and($group->payload['desc'])->toBe('Comunidade dev') + ->and($group->payload['owner'])->toBe('100000000000001@lid') + ->and(WhatsAppParticipant::query()->count())->toBe(3) + ->and(WhatsAppGroupParticipant::query()->count())->toBe(3); + + $super = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000001@lid') + ->firstOrFail(); + expect($super->admin_role)->toBe('superadmin') + ->and($super->left_at)->toBeNull() + ->and($super->joined_at)->not->toBeNull(); + + $member = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000003@lid') + ->firstOrFail(); + expect($member->admin_role)->toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php` +Expected: FAIL — `Class "He4rt\IntegrationWhatsapp\Ingest\Handlers\GroupsMetadataHandler" not found`. + +- [ ] **Step 3: Create the `EventHandler` interface** + +`src/Ingest/Handlers/EventHandler.php`: + +```php +group; + + if ($group === null) { + return; + } + + $payload = $event->payload; + + DB::transaction(function () use ($group, $payload): void { + $group->forceFill([ + 'display_name' => $payload['subject'] ?? $group->display_name, + 'payload' => $payload, + ])->save(); + + $participants = $payload['participants'] ?? null; + + if (!is_array($participants)) { + return; + } + + foreach ($participants as $row) { + if (!is_array($row)) { + continue; + } + + $jid = $row['id'] ?? null; + + if (!is_string($jid) || $jid === '') { + continue; + } + + $participant = WhatsAppParticipant::query()->firstOrCreate( + ['external_jid' => $jid], + ['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(); + } + }); + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `php artisan test app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php` +Expected: PASS. + +- [ ] **Step 6: Lint + static analysis** + +Run: `./vendor/bin/pint app-modules/integration-whatsapp && ./vendor/bin/phpstan analyse --no-progress --memory-limit=2G` +Expected: Pint `passed`, PHPStan `0 errors`. + +- [ ] **Step 7: Commit** + +```bash +git add app-modules/integration-whatsapp/src/Ingest/Handlers app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php +git commit -m "feat(integration-whatsapp): add GroupsMetadataHandler (group + membership upsert)" +``` + +--- + +## Task 3: Sync de saída/reentrada + bordas no `GroupsMetadataHandler` + +**Files:** +- Modify: `app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php` +- Test: `app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php` + +- [ ] **Step 1: Write the failing tests** + +Adicionar ao final de `tests/Feature/Ingest/GroupsMetadataHandlerTest.php`: + +```php +test('soft-removes members absent from a newer snapshot and updates roles', function (): void { + $first = metadataEvent([ + ['id' => '100000000000001@lid', 'admin' => 'superadmin'], + ['id' => '100000000000002@lid', 'admin' => null], + ]); + app(GroupsMetadataHandler::class)->handle($first); + + // Segundo snapshot: 002 saiu, 003 entrou, 002 some; mesmo grupo (mesmo external_jid). + $second = WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $first->group_id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs', + 'participants' => [ + ['id' => '100000000000001@lid', 'admin' => 'admin'], // rebaixado + ['id' => '100000000000003@lid', 'admin' => null], // novo + ], + ], + ]); + app(GroupsMetadataHandler::class)->handle($second); + + $gone = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000002@lid') + ->firstOrFail(); + $stayed = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000001@lid') + ->firstOrFail(); + + expect($gone->left_at)->not->toBeNull() // saiu → soft remove + ->and($stayed->left_at)->toBeNull() // continua ativo + ->and($stayed->admin_role)->toBe('admin') // role atualizado + ->and(WhatsAppGroupParticipant::query()->whereNull('left_at')->count())->toBe(2); +}); + +test('rejoining a member clears left_at', function (): void { + $first = metadataEvent([['id' => '100000000000002@lid', 'admin' => null]]); + app(GroupsMetadataHandler::class)->handle($first); + + // Snapshot sem o 002 → soft remove. + $second = WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $first->group_id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'participants' => [['id' => '100000000000001@lid', 'admin' => null]], + ], + ]); + app(GroupsMetadataHandler::class)->handle($second); + + // Snapshot com o 002 de volta. + $third = WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $first->group_id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'participants' => [['id' => '100000000000002@lid', 'admin' => null]], + ], + ]); + app(GroupsMetadataHandler::class)->handle($third); + + $rejoined = WhatsAppGroupParticipant::query() + ->whereRelation('participant', 'external_jid', '100000000000002@lid') + ->firstOrFail(); + expect($rejoined->left_at)->toBeNull(); +}); + +test('does not touch membership when payload has no participants', function (): void { + $first = metadataEvent([['id' => '100000000000001@lid', 'admin' => null]]); + app(GroupsMetadataHandler::class)->handle($first); + + // Snapshot sem a chave participants → atualiza grupo, NÃO soft-remove em massa. + $second = WhatsAppEvent::factory()->create([ + 'type' => 'groups.metadata', + 'group_id' => $first->group_id, + 'participant_id' => null, + 'participant_alt' => null, + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs (renomeado)', + ], + ]); + app(GroupsMetadataHandler::class)->handle($second); + + $group = WhatsAppGroup::query()->findOrFail($first->group_id); + expect($group->display_name)->toBe('He4rt Devs (renomeado)') + ->and(WhatsAppGroupParticipant::query()->whereNull('left_at')->count())->toBe(1); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php artisan test app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php` +Expected: `soft-removes members...` FAIL (o membro ausente continua com `left_at = null`, contagem de ativos = 3 em vez de 2). Os outros dois podem passar parcialmente; o objetivo é ver o de soft-remove falhar. + +- [ ] **Step 3: Add the soft-remove block to the handler** + +Em `src/Ingest/Handlers/GroupsMetadataHandler.php`, substituir o corpo do `DB::transaction(...)` por esta versão (acrescenta o rastreio de `seen` e o soft-remove): + +```php + DB::transaction(function () use ($group, $payload): void { + $group->forceFill([ + 'display_name' => $payload['subject'] ?? $group->display_name, + 'payload' => $payload, + ])->save(); + + $participants = $payload['participants'] ?? null; + + if (!is_array($participants) || $participants === []) { + return; + } + + $seenParticipantIds = []; + + foreach ($participants as $row) { + if (!is_array($row)) { + continue; + } + + $jid = $row['id'] ?? null; + + if (!is_string($jid) || $jid === '') { + continue; + } + + $participant = WhatsAppParticipant::query()->firstOrCreate( + ['external_jid' => $jid], + ['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; + } + + // Soft-remove: vínculos ativos cujo participante não está no snapshot atual. + // Só roda se vimos ao menos um participante válido, para nunca esvaziar o grupo por engano. + if ($seenParticipantIds !== []) { + WhatsAppGroupParticipant::query() + ->where('group_id', $group->id) + ->whereNull('left_at') + ->whereNotIn('participant_id', $seenParticipantIds) + ->update(['left_at' => now()]); + } + }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php artisan test app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php` +Expected: PASS (4 testes do arquivo). + +- [ ] **Step 5: Lint + static analysis** + +Run: `./vendor/bin/pint app-modules/integration-whatsapp && ./vendor/bin/phpstan analyse --no-progress --memory-limit=2G` +Expected: Pint `passed`, PHPStan `0 errors`. + +- [ ] **Step 6: Commit** + +```bash +git add app-modules/integration-whatsapp/src/Ingest/Handlers/GroupsMetadataHandler.php app-modules/integration-whatsapp/tests/Feature/Ingest/GroupsMetadataHandlerTest.php +git commit -m "feat(integration-whatsapp): sync membership soft-remove/rejoin in GroupsMetadataHandler" +``` + +--- + +## Task 4: Wiring no job + teste e2e via webhook + +**Files:** +- Modify: `app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php` +- Test: `app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php` + +- [ ] **Step 1: Write the failing test** + +Adicionar ao final de `tests/Feature/Ingest/WebhookIngestTest.php`: + +```php +test('groups.metadata populates group and membership end-to-end', function (): void { + $body = [ + 'type' => 'groups.metadata', + 'group_jid' => '120363000000000000@g.us', + 'participant_jid' => null, + 'participant_alt' => null, + 'occurred_at' => '2026-05-21T12:43:45+00:00', + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs', + 'desc' => 'Comunidade dev', + 'owner' => '100000000000001@lid', + 'participants' => [ + ['id' => '100000000000001@lid', 'admin' => 'superadmin'], + ['id' => '100000000000002@lid', 'admin' => 'admin'], + ['id' => '100000000000003@lid', 'admin' => null], + ], + ], + ]; + + postEvent($body)->assertStatus(202); + + $group = WhatsAppGroup::query()->where('external_jid', '120363000000000000@g.us')->sole(); + expect($group->display_name)->toBe('He4rt Devs') + ->and($group->payload['desc'])->toBe('Comunidade dev') + ->and($group->groupParticipants()->whereNull('left_at')->count())->toBe(3); +}); + +test('groups.metadata resend with same event_id does not reprocess', function (): void { + $body = [ + 'type' => 'groups.metadata', + 'group_jid' => '120363000000000000@g.us', + 'participant_jid' => null, + 'participant_alt' => null, + 'occurred_at' => '2026-05-21T12:43:45+00:00', + 'occurred_at_source' => 'received', + 'payload' => [ + 'id' => '120363000000000000@g.us', + 'subject' => 'He4rt Devs', + 'participants' => [ + ['id' => '100000000000001@lid', 'admin' => 'superadmin'], + ['id' => '100000000000002@lid', 'admin' => 'admin'], + ['id' => '100000000000003@lid', 'admin' => null], + ], + ], + ]; + $eventId = (string) Str::uuid(); + + postEvent($body, eventId: $eventId)->assertStatus(202)->assertJson(['status' => 'accepted']); + postEvent($body, eventId: $eventId)->assertStatus(202)->assertJson(['status' => 'duplicate']); + + // Reenvio barrado no controller → nada duplicado. + expect(WhatsAppEvent::query()->where('event_id', $eventId)->count())->toBe(1) + ->and(WhatsAppGroupParticipant::query()->count())->toBe(3); +}); +``` + +Garanta os imports no topo de `WebhookIngestTest.php` (se ainda não houver): + +```php +use He4rt\IntegrationWhatsapp\Models\WhatsAppGroup; +use He4rt\IntegrationWhatsapp\Models\WhatsAppGroupParticipant; +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php --filter="groups.metadata populates"` +Expected: FAIL — `display_name` vem `null` e `groupParticipants` count = 0 (o handler ainda não é chamado pelo job). + +- [ ] **Step 3: Wire the handler into the job** + +Em `src/Ingest/Jobs/ProcessWhatsAppEvent.php`, adicionar os imports: + +```php +use He4rt\IntegrationWhatsapp\Ingest\Handlers\EventHandler; +use He4rt\IntegrationWhatsapp\Ingest\Handlers\GroupsMetadataHandler; +``` + +Substituir o bloco final do `handle()` (o `$event = WhatsAppEvent::query()->firstOrCreate(...)` + log `event persisted`) por esta versão, que dispara o handler quando o evento é novo: + +```php + $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); + } + + Log::info('whatsapp-ingest: event persisted', [ + 'event_id' => $this->eventId, + 'event_pk' => $event->id, + 'group_id' => $group?->id, + 'participant_id' => $participant?->id, + 'created' => $event->wasRecentlyCreated, + ]); +``` + +Adicionar o método `resolveHandler()` na classe (logo após `handle()`): + +```php + private function resolveHandler(): ?EventHandler + { + return match ($this->type) { + 'groups.metadata' => app(GroupsMetadataHandler::class), + default => null, + }; + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `php artisan test app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php` +Expected: PASS (todos os testes do arquivo). + +- [ ] **Step 5: Full module suite + lint + static analysis** + +Run: `php artisan test app-modules/integration-whatsapp/tests && ./vendor/bin/pint app-modules/integration-whatsapp && ./vendor/bin/phpstan analyse --no-progress --memory-limit=2G` +Expected: Pest todos verdes, Pint `passed`, PHPStan `0 errors`. + +- [ ] **Step 6: Verification by mutation (prova de detecção)** + +Temporariamente troque, no `handle()`, `if ($event->wasRecentlyCreated)` por `if (false)` e rode: +`php artisan test app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php --filter="groups.metadata populates"` +Expected: FAIL (display_name null / count 0). Em seguida **reverta** a mutação e rode de novo: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add app-modules/integration-whatsapp/src/Ingest/Jobs/ProcessWhatsAppEvent.php app-modules/integration-whatsapp/tests/Feature/Ingest/WebhookIngestTest.php +git commit -m "feat(integration-whatsapp): dispatch groups.metadata handler from ingest job" +``` + +--- + +## Checklist final (após todas as tasks) + +- [ ] `php artisan test app-modules/integration-whatsapp/tests` — tudo verde. +- [ ] `./vendor/bin/pint app-modules/integration-whatsapp` — `passed`. +- [ ] `./vendor/bin/phpstan analyse --no-progress --memory-limit=2G` — `0 errors`. +- [ ] Nenhum dado real (telefone/`@lid`/nome) nos arquivos versionados — só sintéticos (`5511999999999`, `100000000000001@lid`, `He4rt Devs`). +- [ ] Spec [`0001-groups-metadata-design.md`](../specs/0001-groups-metadata-design.md) reflete o que foi implementado (atualizar `Status` se desejado). + +## Fora de escopo (follow-up) + +- Handlers de `group-participants.update` (entrou/saiu/admin em tempo real) e `groups.update` (nome/config): novos `case` no `resolveHandler()` + handlers dedicados. +- Resolução de identidade (`@lid` → membro He4rt unificado). diff --git a/app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md b/app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md new file mode 100644 index 000000000..f83095979 --- /dev/null +++ b/app-modules/integration-whatsapp/docs/specs/0001-groups-metadata-design.md @@ -0,0 +1,206 @@ +# Design — Handler de `groups.metadata` (snapshot de grupo + membership) + +**Data:** 2026-05-21 +**Status:** Aprovado (brainstorming) — aguardando plano de implementação +**Origem:** Brainstorming entre Clinton e Claude (Opus 4.7) + +> Feature da branch `feat/integration-whatsapp-ingest`. Adiciona o tratamento do novo tipo de +> evento `groups.metadata` emitido pelo `wpp-tui`, populando metadata do grupo e o vínculo +> participante↔grupo (com papel de admin). Ver também [spec.md](../spec.md) e +> [ADR-0001](../adr/0001-data-lake-approach.md). + +--- + +## 1. Contexto e problema + +O coletor (`wpp-tui`) passou a enviar um novo evento no webhook `POST /events`: +`groups.metadata` — o snapshot completo da metadata de um grupo (nome, descrição, dono, lista +de participantes com admins), buscado via API do WhatsApp na conexão. + +Hoje o `ProcessWhatsAppEvent` guarda o evento em `whatsapp_events` e cria a linha em +`whatsapp_groups` apenas com o `external_jid` — por isso `display_name`, `internal_name` e +`payload` dos grupos ficam **nulos**. Também não há nenhum registro relacional de quem é +membro de qual grupo nem de quem é admin. + +### Formato do evento (envelope padrão + payload cru do Baileys) + +```json +{ + "type": "groups.metadata", + "group_jid": "120363423768795942@g.us", + "participant_jid": null, + "participant_alt": null, + "occurred_at": "2026-05-21T12:43:45.573Z", + "occurred_at_source": "received", + "payload": { + "id": "120363423768795942@g.us", + "subject": "He4rt Devs", + "desc": "Comunidade de devs da He4rt", + "owner": "100000000000001@lid", + "creation": 1681000000, + "size": 3, + "announce": false, + "isCommunity": false, + "participants": [ + { "id": "100000000000001@lid", "admin": "superadmin" }, + { "id": "100000000000002@lid", "admin": "admin" }, + { "id": "100000000000003@lid", "admin": null } + ] + } +} +``` + +## 2. Decisões (brainstorming 2026-05-21) + +| Eixo | Decisão | Por quê | +|---|---|---| +| Ramificação por tipo | **Strategy: handler por tipo** | Cada handler testável isolado; caminho aberto p/ outros tipos; alinha com a regra Strategy do projeto (vários if/else por tipo). | +| Modelagem da membership | **Tabela pivô relacional** `whatsapp_group_participants` | Admins/membros consultáveis via SQL/relations; mantém `whatsapp_participants` global, como a memória do projeto já previa. | +| Sync de quem saiu | **Soft: `left_at`** | Preserva histórico de quem esteve no grupo — aderente ao data lake. | +| Escopo | **Só `groups.metadata`** | Resolve o `display_name` nulo + sincroniza membros/admins. Eventos real-time (`group-participants.update`, `groups.update`) ficam como follow-up; o Strategy já deixa o encaixe pronto (YAGNI). | + +## 3. Arquitetura (Strategy por tipo) + +O job mantém o **fluxo base comum** (resolver grupo/participante + gravar evento com dedup) e, +**só quando o evento é novo** (`wasRecentlyCreated`), delega para o handler do tipo, se houver. + +``` + ┌─────────────────────────────┐ + │ ProcessWhatsAppEvent (job) │ fluxo base p/ TODO tipo + │ resolveGroup() │ + │ resolveParticipant() │ + │ firstOrCreate(event) │ ← dedup por event_id (UUIDv5) + │ resolveHandler($type) ─────┼──► match($type) + └──────────┬──────────────────┘ │ 'groups.metadata' + │ if wasRecentlyCreated ▼ + ▼ ┌────────────────────────────┐ + (tipos sem handler │ GroupsMetadataHandler │ + seguem genéricos) │ implements EventHandler │ + │ • update grupo: subject + │ + │ payload cru (≠ internal) │ + │ • sync membership + admin │ + └──────────┬──────────────────┘ + ┌────────────────┼────────────────────┐ + ▼ ▼ ▼ + whatsapp_groups whatsapp_participants whatsapp_group_participants + (display_name, (upsert por @lid) (pivô: admin_role, left_at) + payload) +``` + +Componentes novos: +- `Ingest/Handlers/EventHandler.php` — interface: `handle(WhatsAppEvent $event): void`. +- `Ingest/Handlers/GroupsMetadataHandler.php` — lógica específica do snapshot. +- `ProcessWhatsAppEvent::resolveHandler(): ?EventHandler` — `match($type)` fino (só roteamento; a lógica vive no handler). Resolve via container (`app(...)`), não pelo construtor do job (que é serializado na fila). + +## 4. Schema novo — `whatsapp_group_participants` (pivô) + +``` +id uuid (v7) PK -- via HasVersion7Uuids +group_id uuid FK → whatsapp_groups (NOT NULL, cascadeOnDelete) +participant_id uuid FK → whatsapp_participants (NOT NULL, cascadeOnDelete) +admin_role string nullable -- 'superadmin' | 'admin' | null +joined_at timestamp nullable -- 1ª vez visto no grupo +left_at timestamp nullable -- null = ativo +timestamps +UNIQUE(group_id, participant_id) +INDEX(group_id, left_at) +``` + +`whatsapp_participants` continua **global** (sem `group_id`); o vínculo vive só na pivô. + +Relações: +- `WhatsAppGroup hasMany WhatsAppGroupParticipant` (e `belongsToMany WhatsAppParticipant` withPivot `admin_role`, `joined_at`, `left_at`). +- `WhatsAppParticipant hasMany WhatsAppGroupParticipant`. +- Model próprio `WhatsAppGroupParticipant` (pivô estendido com `HasVersion7Uuids`). + +## 5. Fluxo de dados do sync (membership) + +``` +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) + 100..003@lid null ──┘ + + ativos que NÃO estão no snapshot ───► left_at = now() (soft remove) +``` + +> **Atomicidade:** todo o `handle()` (update do grupo + upsert dos participantes + upsert das +> memberships + soft-removes) roda dentro de uma única `DB::transaction()`, para que um snapshot +> nunca deixe a membership pela metade. + +### Ciclo de vida do vínculo + +``` + snapshot inclui o membro + [novo] ───────────────────────────► [ativo] (left_at=null, admin_role=X) + │ ▲ + some do snapshot │ │ volta no snapshot + ▼ │ + [inativo] (left_at=ts) +``` + +## 6. Mudança no job (antes/depois) + +```php +// ANTES +$event = WhatsAppEvent::query()->firstOrCreate([...], [...]); +// (fim) + +// DEPOIS +$event = WhatsAppEvent::query()->firstOrCreate([...], [...]); +if ($event->wasRecentlyCreated) { + $this->resolveHandler()?->handle($event); // null para tipos sem handler +} +``` + +O `resolveGroup()` atual já seta `display_name = payload.subject ?? atual` e `last_seen_at` para +qualquer evento com `subject`. O handler **complementa**: grava o `payload` cru completo do grupo +(desc, owner, creation, announce, size, isCommunity…) e sincroniza a membership. **Não** toca +`internal_name`. + +## 7. Comportamento esperado (BDD) + +- **Happy path:** Dado um `groups.metadata` de grupo novo → o grupo fica com `display_name="He4rt Devs"`, + `payload` cru salvo, `internal_name` intacto, e **3 vínculos** criados (1 `superadmin`, 1 `admin`, + 1 sem role), todos `left_at=null`. +- **Idempotência:** Dado o MESMO snapshot reenviado (mesmo `event_id` UUIDv5) → `202 duplicate`, o + handler **não roda**, nada muda. (O controller barra antes de enfileirar via o `exists()`; o + `wasRecentlyCreated` é a 2ª linha de defesa.) +- **Mudança de estado:** Dado um snapshot com lista alterada (um membro saiu, outro virou admin) → + o que saiu recebe `left_at=now`; o promovido tem `admin_role` atualizado; novos membros criados + com `left_at=null`. Um membro que reentra tem `left_at` zerado de volta para `null`. +- **Borda — envelope sem participante:** `participant_jid`/`participant_alt` são `null` em + `groups.metadata` → o fluxo base não cria participante "do envelope"; os participantes vêm **só** + de `payload.participants[]`. +- **Borda — `participants` ausente/vazio:** se o payload não trouxer `participants`, o grupo é + atualizado (subject/payload) e nenhuma membership é alterada (sem soft-remove em massa por engano). + +## 8. Estratégia de testes + +- **`GroupsMetadataHandler` (isolado, sem HTTP):** snapshot → grupo populado + 3 vínculos; segundo + snapshot alterado → soft-remove do que saiu + `admin_role` atualizado + reentrada zera `left_at`. +- **E2e via webhook (queue `sync`):** POST `groups.metadata` → grupo + pivô; reenvio → `202 duplicate`. +- **Borda:** payload sem `participants` não mexe na membership. +- **Verificação por mutação:** quebrar o sync de propósito e confirmar que o teste falha (prova de + detecção), depois reverter. + +## 9. Arquivos afetados / criados + +**Novos:** +- `database/migrations/2026_05_21_..._create_whatsapp_group_participants_table.php` +- `src/Models/WhatsAppGroupParticipant.php` +- `src/Ingest/Handlers/EventHandler.php` +- `src/Ingest/Handlers/GroupsMetadataHandler.php` +- `database/factories/WhatsAppGroupParticipantFactory.php` +- `tests/Feature/Ingest/GroupsMetadataHandlerTest.php` + +**Alterados:** +- `src/Ingest/Jobs/ProcessWhatsAppEvent.php` — `resolveHandler()` + chamada condicional. +- `src/Models/WhatsAppGroup.php` e `WhatsAppParticipant.php` — relações de membership. +- `tests/Feature/Ingest/WebhookIngestTest.php` — caso e2e de `groups.metadata`. + +## 10. Fora de escopo (follow-up) + +- Handlers de `group-participants.update` (entrou/saiu/admin em tempo real) e `groups.update` + (nome/config). O Strategy já deixa o encaixe pronto — basta novos `case` no `resolveHandler()`. +- Resolução de identidade (`@lid` → membro He4rt unificado).