fix: self-host billing bypass, Facebook API metrics migration, X token refresh, and duplicate social accounts
- Block all billing/usage/subscribe routes in self-hosted mode (redirect to calendar) - Hide billing_email field in account settings when self-hosted - Migrate deprecated Facebook Page Insights metrics to new Media Views API (v25.0) - Fix X analytics token refresh to use Basic Auth (matching XPublisher/ConnectionVerifier) - Prevent duplicate social accounts by using updateOrCreate across all OAuth controllers
This commit is contained in:
parent
ded1c998ec
commit
a8bb44f0e5
20 changed files with 483 additions and 207 deletions
|
|
@ -16,6 +16,10 @@ class BillingController extends Controller
|
|||
{
|
||||
public function subscribe(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$account = $request->user()->account;
|
||||
|
||||
if ($account && $account->hasActiveSubscription()) {
|
||||
|
|
@ -28,8 +32,12 @@ public function subscribe(Request $request): Response|RedirectResponse
|
|||
]);
|
||||
}
|
||||
|
||||
public function checkout(Request $request, Plan $plan): SymfonyResponse
|
||||
public function checkout(Request $request, Plan $plan): SymfonyResponse|RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$account = $user->account;
|
||||
|
||||
|
|
@ -66,8 +74,12 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse
|
|||
return Inertia::location($checkoutSession->url);
|
||||
}
|
||||
|
||||
public function processing(Request $request): Response
|
||||
public function processing(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$account = $request->user()->account;
|
||||
|
||||
return Inertia::render('billing/Processing', [
|
||||
|
|
@ -75,8 +87,12 @@ public function processing(Request $request): Response
|
|||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$account = $request->user()->account;
|
||||
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
|
@ -111,6 +127,10 @@ public function index(Request $request): Response
|
|||
|
||||
public function swap(Request $request, Plan $plan): RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$account = $request->user()->account;
|
||||
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
|
@ -136,6 +156,10 @@ public function swap(Request $request, Plan $plan): RedirectResponse
|
|||
|
||||
public function portal(Request $request): RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$account = $request->user()->account;
|
||||
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public function edit(Request $request): Response
|
|||
'name' => $account->name,
|
||||
'billing_email' => $account->billing_email,
|
||||
],
|
||||
'selfHosted' => config('trypost.self_hosted'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -32,19 +33,24 @@ public function update(Request $request): RedirectResponse
|
|||
{
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
||||
$isSelfHosted = config('trypost.self_hosted');
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'billing_email' => ['required', 'email', 'max:255'],
|
||||
'billing_email' => [$isSelfHosted ? 'nullable' : 'required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
$account = $request->user()->account;
|
||||
|
||||
$account->update([
|
||||
'name' => data_get($validated, 'name'),
|
||||
'billing_email' => data_get($validated, 'billing_email'),
|
||||
]);
|
||||
$data = ['name' => data_get($validated, 'name')];
|
||||
|
||||
if ($account->hasStripeId()) {
|
||||
if (! $isSelfHosted) {
|
||||
$data['billing_email'] = data_get($validated, 'billing_email');
|
||||
}
|
||||
|
||||
$account->update($data);
|
||||
|
||||
if (! $isSelfHosted && $account->hasStripeId()) {
|
||||
$account->updateStripeCustomer([
|
||||
'name' => data_get($validated, 'name'),
|
||||
'email' => data_get($validated, 'billing_email'),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
use App\Features\SocialAccountLimit;
|
||||
use App\Features\WorkspaceLimit;
|
||||
use App\Http\Controllers\App\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
|
@ -19,8 +20,12 @@
|
|||
|
||||
class UsageController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
||||
$account = $request->user()->account;
|
||||
|
|
|
|||
|
|
@ -89,23 +89,28 @@ public function store(Request $request): View|RedirectResponse
|
|||
|
||||
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($data, 'did'),
|
||||
'username' => data_get($data, 'handle'),
|
||||
'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($data, 'accessJwt'),
|
||||
'refresh_token' => data_get($data, 'refreshJwt'),
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'service' => $service,
|
||||
'identifier' => $request->identifier,
|
||||
'password' => encrypt($request->password),
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($data, 'did'),
|
||||
],
|
||||
]);
|
||||
[
|
||||
'username' => data_get($data, 'handle'),
|
||||
'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($data, 'accessJwt'),
|
||||
'refresh_token' => data_get($data, 'refreshJwt'),
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
'meta' => [
|
||||
'service' => $service,
|
||||
'identifier' => $request->identifier,
|
||||
'password' => encrypt($request->password),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $this->popupCallback(true, 'Bluesky account connected!', $this->platform->value);
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -95,24 +95,29 @@ public function callback(Request $request): View|RedirectResponse
|
|||
$page = $pages[0];
|
||||
$avatarPath = uploadFromUrl(data_get($page, 'picture'));
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($page, 'id'),
|
||||
'username' => data_get($page, 'username', null),
|
||||
'display_name' => data_get($page, 'name'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($page, 'access_token'),
|
||||
'refresh_token' => null, // Page tokens don't expire if user token is long-lived
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'page_id' => data_get($page, 'id'),
|
||||
'user_id' => $socialUser->getId(),
|
||||
'user_token' => $socialUser->token,
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($page, 'id'),
|
||||
],
|
||||
]);
|
||||
[
|
||||
'username' => data_get($page, 'username', null),
|
||||
'display_name' => data_get($page, 'name'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($page, 'access_token'),
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
'meta' => [
|
||||
'page_id' => data_get($page, 'id'),
|
||||
'user_id' => $socialUser->getId(),
|
||||
'user_token' => $socialUser->token,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value);
|
||||
}
|
||||
|
|
@ -225,24 +230,29 @@ public function select(Request $request): View
|
|||
}
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($selectedPage, 'id'),
|
||||
'username' => data_get($selectedPage, 'username') ?? null,
|
||||
'display_name' => data_get($selectedPage, 'name'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($selectedPage, 'access_token'),
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'page_id' => data_get($selectedPage, 'id'),
|
||||
'user_id' => data_get($oauthData, 'user_id'),
|
||||
'user_token' => data_get($oauthData, 'user_token'),
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($selectedPage, 'id'),
|
||||
],
|
||||
]);
|
||||
[
|
||||
'username' => data_get($selectedPage, 'username') ?? null,
|
||||
'display_name' => data_get($selectedPage, 'name'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($selectedPage, 'access_token'),
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
'meta' => [
|
||||
'page_id' => data_get($selectedPage, 'id'),
|
||||
'user_id' => data_get($oauthData, 'user_id'),
|
||||
'user_token' => data_get($oauthData, 'user_token'),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
session()->forget(['facebook_oauth', 'social_reconnect_id']);
|
||||
|
||||
|
|
|
|||
|
|
@ -78,22 +78,27 @@ public function callback(Request $request): View
|
|||
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
|
||||
$tokenExpiresAt = now()->addSeconds($expiresIn);
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
'username' => $socialUser->getNickname(),
|
||||
'display_name' => $socialUser->getName() ?? $socialUser->getNickname(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $tokenExpiresAt,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'account_type' => $socialUser->user['account_type'] ?? null,
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
],
|
||||
]);
|
||||
[
|
||||
'username' => $socialUser->getNickname(),
|
||||
'display_name' => $socialUser->getName() ?? $socialUser->getNickname(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $tokenExpiresAt,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
'meta' => [
|
||||
'account_type' => $socialUser->user['account_type'] ?? null,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value);
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -218,10 +218,17 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
|
|||
return $this->popupCallback(true, 'Instagram account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
$account = $workspace->socialAccounts()->create(array_merge($accountData, [
|
||||
'platform' => $this->platform->value,
|
||||
'status' => Status::Connected,
|
||||
]));
|
||||
$account = $workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($pageData, 'ig_id'),
|
||||
],
|
||||
array_merge($accountData, [
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
]),
|
||||
);
|
||||
|
||||
$isOnboarding = session('social_connect_onboarding', false);
|
||||
|
||||
|
|
|
|||
|
|
@ -72,19 +72,24 @@ public function callback(Request $request): View
|
|||
|
||||
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||
|
||||
// Create new account
|
||||
$account = $workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
'username' => $username,
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
'status' => Status::Connected,
|
||||
]);
|
||||
$account = $workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
],
|
||||
[
|
||||
'username' => $username,
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
// Sync tokens to LinkedIn Page if it exists
|
||||
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
|
||||
|
|
|
|||
|
|
@ -199,23 +199,28 @@ public function select(Request $request): View
|
|||
}
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$account = $workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $request->organization_id,
|
||||
'username' => $request->organization_vanity_name,
|
||||
'display_name' => $request->organization_name,
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $pendingData['token'],
|
||||
'refresh_token' => $pendingData['refresh_token'],
|
||||
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'organization_id' => $request->organization_id,
|
||||
'admin_user_id' => $pendingData['user_id'],
|
||||
'admin_name' => $pendingData['name'],
|
||||
$account = $workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $request->organization_id,
|
||||
],
|
||||
]);
|
||||
[
|
||||
'username' => $request->organization_vanity_name,
|
||||
'display_name' => $request->organization_name,
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $pendingData['token'],
|
||||
'refresh_token' => $pendingData['refresh_token'],
|
||||
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
'meta' => [
|
||||
'organization_id' => $request->organization_id,
|
||||
'admin_user_id' => $pendingData['user_id'],
|
||||
'admin_name' => $pendingData['name'],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Sync tokens to LinkedIn personal if it exists
|
||||
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
|
||||
|
|
|
|||
|
|
@ -183,23 +183,28 @@ public function callback(Request $request): View
|
|||
|
||||
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($profile, 'id'),
|
||||
'username' => data_get($profile, 'acct'),
|
||||
'display_name' => data_get($profile, 'display_name') ?: data_get($profile, 'username'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => null, // Mastodon tokens don't expire
|
||||
'token_expires_at' => null,
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'instance' => $instance,
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($profile, 'id'),
|
||||
],
|
||||
]);
|
||||
[
|
||||
'username' => data_get($profile, 'acct'),
|
||||
'display_name' => data_get($profile, 'display_name') ?: data_get($profile, 'username'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
'meta' => [
|
||||
'instance' => $instance,
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->clearMastodonSession();
|
||||
|
||||
|
|
|
|||
|
|
@ -159,19 +159,24 @@ protected function handleCallback(
|
|||
|
||||
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $platform->value,
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
'username' => $socialUser->getNickname(),
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
'status' => Status::Connected,
|
||||
]);
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $platform->value,
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
],
|
||||
[
|
||||
'username' => $socialUser->getNickname(),
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
return $this->popupCallback(true, 'Account connected!', $platform->value);
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -136,19 +136,24 @@ public function callback(Request $request): View
|
|||
$profile = $profileResponse->json();
|
||||
$avatarPath = uploadFromUrl(data_get($profile, 'threads_profile_picture_url', null));
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($profile, 'id'),
|
||||
'username' => data_get($profile, 'username'),
|
||||
'display_name' => data_get($profile, 'name', data_get($profile, 'username')),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $longLivedToken,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
]);
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($profile, 'id'),
|
||||
],
|
||||
[
|
||||
'username' => data_get($profile, 'username'),
|
||||
'display_name' => data_get($profile, 'name', data_get($profile, 'username')),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $longLivedToken,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
||||
|
||||
|
|
|
|||
|
|
@ -75,19 +75,24 @@ public function callback(Request $request): View
|
|||
$username = $socialUser->getNickname();
|
||||
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
'username' => $username,
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
'status' => Status::Connected,
|
||||
]);
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
],
|
||||
[
|
||||
'username' => $username,
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
session()->forget('social_reconnect_id');
|
||||
|
||||
|
|
|
|||
|
|
@ -80,23 +80,28 @@ public function callback(Request $request): View|RedirectResponse
|
|||
$channel = $channels[0];
|
||||
$avatarPath = uploadFromUrl(data_get($channel, 'thumbnail'));
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($channel, 'id'),
|
||||
'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'),
|
||||
'display_name' => data_get($channel, 'title'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'channel_id' => data_get($channel, 'id'),
|
||||
'google_user_id' => $socialUser->getId(),
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($channel, 'id'),
|
||||
],
|
||||
]);
|
||||
[
|
||||
'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'),
|
||||
'display_name' => data_get($channel, 'title'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
'meta' => [
|
||||
'channel_id' => data_get($channel, 'id'),
|
||||
'google_user_id' => $socialUser->getId(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value);
|
||||
}
|
||||
|
|
@ -220,23 +225,28 @@ public function select(Request $request): View
|
|||
}
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($selectedChannel, 'id'),
|
||||
'username' => ltrim(data_get($selectedChannel, 'custom_url', data_get($selectedChannel, 'id')), '@'),
|
||||
'display_name' => data_get($selectedChannel, 'title'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($oauthData, 'access_token'),
|
||||
'refresh_token' => data_get($oauthData, 'refresh_token'),
|
||||
'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'channel_id' => data_get($selectedChannel, 'id'),
|
||||
'google_user_id' => data_get($oauthData, 'user_id'),
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($selectedChannel, 'id'),
|
||||
],
|
||||
]);
|
||||
[
|
||||
'username' => ltrim(data_get($selectedChannel, 'custom_url', data_get($selectedChannel, 'id')), '@'),
|
||||
'display_name' => data_get($selectedChannel, 'title'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($oauthData, 'access_token'),
|
||||
'refresh_token' => data_get($oauthData, 'refresh_token'),
|
||||
'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null,
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
'meta' => [
|
||||
'channel_id' => data_get($selectedChannel, 'id'),
|
||||
'google_user_id' => data_get($oauthData, 'user_id'),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
session()->forget(['youtube_oauth', 'social_reconnect_id']);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class FacebookAnalytics
|
|||
{
|
||||
use HasSocialHttpClient;
|
||||
|
||||
private string $baseUrl = 'https://graph.facebook.com/v20.0';
|
||||
private string $baseUrl = 'https://graph.facebook.com/v25.0';
|
||||
|
||||
private string $accessToken;
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
|
|||
|
||||
$response = $this->getHttpClient()
|
||||
->get("{$this->baseUrl}/{$account->platform_user_id}/insights", [
|
||||
'metric' => 'page_impressions_unique,page_posts_impressions_unique,page_post_engagements,page_daily_follows,page_video_views',
|
||||
'metric' => 'page_total_media_view_unique,post_total_media_view_unique,page_post_engagements,page_daily_follows,page_media_view',
|
||||
'period' => 'day',
|
||||
'since' => $since->startOfDay()->unix(),
|
||||
'until' => $until->endOfDay()->unix(),
|
||||
|
|
@ -67,11 +67,11 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
|
|||
$total = collect($values)->sum('value');
|
||||
|
||||
$label = match ($name) {
|
||||
'page_impressions_unique' => 'Page Impressions',
|
||||
'page_posts_impressions_unique' => 'Posts Impressions',
|
||||
'page_total_media_view_unique' => 'Page Reach',
|
||||
'post_total_media_view_unique' => 'Posts Reach',
|
||||
'page_post_engagements' => 'Posts Engagement',
|
||||
'page_daily_follows' => 'Page Followers',
|
||||
'page_video_views' => 'Video Views',
|
||||
'page_media_view' => 'Page Views',
|
||||
default => ucfirst(str_replace('_', ' ', $name)),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -160,11 +160,13 @@ private function refreshToken(SocialAccount $account): void
|
|||
throw new TokenExpiredException('No refresh token available for X account');
|
||||
}
|
||||
|
||||
$response = $this->socialHttp()->asForm()->post('https://api.x.com/2/oauth2/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_id' => config('services.x.client_id'),
|
||||
]);
|
||||
$response = $this->socialHttp()
|
||||
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
|
||||
->asForm()
|
||||
->post('https://api.x.com/2/oauth2/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('X token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ interface AccountData {
|
|||
billing_email: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
account: AccountData;
|
||||
selfHosted: boolean;
|
||||
}>();
|
||||
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
|
|
@ -54,7 +55,7 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
|||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div v-if="!selfHosted" class="grid gap-2">
|
||||
<Label for="billing-email">{{ $t('settings.account.billing_email') }}</Label>
|
||||
<Input
|
||||
id="billing-email"
|
||||
|
|
|
|||
87
tests/Feature/AccountControllerTest.php
Normal file
87
tests/Feature/AccountControllerTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->account = Account::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->account->update(['owner_id' => $this->user->id]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('account edit shows account settings with billing email when not self hosted', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.account.edit'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('settings/Account', false)
|
||||
->where('selfHosted', false)
|
||||
->has('account.billing_email')
|
||||
);
|
||||
});
|
||||
|
||||
test('account edit shows self hosted flag when self hosted', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.account.edit'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('settings/Account', false)
|
||||
->where('selfHosted', true)
|
||||
);
|
||||
});
|
||||
|
||||
test('account update does not require billing email when self hosted', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.account.update'), [
|
||||
'name' => 'Updated Account',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('accounts', [
|
||||
'id' => $this->account->id,
|
||||
'name' => 'Updated Account',
|
||||
]);
|
||||
});
|
||||
|
||||
test('account update requires billing email when not self hosted', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.account.update'), [
|
||||
'name' => 'Updated Account',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('billing_email');
|
||||
});
|
||||
|
|
@ -45,6 +45,8 @@
|
|||
});
|
||||
|
||||
test('subscribe redirects to billing index when account has active subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
|
|
@ -57,6 +59,14 @@
|
|||
$response->assertRedirect(route('app.billing.index'));
|
||||
});
|
||||
|
||||
test('subscribe redirects to calendar in self hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
||||
// Index tests
|
||||
test('billing index requires authentication', function () {
|
||||
$response = $this->get(route('app.billing.index'));
|
||||
|
|
@ -65,6 +75,8 @@
|
|||
});
|
||||
|
||||
test('billing index shows billing dashboard', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
|
|
@ -83,6 +95,14 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('billing index redirects to calendar in self hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.billing.index'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
||||
// Processing tests
|
||||
test('billing processing requires authentication', function () {
|
||||
$response = $this->get(route('app.billing.processing'));
|
||||
|
|
@ -91,6 +111,8 @@
|
|||
});
|
||||
|
||||
test('billing processing shows processing page', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.billing.processing'));
|
||||
|
||||
$response->assertOk();
|
||||
|
|
@ -100,6 +122,13 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('billing processing redirects to calendar in self hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.billing.processing'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
||||
// Checkout tests
|
||||
test('checkout requires authentication', function () {
|
||||
|
|
@ -118,6 +147,8 @@
|
|||
|
||||
// Authorization tests
|
||||
test('non-owner admin cannot access billing index', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$admin = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
|
|
@ -136,6 +167,8 @@
|
|||
});
|
||||
|
||||
test('member cannot access billing index', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$member = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
|
|
|
|||
51
tests/Feature/UsageControllerTest.php
Normal file
51
tests/Feature/UsageControllerTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->account = Account::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'setup' => Setup::Completed,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$this->account->update(['owner_id' => $this->user->id]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('usage index redirects to calendar in self hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.usage.index'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
||||
test('usage index shows usage page when not self hosted', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.usage.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('settings/Usage', false)
|
||||
->has('usage')
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue