Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).
Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().
Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.
Refs #126
232 lines
8.4 KiB
PHP
232 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Social;
|
|
|
|
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\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class LinkedInPageAnalytics
|
|
{
|
|
use HasSocialHttpClient;
|
|
|
|
private string $baseUrl;
|
|
|
|
private string $accessToken;
|
|
|
|
public function __construct()
|
|
{
|
|
// Versioned API (`/rest/`) is the only one that honours the
|
|
// LinkedIn-Version header and the current analytics schemas.
|
|
// The legacy `/v2/` path rejects newer parameter formats with
|
|
// "Parameter 'timeIntervals' is invalid".
|
|
$this->baseUrl = config('trypost.platforms.linkedin-page.api').'/rest';
|
|
}
|
|
|
|
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
|
|
{
|
|
$since ??= now()->subDays(7);
|
|
$until ??= now();
|
|
|
|
$cacheKey = "analytics:linkedin-page:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}";
|
|
$cacheTtl = app()->isProduction() ? 3600 : 1;
|
|
|
|
return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) {
|
|
return $this->fetchMetricsFromApi($account, $since, $until);
|
|
});
|
|
}
|
|
|
|
public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|
{
|
|
$account = $postPlatform->socialAccount;
|
|
|
|
if (! $account || ! $postPlatform->platform_post_id) {
|
|
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
|
}
|
|
|
|
if ($account->needsProactiveTokenRefresh()) {
|
|
app(ConnectionVerifier::class)->refreshToken($account);
|
|
}
|
|
|
|
// platform_post_id is the share URN (e.g., "urn:li:share:12345").
|
|
$shareUrn = urlencode($postPlatform->platform_post_id);
|
|
|
|
$response = $this->socialHttp()
|
|
->withToken($account->access_token)
|
|
->get("{$this->baseUrl}/socialActions/{$shareUrn}");
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('LinkedIn Page post metrics fetch failed', [
|
|
'body' => $this->redactResponseBody($response->body()),
|
|
]);
|
|
|
|
return ['unsupported' => true, 'reason' => 'api_error'];
|
|
}
|
|
|
|
$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)],
|
|
];
|
|
}
|
|
|
|
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
|
{
|
|
if ($account->needsProactiveTokenRefresh()) {
|
|
app(ConnectionVerifier::class)->refreshToken($account);
|
|
}
|
|
|
|
$this->accessToken = $account->access_token;
|
|
|
|
$orgUrn = "urn:li:organization:{$account->platform_user_id}";
|
|
// LinkedIn requires both endpoints of the timeRange to be at midnight
|
|
// UTC (00:00:00.000). endOfDay() produces 23:59:59.999 which the API
|
|
// silently rejects with "Parameter 'timeIntervals' is invalid".
|
|
$startMs = $since->copy()->utc()->startOfDay()->getTimestampMs();
|
|
$endMs = $until->copy()->utc()->startOfDay()->addDay()->getTimestampMs();
|
|
$timeInterval = "(timeRange:(start:{$startMs},end:{$endMs}),timeGranularityType:DAY)";
|
|
|
|
$metrics = [];
|
|
|
|
// Page statistics (page views)
|
|
$pageStats = $this->fetchPageStatistics($orgUrn, $timeInterval);
|
|
$metrics = array_merge($metrics, $pageStats);
|
|
|
|
// Follower statistics
|
|
$followerStats = $this->fetchFollowerStatistics($orgUrn, $timeInterval);
|
|
$metrics = array_merge($metrics, $followerStats);
|
|
|
|
// Share statistics (engagement)
|
|
$shareStats = $this->fetchShareStatistics($orgUrn, $timeInterval);
|
|
$metrics = array_merge($metrics, $shareStats);
|
|
|
|
return $metrics;
|
|
}
|
|
|
|
private function fetchPageStatistics(string $orgUrn, string $timeInterval): array
|
|
{
|
|
$org = rawurlencode($orgUrn);
|
|
$response = $this->getHttpClient()
|
|
->get("{$this->baseUrl}/organizationPageStatistics?q=organization&organization={$org}&timeIntervals={$timeInterval}");
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('LinkedIn page statistics fetch failed', [
|
|
'body' => $this->redactResponseBody($response->body()),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$elements = data_get($response->json(), 'elements', []);
|
|
$totalPageViews = 0;
|
|
|
|
foreach ($elements as $element) {
|
|
$totalPageViews += data_get($element, 'totalPageStatistics.views.allPageViews.pageViews', 0);
|
|
}
|
|
|
|
return $totalPageViews > 0 ? [['label' => __('analytics.metrics.page_views'), 'value' => $totalPageViews]] : [];
|
|
}
|
|
|
|
private function fetchFollowerStatistics(string $orgUrn, string $timeInterval): array
|
|
{
|
|
$org = rawurlencode($orgUrn);
|
|
$response = $this->getHttpClient()
|
|
->get("{$this->baseUrl}/organizationalEntityFollowerStatistics?q=organizationalEntity&organizationalEntity={$org}&timeIntervals={$timeInterval}");
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('LinkedIn follower statistics fetch failed', [
|
|
'body' => $this->redactResponseBody($response->body()),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$elements = data_get($response->json(), 'elements', []);
|
|
$organicFollowers = 0;
|
|
$paidFollowers = 0;
|
|
|
|
foreach ($elements as $element) {
|
|
$organicFollowers += data_get($element, 'followerGains.organicFollowerGain', 0);
|
|
$paidFollowers += data_get($element, 'followerGains.paidFollowerGain', 0);
|
|
}
|
|
|
|
$metrics = [];
|
|
|
|
if ($organicFollowers > 0) {
|
|
$metrics[] = ['label' => __('analytics.metrics.organic_followers'), 'value' => $organicFollowers];
|
|
}
|
|
|
|
if ($paidFollowers > 0) {
|
|
$metrics[] = ['label' => __('analytics.metrics.paid_followers'), 'value' => $paidFollowers];
|
|
}
|
|
|
|
return $metrics;
|
|
}
|
|
|
|
private function fetchShareStatistics(string $orgUrn, string $timeInterval): array
|
|
{
|
|
$org = rawurlencode($orgUrn);
|
|
$response = $this->getHttpClient()
|
|
->get("{$this->baseUrl}/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity={$org}&timeIntervals={$timeInterval}");
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('LinkedIn share statistics fetch failed', [
|
|
'body' => $this->redactResponseBody($response->body()),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$elements = data_get($response->json(), 'elements', []);
|
|
$totalShares = 0;
|
|
$totalClicks = 0;
|
|
$totalLikes = 0;
|
|
$totalComments = 0;
|
|
$totalImpressions = 0;
|
|
|
|
foreach ($elements as $element) {
|
|
$stats = data_get($element, 'totalShareStatistics', []);
|
|
$totalShares += data_get($stats, 'shareCount', 0);
|
|
$totalClicks += data_get($stats, 'clickCount', 0);
|
|
$totalLikes += data_get($stats, 'likeCount', 0);
|
|
$totalComments += data_get($stats, 'commentCount', 0);
|
|
$totalImpressions += data_get($stats, 'impressionCount', 0);
|
|
}
|
|
|
|
$metrics = [];
|
|
|
|
if ($totalImpressions > 0) {
|
|
$metrics[] = ['label' => __('analytics.metrics.impressions'), 'value' => $totalImpressions];
|
|
}
|
|
if ($totalClicks > 0) {
|
|
$metrics[] = ['label' => __('analytics.metrics.clicks'), 'value' => $totalClicks];
|
|
}
|
|
if ($totalLikes > 0) {
|
|
$metrics[] = ['label' => __('analytics.metrics.likes'), 'value' => $totalLikes];
|
|
}
|
|
if ($totalComments > 0) {
|
|
$metrics[] = ['label' => __('analytics.metrics.comments'), 'value' => $totalComments];
|
|
}
|
|
if ($totalShares > 0) {
|
|
$metrics[] = ['label' => __('analytics.metrics.shares'), 'value' => $totalShares];
|
|
}
|
|
|
|
return $metrics;
|
|
}
|
|
|
|
private function getHttpClient(): PendingRequest
|
|
{
|
|
return $this->socialHttp()->withToken($this->accessToken)
|
|
->withHeaders([
|
|
'Linkedin-Version' => '202601',
|
|
'X-Restli-Protocol-Version' => '2.0.0',
|
|
]);
|
|
}
|
|
}
|