fix: withoutOverlapping on schedules, publisher token refresh lock, PublishPost tries:1, TokenExpired allows publish, API validation scoping

This commit is contained in:
Paulo Castellano 2026-04-01 12:49:01 -03:00
parent 9d0a860d87
commit a9184c9401
16 changed files with 47 additions and 24 deletions

View file

@ -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'],

View file

@ -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'],

View file

@ -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) {}

View file

@ -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();

View file

@ -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();
}

View file

@ -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(

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();

View file

@ -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 () {