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
2 changes: 2 additions & 0 deletions app/Services/Post/PostMetricsFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Services\Social\Discord\DiscordAnalytics;
use App\Services\Social\FacebookAnalytics;
use App\Services\Social\InstagramAnalytics;
use App\Services\Social\LinkedInAnalytics;
use App\Services\Social\LinkedInPageAnalytics;
use App\Services\Social\MastodonAnalytics;
use App\Services\Social\PinterestAnalytics;
Expand Down Expand Up @@ -76,6 +77,7 @@ public function forPlatform(PostPlatform $postPlatform): array
Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::LinkedIn => app(LinkedInAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform),
Expand Down
111 changes: 111 additions & 0 deletions app/Services/Social/LinkedInAnalytics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

namespace App\Services\Social;

use App\Enums\SocialAccount\Status;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;

class LinkedInAnalytics
{
use HasSocialHttpClient;

/**
* Personal-profile likes/comments live on the unversioned
* `/v2/socialActions/{shareUrn}` edge. `/rest/socialActions` and
* `memberCreatorPostAnalytics` both 403 with a member token
* (`ACCESS_DENIED` / missing `r_member_postAnalytics`).
*
* @see https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/network-update-social-actions
*
* @return array<int, array{label: string, value: int}>|array{unsupported: true, reason: string}
*/
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
if (! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}

$bound = $postPlatform->socialAccount;
$accounts = $bound ? collect([$bound]) : $this->connectedWorkspaceAccounts($postPlatform);

if ($accounts->isEmpty()) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}

foreach ($accounts as $account) {
$metrics = $this->metricsFrom($account, $postPlatform);

if ($metrics !== null) {
return $metrics;
}

if ($bound) {
return ['unsupported' => true, 'reason' => 'api_error'];
}
}

return ['unsupported' => true, 'reason' => 'api_error'];
}

/**
* Disconnect deletes the social account and nulls `social_account_id`.
* A workspace may have several member LinkedIns, so try each connected
* token until one can read this URN.
*
* @return Collection<int, SocialAccount>
*/
private function connectedWorkspaceAccounts(PostPlatform $postPlatform): Collection
{
$workspaceId = $postPlatform->post?->workspace_id;

if (! $workspaceId) {
return collect();
}

return SocialAccount::query()
->where('workspace_id', $workspaceId)
->where('platform', $postPlatform->platform)
->where('status', Status::Connected)
->orderBy('created_at')
->get();
}

/**
* @return array<int, array{label: string, value: int}>|null
*/
private function metricsFrom(SocialAccount $account, PostPlatform $postPlatform): ?array
{
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}

$shareUrn = urlencode($postPlatform->platform_post_id);
$baseUrl = config('trypost.platforms.linkedin.api');

$response = $this->socialHttp()
->withToken($account->access_token)
->get("{$baseUrl}/v2/socialActions/{$shareUrn}");

if ($response->failed()) {
Log::warning('LinkedIn post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);

return null;
}

$data = $response->json();

return [
['label' => __('analytics.metrics.likes'), 'value' => (int) data_get($data, 'likesSummary.totalLikes', 0)],
['label' => __('analytics.metrics.comments'), 'value' => (int) data_get($data, 'commentsSummary.aggregatedTotalComments', 0)],
];
}
}
97 changes: 86 additions & 11 deletions app/Services/Social/LinkedInPageAnalytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

namespace App\Services\Social;

use App\Enums\SocialAccount\Status;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

Expand Down Expand Up @@ -44,36 +46,109 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul

public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if (! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}

$bound = $postPlatform->socialAccount;
$accounts = $bound ? collect([$bound]) : $this->connectedWorkspaceAccounts($postPlatform);

if (! $account || ! $postPlatform->platform_post_id) {
if ($accounts->isEmpty()) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}

foreach ($accounts as $account) {
$metrics = $this->metricsFrom($account, $postPlatform, requireHit: $bound === null);

if ($metrics !== null) {
return $metrics;
}

if ($bound) {
return ['unsupported' => true, 'reason' => 'api_error'];
}
}

return ['unsupported' => true, 'reason' => 'api_error'];
}

/**
* Disconnect deletes the social account and nulls `social_account_id`.
* A workspace may have several Pages, so try each connected token
* until one owns this URN (empty `elements` means the wrong org).
*
* @return Collection<int, SocialAccount>
*/
private function connectedWorkspaceAccounts(PostPlatform $postPlatform): Collection
{
$workspaceId = $postPlatform->post?->workspace_id;

if (! $workspaceId) {
return collect();
}

return SocialAccount::query()
->where('workspace_id', $workspaceId)
->where('platform', $postPlatform->platform)
->where('status', Status::Connected)
->orderBy('created_at')
->get();
}

/**
* Per-post lifetime stats. `/rest/socialActions` 403s
* (`partnerApiSocialActions`) with a Page token; this endpoint is the
* official org share-statistics path.
*
* @see https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/share-statistics
*
* @return array<int, array{label: string, value: int}>|null
*/
private function metricsFrom(SocialAccount $account, PostPlatform $postPlatform, bool $requireHit): ?array
{
if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}

// platform_post_id is the share URN (e.g., "urn:li:share:12345").
$shareUrn = urlencode($postPlatform->platform_post_id);
$this->accessToken = $account->access_token;

$org = rawurlencode("urn:li:organization:{$account->platform_user_id}");
$shareUrn = rawurlencode($postPlatform->platform_post_id);
$filter = str_contains($postPlatform->platform_post_id, 'ugcPost') ? 'ugcPosts' : 'shares';

$response = $this->socialHttp()
->withToken($account->access_token)
->get("{$this->baseUrl}/socialActions/{$shareUrn}");
$response = $this->getHttpClient()
->get("{$this->baseUrl}/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity={$org}&{$filter}=List({$shareUrn})");

if ($response->failed()) {
Log::warning('LinkedIn Page post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);

return ['unsupported' => true, 'reason' => 'api_error'];
return null;
}

$stats = data_get($response->json(), 'elements.0.totalShareStatistics');

if (! is_array($stats)) {
return $requireHit ? null : $this->postMetricsFromStats([]);
}

$data = $response->json();
return $this->postMetricsFromStats($stats);
}

/**
* @param array<string, mixed> $stats
* @return array<int, array{label: string, value: int}>
*/
private function postMetricsFromStats(array $stats): array
{
return [
['label' => __('analytics.metrics.likes'), 'value' => (int) data_get($data, 'likesSummary.totalLikes', 0)],
['label' => __('analytics.metrics.comments'), 'value' => (int) data_get($data, 'commentsSummary.aggregatedTotalComments', 0)],
['label' => __('analytics.metrics.impressions'), 'value' => (int) data_get($stats, 'impressionCount', 0)],
['label' => __('analytics.metrics.clicks'), 'value' => (int) data_get($stats, 'clickCount', 0)],
['label' => __('analytics.metrics.likes'), 'value' => (int) data_get($stats, 'likeCount', 0)],
['label' => __('analytics.metrics.comments'), 'value' => (int) data_get($stats, 'commentCount', 0)],
['label' => __('analytics.metrics.shares'), 'value' => (int) data_get($stats, 'shareCount', 0)],
];
}

Expand Down
82 changes: 82 additions & 0 deletions tests/Feature/PostControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,88 @@
$response->assertJsonFragment(['label' => 'Likes', 'value' => 11]);
});

test('platform metrics dispatches LinkedIn analytics for LinkedIn platform', function () {
$linkedinAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'token_expires_at' => now()->addDay(),
]);

$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);

$pp = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $linkedinAccount->id,
'platform' => Platform::LinkedIn,
'content_type' => ContentType::LinkedInPost,
'status' => Status::Published,
'platform_post_id' => 'urn:li:share:7503082467755646976',
]);

$api = config('trypost.platforms.linkedin.api');

Http::fake([
"{$api}/v2/socialActions/*" => Http::response([
'likesSummary' => ['totalLikes' => 1],
'commentsSummary' => ['aggregatedTotalComments' => 0],
], 200),
]);

$response = $this->actingAs($this->user)
->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id]));

$response->assertOk();
$response->assertJsonFragment(['label' => 'Likes', 'value' => 1]);
$response->assertJsonFragment(['label' => 'Comments', 'value' => 0]);
});

test('platform metrics dispatches LinkedIn Page analytics for LinkedIn Page platform', function () {
$pageAccount = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'token_expires_at' => now()->addDay(),
'platform_user_id' => '99920311',
]);

$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);

$pp = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $pageAccount->id,
'platform' => Platform::LinkedInPage,
'content_type' => ContentType::LinkedInPagePost,
'status' => Status::Published,
'platform_post_id' => 'urn:li:ugcPost:7504988143797075969',
]);

$api = config('trypost.platforms.linkedin-page.api');

Http::fake([
"{$api}/rest/organizationalEntityShareStatistics*" => Http::response([
'elements' => [[
'totalShareStatistics' => [
'impressionCount' => 99,
'clickCount' => 14,
'likeCount' => 0,
'commentCount' => 0,
'shareCount' => 0,
],
]],
], 200),
]);

$response = $this->actingAs($this->user)
->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id]));

$response->assertOk();
$response->assertJsonFragment(['label' => 'Impressions', 'value' => 99]);
$response->assertJsonFragment(['label' => 'Clicks', 'value' => 14]);
});

test('show page renders for non-editable posts', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
Expand Down
Loading
Loading