fix: VerifyWorkspaceConnections uses TokenExpired/Disconnected escalation, eager load workspace

This commit is contained in:
Paulo Castellano 2026-04-01 11:52:03 -03:00
parent 8ca846a865
commit a69dc28298
3 changed files with 34 additions and 62 deletions

View file

@ -29,24 +29,24 @@ public function __construct(public Workspace $workspace) {}
public function handle(ConnectionVerifier $verifier): void
{
$connectedAccounts = $this->workspace->socialAccounts()
$accounts = $this->workspace->socialAccounts()
->with('workspace.owner')
->whereIn('status', [Status::Connected, Status::TokenExpired])
->get();
if ($connectedAccounts->isEmpty()) {
if ($accounts->isEmpty()) {
return;
}
Log::info('Verifying workspace connections', [
'workspace_id' => $this->workspace->id,
'workspace_name' => $this->workspace->name,
'account_count' => $connectedAccounts->count(),
]);
$disconnectedAccounts = collect();
foreach ($connectedAccounts as $account) {
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;
}
@ -63,11 +63,6 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
try {
$verifier->verify($account);
Log::info('Social account connection verified', [
'account_id' => $account->id,
'platform' => $account->platform->value,
]);
return true;
} catch (TokenExpiredException $e) {
Log::warning('Social account connection is invalid', [
@ -76,11 +71,13 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
'error' => $e->getMessage(),
]);
$account->update([
'status' => Status::Disconnected,
'error_message' => $e->getMessage(),
'disconnected_at' => now(),
]);
if ($account->status === Status::TokenExpired) {
// Second failure — escalate to Disconnected
$account->markAsDisconnected($e->getMessage());
} else {
// First failure — mark as TokenExpired (softer state)
$account->markAsTokenExpired($e->getMessage());
}
return false;
} catch (\Exception $e) {
@ -90,6 +87,7 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
'error' => $e->getMessage(),
]);
// Unknown error — don't mark as disconnected, retry next time
return true;
}
}
@ -105,11 +103,6 @@ private function notifyOwner(Collection $disconnectedAccounts): void
return;
}
Log::info('Sending workspace disconnection notification', [
'workspace_id' => $this->workspace->id,
'disconnected_count' => $disconnectedAccounts->count(),
]);
$accountNames = $disconnectedAccounts
->map(fn ($account) => $account->platform->label().' (@'.($account->username ?? $account->display_name).')')
->implode(', ');

View file

@ -133,38 +133,11 @@ public function markAsDisconnected(string $errorMessage): void
public function markAsTokenExpired(string $errorMessage): void
{
$lock = Cache::lock("social_account_disconnect:{$this->id}", 10);
if ($lock->get()) {
try {
$this->refresh();
$wasConnected = $this->status === Status::Connected;
$this->update([
'status' => Status::TokenExpired,
'error_message' => $errorMessage,
'disconnected_at' => now(),
]);
if ($wasConnected && $this->workspace->owner) {
$platformName = $this->platform->label();
$accountName = $this->username ?? $this->display_name;
SendNotification::dispatch(
user: $this->workspace->owner,
workspaceId: $this->workspace_id,
type: Type::AccountDisconnected,
channel: Channel::Both,
title: "{$platformName} token expired",
body: "@{$accountName} needs to be reconnected",
data: ['social_account_id' => $this->id],
mailable: new AccountDisconnected($this),
);
}
} finally {
$lock->release();
}
}
$this->update([
'status' => Status::TokenExpired,
'error_message' => $errorMessage,
'disconnected_at' => now(),
]);
}
public function markAsConnected(): void

View file

@ -38,7 +38,7 @@
Mail::assertNothingSent();
});
test('job marks account as disconnected and sends email when token is invalid', function () {
test('job marks account as token expired on first failure and disconnected on second', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
@ -50,10 +50,16 @@
app()->instance(ConnectionVerifier::class, $verifier);
// First run — marks as TokenExpired
VerifyWorkspaceConnections::dispatch($workspace);
expect($account->fresh()->status)->toBe(Status::TokenExpired);
expect($account->fresh()->error_message)->toBe('Token expired');
// Second run — escalates to Disconnected
VerifyWorkspaceConnections::dispatch($workspace);
expect($account->fresh()->status)->toBe(Status::Disconnected);
expect($account->fresh()->error_message)->toBe('Token expired');
Mail::assertQueued(WorkspaceConnectionsDisconnected::class, function ($mail) use ($workspace) {
return $mail->workspace->id === $workspace->id
@ -61,7 +67,7 @@
});
});
test('job sends single email with all disconnected accounts', function () {
test('job sends single email with all failed accounts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
@ -76,8 +82,8 @@
VerifyWorkspaceConnections::dispatch($workspace);
expect($account1->fresh()->status)->toBe(Status::Disconnected);
expect($account2->fresh()->status)->toBe(Status::Disconnected);
expect($account1->fresh()->status)->toBe(Status::TokenExpired);
expect($account2->fresh()->status)->toBe(Status::TokenExpired);
Mail::assertQueued(WorkspaceConnectionsDisconnected::class, function ($mail) use ($workspace) {
return $mail->workspace->id === $workspace->id
@ -107,7 +113,7 @@
VerifyWorkspaceConnections::dispatch($workspace);
expect($validAccount->fresh()->status)->toBe(Status::Connected);
expect($invalidAccount->fresh()->status)->toBe(Status::Disconnected);
expect($invalidAccount->fresh()->status)->toBe(Status::TokenExpired);
Mail::assertQueued(WorkspaceConnectionsDisconnected::class, function ($mail) use ($invalidAccount) {
return $mail->disconnectedAccounts->count() === 1