trypost/app/Jobs/VerifyWorkspaceConnections.php
Paulo Castellano 620d23187e fix(social): handle TokenExpired status fail-fast and notify user
Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:

1. **PublishToSocialPlatform**: fail-fast when account status is
   `TokenExpired`. Previously the job tried to publish, the publisher
   internally tried to refresh, the provider rejected the rotated
   refresh_token, and the failure surfaced as a generic 'unknown' error
   instead of a clear 'reconnect your account' signal.

2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
   refresh_token (typically because it was rotated/revoked at X), log the
   raw response and throw `TokenExpiredException` instead of falling
   through to `XPublishException::fromApiResponse` which expects the
   tweet-API response shape (`type`/`title`/`detail`) and treats
   OAuth-style responses (`error`/`error_description`) as 'Unknown'.

3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
   notification (`Type::AccountDisconnected`) when an account
   transitions from `Connected` → `TokenExpired`, mirroring the
   existing pattern in `markAsDisconnected`. Wrapped in a lock to
   prevent duplicate notifications on concurrent transitions. Accepts an
   optional `notify: false` so the batch verifier
   (`VerifyWorkspaceConnections`) can suppress per-account
   notifications and rely on its summary email.
2026-05-12 18:46:06 -03:00

128 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Enums\SocialAccount\Status;
use App\Exceptions\TokenExpiredException;
use App\Mail\WorkspaceConnectionsDisconnected;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class VerifyWorkspaceConnections implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 120;
public function __construct(public Workspace $workspace) {}
public function handle(ConnectionVerifier $verifier): void
{
$accounts = $this->workspace->socialAccounts()
->with('workspace.owner')
->whereIn('status', [Status::Connected, Status::TokenExpired])
->get();
if ($accounts->isEmpty()) {
return;
}
$disconnectedAccounts = collect();
foreach ($accounts as $account) {
if ($this->verifyAccount($verifier, $account)) {
// If was TokenExpired but now verified OK, mark as connected again
if ($account->status === Status::TokenExpired) {
$account->markAsConnected();
}
continue;
}
$disconnectedAccounts->push($account);
}
if ($disconnectedAccounts->isNotEmpty()) {
$this->notifyOwner($disconnectedAccounts);
}
}
private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $account): bool
{
try {
$verifier->verify($account);
return true;
} catch (TokenExpiredException $e) {
Log::warning('Social account connection is invalid', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
if ($account->status === Status::TokenExpired) {
// Second failure — escalate to Disconnected (no individual notification,
// the batch notification from notifyOwner() handles it)
$account->update([
'status' => Status::Disconnected,
'error_message' => $e->getMessage(),
'disconnected_at' => now(),
]);
} else {
// First failure — mark as TokenExpired (softer state).
// Suppress per-account notification; the batch notifyOwner()
// sends a single summary email for all failures at the end.
$account->markAsTokenExpired($e->getMessage(), notify: false);
}
return false;
} catch (\Exception $e) {
Log::error('Failed to verify social account connection', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
// Unknown error — don't mark as disconnected, retry next time
return true;
}
}
/**
* @param Collection<int, SocialAccount> $disconnectedAccounts
*/
private function notifyOwner(Collection $disconnectedAccounts): void
{
$owner = $this->workspace->owner;
if (! $owner) {
return;
}
$accountNames = $disconnectedAccounts
->map(fn ($account) => $account->platform->label().' (@'.($account->username ?? $account->display_name).')')
->implode(', ');
SendNotification::dispatch(
user: $owner,
workspaceId: $this->workspace->id,
type: Type::AccountDisconnected,
channel: Channel::Both,
title: $disconnectedAccounts->count().' '.($disconnectedAccounts->count() === 1 ? 'account' : 'accounts').' disconnected',
body: $accountNames,
data: ['workspace_id' => $this->workspace->id],
mailable: new WorkspaceConnectionsDisconnected($this->workspace, $disconnectedAccounts),
);
}
}