diff --git a/src/Publishers/MixpanelPublisher.php b/src/Publishers/MixpanelPublisher.php index d0a6040..91de9af 100644 --- a/src/Publishers/MixpanelPublisher.php +++ b/src/Publishers/MixpanelPublisher.php @@ -4,9 +4,12 @@ use BetterWorld\Scribe\Contracts\Book; use BetterWorld\Scribe\Contracts\Metadata; +use BetterWorld\Scribe\Contracts\Narrative; use BetterWorld\Scribe\Contracts\Publisher; +use BetterWorld\Scribe\Exceptions\MissingArrayKeyException; use Exception; use Mixpanel; +use Producers_MixpanelGroups; use function BetterWorld\Scribe\Support\array_value; @@ -16,6 +19,8 @@ /** * @param array $options + * + * @throws MissingArrayKeyException */ public function __construct( private string $name, @@ -35,23 +40,8 @@ public function name(): string public function publish(Book $book): bool { foreach ($book->read() as $narrative) { - $metadata = $narrative instanceof Metadata ? $narrative->metadata() : []; - try { - if (isset($metadata['user_id']) && is_string($metadata['user_id'])) { - $userId = $metadata['user_id']; - $this->mixpanel->identify(user_id: $userId); - - if (isset($metadata['properties']) && is_array($metadata['properties'])) { - $this->mixpanel->people->setOnce($userId, $metadata['properties']); - } - } - - $this->mixpanel->track( - event: (string) $narrative::key(), - properties: $narrative->values() - ); - + $this->publishNarrative($narrative); } catch (Exception $e) { error_log('Mixpanel tracking failed: '.$e->getMessage()); } @@ -59,4 +49,95 @@ public function publish(Book $book): bool return true; } + + /** + * @param Narrative $narrative + */ + private function publishNarrative($narrative): void + { + $metadata = $narrative instanceof Metadata ? $narrative->metadata() : []; + $trackProperties = $narrative->values(); + + $userId = $this->handleUserMetadata($metadata); + $orgId = $this->handleOrganizationMetadata($metadata); + + $this->addContextToTrackProperties($trackProperties, $userId, $orgId); + $this->trackEvent($narrative, $trackProperties); + } + + /** + * @param array $metadata + */ + private function handleUserMetadata(array $metadata): ?string + { + if (! isset($metadata['user']) || ! is_array($metadata['user'])) { + return null; + } + + $userData = $metadata['user']; + $userId = $userData['id'] ?? null; + + if (! is_string($userId)) { + return null; + } + + $this->mixpanel->identify(user_id: $userId); + + if (isset($userData['properties']) && is_array($userData['properties'])) { + $this->mixpanel->people->setOnce($userId, $userData['properties']); + } + + return $userId; + } + + /** + * @param array $metadata + */ + private function handleOrganizationMetadata(array $metadata): ?string + { + if (! isset($metadata['organization']) || ! is_array($metadata['organization'])) { + return null; + } + + $orgData = $metadata['organization']; + $orgId = $orgData['id'] ?? null; + + if (! is_string($orgId)) { + return null; + } + + if (isset($orgData['properties']) && is_array($orgData['properties'])) { + /** @var Producers_MixpanelGroups $group */ + $group = $this->mixpanel->group; + $group->setOnce('organization_id', $orgId, $orgData['properties']); + } + + return $orgId; + } + + /** + * @param array $trackProperties + */ + private function addContextToTrackProperties(array &$trackProperties, ?string $userId, ?string $orgId): void + { + if ($userId) { + $trackProperties['distinct_id'] = $userId; + } + + if ($orgId) { + $trackProperties['organization_id'] = $orgId; + $trackProperties['$groups'] = ['organization_id' => $orgId]; + } + } + + /** + * @param array $trackProperties + */ + private function trackEvent(Narrative $narrative, array $trackProperties): void + { + $this->mixpanel->track( + event: $narrative::key(), + properties: $trackProperties + ); + } } diff --git a/tests/Unit/MixpanelPublisherTest.php b/tests/Unit/MixpanelPublisherTest.php new file mode 100644 index 0000000..49bb58a --- /dev/null +++ b/tests/Unit/MixpanelPublisherTest.php @@ -0,0 +1,208 @@ + $token]); +} + +/** + * @param array $values + * @param array|null $metadata + * @return \BetterWorld\Scribe\Contracts\Narrative&Metadata + */ +function createTestNarrative(array $values, ?array $metadata = null): object +{ + return new class($values, $metadata) extends Narrative implements Metadata + { + /** + * @param array $eventValues + * @param array|null $eventMetadata + */ + public function __construct( + private readonly array $eventValues, + private readonly ?array $eventMetadata + ) {} + + public static function key(): string + { + return 'test_event'; + } + + /** + * @return array + */ + public function values(): array + { + return $this->eventValues; + } + + /** + * @return array + */ + public function metadata(): array + { + return $this->eventMetadata ?? []; + } + }; +} + +/** + * @param array<\BetterWorld\Scribe\Contracts\Narrative> $narratives + */ +function createTestBook(array $narratives): Book +{ + return new class($narratives) implements Book + { + /** + * @param array<\BetterWorld\Scribe\Contracts\Narrative> $narratives + */ + public function __construct(private array $narratives) {} + + public function name(): string + { + return 'test-book'; + } + + /** + * @return array<\BetterWorld\Scribe\Contracts\Narrative> + */ + public function read(): array + { + return $this->narratives; + } + + public function write(\BetterWorld\Scribe\Contracts\Narrative $narrative): static + { + $this->narratives[] = $narrative; + + return $this; + } + + public function publishers(): array + { + return []; + } + + public function publish(): void {} + }; +} + +test('mixpanel publisher has a name', function (): void { + $publisher = createMixpanelPublisher(); + + expect($publisher->name())->toBe('test-mixpanel'); +}); + +test('mixpanel publisher creates mixpanel instance with token', function (): void { + $publisher = createMixpanelPublisher(); + + expect($publisher)->toBeInstanceOf(MixpanelPublisher::class); +}); + +test('mixpanel publisher publishes narratives without metadata', function (): void { + $publisher = createMixpanelPublisher(); + $narrative = createTestNarrative(['property1' => 'value1', 'property2' => 'value2']); + $book = createTestBook([$narrative]); + + $result = $publisher->publish($book); + + expect($result)->toBeTrue(); +}); + +test('mixpanel publisher publishes narratives with user metadata', function (): void { + $publisher = createMixpanelPublisher(); + $metadata = [ + 'user' => [ + 'id' => 'USER5', + 'properties' => [ + '$name' => 'Tien Le', + 'email' => 'tien@example.com', + ], + ], + ]; + $narrative = createTestNarrative(['action' => 'login'], $metadata); + $book = createTestBook([$narrative]); + + $result = $publisher->publish($book); + + expect($result)->toBeTrue(); +}); + +test('mixpanel publisher publishes narratives with organization metadata', function (): void { + $publisher = createMixpanelPublisher(); + $metadata = [ + 'organization' => [ + 'id' => 'COMP7', + 'properties' => [ + 'Name' => 'BetterWorld', + 'Plan' => 'Enterprise', + ], + ], + ]; + $narrative = createTestNarrative(['action' => 'org_created'], $metadata); + $book = createTestBook([$narrative]); + + $result = $publisher->publish($book); + + expect($result)->toBeTrue(); +}); + +test('mixpanel publisher publishes narratives with both user and organization metadata', function (): void { + $publisher = createMixpanelPublisher(); + $metadata = [ + 'user' => [ + 'id' => 'USER5', + 'properties' => [ + '$name' => 'Tien Le', + 'organization_id' => 'COMP7', + ], + ], + 'organization' => [ + 'id' => 'COMP7', + 'properties' => [ + 'Name' => 'BetterWorld', + ], + ], + ]; + $narrative = createTestNarrative(['action' => 'login'], $metadata); + $book = createTestBook([$narrative]); + + $result = $publisher->publish($book); + + expect($result)->toBeTrue(); +}); + +test('mixpanel publisher handles multiple narratives in book', function (): void { + $publisher = createMixpanelPublisher(); + $narrative1 = createTestNarrative(['data' => 'value1']); + $narrative2 = createTestNarrative(['data' => 'value2']); + $book = createTestBook([$narrative1, $narrative2]); + + $result = $publisher->publish($book); + + expect($result)->toBeTrue(); +}); + +test('mixpanel publisher handles empty book', function (): void { + $publisher = createMixpanelPublisher(); + $book = createTestBook([]); + + $result = $publisher->publish($book); + + expect($result)->toBeTrue(); +}); + +test('mixpanel publisher handles exceptions gracefully', function (): void { + $publisher = createMixpanelPublisher('invalid-token'); + $narrative = createTestNarrative(['data' => 'value']); + $book = createTestBook([$narrative]); + + $result = $publisher->publish($book); + + expect($result)->toBeTrue(); +});