diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index ae76d51c..91d8707a 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -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); diff --git a/app/Http/Controllers/App/Settings/AccountController.php b/app/Http/Controllers/App/Settings/AccountController.php index 39f60ec0..3f433e1b 100644 --- a/app/Http/Controllers/App/Settings/AccountController.php +++ b/app/Http/Controllers/App/Settings/AccountController.php @@ -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'), diff --git a/app/Http/Controllers/App/Settings/UsageController.php b/app/Http/Controllers/App/Settings/UsageController.php index 6f30b4c0..e53a8342 100644 --- a/app/Http/Controllers/App/Settings/UsageController.php +++ b/app/Http/Controllers/App/Settings/UsageController.php @@ -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; diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index f799f993..b29ce169 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -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) { diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index a2f55aae..ff6aef0c 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -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']); diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index 2fdb5d94..b9379e15 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -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) { diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 8480e897..cc3fb1ed 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -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); diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index 3d5f85af..86239b66 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -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); diff --git a/app/Http/Controllers/Auth/LinkedInPageController.php b/app/Http/Controllers/Auth/LinkedInPageController.php index 1d619815..4d9514e2 100644 --- a/app/Http/Controllers/Auth/LinkedInPageController.php +++ b/app/Http/Controllers/Auth/LinkedInPageController.php @@ -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); diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index 9cb57679..67edaffe 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -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(); diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 0502363f..b687b590 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -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) { diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 624a165c..86e2ff1e 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -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']); diff --git a/app/Http/Controllers/Auth/TikTokController.php b/app/Http/Controllers/Auth/TikTokController.php index 04bdea5f..e0920f0f 100644 --- a/app/Http/Controllers/Auth/TikTokController.php +++ b/app/Http/Controllers/Auth/TikTokController.php @@ -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'); diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index f574fbcf..b6a27f9a 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -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']); diff --git a/app/Services/Social/FacebookAnalytics.php b/app/Services/Social/FacebookAnalytics.php index 5f2de38a..ccb6f82b 100644 --- a/app/Services/Social/FacebookAnalytics.php +++ b/app/Services/Social/FacebookAnalytics.php @@ -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)), }; diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index d5b0860f..2af588e3 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -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())]); diff --git a/resources/js/pages/settings/Account.vue b/resources/js/pages/settings/Account.vue index 9b6ded68..fe07edc2 100644 --- a/resources/js/pages/settings/Account.vue +++ b/resources/js/pages/settings/Account.vue @@ -18,8 +18,9 @@ interface AccountData { billing_email: string; } -defineProps<{ +const props = defineProps<{ account: AccountData; + selfHosted: boolean; }>(); const breadcrumbItems = computed(() => [ @@ -54,7 +55,7 @@ const breadcrumbItems = computed(() => [ -
+
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'); +}); diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index e5062220..d8093129 100644 --- a/tests/Feature/BillingControllerTest.php +++ b/tests/Feature/BillingControllerTest.php @@ -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, diff --git a/tests/Feature/UsageControllerTest.php b/tests/Feature/UsageControllerTest.php new file mode 100644 index 00000000..db573339 --- /dev/null +++ b/tests/Feature/UsageControllerTest.php @@ -0,0 +1,51 @@ +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') + ); +});