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:
parent
59e837fd47
commit
37d0838571
8 changed files with 492 additions and 4 deletions
29
app/Console/Commands/CheckSocialConnections.php
Normal file
29
app/Console/Commands/CheckSocialConnections.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
107
app/Jobs/VerifyWorkspaceConnections.php
Normal file
107
app/Jobs/VerifyWorkspaceConnections.php
Normal 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));
|
||||
}
|
||||
}
|
||||
222
app/Services/Social/ConnectionVerifier.php
Normal file
222
app/Services/Social/ConnectionVerifier.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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();
|
||||
|
|
|
|||
130
tests/Feature/VerifyWorkspaceConnectionsTest.php
Normal file
130
tests/Feature/VerifyWorkspaceConnectionsTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue