feat: Implement social connection verification with a new command, job, and service, while removing unused language files and updating Cashier configuration.

This commit is contained in:
Paulo Castellano 2026-01-25 17:28:01 -03:00
parent 59e837fd47
commit 37d0838571
8 changed files with 492 additions and 4 deletions

View file

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Enums\SocialAccount\Status;
use App\Jobs\VerifyWorkspaceConnections;
use App\Models\Workspace;
use Illuminate\Console\Command;
class CheckSocialConnections extends Command
{
protected $signature = 'social:check-connections';
protected $description = 'Verify that all connected social accounts have valid tokens';
public function handle(): void
{
Workspace::query()
->whereHas('socialAccounts', function ($query) {
$query->where('status', Status::Connected);
})
->with('owner')
->chunk(100, function ($workspaces) {
foreach ($workspaces as $workspace) {
VerifyWorkspaceConnections::dispatch($workspace);
}
});
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Jobs;
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;
use Illuminate\Support\Facades\Mail;
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
{
$connectedAccounts = $this->workspace->socialAccounts()
->where('status', Status::Connected)
->get();
if ($connectedAccounts->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) {
if ($this->verifyAccount($verifier, $account)) {
continue;
}
$disconnectedAccounts->push($account);
}
if ($disconnectedAccounts->isNotEmpty()) {
$this->notifyOwner($disconnectedAccounts);
}
}
private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $account): bool
{
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', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
$account->update([
'status' => Status::Disconnected,
'error_message' => $e->getMessage(),
'disconnected_at' => now(),
]);
return false;
} catch (\Exception $e) {
Log::error('Failed to verify social account connection', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
return true;
}
}
/**
* @param Collection<int, SocialAccount> $disconnectedAccounts
*/
private function notifyOwner(Collection $disconnectedAccounts): void
{
Log::info('Sending workspace disconnection notification', [
'workspace_id' => $this->workspace->id,
'disconnected_count' => $disconnectedAccounts->count(),
]);
Mail::to($this->workspace->owner)
->send(new WorkspaceConnectionsDisconnected($this->workspace, $disconnectedAccounts));
}
}

View file

@ -0,0 +1,222 @@
<?php
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;
class ConnectionVerifier
{
/**
* Verify that a social account connection is still valid.
*
* @throws TokenExpiredException if the connection is invalid
*/
public function verify(SocialAccount $account): bool
{
return match ($account->platform) {
Platform::LinkedIn => $this->verifyLinkedIn($account),
Platform::LinkedInPage => $this->verifyLinkedInPage($account),
Platform::X => $this->verifyX($account),
Platform::Instagram => $this->verifyInstagram($account),
Platform::Facebook => $this->verifyFacebook($account),
Platform::Threads => $this->verifyThreads($account),
Platform::TikTok => $this->verifyTikTok($account),
Platform::YouTube => $this->verifyYouTube($account),
Platform::Pinterest => $this->verifyPinterest($account),
Platform::Bluesky => $this->verifyBluesky($account),
Platform::Mastodon => $this->verifyMastodon($account),
};
}
private function verifyLinkedIn(SocialAccount $account): bool
{
$response = Http::withToken($account->access_token)
->withHeaders([
'X-Restli-Protocol-Version' => '2.0.0',
'LinkedIn-Version' => '202601',
])
->get('https://api.linkedin.com/rest/userinfo');
if ($response->status() === 401) {
throw new TokenExpiredException('LinkedIn access token is invalid or expired');
}
return $response->successful();
}
private function verifyLinkedInPage(SocialAccount $account): bool
{
$response = Http::withToken($account->access_token)
->withHeaders([
'X-Restli-Protocol-Version' => '2.0.0',
'LinkedIn-Version' => '202601',
])
->get('https://api.linkedin.com/rest/organizationAcls', [
'q' => 'roleAssignee',
]);
if ($response->status() === 401) {
throw new TokenExpiredException('LinkedIn Page access token is invalid or expired');
}
return $response->successful();
}
private function verifyX(SocialAccount $account): bool
{
$response = Http::withToken($account->access_token)
->get('https://api.x.com/2/users/me');
if ($response->status() === 401) {
throw new TokenExpiredException('X access token is invalid or expired');
}
return $response->successful();
}
private function verifyInstagram(SocialAccount $account): bool
{
$response = Http::get('https://graph.instagram.com/v24.0/me', [
'fields' => 'id,username',
'access_token' => $account->access_token,
]);
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}
}
return $response->successful();
}
private function verifyFacebook(SocialAccount $account): bool
{
$response = Http::get('https://graph.facebook.com/v24.0/me', [
'fields' => 'id,name',
'access_token' => $account->access_token,
]);
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}
}
return $response->successful();
}
private function verifyThreads(SocialAccount $account): bool
{
$response = Http::get('https://graph.threads.net/v1.0/me', [
'fields' => 'id,username',
'access_token' => $account->access_token,
]);
$body = $response->json() ?? [];
if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;
if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}
}
return $response->successful();
}
private function verifyTikTok(SocialAccount $account): bool
{
$response = Http::withToken($account->access_token)
->withHeaders([
'Content-Type' => 'application/json',
])
->get('https://open.tiktokapis.com/v2/user/info/', [
'fields' => 'open_id,display_name',
]);
$body = $response->json() ?? [];
$errorCode = $body['error']['code'] ?? null;
if ($response->status() === 401 || in_array($errorCode, ['access_token_invalid', 'access_token_expired', 10001, 10002])) {
throw new TokenExpiredException('TikTok access token is invalid or expired');
}
return $response->successful();
}
private function verifyYouTube(SocialAccount $account): bool
{
$response = Http::withToken($account->access_token)
->get('https://www.googleapis.com/youtube/v3/channels', [
'part' => 'id',
'mine' => 'true',
]);
if ($response->status() === 401) {
throw new TokenExpiredException('YouTube access token is invalid or expired');
}
return $response->successful();
}
private function verifyPinterest(SocialAccount $account): bool
{
$response = Http::withToken($account->access_token)
->get('https://api.pinterest.com/v5/user_account');
if ($response->status() === 401) {
throw new TokenExpiredException('Pinterest access token is invalid or expired');
}
return $response->successful();
}
private function verifyBluesky(SocialAccount $account): bool
{
$service = $account->meta['service'] ?? 'https://bsky.social';
$response = Http::withToken($account->access_token)
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
'actor' => $account->platform_user_id,
]);
$body = $response->json() ?? [];
$error = $body['error'] ?? null;
if ($error === 'ExpiredToken' || $error === 'InvalidToken') {
throw new TokenExpiredException('Bluesky access token is invalid or expired');
}
return $response->successful();
}
private function verifyMastodon(SocialAccount $account): bool
{
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
$response = Http::withToken($account->access_token)
->get("{$instance}/api/v1/accounts/verify_credentials");
if ($response->status() === 401 || $response->status() === 403) {
throw new TokenExpiredException('Mastodon access token is invalid or expired');
}
return $response->successful();
}
}

View file

@ -138,12 +138,12 @@
'plans' => [
'monthly' => [
'price_id' => env('STRIPE_PRICE_MONTHLY'),
'price' => 25,
'price' => 20,
'interval' => 'month',
],
'yearly' => [
'price_id' => env('STRIPE_PRICE_YEARLY'),
'price' => 20,
'price' => 16,
'interval' => 'year',
],
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,8 @@
<?php
use App\Console\Commands\CheckSocialConnections;
use App\Console\Commands\ProcessScheduledPosts;
use Illuminate\Support\Facades\Schedule;
Schedule::command(ProcessScheduledPosts::class)->everyMinute();
Schedule::command(CheckSocialConnections::class)->daily();

View file

@ -0,0 +1,130 @@
<?php
use App\Enums\SocialAccount\Status;
use App\Exceptions\TokenExpiredException;
use App\Jobs\VerifyWorkspaceConnections;
use App\Mail\WorkspaceConnectionsDisconnected;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use Illuminate\Support\Facades\Mail;
test('job does nothing when workspace has no connected accounts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
VerifyWorkspaceConnections::dispatch($workspace);
Mail::assertNothingSent();
});
test('job does not send email when all connections are valid', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
SocialAccount::factory()->x()->create(['workspace_id' => $workspace->id]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->andReturn(true);
app()->instance(ConnectionVerifier::class, $verifier);
VerifyWorkspaceConnections::dispatch($workspace);
Mail::assertNothingSent();
});
test('job marks account as disconnected and sends email when token is invalid', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')
->andThrow(new TokenExpiredException('Token expired'));
app()->instance(ConnectionVerifier::class, $verifier);
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
&& $mail->disconnectedAccounts->count() === 1;
});
});
test('job sends single email with all disconnected accounts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account1 = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$account2 = SocialAccount::factory()->x()->create(['workspace_id' => $workspace->id]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')
->andThrow(new TokenExpiredException('Token expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyWorkspaceConnections::dispatch($workspace);
expect($account1->fresh()->status)->toBe(Status::Disconnected);
expect($account2->fresh()->status)->toBe(Status::Disconnected);
Mail::assertQueued(WorkspaceConnectionsDisconnected::class, function ($mail) use ($workspace) {
return $mail->workspace->id === $workspace->id
&& $mail->disconnectedAccounts->count() === 2;
});
Mail::assertQueuedCount(1);
});
test('job only includes failed accounts in email', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$validAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$invalidAccount = SocialAccount::factory()->x()->create(['workspace_id' => $workspace->id]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')
->with(\Mockery::on(fn ($acc) => $acc->id === $validAccount->id))
->andReturn(true);
$verifier->shouldReceive('verify')
->with(\Mockery::on(fn ($acc) => $acc->id === $invalidAccount->id))
->andThrow(new TokenExpiredException('Token expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyWorkspaceConnections::dispatch($workspace);
expect($validAccount->fresh()->status)->toBe(Status::Connected);
expect($invalidAccount->fresh()->status)->toBe(Status::Disconnected);
Mail::assertQueued(WorkspaceConnectionsDisconnected::class, function ($mail) use ($invalidAccount) {
return $mail->disconnectedAccounts->count() === 1
&& $mail->disconnectedAccounts->first()->id === $invalidAccount->id;
});
});
test('job skips already disconnected accounts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
SocialAccount::factory()->linkedin()->disconnected()->create(['workspace_id' => $workspace->id]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyWorkspaceConnections::dispatch($workspace);
Mail::assertNothingSent();
});