diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index 85bb3ba3c..dec6438a9 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -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; @@ -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), diff --git a/app/Services/Social/LinkedInAnalytics.php b/app/Services/Social/LinkedInAnalytics.php new file mode 100644 index 000000000..76a0d6921 --- /dev/null +++ b/app/Services/Social/LinkedInAnalytics.php @@ -0,0 +1,111 @@ +|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 + */ + 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|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)], + ]; + } +} diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 5573a7497..4f1603e15 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -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; @@ -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 + */ + 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|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 $stats + * @return array + */ + 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)], ]; } diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 66c05ddb6..1ba70c865 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -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, diff --git a/tests/Feature/Services/Social/LinkedInAnalyticsTest.php b/tests/Feature/Services/Social/LinkedInAnalyticsTest.php new file mode 100644 index 000000000..8ec3ae115 --- /dev/null +++ b/tests/Feature/Services/Social/LinkedInAnalyticsTest.php @@ -0,0 +1,145 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->socialAccount = SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->addDay(), + ]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $this->postPlatform = PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $this->socialAccount->id, + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'status' => Status::Published, + 'platform_post_id' => 'urn:li:share:7503082467755646976', + ]); + $this->api = config('trypost.platforms.linkedin.api'); +}); + +test('linkedin member post metrics read likes and comments from v2 socialActions', function () { + Http::preventStrayRequests(); + Http::fake([ + "{$this->api}/v2/socialActions/*" => Http::response([ + 'likesSummary' => ['totalLikes' => 1], + 'commentsSummary' => ['aggregatedTotalComments' => 0], + ], 200), + ]); + + expect((new LinkedInAnalytics)->fetchPostMetrics($this->postPlatform))->toEqual([ + ['label' => __('analytics.metrics.likes'), 'value' => 1], + ['label' => __('analytics.metrics.comments'), 'value' => 0], + ]); + + Http::assertSent(fn ($request) => str_contains( + $request->url(), + "{$this->api}/v2/socialActions/".urlencode('urn:li:share:7503082467755646976'), + )); +}); + +test('linkedin member post metrics refresh an expired token against the configured oauth host', function () { + $this->socialAccount->update([ + 'token_expires_at' => now()->subHour(), + 'refresh_token' => 'old_refresh_token', + ]); + + $oauthApi = config('trypost.platforms.linkedin.oauth_api'); + + Http::preventStrayRequests(); + Http::fake([ + "{$oauthApi}/oauth/v2/accessToken" => Http::response([ + 'access_token' => 'new_token', + 'refresh_token' => 'new_refresh_token', + 'expires_in' => 5184000, + ], 200), + "{$this->api}/v2/socialActions/*" => Http::response([ + 'likesSummary' => ['totalLikes' => 4], + 'commentsSummary' => ['aggregatedTotalComments' => 2], + ], 200), + ]); + + expect((new LinkedInAnalytics)->fetchPostMetrics($this->postPlatform->fresh()))->toEqual([ + ['label' => __('analytics.metrics.likes'), 'value' => 4], + ['label' => __('analytics.metrics.comments'), 'value' => 2], + ]); + + Http::assertSent(fn ($request) => str_contains($request->url(), "{$oauthApi}/oauth/v2/accessToken")); +}); + +test('linkedin member post metrics return unsupported when the share id is missing', function () { + $this->postPlatform->update(['platform_post_id' => null]); + + expect((new LinkedInAnalytics)->fetchPostMetrics($this->postPlatform->fresh())) + ->toBe(['unsupported' => true, 'reason' => 'missing_post_id']); +}); + +test('linkedin member post metrics skip a workspace token that cannot read the share', function () { + $this->postPlatform->update(['social_account_id' => null]); + + SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->addDay(), + ]); + + Http::preventStrayRequests(); + Http::fake([ + "{$this->api}/v2/socialActions/*" => Http::sequence() + ->push(['message' => 'ACCESS_DENIED'], 403) + ->push([ + 'likesSummary' => ['totalLikes' => 12], + 'commentsSummary' => ['aggregatedTotalComments' => 1], + ], 200), + ]); + + expect((new LinkedInAnalytics)->fetchPostMetrics($this->postPlatform->fresh()))->toEqual([ + ['label' => __('analytics.metrics.likes'), 'value' => 12], + ['label' => __('analytics.metrics.comments'), 'value' => 1], + ]); + + Http::assertSentCount(2); +}); + +test('linkedin member post metrics reuse a workspace token when the published row lost its account', function () { + $this->postPlatform->update(['social_account_id' => null]); + + Http::preventStrayRequests(); + Http::fake([ + "{$this->api}/v2/socialActions/*" => Http::response([ + 'likesSummary' => ['totalLikes' => 12], + 'commentsSummary' => ['aggregatedTotalComments' => 1], + ], 200), + ]); + + expect((new LinkedInAnalytics)->fetchPostMetrics($this->postPlatform->fresh()))->toEqual([ + ['label' => __('analytics.metrics.likes'), 'value' => 12], + ['label' => __('analytics.metrics.comments'), 'value' => 1], + ]); +}); + +test('linkedin member post metrics return unsupported when socialActions fails', function () { + Http::preventStrayRequests(); + Http::fake([ + "{$this->api}/v2/socialActions/*" => Http::response(['message' => 'ACCESS_DENIED'], 403), + ]); + + expect((new LinkedInAnalytics)->fetchPostMetrics($this->postPlatform)) + ->toBe(['unsupported' => true, 'reason' => 'api_error']); +}); diff --git a/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php b/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php index 6805fd4d0..9ba914431 100644 --- a/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php +++ b/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php @@ -43,9 +43,16 @@ 'refresh_token' => 'new_refresh_token', 'expires_in' => 5184000, ], 200), - "{$api}/rest/socialActions/*" => Http::response([ - 'likesSummary' => ['totalLikes' => 0], - 'commentsSummary' => ['aggregatedTotalComments' => 0], + "{$api}/rest/organizationalEntityShareStatistics*" => Http::response([ + 'elements' => [[ + 'totalShareStatistics' => [ + 'impressionCount' => 0, + 'clickCount' => 0, + 'likeCount' => 0, + 'commentCount' => 0, + 'shareCount' => 0, + ], + ]], ], 200), ]); @@ -53,3 +60,139 @@ Http::assertSent(fn ($request) => str_contains($request->url(), "{$oauthApi}/oauth/v2/accessToken")); }); + +test('linkedin page post metrics read lifetime share statistics for a ugcPost', function () { + $this->socialAccount->update(['token_expires_at' => now()->addDay()]); + $this->postPlatform->update(['platform_post_id' => 'urn:li:ugcPost:7504988143797075969']); + + $api = config('trypost.platforms.linkedin-page.api'); + + Http::preventStrayRequests(); + Http::fake([ + "{$api}/rest/organizationalEntityShareStatistics*" => Http::response([ + 'elements' => [[ + 'ugcPost' => 'urn:li:ugcPost:7504988143797075969', + 'totalShareStatistics' => [ + 'impressionCount' => 99, + 'clickCount' => 14, + 'likeCount' => 0, + 'commentCount' => 0, + 'shareCount' => 0, + ], + ]], + ], 200), + ]); + + expect((new LinkedInPageAnalytics)->fetchPostMetrics($this->postPlatform->fresh()))->toEqual([ + ['label' => __('analytics.metrics.impressions'), 'value' => 99], + ['label' => __('analytics.metrics.clicks'), 'value' => 14], + ['label' => __('analytics.metrics.likes'), 'value' => 0], + ['label' => __('analytics.metrics.comments'), 'value' => 0], + ['label' => __('analytics.metrics.shares'), 'value' => 0], + ]); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'ugcPosts=List(') + && str_contains($request->url(), rawurlencode('urn:li:ugcPost:7504988143797075969'))); +}); + +test('linkedin page post metrics filter share URNs with the shares list', function () { + $this->socialAccount->update(['token_expires_at' => now()->addDay()]); + + $api = config('trypost.platforms.linkedin-page.api'); + + Http::preventStrayRequests(); + Http::fake([ + "{$api}/rest/organizationalEntityShareStatistics*" => Http::response([ + 'elements' => [[ + 'share' => 'urn:li:share:1234567890', + 'totalShareStatistics' => [ + 'impressionCount' => 10, + 'clickCount' => 2, + 'likeCount' => 3, + 'commentCount' => 1, + 'shareCount' => 0, + ], + ]], + ], 200), + ]); + + expect((new LinkedInPageAnalytics)->fetchPostMetrics($this->postPlatform->fresh()))->toEqual([ + ['label' => __('analytics.metrics.impressions'), 'value' => 10], + ['label' => __('analytics.metrics.clicks'), 'value' => 2], + ['label' => __('analytics.metrics.likes'), 'value' => 3], + ['label' => __('analytics.metrics.comments'), 'value' => 1], + ['label' => __('analytics.metrics.shares'), 'value' => 0], + ]); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'shares=List(')); +}); + +test('linkedin page post metrics skip a workspace page that does not own the share', function () { + $this->socialAccount->update(['token_expires_at' => now()->addDay()]); + $this->postPlatform->update(['social_account_id' => null]); + + SocialAccount::factory()->linkedinPage()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->addDay(), + 'platform_user_id' => '99920311', + ]); + + $api = config('trypost.platforms.linkedin-page.api'); + + Http::preventStrayRequests(); + Http::fake([ + "{$api}/rest/organizationalEntityShareStatistics*" => Http::sequence() + ->push(['elements' => []], 200) + ->push([ + 'elements' => [[ + 'totalShareStatistics' => [ + 'impressionCount' => 99, + 'clickCount' => 14, + 'likeCount' => 0, + 'commentCount' => 0, + 'shareCount' => 0, + ], + ]], + ], 200), + ]); + + expect((new LinkedInPageAnalytics)->fetchPostMetrics($this->postPlatform->fresh()))->toEqual([ + ['label' => __('analytics.metrics.impressions'), 'value' => 99], + ['label' => __('analytics.metrics.clicks'), 'value' => 14], + ['label' => __('analytics.metrics.likes'), 'value' => 0], + ['label' => __('analytics.metrics.comments'), 'value' => 0], + ['label' => __('analytics.metrics.shares'), 'value' => 0], + ]); + + Http::assertSentCount(2); +}); + +test('linkedin page post metrics reuse a workspace token when the published row lost its account', function () { + $this->socialAccount->update(['token_expires_at' => now()->addDay()]); + $this->postPlatform->update(['social_account_id' => null]); + + $api = config('trypost.platforms.linkedin-page.api'); + + Http::preventStrayRequests(); + Http::fake([ + "{$api}/rest/organizationalEntityShareStatistics*" => Http::response([ + 'elements' => [[ + 'totalShareStatistics' => [ + 'impressionCount' => 99, + 'clickCount' => 14, + 'likeCount' => 0, + 'commentCount' => 0, + 'shareCount' => 0, + ], + ]], + ], 200), + ]); + + expect((new LinkedInPageAnalytics)->fetchPostMetrics($this->postPlatform->fresh()))->toEqual([ + ['label' => __('analytics.metrics.impressions'), 'value' => 99], + ['label' => __('analytics.metrics.clicks'), 'value' => 14], + ['label' => __('analytics.metrics.likes'), 'value' => 0], + ['label' => __('analytics.metrics.comments'), 'value' => 0], + ['label' => __('analytics.metrics.shares'), 'value' => 0], + ]); +});