Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions src/Events/ResourceLifecycleEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, Channel> $channels
*
* @return array<int, Channel>
*/
public static function uniqueChannels(array $channels): array
{
$unique = [];
foreach ($channels as $channel) {
$unique[(string) $channel] ??= $channel;
}

return array_values($unique);
}

/**
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
72 changes: 71 additions & 1 deletion tests/Unit/EventsAndExceptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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']);

Expand Down
Loading