trypost/app/Jobs/VerifyWorkspaceConnections.php
Paulo Castellano d5e28e3d02 fix(social): move Mastodon default instance to config + cleanup
Review follow-ups:

- verifyMastodon was the last hardcoded host left after the PR moved
  LinkedIn/YouTube/Bluesky to config. Adds trypost.platforms.mastodon
  .default_instance (env MASTODON_DEFAULT_INSTANCE) and reads from it.
- refreshToken() docblock now declares @throws PlatformUnavailableException
  (the whole point of the PR was missing from its contract).
- Strip the new explanatory comments inside catch blocks and tests —
  rationale lives in the commit / PR, not inline. The two comments
  inside empty `catch (TokenExpiredException) {}` blocks stay because
  there the comment is the only thing telling the reader why the
  exception is swallowed.
2026-05-19 08:27:46 -03:00

137 lines
4.6 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\PlatformUnavailableException;
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 (PlatformUnavailableException $e) {
Log::warning('Social account verification skipped: platform unavailable', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
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),
);
}
}