Skip to content
Closed
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
113 changes: 97 additions & 16 deletions src/Publishers/MixpanelPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,6 +19,8 @@

/**
* @param array<string,mixed> $options
*
* @throws MissingArrayKeyException
*/
public function __construct(
private string $name,
Expand All @@ -35,28 +40,104 @@ 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());
}
}

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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $trackProperties
*/
private function trackEvent(Narrative $narrative, array $trackProperties): void
{
$this->mixpanel->track(
event: $narrative::key(),
properties: $trackProperties
);
}
}
208 changes: 208 additions & 0 deletions tests/Unit/MixpanelPublisherTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
<?php

use BetterWorld\Scribe\Contracts\Book;
use BetterWorld\Scribe\Contracts\Metadata;
use BetterWorld\Scribe\Narrative;
use BetterWorld\Scribe\Publishers\MixpanelPublisher;

function createMixpanelPublisher(string $token = 'test-token'): MixpanelPublisher
{
return new MixpanelPublisher('test-mixpanel', ['token' => $token]);
}

/**
* @param array<string, mixed> $values
* @param array<string, mixed>|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<string, mixed> $eventValues
* @param array<string, mixed>|null $eventMetadata
*/
public function __construct(
private readonly array $eventValues,
private readonly ?array $eventMetadata
) {}

public static function key(): string
{
return 'test_event';
}

/**
* @return array<string, mixed>
*/
public function values(): array
{
return $this->eventValues;
}

/**
* @return array<string, mixed>
*/
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();
});