diff --git a/src/Events/ResourceLifecycleEvent.php b/src/Events/ResourceLifecycleEvent.php index 5a9e444c..b6224500 100644 --- a/src/Events/ResourceLifecycleEvent.php +++ b/src/Events/ResourceLifecycleEvent.php @@ -156,7 +156,26 @@ public function broadcastOn(): array|Channel $this->addRelationshipSpecificChannels($model, $channels); $this->addChatChannels($model, $channels); - return $channels; + return static::uniqueChannels($channels); + } + + /** + * Drops channels whose name is already in the list, keeping the first, so the event goes out + * once per channel name (e.g. `company.{uuid}` from both the session and the model, or a + * driver's own `driver.{id}` channel). + * + * @param array $channels + * + * @return array + */ + public static function uniqueChannels(array $channels): array + { + $unique = []; + foreach ($channels as $channel) { + $unique[(string) $channel] ??= $channel; + } + + return array_values($unique); } /** @@ -218,20 +237,33 @@ protected function addApiCredentialChannel($model, &$channels) /** * Adds channels related to relationships defined in the model such as driver, customer, facilitator, and vendor. * + * A record assigned to a driver is published on the driver's own channel (`driver.{public_id}` + * and `driver.{uuid}`), next to the driver's location and geofence events. + * + * Deprecated: `driverAssigned.{uuid}` and `driverAssigned.{public_id}` are still published so + * existing subscribers keep working. New clients should subscribe to `driver.{public_id}`. + * * @param object $model the model related to the event * @param array $channels reference to the channels array */ protected function addRelationshipSpecificChannels($model, &$channels) { - $relationships = ['driverAssigned', 'customer', 'facilitator', 'vendor']; - foreach ($relationships as $relationship) { + $relationships = [ + 'driverAssigned' => ['driver', 'driverAssigned'], + 'customer' => ['customer'], + 'facilitator' => ['facilitator'], + 'vendor' => ['vendor'], + ]; + foreach ($relationships as $relationship => $prefixes) { // Foreign keys are snake case (`driver_assigned_uuid`), the relationship names are camel case. $foreignKey = Str::snake($relationship) . '_uuid'; - if ($model && isset($model->{$foreignKey})) { - $channels[] = new Channel($relationship . '.' . $model->{$foreignKey}); - } - if ($model && isset($model->{$relationship})) { - $channels[] = new Channel($relationship . '.' . $model->{$relationship}->public_id); + foreach ($prefixes as $prefix) { + if ($model && isset($model->{$foreignKey})) { + $channels[] = new Channel($prefix . '.' . $model->{$foreignKey}); + } + if ($model && isset($model->{$relationship})) { + $channels[] = new Channel($prefix . '.' . $model->{$relationship}->public_id); + } } } diff --git a/tests/Unit/EventsAndExceptionsTest.php b/tests/Unit/EventsAndExceptionsTest.php index 1a1e5274..72553dbe 100644 --- a/tests/Unit/EventsAndExceptionsTest.php +++ b/tests/Unit/EventsAndExceptionsTest.php @@ -759,6 +759,8 @@ public function toArray($request): array ->and($channels)->toContain('company.company_7654321') ->and($channels)->toContain('chat_channel.chat_7654321') ->and($channels)->toContain('chat_channel.33333333-3333-4333-8333-333333333333') + ->and($channels)->toContain('driver.driver-assigned-uuid') + ->and($channels)->toContain('driver.user_7654321') ->and($channels)->toContain('driverAssigned.driver-assigned-uuid') ->and($channels)->toContain('driverAssigned.user_7654321') ->and($channels)->toContain('customer.customer-uuid') @@ -813,12 +815,80 @@ public function toArray($request): array $channels = array_map(fn ($channel) => (string) $channel, $event->broadcastOn()); - expect($channels)->toContain('driverAssigned.driver-uuid-only') + expect($channels)->toContain('driver.driver-uuid-only') + ->and($channels)->toContain('driverAssigned.driver-uuid-only') ->and($channels)->toContain('customer.customer-uuid-only') + ->and($channels)->not->toContain('driver.') ->and($channels)->not->toContain('driverAssigned.') ->and($channels)->not->toContain('customer.'); }); +test('resource lifecycle events publish each channel name once', function () { + bind_test_container(); + session()->flush(); + // The session company and the model's company are the same uuid, so `company.{uuid}` is + // produced twice before deduplication. + session(['company' => 'same-company', 'user' => 'session-user']); + + $driver = new User(); + $driver->setRawAttributes(['public_id' => 'driver_abc123'], true); + + $channel = new ChatChannel(); + $channel->setRawAttributes([ + 'uuid' => '55555555-5555-4555-8555-555555555555', + 'public_id' => 'chat_2222222', + 'company_uuid' => 'same-company', + 'created_by_uuid' => 'creator-user', + 'driver_assigned_uuid' => 'driver-uuid', + ], true); + $channel->setRelation('company', null); + $channel->setRelation('driverAssigned', $driver); + + $event = EventsAndExceptionsLifecycleEvent::fake([ + 'modelName' => 'chat_channel', + 'modelClassNamespace' => ChatChannel::class, + 'modelClassName' => 'ChatChannel', + 'modelHumanName' => 'chat channel', + 'modelRecordName' => null, + 'modelUuid' => '55555555-5555-4555-8555-555555555555', + 'namespace' => '\\Fleetbase', + 'version' => 1, + 'eventName' => 'updated', + 'sentAt' => '2026-10-15 12:00:00', + 'eventId' => 'event_unique_channels', + 'apiVersion' => 'v1', + 'requestMethod' => 'PUT', + 'apiCredential' => 'console', + 'apiSecret' => 'internal', + 'apiKey' => null, + 'apiEnvironment' => 'live', + 'isSandbox' => false, + 'data' => [], + 'userSession' => 'session-user', + 'companySession' => 'same-company', + ], $channel); + + $broadcastOn = $event->broadcastOn(); + $channels = array_map(fn ($channel) => (string) $channel, $broadcastOn); + + expect($broadcastOn)->each->toBeInstanceOf(Channel::class) + ->and($channels)->toBe(array_values(array_unique($channels))) + ->and(array_count_values($channels)['company.same-company'])->toBe(1) + ->and($channels)->toContain('driver.driver_abc123') + ->and($channels)->toContain('driver.driver-uuid') + ->and($channels)->toContain('driverAssigned.driver_abc123') + ->and($channels)->toContain('driverAssigned.driver-uuid'); +}); + +test('unique channels keeps the first channel of each name in order', function () { + $first = new Channel('driver.driver_abc123'); + $second = new Channel('order.order_abc123'); + + expect(ResourceLifecycleEvent::uniqueChannels([$first, $second, new Channel('driver.driver_abc123'), $second])) + ->toBe([$first, $second]) + ->and(ResourceLifecycleEvent::uniqueChannels([]))->toBe([]); +}); + test('resource lifecycle events prefer webhook payloads when resources provide them', function () { bind_test_container(['api.version' => 'v1']);