refactor(social): single source of truth for token refresh

Every per-platform publisher and analytics class had its own private
refreshToken() implementation, all doing essentially the same thing as
ConnectionVerifier's per-platform refresh*Token methods. ~17 copies of
near-identical OAuth refresh logic across the codebase, which is how
the 5xx→TokenExpired bug got planted in the publish path even after we
fixed it on the hourly/daily jobs.

Consolidation:
- All publish/analytics paths call app(ConnectionVerifier::class)
  ->refreshToken($account) instead of their own implementation.
- 17 private refreshToken() methods deleted.
- refreshTokenWithLock helper removed from HasSocialHttpClient trait
  (its lock semantics are duplicated by ConnectionVerifier's per-
  account lock).
- ConnectionVerifier::refreshLinkedInToken passes $account->platform
  to TokenRefreshClient so the "LinkedIn" vs "LinkedIn Page" label
  in error messages is preserved.
- TokenRefreshClient now treats HTTP 429 the same as 5xx (raises
  PlatformUnavailableException) — replaces the retry-on-429 behavior
  that socialHttp() provided to the deleted refresh methods.

Net: -460 LOC. Single per-platform refresh implementation. Every fix
or new platform now lands in exactly one place.
This commit is contained in:
Paulo Castellano 2026-05-19 09:18:58 -03:00
parent 32e1f89adb
commit fcb70b3599
20 changed files with 29 additions and 489 deletions

View file

@ -6,7 +6,6 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\BlueskyPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
@ -30,7 +29,7 @@ public function publish(PostPlatform $postPlatform): array
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -268,50 +267,6 @@ private function buildPostUrl(string $handle, string $postId): string
return "https://bsky.app/profile/{$handle}/post/{$postId}";
}
public function refreshToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
$client = TokenRefreshClient::for(Platform::Bluesky);
try {
$response = $client->send(fn () => $this->socialHttp()->withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession"));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
return;
} catch (TokenExpiredException) {
// refresh token rejected (4xx) — fall back to re-auth below
}
if (isset($account->meta['password'])) {
try {
$reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $account->meta['identifier'],
'password' => decrypt($account->meta['password']),
]));
$data = $reauth->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
return;
} catch (TokenExpiredException) {
// re-auth rejected with stored credentials — fall through
}
}
throw new TokenExpiredException('Bluesky session expired');
}
private function handleApiError(Response $response): never
{
throw BlueskyPublishException::fromApiResponse($response);

View file

@ -5,9 +5,7 @@
namespace App\Services\Social\Concerns;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
trait HasSocialHttpClient
@ -28,25 +26,6 @@ protected function validateContentLength(PostPlatform $postPlatform): void
);
}
protected function refreshTokenWithLock(SocialAccount $account, callable $refreshFn): void
{
$lock = Cache::lock("token_refresh:{$account->id}", 30);
if (! $lock->get()) {
// Another process is refreshing, wait and reload
sleep(2);
$account->refresh();
return;
}
try {
$refreshFn();
} finally {
$lock->release();
}
}
protected function socialHttp(): PendingRequest
{
return Http::retry(

View file

@ -112,10 +112,10 @@ public function refreshToken(SocialAccount $account): void
private function refreshLinkedInToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn account');
throw new TokenExpiredException("No refresh token available for {$account->platform->label()} account");
}
$response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm()
$response = TokenRefreshClient::for($account->platform)->send(fn () => Http::asForm()
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,

View file

@ -5,15 +5,12 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
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\Http;
use Illuminate\Support\Facades\Log;
class InstagramAnalytics
@ -48,7 +45,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -92,7 +89,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -200,27 +197,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp();
}
private function refreshToken(SocialAccount $account): void
{
if ($account->platform === Platform::InstagramFacebook) {
return;
}
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for Instagram account');
}
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -5,11 +5,9 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
@ -32,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -395,29 +393,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
Log::warning('Instagram media processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
// Instagram via Facebook uses page tokens that don't expire
if ($account->platform === Platform::InstagramFacebook) {
return;
}
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw InstagramPublishException::fromApiResponse($response);

View file

@ -4,15 +4,12 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
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\Http;
use Illuminate\Support\Facades\Log;
class LinkedInPageAnalytics
@ -54,7 +51,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -84,7 +81,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -234,27 +231,4 @@ private function getHttpClient(): PendingRequest
'X-Restli-Protocol-Version' => '2.0.0',
]);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = TokenRefreshClient::for(Platform::LinkedInPage)->send(fn () => Http::asForm()
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -46,7 +46,7 @@ public function publish(PostPlatform $postPlatform): array
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
$this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account));
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->account->refresh();
}
@ -81,7 +81,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$this->hasRetried = true;
try {
$this->refreshToken($this->account);
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->account->refresh();
$this->accessToken = $this->account->access_token;
@ -452,32 +452,6 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
Log::warning('LinkedIn Page video processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = TokenRefreshClient::for(Platform::LinkedInPage)->send(fn () => Http::asForm()
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
// Sync tokens to LinkedIn personal if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);

View file

@ -46,7 +46,7 @@ public function publish(PostPlatform $postPlatform): array
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
$this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account));
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->account->refresh();
}
@ -75,7 +75,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$this->hasRetried = true;
try {
$this->refreshToken($this->account);
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->account->refresh();
$this->accessToken = $this->account->access_token;
@ -433,32 +433,6 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
Log::warning('LinkedIn video processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn account');
}
$response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm()
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
// Sync tokens to LinkedIn Page if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);

View file

@ -4,15 +4,12 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
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\Http;
use Illuminate\Support\Facades\Log;
class PinterestAnalytics
@ -50,7 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -95,7 +92,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -161,27 +158,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for Pinterest account');
}
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret'),
)->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -400,34 +400,13 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
);
}
private function refreshToken(SocialAccount $account): void
{
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::asForm()
->withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret')
)
->post($this->baseUrl.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : now()->addDays(30),
]);
}
/**
* Get user's boards for board selection.
*/
public function getBoards(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
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\Http;
use Illuminate\Support\Facades\Log;
class ThreadsAnalytics
@ -49,7 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -81,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -138,19 +136,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp();
}
private function refreshToken(SocialAccount $account): void
{
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -4,13 +4,10 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ThreadsPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ThreadsPublisher
@ -33,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -303,25 +300,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
throw new \Exception('Threads media processing timeout after '.$maxAttempts.' attempts');
}
private function refreshToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw ThreadsPublishException::fromApiResponse($response);

View file

@ -4,13 +4,10 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokAnalytics
@ -39,7 +36,7 @@ public function getMetrics(SocialAccount $account): array
private function fetchMetricsFromApi(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -160,27 +157,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -4,13 +4,10 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokCreatorInfo
@ -62,7 +59,7 @@ public function fetch(SocialAccount $account): array
private function fetchFresh(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -124,27 +121,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -7,13 +7,11 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TikTokPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokPublisher
@ -38,7 +36,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -317,30 +315,6 @@ private function buildTikTokUrl(SocialAccount $account, ?string $postId = null):
return null;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw TikTokPublishException::fromApiResponse($response);

View file

@ -49,7 +49,7 @@ public function send(Closure $request): Response
throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}");
}
if ($response->serverError()) {
if ($response->serverError() || $response->status() === 429) {
throw new PlatformUnavailableException(
"{$name} API returned {$response->status()} during token refresh",
$response->status(),

View file

@ -4,8 +4,6 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -49,7 +47,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -165,7 +163,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -200,27 +198,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for X account');
}
$response = TokenRefreshClient::for(Platform::X)->send(fn () => $this->socialHttp()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->asForm()
->post(config('trypost.platforms.x.api').'/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -6,9 +6,7 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\XPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
@ -39,7 +37,7 @@ public function publish(PostPlatform $postPlatform): array
// Refresh token if expired or expiring soon
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -329,28 +327,6 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool
return false;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for X account');
}
$response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->post("{$this->baseUrl}/oauth2/token", [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
]);
}
private function handleApiError(Response $response): never
{
throw XPublishException::fromApiResponse($response);

View file

@ -4,15 +4,12 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
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\Http;
use Illuminate\Support\Facades\Log;
class YouTubeAnalytics
@ -50,7 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -102,7 +99,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -162,27 +159,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm()
->post(config('trypost.platforms.youtube.oauth_api').'/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -4,10 +4,8 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\YouTubePublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -36,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
}
@ -224,30 +222,6 @@ private function buildTitle(string $content): string
return $title.$shortsTag;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm()
->post(config('trypost.platforms.youtube.oauth_api').'/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleGoogleError(Exception $e): never
{
throw YouTubePublishException::fromGoogleException($e);