From a9184c94017a6d7f995a6973cf8b24ac084caea7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 1 Apr 2026 12:49:01 -0300 Subject: [PATCH] fix: withoutOverlapping on schedules, publisher token refresh lock, PublishPost tries:1, TokenExpired allows publish, API validation scoping --- .../Requests/Api/Post/StorePostRequest.php | 2 +- .../Requests/Api/Post/UpdatePostRequest.php | 3 ++- app/Jobs/PublishPost.php | 4 +--- app/Jobs/PublishToSocialPlatform.php | 3 ++- app/Services/Social/BlueskyPublisher.php | 2 +- .../Social/Concerns/HasSocialHttpClient.php | 21 +++++++++++++++++++ app/Services/Social/InstagramPublisher.php | 2 +- app/Services/Social/LinkedInPagePublisher.php | 2 +- app/Services/Social/LinkedInPublisher.php | 2 +- app/Services/Social/PinterestPublisher.php | 4 ++-- app/Services/Social/ThreadsPublisher.php | 2 +- app/Services/Social/TikTokPublisher.php | 2 +- app/Services/Social/XPublisher.php | 2 +- app/Services/Social/YouTubePublisher.php | 2 +- routes/console.php | 8 +++---- .../Jobs/PublishToSocialPlatformTest.php | 10 +++++---- 16 files changed, 47 insertions(+), 24 deletions(-) diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index 2fd77853..4db0aa00 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -20,7 +20,7 @@ public function rules(): array { return [ 'platforms' => ['required', 'array', 'min:1'], - 'platforms.*.social_account_id' => ['required', 'uuid'], + 'platforms.*.social_account_id' => ['required', 'uuid', Rule::exists('social_accounts', 'id')->where('workspace_id', $this->workspace->id)], 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], 'platforms.*.content' => ['nullable', 'string', 'max:63206'], 'scheduled_at' => ['nullable', 'date', 'after:now'], diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index c439b430..88c2b587 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -6,6 +6,7 @@ use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; +use App\Models\Post; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -22,7 +23,7 @@ public function rules(): array 'status' => ['required', 'string', Rule::in(array_column(Status::cases(), 'value'))], 'synced' => ['required', 'boolean'], 'platforms' => ['required', 'array'], - 'platforms.*.id' => ['required', 'uuid'], + 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))], 'platforms.*.content' => ['nullable', 'string', 'max:63206'], 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], 'platforms.*.meta' => ['nullable', 'array'], diff --git a/app/Jobs/PublishPost.php b/app/Jobs/PublishPost.php index 928e0588..792a5368 100644 --- a/app/Jobs/PublishPost.php +++ b/app/Jobs/PublishPost.php @@ -13,9 +13,7 @@ class PublishPost implements ShouldQueue { use Queueable; - public int $tries = 3; - - public int $backoff = 30; + public int $tries = 1; public function __construct(public Post $post) {} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 20fd03d5..69ee3f47 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -8,6 +8,7 @@ use App\Enums\Notification\Type; use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform as SocialPlatform; +use App\Enums\SocialAccount\Status; use App\Events\PostPlatformStatusUpdated; use App\Exceptions\Social\SocialPublishException; use App\Exceptions\TokenExpiredException; @@ -61,7 +62,7 @@ public function handle(): void return; } - if ($this->postPlatform->socialAccount->isDisconnected()) { + if ($this->postPlatform->socialAccount->status === Status::Disconnected) { $this->postPlatform->markAsFailed(__('posts.errors.account_disconnected')); $this->updatePostStatus(); $this->broadcastStatus(); diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index b2b906ee..28c5f95e 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -30,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array // Refresh token if needed if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index ac823ec0..f914040b 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -5,7 +5,9 @@ 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 @@ -22,6 +24,25 @@ 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( diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index b8bf134d..06cc4004 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -27,7 +27,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 0f890b9f..a5e95bd7 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -41,7 +41,7 @@ public function publish(PostPlatform $postPlatform): array $this->hasRetried = false; if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { - $this->refreshToken($this->account); + $this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account)); $this->account->refresh(); } diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 80ec8ee6..5ae0ec75 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -41,7 +41,7 @@ public function publish(PostPlatform $postPlatform): array $this->hasRetried = false; if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { - $this->refreshToken($this->account); + $this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account)); $this->account->refresh(); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index cbce0587..6e3af4d4 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } @@ -383,7 +383,7 @@ private function refreshToken(SocialAccount $account): void public function getBoards(SocialAccount $account): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 520cbdd5..a9744ce2 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -27,7 +27,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 17262c1b..3de19220 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -31,7 +31,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index dbbdc876..ec6113d5 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -34,7 +34,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->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 291f5d6c..1cb271e6 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } diff --git a/routes/console.php b/routes/console.php index 5b2f7644..d4657ea4 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,7 +8,7 @@ use App\Console\Commands\RefreshExpiringTokens; use Illuminate\Support\Facades\Schedule; -Schedule::command(ProcessScheduledPosts::class)->everyMinute(); -Schedule::command(CheckSocialConnections::class)->daily(); -Schedule::command(RefreshExpiringTokens::class)->hourly(); -Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes(); +Schedule::command(ProcessScheduledPosts::class)->everyMinute()->withoutOverlapping()->onOneServer(); +Schedule::command(CheckSocialConnections::class)->daily()->withoutOverlapping()->onOneServer(); +Schedule::command(RefreshExpiringTokens::class)->hourly()->withoutOverlapping()->onOneServer(); +Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer(); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 4dcb4f98..7935ec0d 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -204,7 +204,7 @@ expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_inactive')); }); -test('publish to social platform skips publishing when account token is expired', function () { +test('publish to social platform attempts publishing when account token is expired', function () { Event::fake(); $this->socialAccount->update([ @@ -212,15 +212,17 @@ ]); $publisher = Mockery::mock(LinkedInPublisher::class); - $publisher->shouldNotReceive('publish'); + $publisher->shouldReceive('publish')->andReturn([ + 'id' => 'post-123', + 'url' => 'https://linkedin.com/post/123', + ]); $this->app->instance(LinkedInPublisher::class, $publisher); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); - expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); - expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected')); + expect($this->postPlatform->status)->toBe(PlatformStatus::Published); }); test('publish to social platform dispatches success notification when all platforms published', function () {