From 7c00c3387e3191b5baa864ac334a45e7707fec59 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 16 Jan 2026 23:46:30 -0300 Subject: [PATCH] feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates. --- .env.example | 7 + README.md | 15 + app/Actions/Fortify/CreateNewUser.php | 8 +- app/Enums/Status.php | 28 ++ app/Enums/User/Persona.php | 65 ++++ app/Enums/User/Setup.php | 34 ++ app/Events/SubscriptionCreated.php | 32 ++ app/Exceptions/TokenExpiredException.php | 15 + .../Controllers/Auth/FacebookController.php | 136 ++++++-- .../Controllers/Auth/InstagramController.php | 263 +++++----------- .../Controllers/Auth/LinkedInController.php | 68 ++-- .../Auth/LinkedInPageController.php | 90 ++++-- .../Controllers/Auth/SocialController.php | 110 +++++-- .../Controllers/Auth/ThreadsController.php | 81 +++-- .../Controllers/Auth/TikTokController.php | 67 +++- app/Http/Controllers/Auth/XController.php | 21 +- .../Controllers/Auth/YouTubeController.php | 144 ++++++--- app/Http/Controllers/BillingController.php | 82 +++-- app/Http/Controllers/OnboardingController.php | 117 +++++++ app/Http/Controllers/PostController.php | 83 +++-- app/Http/Controllers/WorkspaceController.php | 96 +++--- .../Controllers/WorkspaceInviteController.php | 47 ++- app/Http/Middleware/EnsureSubscribed.php | 32 ++ .../Middleware/EnsureUserSetupIsComplete.php | 55 ++++ app/Http/Middleware/HandleInertiaRequests.php | 6 +- app/Http/Responses/LoginResponse.php | 28 ++ app/Http/Responses/RegisterResponse.php | 16 + app/Jobs/PublishToSocialPlatform.php | 11 + app/Listeners/StripeEventListener.php | 61 ++++ app/Models/PostMedia.php | 2 + app/Models/SocialAccount.php | 46 +++ app/Models/User.php | 41 +++ .../AccountDisconnectedNotification.php | 46 +++ app/Providers/AppServiceProvider.php | 21 +- app/Providers/FortifyServiceProvider.php | 7 +- app/Services/Social/FacebookPublisher.php | 51 ++- app/Services/Social/InstagramPublisher.php | 51 ++- app/Services/Social/LinkedInPagePublisher.php | 47 ++- app/Services/Social/LinkedInPublisher.php | 47 ++- app/Services/Social/ThreadsPublisher.php | 55 +++- app/Services/Social/TikTokPublisher.php | 55 +++- app/Services/Social/XPublisher.php | 54 +++- app/Services/Social/YouTubePublisher.php | 60 +++- app/Socialite/InstagramExtendSocialite.php | 13 - app/Socialite/InstagramProvider.php | 95 ++++++ bootstrap/app.php | 5 + composer.json | 2 + composer.lock | 93 +++++- config/cashier.php | 35 +++ config/fortify.php | 2 +- config/mail.php | 2 +- config/services.php | 5 - .../0001_01_01_000000_create_users_table.php | 7 + ...6_01_14_232315_create_workspaces_table.php | 1 + ...14_232317_create_social_accounts_table.php | 3 + ..._14_232319_create_post_platforms_table.php | 1 + ...6_01_14_232320_create_post_media_table.php | 6 +- ...ost_platform_id_nullable_on_post_media.php | 28 -- ...20942_add_timezone_to_workspaces_table.php | 28 -- ...45_add_enabled_to_post_platforms_table.php | 28 -- resources/css/app.css | 206 +++++++----- resources/js/components/AppHeader.vue | 58 +++- resources/js/components/AppSidebar.vue | 244 ++++++++++++++ .../js/components/SocialAccountsGrid.vue | 264 ++++++++++++++++ resources/js/components/WorkspaceSwitcher.vue | 66 ++++ resources/js/layouts/AppLayout.vue | 2 +- resources/js/layouts/GuestLayout.vue | 6 +- resources/js/layouts/OnboardingLayout.vue | 44 +++ resources/js/layouts/PopupLayout.vue | 17 + resources/js/pages/Dashboard.vue | 49 --- resources/js/pages/Welcome.vue | 10 +- resources/js/pages/accounts/Index.vue | 160 +--------- .../js/pages/accounts/LinkedInPageSelect.vue | 98 ++---- .../pages/accounts/YouTubeChannelSelect.vue | 149 --------- resources/js/pages/billing/Index.vue | 121 +++---- resources/js/pages/billing/Processing.vue | 99 ++++++ resources/js/pages/billing/Subscribe.vue | 100 ++++++ resources/js/pages/onboarding/Step1.vue | 88 ++++++ resources/js/pages/onboarding/Step2.vue | 68 ++++ resources/js/pages/posts/Calendar.vue | 21 +- resources/js/pages/posts/Edit.vue | 10 +- resources/js/pages/posts/Show.vue | 8 +- resources/js/pages/workspaces/Create.vue | 9 +- resources/js/pages/workspaces/Index.vue | 45 +-- resources/js/pages/workspaces/Invites.vue | 26 +- resources/js/pages/workspaces/Settings.vue | 15 +- resources/js/pages/workspaces/Show.vue | 20 +- .../views/auth/social-callback.blade.php | 71 +++++ .../views/vendor/mail/html/button.blade.php | 24 ++ .../views/vendor/mail/html/footer.blade.php | 11 + .../views/vendor/mail/html/header.blade.php | 8 + .../views/vendor/mail/html/layout.blade.php | 58 ++++ .../views/vendor/mail/html/message.blade.php | 27 ++ .../views/vendor/mail/html/panel.blade.php | 14 + .../views/vendor/mail/html/subcopy.blade.php | 7 + .../views/vendor/mail/html/table.blade.php | 3 + .../views/vendor/mail/html/themes/default.css | 297 ++++++++++++++++++ .../views/vendor/mail/text/button.blade.php | 1 + .../views/vendor/mail/text/footer.blade.php | 1 + .../views/vendor/mail/text/header.blade.php | 1 + .../views/vendor/mail/text/layout.blade.php | 9 + .../views/vendor/mail/text/message.blade.php | 27 ++ .../views/vendor/mail/text/panel.blade.php | 1 + .../views/vendor/mail/text/subcopy.blade.php | 1 + .../views/vendor/mail/text/table.blade.php | 1 + routes/channels.php | 4 + routes/web.php | 170 +++++----- tests/Feature/DashboardTest.php | 25 +- 108 files changed, 4101 insertions(+), 1418 deletions(-) create mode 100644 README.md create mode 100644 app/Enums/Status.php create mode 100644 app/Enums/User/Persona.php create mode 100644 app/Enums/User/Setup.php create mode 100644 app/Events/SubscriptionCreated.php create mode 100644 app/Exceptions/TokenExpiredException.php create mode 100644 app/Http/Controllers/OnboardingController.php create mode 100644 app/Http/Middleware/EnsureSubscribed.php create mode 100644 app/Http/Middleware/EnsureUserSetupIsComplete.php create mode 100644 app/Http/Responses/LoginResponse.php create mode 100644 app/Http/Responses/RegisterResponse.php create mode 100644 app/Listeners/StripeEventListener.php create mode 100644 app/Notifications/AccountDisconnectedNotification.php delete mode 100644 app/Socialite/InstagramExtendSocialite.php create mode 100644 app/Socialite/InstagramProvider.php delete mode 100644 database/migrations/2026_01_15_013103_make_post_platform_id_nullable_on_post_media.php delete mode 100644 database/migrations/2026_01_15_020942_add_timezone_to_workspaces_table.php delete mode 100644 database/migrations/2026_01_15_022545_add_enabled_to_post_platforms_table.php create mode 100644 resources/js/components/AppSidebar.vue create mode 100644 resources/js/components/SocialAccountsGrid.vue create mode 100644 resources/js/components/WorkspaceSwitcher.vue create mode 100644 resources/js/layouts/OnboardingLayout.vue create mode 100644 resources/js/layouts/PopupLayout.vue delete mode 100644 resources/js/pages/Dashboard.vue delete mode 100644 resources/js/pages/accounts/YouTubeChannelSelect.vue create mode 100644 resources/js/pages/billing/Processing.vue create mode 100644 resources/js/pages/billing/Subscribe.vue create mode 100644 resources/js/pages/onboarding/Step1.vue create mode 100644 resources/js/pages/onboarding/Step2.vue create mode 100644 resources/views/auth/social-callback.blade.php create mode 100644 resources/views/vendor/mail/html/button.blade.php create mode 100644 resources/views/vendor/mail/html/footer.blade.php create mode 100644 resources/views/vendor/mail/html/header.blade.php create mode 100644 resources/views/vendor/mail/html/layout.blade.php create mode 100644 resources/views/vendor/mail/html/message.blade.php create mode 100644 resources/views/vendor/mail/html/panel.blade.php create mode 100644 resources/views/vendor/mail/html/subcopy.blade.php create mode 100644 resources/views/vendor/mail/html/table.blade.php create mode 100644 resources/views/vendor/mail/html/themes/default.css create mode 100644 resources/views/vendor/mail/text/button.blade.php create mode 100644 resources/views/vendor/mail/text/footer.blade.php create mode 100644 resources/views/vendor/mail/text/header.blade.php create mode 100644 resources/views/vendor/mail/text/layout.blade.php create mode 100644 resources/views/vendor/mail/text/message.blade.php create mode 100644 resources/views/vendor/mail/text/panel.blade.php create mode 100644 resources/views/vendor/mail/text/subcopy.blade.php create mode 100644 resources/views/vendor/mail/text/table.blade.php diff --git a/.env.example b/.env.example index 0ed58871..70d0f011 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,10 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +STRIPE_KEY= +STRIPE_SECRET= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_MONTHLY= +STRIPE_PRICE_YEARLY= +CASHIER_TRIAL_DAYS=7 diff --git a/README.md b/README.md new file mode 100644 index 00000000..387cff9e --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ + +## Migration with base seed +```sh +php artisan migrate:fresh --seed +``` + +# Start queue worker +```sh +php artisan horizon:watch +``` + +# Share with Ngrok: +```sh +ngrok http --host-header=rewrite trypost.test:443 +``` \ No newline at end of file diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index d827b916..8f2f8c95 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -3,6 +3,7 @@ namespace App\Actions\Fortify; use App\Concerns\ProfileValidationRules; +use App\Enums\User\Setup; use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -30,16 +31,21 @@ public function create(array $input): User 'name' => $input['name'], 'email' => $input['email'], 'password' => $input['password'], + 'setup' => Setup::Role, ]); // Create default workspace for new user $workspace = $user->workspaces()->create([ - 'name' => 'Meu Workspace', + 'name' => 'My Workspace', + 'timezone' => 'UTC', ]); // Add user as owner member $workspace->members()->attach($user->id, ['role' => 'owner']); + // Set as current workspace + $user->update(['current_workspace_id' => $workspace->id]); + return $user; }); } diff --git a/app/Enums/Status.php b/app/Enums/Status.php new file mode 100644 index 00000000..584917cc --- /dev/null +++ b/app/Enums/Status.php @@ -0,0 +1,28 @@ + 'Connected', + self::Disconnected => 'Disconnected', + self::TokenExpired => 'Token Expired', + }; + } + + public function color(): string + { + return match ($this) { + self::Connected => 'green', + self::Disconnected => 'red', + self::TokenExpired => 'red', + }; + } +} diff --git a/app/Enums/User/Persona.php b/app/Enums/User/Persona.php new file mode 100644 index 00000000..c14db4f9 --- /dev/null +++ b/app/Enums/User/Persona.php @@ -0,0 +1,65 @@ + 'Founder', + self::Creator => 'Creator', + self::Agency => 'Agency', + self::Enterprise => 'Enterprise', + self::SmallBusiness => 'Small Business', + self::Personal => 'Personal', + }; + } + + public function description(): string + { + return match ($this) { + self::Founder => 'Building a startup or new venture', + self::Creator => 'Content creator or influencer', + self::Agency => 'Marketing or social media agency', + self::Enterprise => 'Large company or corporation', + self::SmallBusiness => 'Small to medium business', + self::Personal => 'Personal brand or hobby', + }; + } + + public function icon(): string + { + return match ($this) { + self::Founder => 'rocket', + self::Creator => 'sparkles', + self::Agency => 'building', + self::Enterprise => 'building-2', + self::SmallBusiness => 'store', + self::Personal => 'user', + }; + } + + /** + * @return array + */ + public static function toSelectArray(): array + { + return array_map( + fn (self $case) => [ + 'value' => $case->value, + 'label' => $case->label(), + 'description' => $case->description(), + 'icon' => $case->icon(), + ], + self::cases() + ); + } +} diff --git a/app/Enums/User/Setup.php b/app/Enums/User/Setup.php new file mode 100644 index 00000000..37bd964c --- /dev/null +++ b/app/Enums/User/Setup.php @@ -0,0 +1,34 @@ + 'Registering', + self::Role => 'Select Role', + self::Connections => 'Connect Accounts', + self::Subscription => 'Start Subscription', + self::Completed => 'Completed', + }; + } + + public function stepNumber(): int + { + return match ($this) { + self::Registering => 0, + self::Role => 1, + self::Connections => 2, + self::Subscription => 3, + self::Completed => 4, + }; + } +} diff --git a/app/Events/SubscriptionCreated.php b/app/Events/SubscriptionCreated.php new file mode 100644 index 00000000..4927c7c2 --- /dev/null +++ b/app/Events/SubscriptionCreated.php @@ -0,0 +1,32 @@ +user->id), + ]; + } + + public function broadcastWith(): array + { + return [ + 'status' => 'success', + 'message' => 'Subscription created successfully', + ]; + } +} diff --git a/app/Exceptions/TokenExpiredException.php b/app/Exceptions/TokenExpiredException.php new file mode 100644 index 00000000..78435c03 --- /dev/null +++ b/app/Exceptions/TokenExpiredException.php @@ -0,0 +1,15 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); - if ($workspace->hasConnectedPlatform($this->platform->value)) { + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { return back()->with('error', 'This platform is already connected.'); } - session(['social_connect_workspace' => $workspace->id]); + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => $existingAccount?->id, + 'social_connect_onboarding' => $request->boolean('onboarding'), + ]); return Inertia::location( Socialite::driver($this->driver) @@ -43,25 +62,26 @@ public function connect(Request $request, Workspace $workspace): Response ); } - public function callback(Request $request): RedirectResponse + public function callback(Request $request): View|RedirectResponse { $workspaceId = session('social_connect_workspace'); if (! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } - if ($workspace->hasConnectedPlatform($this->platform->value)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'This platform is already connected.'); + $reconnectId = session('social_reconnect_id'); + $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + + // If account exists and is connected, don't allow duplicate + if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) { + return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value); } try { @@ -71,8 +91,7 @@ public function callback(Request $request): RedirectResponse $pages = $this->fetchPages($socialUser->token); if (empty($pages)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'No Facebook Pages found. You need to be an admin of at least one page.'); + return $this->popupCallback(false, 'No Facebook Pages found. You need to be an admin of at least one page.', $this->platform->value); } // If only one page, connect directly @@ -80,6 +99,31 @@ public function callback(Request $request): RedirectResponse $page = $pages[0]; $avatarPath = uploadFromUrl($page['picture']); + if ($existingAccount) { + // Reconnect existing account + $existingAccount->update([ + 'platform_user_id' => $page['id'], + 'username' => $page['username'] ?? null, + 'display_name' => $page['name'], + 'avatar_url' => $avatarPath, + 'access_token' => $page['access_token'], + 'refresh_token' => null, + 'token_expires_at' => null, + 'scopes' => $this->scopes, + 'meta' => [ + 'page_id' => $page['id'], + 'user_id' => $socialUser->getId(), + 'user_token' => $socialUser->token, + ], + ]); + $existingAccount->markAsConnected(); + + session()->forget('social_reconnect_id'); + + return $this->popupCallback(true, 'Facebook Page reconnected!', $this->platform->value); + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, 'platform_user_id' => $page['id'], @@ -90,6 +134,7 @@ public function callback(Request $request): RedirectResponse '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' => $page['id'], 'user_id' => $socialUser->getId(), @@ -97,10 +142,9 @@ public function callback(Request $request): RedirectResponse ], ]); - session()->forget('social_connect_workspace'); + session()->forget('social_reconnect_id'); - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'Facebook Page connected successfully!'); + return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value); } // Multiple pages - store data and show selection @@ -109,6 +153,7 @@ public function callback(Request $request): RedirectResponse 'user_token' => $socialUser->token, 'user_id' => $socialUser->getId(), 'pages' => $pages, + 'reconnect_id' => $reconnectId, ], ]); @@ -119,8 +164,7 @@ public function callback(Request $request): RedirectResponse 'trace' => $e->getTraceAsString(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting account. Please try again.'); + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); } } @@ -130,14 +174,14 @@ public function selectPage(Request $request) $workspaceId = session('social_connect_workspace'); if (! $oauthData || ! $workspaceId) { - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->with('error', 'Session expired. Please try again.'); } $workspace = Workspace::find($workspaceId); if (! $workspace) { - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->with('error', 'Workspace not found.'); } @@ -147,7 +191,7 @@ public function selectPage(Request $request) ]); } - public function select(Request $request): RedirectResponse + public function select(Request $request): View { $request->validate([ 'page_id' => 'required|string', @@ -157,27 +201,54 @@ public function select(Request $request): RedirectResponse $workspaceId = session('social_connect_workspace'); if (! $oauthData || ! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } try { $selectedPage = collect($oauthData['pages'])->firstWhere('id', $request->page_id); if (! $selectedPage) { - return redirect()->route('social.facebook.select-page') - ->with('error', 'Page not found.'); + return $this->popupCallback(false, 'Page not found.', $this->platform->value); } $avatarPath = uploadFromUrl($selectedPage['picture']); + $reconnectId = $oauthData['reconnect_id'] ?? null; + if ($reconnectId) { + // Reconnect existing account + $existingAccount = $workspace->socialAccounts()->find($reconnectId); + + if ($existingAccount) { + $existingAccount->update([ + 'platform_user_id' => $selectedPage['id'], + 'username' => $selectedPage['username'] ?? null, + 'display_name' => $selectedPage['name'], + 'avatar_url' => $avatarPath, + 'access_token' => $selectedPage['access_token'], + 'refresh_token' => null, + 'token_expires_at' => null, + 'scopes' => $this->scopes, + 'meta' => [ + 'page_id' => $selectedPage['id'], + 'user_id' => $oauthData['user_id'], + 'user_token' => $oauthData['user_token'], + ], + ]); + $existingAccount->markAsConnected(); + + session()->forget(['facebook_oauth', 'social_reconnect_id']); + + return $this->popupCallback(true, 'Facebook Page reconnected!', $this->platform->value); + } + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, 'platform_user_id' => $selectedPage['id'], @@ -188,6 +259,7 @@ public function select(Request $request): RedirectResponse 'refresh_token' => null, 'token_expires_at' => null, 'scopes' => $this->scopes, + 'status' => Status::Connected, 'meta' => [ 'page_id' => $selectedPage['id'], 'user_id' => $oauthData['user_id'], @@ -195,24 +267,22 @@ public function select(Request $request): RedirectResponse ], ]); - session()->forget(['facebook_oauth', 'social_connect_workspace']); + session()->forget(['facebook_oauth', 'social_reconnect_id']); - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'Facebook Page connected successfully!'); + return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value); } catch (\Exception $e) { Log::error('Facebook page selection error', [ 'error' => $e->getMessage(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting page. Please try again.'); + return $this->popupCallback(false, 'Error connecting page. Please try again.', $this->platform->value); } } private function fetchPages(string $userToken): array { try { - $response = Http::get('https://graph.facebook.com/v21.0/me/accounts', [ + $response = Http::get('https://graph.facebook.com/v24.0/me/accounts', [ 'access_token' => $userToken, 'fields' => 'id,name,username,picture{url},access_token', ]); diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index de93dd90..56739303 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -3,11 +3,12 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialPlatform; +use App\Enums\Status; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\View\View; use Inertia\Inertia; use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\Response; @@ -19,243 +20,125 @@ class InstagramController extends SocialController protected SocialPlatform $platform = SocialPlatform::Instagram; protected array $scopes = [ - 'instagram_basic', - 'instagram_content_publish', - 'pages_show_list', - 'pages_read_engagement', + 'instagram_business_basic', + 'instagram_business_content_publish', ]; - public function connect(Request $request, Workspace $workspace): Response + public function connect(Request $request): Response|RedirectResponse { $this->ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); - if ($workspace->hasConnectedPlatform($this->platform->value)) { + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { return back()->with('error', 'This platform is already connected.'); } - session(['social_connect_workspace' => $workspace->id]); + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => $existingAccount?->id, + 'social_connect_onboarding' => $request->boolean('onboarding'), + ]); - return Inertia::location( - Socialite::driver($this->driver) - ->scopes($this->scopes) - ->redirect() - ->getTargetUrl() - ); + $url = Socialite::driver($this->driver) + ->scopes($this->scopes) + ->redirect() + ->getTargetUrl(); + + return Inertia::location($url); } - public function callback(Request $request): RedirectResponse + public function callback(Request $request): View { $workspaceId = session('social_connect_workspace'); if (! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } - if ($workspace->hasConnectedPlatform($this->platform->value)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'This platform is already connected.'); + $reconnectId = session('social_reconnect_id'); + $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + + // If account exists and is connected, don't allow duplicate + if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) { + return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value); } try { $socialUser = Socialite::driver($this->driver)->user(); - // Fetch Instagram accounts linked to Facebook pages - $accounts = $this->fetchInstagramAccounts($socialUser->token); + // Instagram API with Instagram Login returns the user directly + $avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null; - if (empty($accounts)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'No Instagram Business accounts found. Make sure your Instagram is connected to a Facebook Page.'); - } + // Calculate token expiration (long-lived tokens last 60 days) + $expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds + $tokenExpiresAt = now()->addSeconds($expiresIn); - // If only one account, connect directly - if (count($accounts) === 1) { - $account = $accounts[0]; - $avatarPath = uploadFromUrl($account['profile_picture_url']); - - $workspace->socialAccounts()->create([ - 'platform' => $this->platform->value, - 'platform_user_id' => $account['id'], - 'username' => $account['username'], - 'display_name' => $account['name'] ?? $account['username'], + if ($existingAccount) { + // Reconnect existing account + $existingAccount->update([ + 'platform_user_id' => $socialUser->getId(), + 'username' => $socialUser->getNickname(), + 'display_name' => $socialUser->getName() ?? $socialUser->getNickname(), 'avatar_url' => $avatarPath, - 'access_token' => $account['page_access_token'], - 'refresh_token' => null, - 'token_expires_at' => null, + 'access_token' => $socialUser->token, + 'refresh_token' => $socialUser->refreshToken, + 'token_expires_at' => $tokenExpiresAt, 'scopes' => $this->scopes, 'meta' => [ - 'instagram_id' => $account['id'], - 'page_id' => $account['page_id'], - 'user_id' => $socialUser->getId(), - 'user_token' => $socialUser->token, + 'account_type' => $socialUser->user['account_type'] ?? null, ], ]); + $existingAccount->markAsConnected(); - session()->forget('social_connect_workspace'); + session()->forget('social_reconnect_id'); - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'Instagram account connected successfully!'); + return $this->popupCallback(true, 'Instagram account reconnected!', $this->platform->value); } - // Multiple accounts - store data and show selection - session([ - 'instagram_oauth' => [ - 'user_token' => $socialUser->token, - 'user_id' => $socialUser->getId(), - 'accounts' => $accounts, + // 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, ], ]); - return redirect()->route('social.instagram.select-account'); + session()->forget('social_reconnect_id'); + + return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value); } catch (\Exception $e) { Log::error('Instagram OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting account. Please try again.'); - } - } - - public function selectAccount(Request $request) - { - $oauthData = session('instagram_oauth'); - $workspaceId = session('social_connect_workspace'); - - if (! $oauthData || ! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); - } - - return Inertia::render('accounts/InstagramAccountSelect', [ - 'workspace' => $workspace, - 'accounts' => $oauthData['accounts'], - ]); - } - - public function select(Request $request): RedirectResponse - { - $request->validate([ - 'account_id' => 'required|string', - ]); - - $oauthData = session('instagram_oauth'); - $workspaceId = session('social_connect_workspace'); - - if (! $oauthData || ! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); - } - - try { - $selectedAccount = collect($oauthData['accounts'])->firstWhere('id', $request->account_id); - - if (! $selectedAccount) { - return redirect()->route('social.instagram.select-account') - ->with('error', 'Account not found.'); - } - - $avatarPath = uploadFromUrl($selectedAccount['profile_picture_url']); - - $workspace->socialAccounts()->create([ - 'platform' => $this->platform->value, - 'platform_user_id' => $selectedAccount['id'], - 'username' => $selectedAccount['username'], - 'display_name' => $selectedAccount['name'] ?? $selectedAccount['username'], - 'avatar_url' => $avatarPath, - 'access_token' => $selectedAccount['page_access_token'], - 'refresh_token' => null, - 'token_expires_at' => null, - 'scopes' => $this->scopes, - 'meta' => [ - 'instagram_id' => $selectedAccount['id'], - 'page_id' => $selectedAccount['page_id'], - 'user_id' => $oauthData['user_id'], - 'user_token' => $oauthData['user_token'], - ], - ]); - - session()->forget(['instagram_oauth', 'social_connect_workspace']); - - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'Instagram account connected successfully!'); - } catch (\Exception $e) { - Log::error('Instagram account selection error', [ - 'error' => $e->getMessage(), - ]); - - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting account. Please try again.'); - } - } - - private function fetchInstagramAccounts(string $userToken): array - { - try { - // First, get all pages with their Instagram business accounts - $response = Http::get('https://graph.facebook.com/v21.0/me/accounts', [ - 'access_token' => $userToken, - 'fields' => 'id,name,access_token,instagram_business_account{id,username,name,profile_picture_url,followers_count}', - ]); - - if ($response->failed()) { - Log::error('Instagram accounts fetch failed', [ - 'status' => $response->status(), - 'body' => $response->body(), - ]); - - return []; - } - - $data = $response->json(); - $accounts = []; - - foreach ($data['data'] ?? [] as $page) { - if (isset($page['instagram_business_account'])) { - $ig = $page['instagram_business_account']; - $accounts[] = [ - 'id' => $ig['id'], - 'username' => $ig['username'], - 'name' => $ig['name'] ?? $ig['username'], - 'profile_picture_url' => $ig['profile_picture_url'] ?? null, - 'followers_count' => $ig['followers_count'] ?? 0, - 'page_id' => $page['id'], - 'page_name' => $page['name'], - 'page_access_token' => $page['access_token'], - ]; - } - } - - return $accounts; - } catch (\Exception $e) { - Log::error('Instagram accounts fetch error', [ - 'error' => $e->getMessage(), - ]); - - return []; + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); } } } diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index c28dfde7..4b11ffe4 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -3,11 +3,13 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialPlatform; +use App\Enums\Status; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\View\View; use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\Response; @@ -21,45 +23,56 @@ class LinkedInController extends SocialController 'openid', 'profile', 'email', - 'r_basicprofile', 'w_member_social', ]; - public function connect(Request $request, Workspace $workspace): Response + public function connect(Request $request): Response|RedirectResponse { $this->ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); - if ($workspace->hasConnectedPlatform($this->platform->value)) { + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { return back()->with('error', 'This platform is already connected.'); } - return $this->redirectToProvider($workspace, $this->driver, $this->scopes); + return $this->redirectToProvider($request, $this->driver, $this->scopes); } - public function callback(Request $request): RedirectResponse + public function callback(Request $request): View { $workspaceId = session('social_connect_workspace'); if (! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); - } - - if ($workspace->hasConnectedPlatform($this->platform->value)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'This platform is already connected.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } try { $socialUser = Socialite::driver($this->driver)->user(); + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + // If account exists and is connected, don't allow duplicate + if ($existingAccount && ! $existingAccount->isDisconnected()) { + return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value); + } // Fetch vanityName from LinkedIn API (not available via OpenID) $username = $this->fetchVanityName($socialUser->token); @@ -72,6 +85,24 @@ public function callback(Request $request): RedirectResponse $avatarPath = uploadFromUrl($socialUser->getAvatar()); + if ($existingAccount) { + // Reconnect existing account + $existingAccount->update([ + '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, + ]); + $existingAccount->markAsConnected(); + + return $this->popupCallback(true, 'LinkedIn account reconnected!', $this->platform->value); + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, 'platform_user_id' => $socialUser->getId(), @@ -82,19 +113,16 @@ public function callback(Request $request): RedirectResponse 'refresh_token' => $socialUser->refreshToken, 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null, 'scopes' => $socialUser->approvedScopes ?? null, + 'status' => Status::Connected, ]); - session()->forget('social_connect_workspace'); - - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'Account connected successfully!'); + return $this->popupCallback(true, 'LinkedIn account connected!', $this->platform->value); } catch (\Exception $e) { Log::error('LinkedIn OAuth Error', [ 'error' => $e->getMessage(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting account. Please try again.'); + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); } } diff --git a/app/Http/Controllers/Auth/LinkedInPageController.php b/app/Http/Controllers/Auth/LinkedInPageController.php index b083e348..de390cc1 100644 --- a/app/Http/Controllers/Auth/LinkedInPageController.php +++ b/app/Http/Controllers/Auth/LinkedInPageController.php @@ -3,11 +3,13 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialPlatform; +use App\Enums\Status; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\View\View; use Inertia\Inertia; use Inertia\Response; use Laravel\Socialite\Facades\Socialite; @@ -29,16 +31,31 @@ class LinkedInPageController extends SocialController 'w_member_social', ]; - public function connect(Request $request, Workspace $workspace): SymfonyResponse + public function connect(Request $request): SymfonyResponse|RedirectResponse { $this->ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); - if ($workspace->hasConnectedPlatform($this->platform->value)) { + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { return back()->with('error', 'This platform is already connected.'); } - session(['social_connect_workspace' => $workspace->id]); + session([ + 'social_connect_workspace' => $workspace->id, + 'linkedin_page_reconnect_id' => $existingAccount?->id, + 'social_connect_onboarding' => $request->boolean('onboarding'), + ]); return Inertia::location( Socialite::driver($this->driver) @@ -51,20 +68,18 @@ public function connect(Request $request, Workspace $workspace): SymfonyResponse ); } - public function callback(Request $request): RedirectResponse + public function callback(Request $request): View|RedirectResponse { $workspaceId = session('social_connect_workspace'); if (! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } try { @@ -79,10 +94,7 @@ public function callback(Request $request): RedirectResponse $organizations = $this->fetchOrganizations($socialUser->token); if (empty($organizations)) { - session()->forget('social_connect_workspace'); - - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'You are not an administrator of any LinkedIn page.'); + return $this->popupCallback(false, 'You are not an administrator of any LinkedIn page.', $this->platform->value); } // Store data in session and redirect to selection page @@ -96,6 +108,7 @@ public function callback(Request $request): RedirectResponse 'refresh_token' => $socialUser->refreshToken, 'expires_in' => $socialUser->expiresIn, 'organizations' => $organizations, + 'reconnect_id' => session('linkedin_page_reconnect_id'), ], ]); @@ -105,8 +118,7 @@ public function callback(Request $request): RedirectResponse 'error' => $e->getMessage(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting account. Please try again.'); + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); } } @@ -115,14 +127,14 @@ public function selectPage(Request $request): Response|RedirectResponse $pendingData = session('linkedin_page_pending'); if (! $pendingData) { - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->with('error', 'Session expired. Please try again.'); } $workspace = Workspace::find($pendingData['workspace_id']); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->with('error', 'Workspace not found.'); } @@ -132,7 +144,7 @@ public function selectPage(Request $request): Response|RedirectResponse ]); } - public function select(Request $request): RedirectResponse + public function select(Request $request): View { $request->validate([ 'organization_id' => 'required', @@ -144,20 +156,47 @@ public function select(Request $request): RedirectResponse $pendingData = session('linkedin_page_pending'); if (! $pendingData) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($pendingData['workspace_id']); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } try { $avatarPath = uploadFromUrl($request->organization_logo); + $reconnectId = $pendingData['reconnect_id'] ?? null; + if ($reconnectId) { + // Reconnect existing account + $existingAccount = $workspace->socialAccounts()->find($reconnectId); + + if ($existingAccount) { + $existingAccount->update([ + '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, + 'meta' => [ + 'organization_id' => $request->organization_id, + 'admin_user_id' => $pendingData['user_id'], + 'admin_name' => $pendingData['name'], + ], + ]); + $existingAccount->markAsConnected(); + + session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']); + + return $this->popupCallback(true, 'LinkedIn Page reconnected!', $this->platform->value); + } + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, 'platform_user_id' => $request->organization_id, @@ -167,6 +206,7 @@ public function select(Request $request): RedirectResponse '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'], @@ -174,17 +214,15 @@ public function select(Request $request): RedirectResponse ], ]); - session()->forget(['social_connect_workspace', 'linkedin_page_pending']); + session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']); - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'LinkedIn Page connected successfully!'); + return $this->popupCallback(true, 'LinkedIn Page connected!', $this->platform->value); } catch (\Exception $e) { Log::error('LinkedIn Page selection error', [ 'error' => $e->getMessage(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting page. Please try again.'); + return $this->popupCallback(false, 'Error connecting page. Please try again.', $this->platform->value); } } diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 94bf738d..5d30a5aa 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -3,12 +3,14 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialPlatform; +use App\Enums\Status; use App\Http\Controllers\Controller; use App\Models\SocialAccount; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\View\View; use Inertia\Inertia; use Inertia\Response; use Laravel\Socialite\Facades\Socialite; @@ -25,8 +27,14 @@ protected function ensurePlatformEnabled(): void } } - public function index(Workspace $workspace): Response + public function index(Request $request): Response|RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); $connectedAccounts = $workspace->socialAccounts; @@ -49,8 +57,14 @@ public function index(Workspace $workspace): Response ]); } - public function disconnect(Workspace $workspace, SocialAccount $account): RedirectResponse + public function disconnect(Request $request, SocialAccount $account): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); if ($account->workspace_id !== $workspace->id) { @@ -65,9 +79,16 @@ public function disconnect(Workspace $workspace, SocialAccount $account): Redire return back(); } - protected function redirectToProvider(Workspace $workspace, string $driver, array $scopes): SymfonyResponse + protected function redirectToProvider(Request $request, string $driver, array $scopes): SymfonyResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + session(['social_connect_workspace' => $workspace->id]); + session(['social_connect_onboarding' => $request->boolean('onboarding')]); return Inertia::location( Socialite::driver($driver) @@ -81,36 +102,50 @@ protected function handleCallback( Request $request, SocialPlatform $platform, string $driver - ): RedirectResponse { + ): View { $workspaceId = session('social_connect_workspace'); if (! $workspaceId) { - session()->flash('flash.banner', 'Session expired. Please try again.'); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('workspaces.index'); + return $this->popupCallback(false, 'Session expired. Please try again.', $platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - session()->flash('flash.banner', 'Workspace not found.'); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('workspaces.index'); - } - - if ($workspace->hasConnectedPlatform($platform->value)) { - session()->flash('flash.banner', 'This platform is already connected.'); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('workspaces.accounts', $workspace); + return $this->popupCallback(false, 'Workspace not found.', $platform->value); } try { $socialUser = Socialite::driver($driver)->user(); + $existingAccount = $workspace->socialAccounts() + ->where('platform', $platform->value) + ->first(); + + // If account exists and is connected, don't allow duplicate + if ($existingAccount && ! $existingAccount->isDisconnected()) { + return $this->popupCallback(false, 'This platform is already connected.', $platform->value); + } + $avatarPath = uploadFromUrl($socialUser->getAvatar()); + if ($existingAccount) { + // Reconnect existing account + $existingAccount->update([ + '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, + ]); + $existingAccount->markAsConnected(); + + return $this->popupCallback(true, 'Account reconnected!', $platform->value); + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $platform->value, 'platform_user_id' => $socialUser->getId(), @@ -121,24 +156,41 @@ protected function handleCallback( 'refresh_token' => $socialUser->refreshToken, 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null, 'scopes' => $socialUser->approvedScopes ?? null, + 'status' => Status::Connected, ]); - session()->forget('social_connect_workspace'); - - session()->flash('flash.banner', 'Account connected successfully!'); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('workspaces.accounts', $workspace); + return $this->popupCallback(true, 'Account connected!', $platform->value); } catch (\Exception $e) { Log::error('Social OAuth Error', [ 'platform' => $platform->value, 'error' => $e->getMessage(), ]); - session()->flash('flash.banner', 'Error connecting account. Please try again.'); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('workspaces.accounts', $workspace); + return $this->popupCallback(false, 'Error connecting account. Please try again.', $platform->value); } } + + protected function forgetSocialConnectSession(): void + { + session()->forget(['social_connect_workspace', 'social_connect_onboarding']); + } + + protected function getRedirectRoute(): string + { + return session('social_connect_onboarding', false) ? 'onboarding.step2' : 'accounts'; + } + + /** + * Return a view that closes the popup and notifies the parent window. + */ + protected function popupCallback(bool $success, string $message, ?string $platform = null): View + { + $this->forgetSocialConnectSession(); + + return view('auth.social-callback', [ + 'success' => $success, + 'message' => $message, + 'platform' => $platform, + ]); + } } diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 84663cf6..7c28b438 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -3,11 +3,13 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialPlatform; +use App\Enums\Status; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\View\View; use Inertia\Inertia; use Symfony\Component\HttpFoundation\Response; @@ -22,16 +24,30 @@ class ThreadsController extends SocialController 'threads_read_replies', ]; - public function connect(Request $request, Workspace $workspace): Response + public function connect(Request $request): Response|RedirectResponse { $this->ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); - if ($workspace->hasConnectedPlatform($this->platform->value)) { + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { return back()->with('error', 'This platform is already connected.'); } - session(['social_connect_workspace' => $workspace->id]); + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => $existingAccount?->id, + ]); $state = bin2hex(random_bytes(16)); session(['threads_oauth_state' => $state]); @@ -47,31 +63,39 @@ public function connect(Request $request, Workspace $workspace): Response return Inertia::location("https://threads.net/oauth/authorize?{$params}"); } - public function callback(Request $request): RedirectResponse + public function callback(Request $request): View { $workspaceId = session('social_connect_workspace'); $savedState = session('threads_oauth_state'); if (! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + session()->forget(['threads_oauth_state', 'social_reconnect_id']); + + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } if ($request->state !== $savedState) { - return redirect()->route('workspaces.index') - ->with('error', 'Invalid state. Please try again.'); + session()->forget(['threads_oauth_state', 'social_reconnect_id']); + + return $this->popupCallback(false, 'Invalid state. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + session()->forget(['threads_oauth_state', 'social_reconnect_id']); + + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } - if ($workspace->hasConnectedPlatform($this->platform->value)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'This platform is already connected.'); + $reconnectId = session('social_reconnect_id'); + $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + + // If account exists and is connected, don't allow duplicate + if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) { + session()->forget(['threads_oauth_state', 'social_reconnect_id']); + + return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value); } try { @@ -128,6 +152,26 @@ public function callback(Request $request): RedirectResponse $profile = $profileResponse->json(); $avatarPath = uploadFromUrl($profile['threads_profile_picture_url'] ?? null); + if ($existingAccount) { + // Reconnect existing account + $existingAccount->update([ + 'platform_user_id' => $profile['id'], + 'username' => $profile['username'], + 'display_name' => $profile['name'] ?? $profile['username'], + 'avatar_url' => $avatarPath, + 'access_token' => $longLivedToken, + 'refresh_token' => null, + 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, + 'scopes' => $this->scopes, + ]); + $existingAccount->markAsConnected(); + + session()->forget(['threads_oauth_state', 'social_reconnect_id']); + + return $this->popupCallback(true, 'Threads account reconnected!', $this->platform->value); + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, 'platform_user_id' => $profile['id'], @@ -138,20 +182,21 @@ public function callback(Request $request): RedirectResponse 'refresh_token' => null, 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, 'scopes' => $this->scopes, + 'status' => Status::Connected, ]); - session()->forget(['social_connect_workspace', 'threads_oauth_state']); + session()->forget(['threads_oauth_state', 'social_reconnect_id']); - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'Threads account connected successfully!'); + return $this->popupCallback(true, 'Threads account connected!', $this->platform->value); } catch (\Exception $e) { Log::error('Threads OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting account. Please try again.'); + session()->forget(['threads_oauth_state', 'social_reconnect_id']); + + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); } } } diff --git a/app/Http/Controllers/Auth/TikTokController.php b/app/Http/Controllers/Auth/TikTokController.php index 4dcbbadf..092aaadc 100644 --- a/app/Http/Controllers/Auth/TikTokController.php +++ b/app/Http/Controllers/Auth/TikTokController.php @@ -3,10 +3,12 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialPlatform; +use App\Enums\Status; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\View\View; use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\Response; @@ -22,37 +24,51 @@ class TikTokController extends SocialController 'video.publish', ]; - public function connect(Request $request, Workspace $workspace): Response + public function connect(Request $request): Response|RedirectResponse { $this->ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); - if ($workspace->hasConnectedPlatform($this->platform->value)) { + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { return back()->with('error', 'This platform is already connected.'); } - return $this->redirectToProvider($workspace, $this->driver, $this->scopes); + session(['social_reconnect_id' => $existingAccount?->id]); + + return $this->redirectToProvider($request, $this->driver, $this->scopes); } - public function callback(Request $request): RedirectResponse + public function callback(Request $request): View { $workspaceId = session('social_connect_workspace'); if (! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } - if ($workspace->hasConnectedPlatform($this->platform->value)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'This platform is already connected.'); + $reconnectId = session('social_reconnect_id'); + $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + + // If account exists and is connected, don't allow duplicate + if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) { + return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value); } try { @@ -70,6 +86,26 @@ public function callback(Request $request): RedirectResponse $username = $socialUser->getNickname(); $avatarPath = uploadFromUrl($socialUser->getAvatar()); + if ($existingAccount) { + // Reconnect existing account + $existingAccount->update([ + '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, + ]); + $existingAccount->markAsConnected(); + + session()->forget('social_reconnect_id'); + + return $this->popupCallback(true, 'TikTok account reconnected!', $this->platform->value); + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, 'platform_user_id' => $socialUser->getId(), @@ -80,19 +116,18 @@ public function callback(Request $request): RedirectResponse 'refresh_token' => $socialUser->refreshToken, 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null, 'scopes' => $socialUser->approvedScopes ?? null, + 'status' => Status::Connected, ]); - session()->forget('social_connect_workspace'); + session()->forget('social_reconnect_id'); - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'Account connected successfully!'); + return $this->popupCallback(true, 'TikTok account connected!', $this->platform->value); } catch (\Exception $e) { Log::error('TikTok OAuth Error', [ 'error' => $e->getMessage(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting account. Please try again.'); + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); } } } diff --git a/app/Http/Controllers/Auth/XController.php b/app/Http/Controllers/Auth/XController.php index 3bfdde15..9fd05433 100644 --- a/app/Http/Controllers/Auth/XController.php +++ b/app/Http/Controllers/Auth/XController.php @@ -3,9 +3,9 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialPlatform; -use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\View\View; use Symfony\Component\HttpFoundation\Response; class XController extends SocialController @@ -22,19 +22,30 @@ class XController extends SocialController 'offline.access', ]; - public function connect(Request $request, Workspace $workspace): Response + public function connect(Request $request): Response|RedirectResponse { $this->ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); - if ($workspace->hasConnectedPlatform($this->platform->value)) { + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { return back()->with('error', 'This platform is already connected.'); } - return $this->redirectToProvider($workspace, $this->driver, $this->scopes); + return $this->redirectToProvider($request, $this->driver, $this->scopes); } - public function callback(Request $request): RedirectResponse + public function callback(Request $request): View { return $this->handleCallback($request, $this->platform, $this->driver); } diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index b243085b..3ce43047 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -3,10 +3,12 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialPlatform; +use App\Enums\Status; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\View\View; use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\Response; @@ -22,39 +24,55 @@ class YouTubeController extends SocialController 'https://www.googleapis.com/auth/youtube.force-ssl', ]; - public function connect(Request $request, Workspace $workspace): Response + public function connect(Request $request): Response|RedirectResponse { $this->ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageAccounts', $workspace); - if ($workspace->hasConnectedPlatform($this->platform->value)) { + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { return back()->with('error', 'This platform is already connected.'); } - session(['social_connect_workspace' => $workspace->id]); + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => $existingAccount?->id, + 'social_connect_onboarding' => $request->boolean('onboarding'), + ]); - return $this->redirectToGoogle($workspace); + return $this->redirectToGoogle(); } - public function callback(Request $request): RedirectResponse + public function callback(Request $request): View|RedirectResponse { $workspaceId = session('social_connect_workspace'); if (! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } - if ($workspace->hasConnectedPlatform($this->platform->value)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'This platform is already connected.'); + $reconnectId = session('social_reconnect_id'); + $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + + // If account exists and is connected, don't allow duplicate + if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) { + return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value); } try { @@ -64,8 +82,7 @@ public function callback(Request $request): RedirectResponse $channels = $this->fetchChannels($socialUser->token); if (empty($channels)) { - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'No YouTube channels found. Please create a channel first.'); + return $this->popupCallback(false, 'No YouTube channels found. Please create a channel first.', $this->platform->value); } // If only one channel, connect directly (most common case) @@ -73,26 +90,50 @@ public function callback(Request $request): RedirectResponse $channel = $channels[0]; $avatarPath = uploadFromUrl($channel['thumbnail']); + if ($existingAccount) { + // Reconnect existing account + $existingAccount->update([ + 'platform_user_id' => $channel['id'], + 'username' => ltrim($channel['custom_url'] ?? $channel['id'], '@'), + 'display_name' => $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, + 'meta' => [ + 'channel_id' => $channel['id'], + 'google_user_id' => $socialUser->getId(), + ], + ]); + $existingAccount->markAsConnected(); + + session()->forget('social_reconnect_id'); + + return $this->popupCallback(true, 'YouTube channel reconnected!', $this->platform->value); + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, 'platform_user_id' => $channel['id'], - 'username' => $channel['custom_url'] ?? $channel['id'], + 'username' => ltrim($channel['custom_url'] ?? $channel['id'], '@'), 'display_name' => $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' => $channel['id'], 'google_user_id' => $socialUser->getId(), ], ]); - session()->forget('social_connect_workspace'); + session()->forget('social_reconnect_id'); - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'YouTube channel connected successfully!'); + return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value); } // Multiple channels - store data and show selection screen @@ -102,6 +143,7 @@ public function callback(Request $request): RedirectResponse 'refresh_token' => $socialUser->refreshToken, 'expires_in' => $socialUser->expiresIn, 'user_id' => $socialUser->getId(), + 'reconnect_id' => $reconnectId, ], ]); @@ -112,8 +154,7 @@ public function callback(Request $request): RedirectResponse 'trace' => $e->getTraceAsString(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting account. Please try again.'); + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); } } @@ -123,14 +164,14 @@ public function selectChannel(Request $request) $workspaceId = session('social_connect_workspace'); if (! $oauthData || ! $workspaceId) { - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->with('error', 'Session expired. Please try again.'); } $workspace = Workspace::find($workspaceId); if (! $workspace) { - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->with('error', 'Workspace not found.'); } @@ -138,9 +179,11 @@ public function selectChannel(Request $request) $channels = $this->fetchChannels($oauthData['access_token']); if (empty($channels)) { - session()->forget(['youtube_oauth', 'social_connect_workspace']); + $redirectRoute = $this->getRedirectRoute(); + $this->forgetSocialConnectSession(); + session()->forget('youtube_oauth'); - return redirect()->route('workspaces.accounts', $workspace) + return redirect()->route($redirectRoute) ->with('error', 'No YouTube channels found. Please create a channel first.'); } @@ -150,7 +193,7 @@ public function selectChannel(Request $request) ]); } - public function select(Request $request): RedirectResponse + public function select(Request $request): View { $request->validate([ 'channel_id' => 'required|string', @@ -160,15 +203,13 @@ public function select(Request $request): RedirectResponse $workspaceId = session('social_connect_workspace'); if (! $oauthData || ! $workspaceId) { - return redirect()->route('workspaces.index') - ->with('error', 'Session expired. Please try again.'); + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); } $workspace = Workspace::find($workspaceId); if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return redirect()->route('workspaces.index') - ->with('error', 'Workspace not found.'); + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); } try { @@ -176,43 +217,70 @@ public function select(Request $request): RedirectResponse $selectedChannel = collect($channels)->firstWhere('id', $request->channel_id); if (! $selectedChannel) { - return redirect()->route('social.youtube.select-channel') - ->with('error', 'Channel not found.'); + return $this->popupCallback(false, 'Channel not found.', $this->platform->value); } $avatarPath = uploadFromUrl($selectedChannel['thumbnail']); + $reconnectId = $oauthData['reconnect_id'] ?? null; + if ($reconnectId) { + // Reconnect existing account + $existingAccount = $workspace->socialAccounts()->find($reconnectId); + + if ($existingAccount) { + $existingAccount->update([ + 'platform_user_id' => $selectedChannel['id'], + 'username' => ltrim($selectedChannel['custom_url'] ?? $selectedChannel['id'], '@'), + 'display_name' => $selectedChannel['title'], + 'avatar_url' => $avatarPath, + 'access_token' => $oauthData['access_token'], + 'refresh_token' => $oauthData['refresh_token'], + 'token_expires_at' => $oauthData['expires_in'] ? now()->addSeconds($oauthData['expires_in']) : null, + 'scopes' => $this->scopes, + 'meta' => [ + 'channel_id' => $selectedChannel['id'], + 'google_user_id' => $oauthData['user_id'], + ], + ]); + $existingAccount->markAsConnected(); + + session()->forget(['youtube_oauth', 'social_reconnect_id']); + + return $this->popupCallback(true, 'YouTube channel reconnected!', $this->platform->value); + } + } + + // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, 'platform_user_id' => $selectedChannel['id'], - 'username' => $selectedChannel['custom_url'] ?? $selectedChannel['id'], + 'username' => ltrim($selectedChannel['custom_url'] ?? $selectedChannel['id'], '@'), 'display_name' => $selectedChannel['title'], 'avatar_url' => $avatarPath, 'access_token' => $oauthData['access_token'], 'refresh_token' => $oauthData['refresh_token'], 'token_expires_at' => $oauthData['expires_in'] ? now()->addSeconds($oauthData['expires_in']) : null, 'scopes' => $this->scopes, + 'status' => Status::Connected, 'meta' => [ 'channel_id' => $selectedChannel['id'], 'google_user_id' => $oauthData['user_id'], ], ]); - session()->forget(['youtube_oauth', 'social_connect_workspace']); + session()->forget(['youtube_oauth', 'social_reconnect_id']); - return redirect()->route('workspaces.accounts', $workspace) - ->with('success', 'YouTube channel connected successfully!'); + return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value); } catch (\Exception $e) { Log::error('YouTube channel selection error', [ 'error' => $e->getMessage(), ]); - return redirect()->route('workspaces.accounts', $workspace) - ->with('error', 'Error connecting channel. Please try again.'); + return $this->popupCallback(false, 'Error connecting channel. Please try again.', $this->platform->value); } } - private function redirectToGoogle(Workspace $workspace): Response + private function redirectToGoogle(): Response { return \Inertia\Inertia::location( Socialite::driver($this->driver) diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 82a70d49..6ecdffb5 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -6,19 +6,40 @@ use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class BillingController extends Controller { + /** + * Show the subscription selection page for new users. + */ + public function subscribe(Request $request): Response|RedirectResponse + { + $user = $request->user(); + + // If already subscribed, redirect to billing + if ($user->subscribed('default')) { + return redirect()->route('billing.index'); + } + + return Inertia::render('billing/Subscribe', [ + 'trialDays' => config('cashier.trial_days'), + ]); + } + /** * Show the billing dashboard. */ public function index(Request $request): Response { $user = $request->user(); + $subscription = $user->subscription('default'); return Inertia::render('billing/Index', [ - 'hasSubscription' => $user->hasActiveSubscription(), - 'subscription' => $user->subscription('default')?->only([ + 'hasSubscription' => $user->subscribed('default'), + 'onTrial' => $subscription?->onTrial() ?? false, + 'trialEndsAt' => $subscription?->trial_ends_at?->toFormattedDateString(), + 'subscription' => $subscription?->only([ 'stripe_status', 'quantity', 'ends_at', @@ -41,22 +62,47 @@ public function index(Request $request): Response } /** - * Create a Stripe Checkout session for new subscription. + * Create a Stripe Checkout session for new subscription with trial. */ - public function checkout(Request $request): RedirectResponse + public function checkout(Request $request): SymfonyResponse { $user = $request->user(); - // Calculate quantity based on workspaces (minimum 1) - $quantity = max(1, $user->ownedWorkspacesCount()); + $subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id')) + ->allowPromotionCodes() + ->trialDays(config('cashier.trial_days')) + ->quantity(1); - return $user->newSubscription('default', config('services.stripe.price_id')) - ->quantity($quantity) - ->checkout([ - 'success_url' => route('billing.index') . '?checkout=success', - 'cancel_url' => route('billing.index') . '?checkout=cancelled', - ]) - ->redirect(); + $checkoutSession = $subscription->checkout([ + 'success_url' => route('billing.processing').'?status=success', + 'cancel_url' => route('billing.processing').'?status=cancelled', + ]); + + return Inertia::location($checkoutSession->url); + } + + /** + * Show the checkout processing page. + */ + public function processing(Request $request): Response|RedirectResponse + { + $user = $request->user(); + $status = $request->query('status', 'processing'); + + // If already subscribed, redirect to dashboard + if ($user->subscribed('default')) { + return redirect()->route('dashboard'); + } + + // Validate status + if (! in_array($status, ['processing', 'success', 'cancelled'])) { + $status = 'processing'; + } + + return Inertia::render('billing/Processing', [ + 'userId' => $user->id, + 'status' => $status, + ]); } /** @@ -76,14 +122,14 @@ public function addWorkspace(Request $request): RedirectResponse { $user = $request->user(); - if (! $user->hasActiveSubscription()) { + if (! $user->subscribed('default')) { return redirect()->route('billing.index') - ->withErrors(['subscription' => 'Você precisa de uma assinatura ativa.']); + ->withErrors(['subscription' => 'You need an active subscription.']); } $user->incrementWorkspaceQuantity(); - return back()->with('success', 'Workspace adicionado à assinatura.'); + return back()->with('success', 'Workspace added to subscription.'); } /** @@ -93,12 +139,12 @@ public function removeWorkspace(Request $request): RedirectResponse { $user = $request->user(); - if (! $user->hasActiveSubscription()) { + if (! $user->subscribed('default')) { return back(); } $user->decrementWorkspaceQuantity(); - return back()->with('success', 'Workspace removido da assinatura.'); + return back()->with('success', 'Workspace removed from subscription.'); } } diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php new file mode 100644 index 00000000..9a8320a5 --- /dev/null +++ b/app/Http/Controllers/OnboardingController.php @@ -0,0 +1,117 @@ + Persona::toSelectArray(), + ]); + } + + /** + * Store step 1 and proceed to step 2. + */ + public function storeStep1(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'persona' => ['required', Rule::enum(Persona::class)], + ]); + + $request->user()->update([ + 'persona' => $validated['persona'], + 'setup' => Setup::Connections, + ]); + + return redirect()->route('onboarding.step2'); + } + + /** + * Step 2: Connect social accounts. + */ + public function step2(Request $request): Response + { + $user = $request->user(); + $workspace = $user->currentWorkspace; + + $platforms = collect(); + + if ($workspace) { + $connectedAccounts = $workspace->socialAccounts; + + $platforms = collect(SocialPlatform::enabled())->map(function ($platform) use ($connectedAccounts) { + $connected = $connectedAccounts->firstWhere('platform', $platform); + + return [ + 'value' => $platform->value, + 'label' => $platform->label(), + 'color' => $platform->color(), + 'connected' => $connected !== null, + 'account' => $connected, + ]; + })->values(); + } + + return Inertia::render('onboarding/Step2', [ + 'platforms' => $platforms, + 'hasWorkspace' => $workspace !== null, + ]); + } + + /** + * Store step 2 and redirect to Stripe checkout. + */ + public function storeStep2(Request $request): SymfonyResponse + { + $user = $request->user(); + + $user->update([ + 'setup' => Setup::Subscription, + ]); + + // Redirect to Stripe checkout + $subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id')) + ->allowPromotionCodes() + ->trialDays(config('cashier.trial_days')) + ->quantity(1); + + $checkoutSession = $subscription->checkout([ + 'success_url' => route('onboarding.complete').'?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('onboarding.step2'), + ]); + + return Inertia::location($checkoutSession->url); + } + + /** + * Complete onboarding after successful Stripe checkout. + */ + public function complete(Request $request): RedirectResponse + { + $user = $request->user(); + + $user->update([ + 'setup' => Setup::Completed, + ]); + + session()->flash('flash.banner', 'Welcome to TryPost! Your trial has started.'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('calendar'); + } +} diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 62962c5c..238c4481 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -7,7 +7,6 @@ use App\Http\Requests\UpdatePostRequest; use App\Jobs\PublishPost; use App\Models\Post; -use App\Models\Workspace; use Carbon\Carbon; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -16,8 +15,14 @@ class PostController extends Controller { - public function index(Workspace $workspace): Response + public function index(Request $request): Response|RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); $posts = $workspace->posts() @@ -31,8 +36,14 @@ public function index(Workspace $workspace): Response ]); } - public function calendar(Request $request, Workspace $workspace): Response + public function calendar(Request $request): Response|RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); $tz = $workspace->timezone; @@ -61,8 +72,14 @@ public function calendar(Request $request, Workspace $workspace): Response ]); } - public function create(Request $request, Workspace $workspace): RedirectResponse + public function create(Request $request): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); $socialAccounts = $workspace->socialAccounts; @@ -71,7 +88,7 @@ public function create(Request $request, Workspace $workspace): RedirectResponse session()->flash('flash.banner', 'Connect at least one social network before creating a post.'); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('workspaces.accounts', $workspace); + return redirect()->route('accounts'); } // Create a draft post - default to today if no date provided @@ -96,11 +113,17 @@ public function create(Request $request, Workspace $workspace): RedirectResponse ]); } - return redirect()->route('workspaces.posts.edit', [$workspace, $post]); + return redirect()->route('posts.edit', $post); } - public function store(StorePostRequest $request, Workspace $workspace): RedirectResponse + public function store(StorePostRequest $request): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); $post = $workspace->posts()->create([ @@ -125,17 +148,23 @@ public function store(StorePostRequest $request, Workspace $workspace): Redirect } $route = $request->input('status') === PostStatus::Scheduled->value - ? 'workspaces.calendar' - : 'workspaces.posts.index'; + ? 'calendar' + : 'posts.index'; session()->flash('flash.banner', 'Post created successfully!'); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route($route, $workspace); + return redirect()->route($route); } - public function show(Workspace $workspace, Post $post): Response + public function show(Request $request, Post $post): Response|RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); if ($post->workspace_id !== $workspace->id) { @@ -150,8 +179,14 @@ public function show(Workspace $workspace, Post $post): Response ]); } - public function edit(Workspace $workspace, Post $post): Response|RedirectResponse + public function edit(Request $request, Post $post): Response|RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); if ($post->workspace_id !== $workspace->id) { @@ -162,7 +197,7 @@ public function edit(Workspace $workspace, Post $post): Response|RedirectRespons session()->flash('flash.banner', 'Published posts cannot be edited.'); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('workspaces.posts.show', [$workspace, $post]); + return redirect()->route('posts.show', $post); } $post->load(['postPlatforms.socialAccount', 'postPlatforms.media']); @@ -189,8 +224,14 @@ public function edit(Workspace $workspace, Post $post): Response|RedirectRespons ]); } - public function update(UpdatePostRequest $request, Workspace $workspace, Post $post): RedirectResponse + public function update(UpdatePostRequest $request, Post $post): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); if ($post->workspace_id !== $workspace->id) { @@ -238,17 +279,23 @@ public function update(UpdatePostRequest $request, Workspace $workspace, Post $p session()->flash('flash.banner', 'Post is being published!'); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('workspaces.posts.show', [$workspace, $post]); + return redirect()->route('posts.show', $post); } session()->flash('flash.banner', 'Post updated successfully!'); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('workspaces.posts.show', [$workspace, $post]); + return redirect()->route('posts.show', $post); } - public function destroy(Workspace $workspace, Post $post): RedirectResponse + public function destroy(Request $request, Post $post): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('view', $workspace); if ($post->workspace_id !== $workspace->id) { @@ -260,6 +307,6 @@ public function destroy(Workspace $workspace, Post $post): RedirectResponse session()->flash('flash.banner', 'Post deleted successfully!'); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('workspaces.calendar', $workspace); + return redirect()->route('calendar'); } } diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/WorkspaceController.php index c39d6ed9..f455e70a 100644 --- a/app/Http/Controllers/WorkspaceController.php +++ b/app/Http/Controllers/WorkspaceController.php @@ -12,19 +12,27 @@ class WorkspaceController extends Controller { + /** + * List all workspaces. + */ public function index(Request $request): Response { - $workspaces = $request->user() - ->workspaces() + $user = $request->user(); + + $workspaces = $user->workspaces() ->withCount(['socialAccounts', 'posts']) ->latest() ->get(); return Inertia::render('workspaces/Index', [ 'workspaces' => $workspaces, + 'currentWorkspaceId' => $user->current_workspace_id, ]); } + /** + * Show create workspace form. + */ public function create(Request $request): Response|RedirectResponse { $user = $request->user(); @@ -38,6 +46,9 @@ public function create(Request $request): Response|RedirectResponse return Inertia::render('workspaces/Create'); } + /** + * Store a new workspace. + */ public function store(StoreWorkspaceRequest $request): RedirectResponse { $user = $request->user(); @@ -50,57 +61,46 @@ public function store(StoreWorkspaceRequest $request): RedirectResponse $workspace = $user->workspaces()->create($request->validated()); + // Set as current workspace + $user->switchWorkspace($workspace); + // Increment subscription quantity if user has subscription if ($user->hasActiveSubscription()) { $user->incrementWorkspaceQuantity(); } - return redirect()->route('workspaces.show', $workspace) + return redirect()->route('calendar') ->with('success', 'Workspace created successfully!'); } - public function show(Request $request, Workspace $workspace): Response + /** + * Switch to a different workspace. + */ + public function switch(Request $request, Workspace $workspace): RedirectResponse { - $this->authorize('view', $workspace); + $user = $request->user(); - $workspace->load(['socialAccounts', 'posts' => function ($query) { - $query->latest()->take(5); - }]); + if (! $user->belongsToWorkspace($workspace)) { + abort(403); + } - $stats = [ - 'total_posts' => $workspace->posts()->count(), - 'scheduled_posts' => $workspace->posts()->scheduled()->count(), - 'published_posts' => $workspace->posts()->published()->count(), - 'connected_accounts' => $workspace->socialAccounts()->count(), - ]; + $user->switchWorkspace($workspace); - return Inertia::render('workspaces/Show', [ - 'workspace' => $workspace, - 'stats' => $stats, - ]); + return redirect()->route('calendar'); } - public function edit(Workspace $workspace): Response + /** + * Show workspace settings. + */ + public function settings(Request $request): Response|RedirectResponse { - $this->authorize('update', $workspace); + $user = $request->user(); + $workspace = $user->currentWorkspace; - return Inertia::render('workspaces/Edit', [ - 'workspace' => $workspace, - ]); - } + if (! $workspace) { + return redirect()->route('workspaces.create'); + } - public function update(UpdateWorkspaceRequest $request, Workspace $workspace): RedirectResponse - { - $this->authorize('update', $workspace); - - $workspace->update($request->validated()); - - return redirect()->route('workspaces.show', $workspace) - ->with('success', 'Workspace updated successfully!'); - } - - public function settings(Workspace $workspace): Response - { $this->authorize('update', $workspace); $timezones = collect(timezone_identifiers_list()) @@ -113,8 +113,18 @@ public function settings(Workspace $workspace): Response ]); } - public function updateSettings(UpdateWorkspaceRequest $request, Workspace $workspace): RedirectResponse + /** + * Update workspace settings. + */ + public function updateSettings(UpdateWorkspaceRequest $request): RedirectResponse { + $user = $request->user(); + $workspace = $user->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('update', $workspace); $workspace->update($request->validated()); @@ -122,15 +132,23 @@ public function updateSettings(UpdateWorkspaceRequest $request, Workspace $works session()->flash('flash.banner', 'Settings updated successfully!'); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('workspaces.settings', $workspace); + return redirect()->route('settings'); } + /** + * Delete a workspace. + */ public function destroy(Request $request, Workspace $workspace): RedirectResponse { $this->authorize('delete', $workspace); $user = $request->user(); + // If deleting current workspace, clear it + if ($user->current_workspace_id === $workspace->id) { + $user->update(['current_workspace_id' => null]); + } + $workspace->delete(); // Decrement subscription quantity if user has subscription @@ -138,7 +156,7 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons $user->decrementWorkspaceQuantity(); } - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->with('success', 'Workspace deleted successfully!'); } } diff --git a/app/Http/Controllers/WorkspaceInviteController.php b/app/Http/Controllers/WorkspaceInviteController.php index b1309e4f..c898921c 100644 --- a/app/Http/Controllers/WorkspaceInviteController.php +++ b/app/Http/Controllers/WorkspaceInviteController.php @@ -4,7 +4,6 @@ use App\Enums\WorkspaceRole; use App\Http\Requests\StoreWorkspaceInviteRequest; -use App\Models\Workspace; use App\Models\WorkspaceInvite; use App\Notifications\WorkspaceInviteNotification; use Illuminate\Http\RedirectResponse; @@ -14,8 +13,14 @@ class WorkspaceInviteController extends Controller { - public function index(Workspace $workspace): Response + public function index(Request $request): Response|RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageTeam', $workspace); return Inertia::render('workspaces/Invites', [ @@ -48,8 +53,14 @@ public function index(Workspace $workspace): Response ]); } - public function store(StoreWorkspaceInviteRequest $request, Workspace $workspace): RedirectResponse + public function store(StoreWorkspaceInviteRequest $request): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageTeam', $workspace); $existingInvite = $workspace->invites() @@ -80,8 +91,14 @@ public function store(StoreWorkspaceInviteRequest $request, Workspace $workspace return back()->with('success', 'Invite sent successfully!'); } - public function destroy(Workspace $workspace, WorkspaceInvite $invite): RedirectResponse + public function destroy(Request $request, WorkspaceInvite $invite): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageTeam', $workspace); if ($invite->workspace_id !== $workspace->id) { @@ -99,11 +116,11 @@ public function accept(Request $request, string $token): RedirectResponse if (! $invite->isValid()) { if ($invite->isExpired()) { - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->withErrors(['invite' => 'This invite has expired.']); } - return redirect()->route('workspaces.index') + return redirect()->route('dashboard') ->withErrors(['invite' => 'This invite is no longer valid.']); } @@ -117,18 +134,30 @@ public function accept(Request $request, string $token): RedirectResponse } if ($invite->workspace->hasMember($user)) { - return redirect()->route('workspaces.show', $invite->workspace) + // Switch to this workspace + $user->switchWorkspace($invite->workspace); + + return redirect()->route('calendar') ->with('message', 'You are already a member of this workspace.'); } $invite->accept($user); - return redirect()->route('workspaces.show', $invite->workspace) + // Switch to the new workspace + $user->switchWorkspace($invite->workspace); + + return redirect()->route('calendar') ->with('success', 'You are now a member of the workspace!'); } - public function removeMember(Workspace $workspace, string $userId): RedirectResponse + public function removeMember(Request $request, string $userId): RedirectResponse { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + $this->authorize('manageTeam', $workspace); if ($workspace->user_id === $userId) { diff --git a/app/Http/Middleware/EnsureSubscribed.php b/app/Http/Middleware/EnsureSubscribed.php new file mode 100644 index 00000000..14590c54 --- /dev/null +++ b/app/Http/Middleware/EnsureSubscribed.php @@ -0,0 +1,32 @@ +user(); + + if (! $user) { + return redirect()->route('login'); + } + + // Allow access if user has active subscription or is on trial + if ($user->subscribed('default') || $user->onTrial('default')) { + return $next($request); + } + + // Redirect to subscription page + return redirect()->route('subscribe'); + } +} diff --git a/app/Http/Middleware/EnsureUserSetupIsComplete.php b/app/Http/Middleware/EnsureUserSetupIsComplete.php new file mode 100644 index 00000000..17ba7e3b --- /dev/null +++ b/app/Http/Middleware/EnsureUserSetupIsComplete.php @@ -0,0 +1,55 @@ +user(); + + if (! $user) { + return $next($request); + } + + // If setup is completed, allow through + if ($user->setup === Setup::Completed) { + return $next($request); + } + + // Map setup status to allowed routes + $allowedRoutes = match ($user->setup) { + Setup::Role => ['onboarding.step1', 'onboarding.step1.store'], + Setup::Connections => ['onboarding.step2', 'onboarding.step2.store', 'social.*'], + Setup::Subscription => ['onboarding.complete', 'onboarding.step2'], + default => ['onboarding.step1', 'onboarding.step1.store'], + }; + + $currentRoute = $request->route()?->getName(); + + // Check if current route is allowed + foreach ($allowedRoutes as $pattern) { + if ($currentRoute === $pattern || fnmatch($pattern, $currentRoute ?? '')) { + return $next($request); + } + } + + // Redirect to appropriate step + return match ($user->setup) { + Setup::Role => redirect()->route('onboarding.step1'), + Setup::Connections => redirect()->route('onboarding.step2'), + Setup::Subscription => redirect()->route('onboarding.step2'), + default => redirect()->route('onboarding.step1'), + }; + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index db703ad8..38816abc 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -35,12 +35,16 @@ public function version(Request $request): ?string */ public function share(Request $request): array { + $user = $request->user(); + return [ ...parent::share($request), 'name' => config('app.name'), 'auth' => [ - 'user' => $request->user(), + 'user' => $user, ], + 'currentWorkspace' => $user?->currentWorkspace, + 'workspaces' => $user ? $user->workspaces()->select('workspaces.id', 'workspaces.name')->get() : [], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'flash' => $request->session()->get('flash', []), 'env' => config('app.env'), diff --git a/app/Http/Responses/LoginResponse.php b/app/Http/Responses/LoginResponse.php new file mode 100644 index 00000000..c0b6c633 --- /dev/null +++ b/app/Http/Responses/LoginResponse.php @@ -0,0 +1,28 @@ +user(); + + // Determine redirect based on setup status + $redirect = match ($user->setup) { + Setup::Completed => route('calendar'), + Setup::Role => route('onboarding.step1'), + Setup::Connections => route('onboarding.step2'), + Setup::Subscription => route('onboarding.step2'), + default => route('onboarding.step1'), + }; + + return $request->wantsJson() + ? response()->json(['two_factor' => false]) + : redirect()->intended($redirect); + } +} diff --git a/app/Http/Responses/RegisterResponse.php b/app/Http/Responses/RegisterResponse.php new file mode 100644 index 00000000..0a200043 --- /dev/null +++ b/app/Http/Responses/RegisterResponse.php @@ -0,0 +1,16 @@ +wantsJson() + ? response()->json(['two_factor' => false]) + : redirect()->route('onboarding.step1'); + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 50e91a80..0366e258 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -4,6 +4,7 @@ use App\Enums\SocialPlatform; use App\Events\PostPlatformStatusUpdated; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Services\Social\FacebookPublisher; use App\Services\Social\InstagramPublisher; @@ -37,6 +38,16 @@ public function handle(): void $result = $publisher->publish($this->postPlatform); $this->postPlatform->markAsPublished($result['id'], $result['url'] ?? null); + } catch (TokenExpiredException $e) { + Log::error('Token expired while publishing to social platform', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + 'platform_error_code' => $e->platformErrorCode, + ]); + + $this->postPlatform->markAsFailed($e->getMessage()); + $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); } catch (\Exception $e) { Log::error('Failed to publish to social platform', [ 'post_platform_id' => $this->postPlatform->id, diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php new file mode 100644 index 00000000..673c4772 --- /dev/null +++ b/app/Listeners/StripeEventListener.php @@ -0,0 +1,61 @@ +payload['type'] ?? null; + $stripeCustomerId = $event->payload['data']['object']['customer'] ?? null; + + if (! $stripeCustomerId) { + return; + } + + $user = User::where('stripe_id', $stripeCustomerId)->first(); + + if (! $user) { + return; + } + + match ($type) { + 'customer.subscription.created' => $this->handleSubscriptionCreated($user, $event->payload), + 'customer.subscription.updated' => $this->handleSubscriptionUpdated($user, $event->payload), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload), + default => null, + }; + } catch (\Exception $e) { + Log::error('Stripe webhook error: '.$e->getMessage(), [ + 'exception' => $e, + 'payload' => $event->payload, + ]); + } + } + + protected function handleSubscriptionCreated(User $user, array $payload): void + { + SubscriptionCreated::dispatch($user); + } + + protected function handleSubscriptionUpdated(User $user, array $payload): void + { + // Future: dispatch SubscriptionUpdated event if needed + } + + protected function handleSubscriptionDeleted(User $user, array $payload): void + { + // Future: dispatch SubscriptionDeleted event if needed + } +} diff --git a/app/Models/PostMedia.php b/app/Models/PostMedia.php index 51ec54c9..70f71ceb 100644 --- a/app/Models/PostMedia.php +++ b/app/Models/PostMedia.php @@ -15,6 +15,8 @@ class PostMedia extends Model /** @use HasFactory<\Database\Factories\PostMediaFactory> */ use HasFactory, HasUuids; + protected $table = 'post_medias'; + protected $appends = ['url']; protected $fillable = [ diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index d8d51f0a..411f81ff 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -3,12 +3,15 @@ namespace App\Models; use App\Enums\SocialPlatform; +use App\Enums\Status; +use App\Notifications\AccountDisconnectedNotification; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; class SocialAccount extends Model @@ -28,6 +31,9 @@ class SocialAccount extends Model 'token_expires_at', 'scopes', 'meta', + 'status', + 'error_message', + 'disconnected_at', ]; protected $hidden = [ @@ -39,9 +45,11 @@ protected function casts(): array { return [ 'platform' => SocialPlatform::class, + 'status' => Status::class, 'access_token' => 'encrypted', 'refresh_token' => 'encrypted', 'token_expires_at' => 'datetime', + 'disconnected_at' => 'datetime', 'scopes' => 'array', 'meta' => 'array', ]; @@ -77,4 +85,42 @@ protected function avatarUrl(): Attribute get: fn (?string $value) => $value ? Storage::url($value) : null, ); } + + public function markAsDisconnected(string $errorMessage): void + { + $lock = Cache::lock("social_account_disconnect:{$this->id}", 10); + + if ($lock->get()) { + try { + $this->refresh(); + $wasConnected = $this->status !== Status::Disconnected; + + $this->update([ + 'status' => Status::Disconnected, + 'error_message' => $errorMessage, + 'disconnected_at' => now(), + ]); + + if ($wasConnected) { + $this->workspace->owner->notify(new AccountDisconnectedNotification($this)); + } + } finally { + $lock->release(); + } + } + } + + public function markAsConnected(): void + { + $this->update([ + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + ]); + } + + public function isDisconnected(): bool + { + return $this->status === Status::Disconnected || $this->status === Status::TokenExpired; + } } diff --git a/app/Models/User.php b/app/Models/User.php index d941191b..e897e0ef 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,8 +3,11 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\User\Persona; +use App\Enums\User\Setup; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -26,6 +29,9 @@ class User extends Authenticatable 'name', 'email', 'password', + 'setup', + 'persona', + 'current_workspace_id', ]; /** @@ -51,6 +57,8 @@ protected function casts(): array 'email_verified_at' => 'datetime', 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', + 'setup' => Setup::class, + 'persona' => Persona::class, ]; } @@ -73,6 +81,31 @@ public function memberWorkspaces(): BelongsToMany ->withTimestamps(); } + /** + * Get the user's current workspace. + */ + public function currentWorkspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'current_workspace_id'); + } + + /** + * Switch to a different workspace. + */ + public function switchWorkspace(Workspace $workspace): void + { + $this->update(['current_workspace_id' => $workspace->id]); + } + + /** + * Check if user belongs to a workspace (owner or member). + */ + public function belongsToWorkspace(Workspace $workspace): bool + { + return $this->workspaces()->where('id', $workspace->id)->exists() + || $this->memberWorkspaces()->where('workspaces.id', $workspace->id)->exists(); + } + /** * Get the count of workspaces the user owns. */ @@ -89,6 +122,14 @@ public function hasActiveSubscription(): bool return $this->subscribed('default'); } + /** + * Check if user has ever had a subscription (for trial eligibility). + */ + public function hasEverSubscribed(): bool + { + return $this->subscriptions()->exists(); + } + /** * Check if user can create more workspaces based on subscription. */ diff --git a/app/Notifications/AccountDisconnectedNotification.php b/app/Notifications/AccountDisconnectedNotification.php new file mode 100644 index 00000000..bd9c504d --- /dev/null +++ b/app/Notifications/AccountDisconnectedNotification.php @@ -0,0 +1,46 @@ +account->workspace_id); + $platformName = $this->account->platform->label(); + $accountName = $this->account->display_name ?? $this->account->username; + + return (new MailMessage) + ->subject("Your {$platformName} account needs to be reconnected") + ->greeting('Hello!') + ->line("Your **{$platformName}** account **{$accountName}** has been disconnected from TryPost.") + ->line('This may have happened because:') + ->line('- Your access token expired') + ->line('- You revoked access to TryPost') + ->line('- There was an authentication error') + ->line('Please reconnect your account to continue scheduling and publishing posts.') + ->action('Reconnect Account', $reconnectUrl); + } + + public function toArray(object $notifiable): array + { + return [ + 'account_id' => $this->account->id, + 'platform' => $this->account->platform->value, + 'workspace_id' => $this->account->workspace_id, + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d111e7d1..a27beb40 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,8 @@ namespace App\Providers; -use App\Socialite\InstagramExtendSocialite; +use App\Listeners\StripeEventListener; +use App\Socialite\InstagramProvider; use App\Socialite\LinkedInPageExtendSocialite; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; @@ -10,6 +11,9 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Laravel\Cashier\Events\WebhookReceived; +use Laravel\Socialite\Facades\Socialite; +use SocialiteProviders\Facebook\FacebookExtendSocialite; use SocialiteProviders\LinkedIn\LinkedInExtendSocialite; use SocialiteProviders\Manager\SocialiteWasCalled; use SocialiteProviders\TikTok\TikTokExtendSocialite; @@ -31,11 +35,24 @@ public function boot(): void { $this->configureDefaults(); $this->configureSocialite(); + $this->configureStripeWebhooks(); + } + + protected function configureStripeWebhooks(): void + { + Event::listen(WebhookReceived::class, StripeEventListener::class); } protected function configureSocialite(): void { - Event::listen(SocialiteWasCalled::class, InstagramExtendSocialite::class); + // Instagram Business Login + Socialite::extend('instagram', function ($app) { + $config = $app['config']['services.instagram']; + + return Socialite::buildProvider(InstagramProvider::class, $config); + }); + + Event::listen(SocialiteWasCalled::class, FacebookExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class); Event::listen(SocialiteWasCalled::class, TikTokExtendSocialite::class); diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 2caea888..9e74fe30 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -4,12 +4,16 @@ use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\ResetUserPassword; +use App\Http\Responses\LoginResponse; +use App\Http\Responses\RegisterResponse; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Inertia\Inertia; +use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; +use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; use Laravel\Fortify\Features; use Laravel\Fortify\Fortify; @@ -20,7 +24,8 @@ class FortifyServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(LoginResponseContract::class, LoginResponse::class); + $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); } /** diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 951e1f59..31123283 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -2,12 +2,32 @@ namespace App\Services\Social; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class FacebookPublisher { + /** + * Meta Graph API error codes that indicate token issues. + * + * @see https://developers.facebook.com/docs/graph-api/guides/error-handling + */ + private const TOKEN_ERROR_CODES = [ + 190, // Invalid OAuth access token + ]; + + private const TOKEN_ERROR_SUBCODES = [ + 458, // App not installed + 459, // User checkpointed + 460, // Password changed + 463, // Session expired + 464, // Unconfirmed user + 467, // Invalid access token + ]; + private string $baseUrl = 'https://graph.facebook.com/v21.0'; public function publish(PostPlatform $postPlatform): array @@ -57,7 +77,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('Facebook API error: '.$response->body()); + $this->handleApiError($response, 'Facebook API error'); } $data = $response->json(); @@ -84,7 +104,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, str 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('Facebook API error: '.$response->body()); + $this->handleApiError($response, 'Facebook API error'); } $data = $response->json(); @@ -150,7 +170,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, stri 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('Facebook API error: '.$response->body()); + $this->handleApiError($response, 'Facebook API error'); } $data = $response->json(); @@ -178,7 +198,7 @@ private function publishVideoPost(string $pageId, string $accessToken, string $c 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('Facebook API error: '.$response->body()); + $this->handleApiError($response, 'Facebook API error'); } $data = $response->json(); @@ -189,4 +209,27 @@ private function publishVideoPost(string $pageId, string $accessToken, string $c 'url' => "https://www.facebook.com/{$pageId}/videos/{$videoId}", ]; } + + private function handleApiError(Response $response, string $context): void + { + $body = $response->json() ?? []; + $error = $body['error'] ?? []; + $errorCode = $error['code'] ?? null; + $errorSubcode = $error['error_subcode'] ?? null; + $errorType = $error['type'] ?? null; + $message = $error['message'] ?? $response->body(); + + $isTokenError = $errorType === 'OAuthException' + || in_array($errorCode, self::TOKEN_ERROR_CODES) + || in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES); + + if ($isTokenError) { + throw new TokenExpiredException( + "{$context}: {$message}", + $errorCode ? (string) $errorCode : null + ); + } + + throw new \Exception("{$context}: {$message}"); + } } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 7121a040..8845dd76 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -2,12 +2,32 @@ namespace App\Services\Social; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class InstagramPublisher { + /** + * Meta Graph API error codes that indicate token issues. + * + * @see https://developers.facebook.com/docs/graph-api/guides/error-handling + */ + private const TOKEN_ERROR_CODES = [ + 190, // Invalid OAuth access token + ]; + + private const TOKEN_ERROR_SUBCODES = [ + 458, // App not installed + 459, // User checkpointed + 460, // Password changed + 463, // Session expired + 464, // Unconfirmed user + 467, // Invalid access token + ]; + private string $baseUrl = 'https://graph.facebook.com/v21.0'; public function publish(PostPlatform $postPlatform): array @@ -54,7 +74,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, st 'status' => $containerResponse->status(), 'body' => $containerResponse->body(), ]); - throw new \Exception('Instagram API error: '.$containerResponse->body()); + $this->handleApiError($containerResponse, 'Instagram API error'); } $containerId = $containerResponse->json()['id']; @@ -80,7 +100,7 @@ private function publishReel(string $instagramId, string $accessToken, string $c 'status' => $containerResponse->status(), 'body' => $containerResponse->body(), ]); - throw new \Exception('Instagram API error: '.$containerResponse->body()); + $this->handleApiError($containerResponse, 'Instagram API error'); } $containerId = $containerResponse->json()['id']; @@ -153,7 +173,7 @@ private function publishCarousel(string $instagramId, string $accessToken, strin Log::error('Instagram carousel container creation failed', [ 'body' => $carouselResponse->body(), ]); - throw new \Exception('Instagram API error: '.$carouselResponse->body()); + $this->handleApiError($carouselResponse, 'Instagram API error'); } $carouselId = $carouselResponse->json()['id']; @@ -174,7 +194,7 @@ private function publishContainer(string $instagramId, string $accessToken, stri 'status' => $publishResponse->status(), 'body' => $publishResponse->body(), ]); - throw new \Exception('Instagram publish error: '.$publishResponse->body()); + $this->handleApiError($publishResponse, 'Instagram publish error'); } $mediaId = $publishResponse->json()['id']; @@ -226,4 +246,27 @@ private function waitForMediaProcessing(string $containerId, string $accessToken Log::warning('Instagram media processing timeout, proceeding anyway'); } + + private function handleApiError(Response $response, string $context): void + { + $body = $response->json() ?? []; + $error = $body['error'] ?? []; + $errorCode = $error['code'] ?? null; + $errorSubcode = $error['error_subcode'] ?? null; + $errorType = $error['type'] ?? null; + $message = $error['message'] ?? $response->body(); + + $isTokenError = $errorType === 'OAuthException' + || in_array($errorCode, self::TOKEN_ERROR_CODES) + || in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES); + + if ($isTokenError) { + throw new TokenExpiredException( + "{$context}: {$message}", + $errorCode ? (string) $errorCode : null + ); + } + + throw new \Exception("{$context}: {$message}"); + } } diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 8b33e693..e22c2887 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -2,17 +2,30 @@ namespace App\Services\Social; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class LinkedInPagePublisher { + /** + * LinkedIn API error codes that indicate token issues. + * + * @see https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/error-handling + */ + private const TOKEN_ERROR_CODES = [ + 'REVOKED_ACCESS_TOKEN', + 'EXPIRED_ACCESS_TOKEN', + 'INVALID_ACCESS_TOKEN', + ]; + private string $baseUrl = 'https://api.linkedin.com'; - private string $apiVersion = '202501'; + private string $apiVersion = '202601'; private string $accessToken; @@ -72,7 +85,7 @@ public function publish(PostPlatform $postPlatform): array 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('LinkedIn Page API error: '.$response->body()); + $this->handleApiError($response, 'LinkedIn Page API error'); } $postId = $response->header('x-restli-id'); @@ -131,7 +144,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string if ($initResponse->failed()) { Log::error('LinkedIn Page image init failed', ['body' => $initResponse->body()]); - throw new \Exception('Failed to initialize LinkedIn Page image upload: '.$initResponse->body()); + $this->handleApiError($initResponse, 'Failed to initialize LinkedIn Page image upload'); } $initData = $initResponse->json(); @@ -156,7 +169,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string if ($uploadResponse->failed()) { Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]); - throw new \Exception('Failed to upload LinkedIn Page image: '.$uploadResponse->body()); + $this->handleApiError($uploadResponse, 'Failed to upload LinkedIn Page image'); } Log::info('LinkedIn Page image upload success', ['imageUrn' => $imageUrn]); @@ -187,7 +200,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string if ($initResponse->failed()) { Log::error('LinkedIn Page video init failed', ['body' => $initResponse->body()]); - throw new \Exception('Failed to initialize LinkedIn Page video upload: '.$initResponse->body()); + $this->handleApiError($initResponse, 'Failed to initialize LinkedIn Page video upload'); } $initData = $initResponse->json(); @@ -234,7 +247,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string 'index' => $index, 'body' => $chunkResponse->body(), ]); - throw new \Exception('Failed to upload LinkedIn Page video chunk: '.$chunkResponse->body()); + $this->handleApiError($chunkResponse, 'Failed to upload LinkedIn Page video chunk'); } $etag = $chunkResponse->header('etag'); @@ -259,7 +272,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string if ($finalizeResponse->failed()) { Log::error('LinkedIn Page video finalize failed', ['body' => $finalizeResponse->body()]); - throw new \Exception('Failed to finalize LinkedIn Page video upload: '.$finalizeResponse->body()); + $this->handleApiError($finalizeResponse, 'Failed to finalize LinkedIn Page video upload'); } Log::info('LinkedIn Page video upload finalized', ['videoUrn' => $videoUrn]); @@ -307,7 +320,7 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30) private function refreshToken(SocialAccount $account): void { if (! $account->refresh_token) { - throw new \Exception('No refresh token available for LinkedIn Page account'); + throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); } $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ @@ -318,7 +331,7 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - throw new \Exception('Failed to refresh LinkedIn Page token: '.$response->body()); + $this->handleApiError($response, 'Failed to refresh LinkedIn Page token'); } $data = $response->json(); @@ -329,4 +342,20 @@ private function refreshToken(SocialAccount $account): void 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, ]); } + + private function handleApiError(Response $response, string $context): void + { + $body = $response->json() ?? []; + $errorCode = $body['code'] ?? null; + $message = $body['message'] ?? $response->body(); + + if ($response->status() === 401 || in_array($errorCode, self::TOKEN_ERROR_CODES)) { + throw new TokenExpiredException( + "{$context}: {$message}", + $errorCode + ); + } + + throw new \Exception("{$context}: {$message}"); + } } diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 8f4cb21e..6a96e1fd 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -2,17 +2,30 @@ namespace App\Services\Social; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class LinkedInPublisher { + /** + * LinkedIn API error codes that indicate token issues. + * + * @see https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/error-handling + */ + private const TOKEN_ERROR_CODES = [ + 'REVOKED_ACCESS_TOKEN', + 'EXPIRED_ACCESS_TOKEN', + 'INVALID_ACCESS_TOKEN', + ]; + private string $baseUrl = 'https://api.linkedin.com'; - private string $apiVersion = '202501'; + private string $apiVersion = '202601'; private string $accessToken; @@ -66,7 +79,7 @@ public function publish(PostPlatform $postPlatform): array 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('LinkedIn API error: '.$response->body()); + $this->handleApiError($response, 'LinkedIn API error'); } $postId = $response->header('x-restli-id'); @@ -119,7 +132,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string if ($initResponse->failed()) { Log::error('LinkedIn image init failed', ['body' => $initResponse->body()]); - throw new \Exception('Failed to initialize LinkedIn image upload: '.$initResponse->body()); + $this->handleApiError($initResponse, 'Failed to initialize LinkedIn image upload'); } $initData = $initResponse->json(); @@ -144,7 +157,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string if ($uploadResponse->failed()) { Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]); - throw new \Exception('Failed to upload LinkedIn image: '.$uploadResponse->body()); + $this->handleApiError($uploadResponse, 'Failed to upload LinkedIn image'); } Log::info('LinkedIn image upload success', ['imageUrn' => $imageUrn]); @@ -175,7 +188,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string if ($initResponse->failed()) { Log::error('LinkedIn video init failed', ['body' => $initResponse->body()]); - throw new \Exception('Failed to initialize LinkedIn video upload: '.$initResponse->body()); + $this->handleApiError($initResponse, 'Failed to initialize LinkedIn video upload'); } $initData = $initResponse->json(); @@ -222,7 +235,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string 'index' => $index, 'body' => $chunkResponse->body(), ]); - throw new \Exception('Failed to upload LinkedIn video chunk: '.$chunkResponse->body()); + $this->handleApiError($chunkResponse, 'Failed to upload LinkedIn video chunk'); } $etag = $chunkResponse->header('etag'); @@ -247,7 +260,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string if ($finalizeResponse->failed()) { Log::error('LinkedIn video finalize failed', ['body' => $finalizeResponse->body()]); - throw new \Exception('Failed to finalize LinkedIn video upload: '.$finalizeResponse->body()); + $this->handleApiError($finalizeResponse, 'Failed to finalize LinkedIn video upload'); } Log::info('LinkedIn video upload finalized', ['videoUrn' => $videoUrn]); @@ -295,7 +308,7 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30) private function refreshToken(SocialAccount $account): void { if (! $account->refresh_token) { - throw new \Exception('No refresh token available for LinkedIn account'); + throw new TokenExpiredException('No refresh token available for LinkedIn account'); } $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ @@ -306,7 +319,7 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - throw new \Exception('Failed to refresh LinkedIn token: '.$response->body()); + $this->handleApiError($response, 'Failed to refresh LinkedIn token'); } $data = $response->json(); @@ -317,4 +330,20 @@ private function refreshToken(SocialAccount $account): void 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, ]); } + + private function handleApiError(Response $response, string $context): void + { + $body = $response->json() ?? []; + $errorCode = $body['code'] ?? null; + $message = $body['message'] ?? $response->body(); + + if ($response->status() === 401 || in_array($errorCode, self::TOKEN_ERROR_CODES)) { + throw new TokenExpiredException( + "{$context}: {$message}", + $errorCode + ); + } + + throw new \Exception("{$context}: {$message}"); + } } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 41fc48b2..03b908a9 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -2,13 +2,33 @@ namespace App\Services\Social; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class ThreadsPublisher { + /** + * Meta Graph API error codes that indicate token issues. + * + * @see https://developers.facebook.com/docs/threads/error-handling + */ + private const TOKEN_ERROR_CODES = [ + 190, // Invalid OAuth access token + ]; + + private const TOKEN_ERROR_SUBCODES = [ + 458, // App not installed + 459, // User checkpointed + 460, // Password changed + 463, // Session expired + 464, // Unconfirmed user + 467, // Invalid access token + ]; + private string $baseUrl = 'https://graph.threads.net/v1.0'; public function publish(PostPlatform $postPlatform): array @@ -62,7 +82,7 @@ private function publishTextPost(string $userId, string $accessToken, string $co 'status' => $containerResponse->status(), 'body' => $containerResponse->body(), ]); - throw new \Exception('Threads API error: '.$containerResponse->body()); + $this->handleApiError($containerResponse, 'Threads API error'); } $containerId = $containerResponse->json()['id']; @@ -88,7 +108,7 @@ private function publishImagePost(string $userId, string $accessToken, string $c 'status' => $containerResponse->status(), 'body' => $containerResponse->body(), ]); - throw new \Exception('Threads API error: '.$containerResponse->body()); + $this->handleApiError($containerResponse, 'Threads API error'); } $containerId = $containerResponse->json()['id']; @@ -119,7 +139,7 @@ private function publishVideoPost(string $userId, string $accessToken, string $c 'status' => $containerResponse->status(), 'body' => $containerResponse->body(), ]); - throw new \Exception('Threads API error: '.$containerResponse->body()); + $this->handleApiError($containerResponse, 'Threads API error'); } $containerId = $containerResponse->json()['id']; @@ -191,7 +211,7 @@ private function publishCarousel(string $userId, string $accessToken, string $co Log::error('Threads carousel container creation failed', [ 'body' => $carouselResponse->body(), ]); - throw new \Exception('Threads API error: '.$carouselResponse->body()); + $this->handleApiError($carouselResponse, 'Threads API error'); } $carouselId = $carouselResponse->json()['id']; @@ -212,7 +232,7 @@ private function publishContainer(string $userId, string $accessToken, string $c 'status' => $publishResponse->status(), 'body' => $publishResponse->body(), ]); - throw new \Exception('Threads publish error: '.$publishResponse->body()); + $this->handleApiError($publishResponse, 'Threads publish error'); } $mediaId = $publishResponse->json()['id']; @@ -288,7 +308,7 @@ private function refreshToken(SocialAccount $account): void if ($response->failed()) { Log::error('Threads token refresh failed', ['body' => $response->body()]); - throw new \Exception('Failed to refresh Threads token: '.$response->body()); + $this->handleApiError($response, 'Failed to refresh Threads token'); } $data = $response->json(); @@ -300,4 +320,27 @@ private function refreshToken(SocialAccount $account): void Log::info('Threads token refreshed successfully'); } + + private function handleApiError(Response $response, string $context): void + { + $body = $response->json() ?? []; + $error = $body['error'] ?? []; + $errorCode = $error['code'] ?? null; + $errorSubcode = $error['error_subcode'] ?? null; + $errorType = $error['type'] ?? null; + $message = $error['message'] ?? $response->body(); + + $isTokenError = $errorType === 'OAuthException' + || in_array($errorCode, self::TOKEN_ERROR_CODES) + || in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES); + + if ($isTokenError) { + throw new TokenExpiredException( + "{$context}: {$message}", + $errorCode ? (string) $errorCode : null + ); + } + + throw new \Exception("{$context}: {$message}"); + } } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 73ab5aba..effbfb1c 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -2,14 +2,33 @@ namespace App\Services\Social; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class TikTokPublisher { + /** + * TikTok API error codes that indicate token issues. + * + * @see https://developers.tiktok.com/doc/tiktok-api-v2-error-handling + */ + private const TOKEN_ERROR_CODES = [ + 'access_token_invalid', + 'access_token_expired', + 'token_expired', + ]; + + private const TOKEN_ERROR_NUMERIC_CODES = [ + 10001, // Invalid Access Token + 10002, // Access Token Expired + 10003, // Invalid Client Key + ]; + private string $baseUrl = 'https://open.tiktokapis.com/v2'; private string $accessToken; @@ -59,6 +78,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array { Log::info('TikTok publishing video', [ 'video_url' => $media->url, + 'media_full_url' => $media->full_url ?? $media->url, 'content' => $postPlatform->content, ]); @@ -66,7 +86,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array ->post("{$this->baseUrl}/post/publish/video/init/", [ 'post_info' => [ 'title' => $postPlatform->content, - 'privacy_level' => 'PUBLIC_TO_EVERYONE', + 'privacy_level' => 'SELF_ONLY', 'disable_duet' => false, 'disable_comment' => false, 'disable_stitch' => false, @@ -82,7 +102,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('TikTok API error: '.$response->body()); + $this->handleApiError($response, 'TikTok API error'); } $data = $response->json(); @@ -117,6 +137,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar } Log::info('TikTok publishing photos', [ + 'photo_urls' => $photoUrls, 'photo_count' => count($photoUrls), 'content' => $postPlatform->content, ]); @@ -125,7 +146,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar ->post("{$this->baseUrl}/post/publish/content/init/", [ 'post_info' => [ 'title' => $postPlatform->content, - 'privacy_level' => 'PUBLIC_TO_EVERYONE', + 'privacy_level' => 'SELF_ONLY', 'disable_comment' => false, ], 'source_info' => [ @@ -142,7 +163,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('TikTok API error: '.$response->body()); + $this->handleApiError($response, 'TikTok API error'); } $data = $response->json(); @@ -223,7 +244,7 @@ private function buildTikTokUrl(SocialAccount $account): ?string private function refreshToken(SocialAccount $account): void { if (! $account->refresh_token) { - throw new \Exception('No refresh token available for TikTok account'); + throw new TokenExpiredException('No refresh token available for TikTok account'); } $response = Http::asForm()->post('https://open.tiktokapis.com/v2/oauth/token/', [ @@ -235,7 +256,7 @@ private function refreshToken(SocialAccount $account): void if ($response->failed()) { Log::error('TikTok token refresh failed', ['body' => $response->body()]); - throw new \Exception('Failed to refresh TikTok token: '.$response->body()); + $this->handleApiError($response, 'Failed to refresh TikTok token'); } $data = $response->json(); @@ -248,4 +269,26 @@ private function refreshToken(SocialAccount $account): void Log::info('TikTok token refreshed successfully'); } + + private function handleApiError(Response $response, string $context): void + { + $body = $response->json() ?? []; + $error = $body['error'] ?? []; + $errorCode = $error['code'] ?? $body['error']['code'] ?? null; + $errorMessage = $error['message'] ?? $body['error']['message'] ?? $response->body(); + + // TikTok can return error codes as strings or numeric codes + $isTokenError = in_array($errorCode, self::TOKEN_ERROR_CODES) + || in_array((int) $errorCode, self::TOKEN_ERROR_NUMERIC_CODES) + || $response->status() === 401; + + if ($isTokenError) { + throw new TokenExpiredException( + "{$context}: {$errorMessage}", + is_string($errorCode) ? $errorCode : (string) $errorCode + ); + } + + throw new \Exception("{$context}: {$errorMessage}"); + } } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 045e0f02..c6a0b6b9 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -2,14 +2,25 @@ namespace App\Services\Social; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class XPublisher { + /** + * X/Twitter API error titles that indicate token issues. + * + * @see https://developer.twitter.com/en/support/twitter-api/error-troubleshooting + */ + private const TOKEN_ERROR_TITLES = [ + 'Unauthorized', + ]; + private string $baseUrl = 'https://api.x.com'; private string $accessToken; @@ -61,11 +72,18 @@ public function publish(PostPlatform $postPlatform): array Log::info('Posting tweet', ['data' => $data]); $response = $this->getHttpClient() - ->post("{$this->baseUrl}/2/tweets", $data) - ->throw() - ->json(); + ->post("{$this->baseUrl}/2/tweets", $data); - $tweetId = $response['data']['id'] ?? null; + if ($response->failed()) { + Log::error('X post creation failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + $this->handleApiError($response, 'X API error'); + } + + $responseData = $response->json(); + $tweetId = $responseData['data']['id'] ?? null; return [ 'id' => $tweetId ?? 'unknown', @@ -131,7 +149,7 @@ private function uploadMedia($mediaItem): ?array 'status' => $response->status(), 'body' => $response->body(), ]); - throw new \Exception('Failed to upload media: '.$response->status().' - '.$response->body()); + $this->handleApiError($response, 'Failed to upload media'); } $responseData = $response->json(); @@ -175,7 +193,7 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m 'status' => $initResponse->status(), 'body' => $initResponse->body(), ]); - throw new \Exception('Failed to initialize chunked upload: '.$initResponse->body()); + $this->handleApiError($initResponse, 'Failed to initialize chunked upload'); } $initData = $initResponse->json(); @@ -212,7 +230,7 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m 'body' => $appendResponse->body(), 'segment' => $index, ]); - throw new \Exception('Failed to append chunk: '.$appendResponse->body()); + $this->handleApiError($appendResponse, 'Failed to append chunk'); } } @@ -228,7 +246,7 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m 'status' => $finalizeResponse->status(), 'body' => $finalizeResponse->body(), ]); - throw new \Exception('Failed to finalize chunked upload: '.$finalizeResponse->body()); + $this->handleApiError($finalizeResponse, 'Failed to finalize chunked upload'); } $finalizeData = $finalizeResponse->json(); @@ -307,7 +325,7 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool private function refreshToken(SocialAccount $account): void { if (! $account->refresh_token) { - throw new \Exception('No refresh token available for X account'); + throw new TokenExpiredException('No refresh token available for X account'); } $response = Http::asForm()->post("{$this->baseUrl}/2/oauth2/token", [ @@ -317,7 +335,7 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - throw new \Exception('Failed to refresh X token: '.$response->body()); + $this->handleApiError($response, 'Failed to refresh X token'); } $data = $response->json(); @@ -328,4 +346,20 @@ private function refreshToken(SocialAccount $account): void 'token_expires_at' => now()->addSeconds($data['expires_in'] ?? 7200), ]); } + + private function handleApiError(Response $response, string $context): void + { + $body = $response->json() ?? []; + $errorTitle = $body['title'] ?? null; + $message = $body['detail'] ?? $response->body(); + + if ($response->status() === 401 || in_array($errorTitle, self::TOKEN_ERROR_TITLES)) { + throw new TokenExpiredException( + "{$context}: {$message}", + $errorTitle + ); + } + + throw new \Exception("{$context}: {$message}"); + } } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index ab79c050..a375bcba 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -2,14 +2,33 @@ namespace App\Services\Social; +use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class YouTubePublisher { + /** + * Google/YouTube API error codes that indicate token issues. + * + * @see https://developers.google.com/youtube/v3/docs/errors + */ + private const TOKEN_ERROR_CODES = [ + 'invalid_grant', + 'invalid_token', + 'unauthorized', + ]; + + private const TOKEN_ERROR_REASONS = [ + 'authError', + 'forbidden', + 'unauthorized', + ]; + private string $baseUrl = 'https://www.googleapis.com'; private string $accessToken; @@ -87,7 +106,7 @@ private function publishShort(PostPlatform $postPlatform, $media): array 'status' => $initResponse->status(), 'body' => $initResponse->body(), ]); - throw new \Exception('YouTube API error: '.$initResponse->body()); + $this->handleApiError($initResponse, 'YouTube API error'); } $uploadUrl = $initResponse->header('Location'); @@ -113,7 +132,7 @@ private function publishShort(PostPlatform $postPlatform, $media): array 'status' => $uploadResponse->status(), 'body' => $uploadResponse->body(), ]); - throw new \Exception('YouTube upload error: '.$uploadResponse->body()); + $this->handleApiError($uploadResponse, 'YouTube upload error'); } $data = $uploadResponse->json(); @@ -154,7 +173,7 @@ private function buildTitle(string $content): string private function refreshToken(SocialAccount $account): void { if (! $account->refresh_token) { - throw new \Exception('No refresh token available for YouTube account'); + throw new TokenExpiredException('No refresh token available for YouTube account'); } $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ @@ -166,7 +185,7 @@ private function refreshToken(SocialAccount $account): void if ($response->failed()) { Log::error('YouTube token refresh failed', ['body' => $response->body()]); - throw new \Exception('Failed to refresh YouTube token: '.$response->body()); + $this->handleApiError($response, 'Failed to refresh YouTube token'); } $data = $response->json(); @@ -179,4 +198,37 @@ private function refreshToken(SocialAccount $account): void Log::info('YouTube token refreshed successfully'); } + + private function handleApiError(Response $response, string $context): void + { + $body = $response->json() ?? []; + + // Google OAuth error format + $errorCode = $body['error'] ?? null; + $errorDescription = $body['error_description'] ?? null; + + // YouTube API error format + $error = $body['error'] ?? []; + if (is_array($error)) { + $errors = $error['errors'] ?? []; + $reason = $errors[0]['reason'] ?? null; + $message = $error['message'] ?? $errorDescription ?? $response->body(); + } else { + $reason = null; + $message = $errorDescription ?? $response->body(); + } + + $isTokenError = $response->status() === 401 + || in_array($errorCode, self::TOKEN_ERROR_CODES) + || in_array($reason, self::TOKEN_ERROR_REASONS); + + if ($isTokenError) { + throw new TokenExpiredException( + "{$context}: {$message}", + is_string($errorCode) ? $errorCode : $reason + ); + } + + throw new \Exception("{$context}: {$message}"); + } } diff --git a/app/Socialite/InstagramExtendSocialite.php b/app/Socialite/InstagramExtendSocialite.php deleted file mode 100644 index 004b59b1..00000000 --- a/app/Socialite/InstagramExtendSocialite.php +++ /dev/null @@ -1,13 +0,0 @@ -extendSocialite('instagram', \Laravel\Socialite\Two\FacebookProvider::class); - } -} diff --git a/app/Socialite/InstagramProvider.php b/app/Socialite/InstagramProvider.php new file mode 100644 index 00000000..63b42ec2 --- /dev/null +++ b/app/Socialite/InstagramProvider.php @@ -0,0 +1,95 @@ + $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'response_type' => 'code', + 'state' => $state, + 'scope' => implode(',', $this->getScopes()), + ]); + } + + protected function getTokenUrl(): string + { + return 'https://api.instagram.com/oauth/access_token'; + } + + protected function getUserByToken($token): array + { + $response = $this->getHttpClient()->get('https://graph.instagram.com/v22.0/me', [ + RequestOptions::QUERY => [ + 'access_token' => $token, + 'fields' => 'id,username,account_type,name,profile_picture_url', + ], + ]); + + return json_decode((string) $response->getBody(), true); + } + + protected function mapUserToObject(array $user): User + { + return (new User)->setRaw($user)->map([ + 'id' => $user['id'], + 'nickname' => $user['username'] ?? null, + 'name' => $user['name'] ?? $user['username'] ?? null, + 'avatar' => $user['profile_picture_url'] ?? null, + ]); + } + + public function getAccessTokenResponse($code): array + { + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + RequestOptions::FORM_PARAMS => $this->getTokenFields($code), + ]); + + $data = json_decode((string) $response->getBody(), true); + + // Exchange short-lived token for long-lived token + return $this->exchangeForLongLivedToken($data); + } + + protected function exchangeForLongLivedToken(array $data): array + { + $response = $this->getHttpClient()->get('https://graph.instagram.com/access_token', [ + RequestOptions::QUERY => [ + 'grant_type' => 'ig_exchange_token', + 'client_secret' => $this->clientSecret, + 'access_token' => $data['access_token'], + ], + ]); + + $longLivedData = json_decode((string) $response->getBody(), true); + + return array_merge($data, [ + 'access_token' => $longLivedData['access_token'], + 'expires_in' => $longLivedData['expires_in'] ?? null, + ]); + } + + protected function getTokenFields($code): array + { + return [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->redirectUrl, + 'code' => $code, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index bc947d8b..fabee377 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ alias([ + 'subscribed' => EnsureSubscribed::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 45722e17..0e50018e 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,8 @@ "laravel/wayfinder": "^0.1.9", "league/flysystem-aws-s3-v3": "^3.0", "predis/predis": "^3.3", + "socialiteproviders/facebook": "^4.1", + "socialiteproviders/instagram": "^5.1", "socialiteproviders/linkedin": "^5.0", "socialiteproviders/tiktok": "^5.2", "socialiteproviders/twitter": "^4.1" diff --git a/composer.lock b/composer.lock index 14001629..94e0af28 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6993d4c308ced358f1dca8da903c9c22", + "content-hash": "2154f2c0783c4818692dc434812ea32b", "packages": [ { "name": "aws/aws-crt-php", @@ -5727,6 +5727,97 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "socialiteproviders/facebook", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Facebook.git", + "reference": "9b94a9334b5d0f61de8f5a20928d63d4d8f4e00d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Facebook/zipball/9b94a9334b5d0f61de8f5a20928d63d4d8f4e00d", + "reference": "9b94a9334b5d0f61de8f5a20928d63d4d8f4e00d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.2 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Facebook\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oleksandr Prypkhan (Alex Wells)", + "email": "autaut03@googlemail.com" + } + ], + "description": "Facebook (facebook.com) OAuth2 Provider for Laravel Socialite", + "support": { + "source": "https://github.com/SocialiteProviders/Facebook/tree/4.1.0" + }, + "time": "2020-12-01T23:10:59+00:00" + }, + { + "name": "socialiteproviders/instagram", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Instagram.git", + "reference": "9b6022f08e328503464cd6480fe65ff0ab5caab9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Instagram/zipball/9b6022f08e328503464cd6480fe65ff0ab5caab9", + "reference": "9b6022f08e328503464cd6480fe65ff0ab5caab9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.0", + "socialiteproviders/manager": "^4.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Instagram\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Faust", + "email": "hello@brianfaust.de" + } + ], + "description": "Instagram OAuth2 Provider for Laravel Socialite", + "keywords": [ + "instagram", + "laravel", + "oauth", + "provider", + "socialite" + ], + "support": { + "docs": "https://socialiteproviders.com/instagram", + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers" + }, + "time": "2025-04-08T06:54:43+00:00" + }, { "name": "socialiteproviders/linkedin", "version": "5.0.0", diff --git a/config/cashier.php b/config/cashier.php index 4a9b024b..3061ee52 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -124,4 +124,39 @@ 'logger' => env('CASHIER_LOGGER'), + /* + |-------------------------------------------------------------------------- + | Subscription Plans + |-------------------------------------------------------------------------- + | + | Define the available subscription plans with their Stripe price IDs. + | The 'monthly' plan bills at $25/workspace/month. + | The 'yearly' plan bills at $20/workspace/month (20% discount). + | + */ + + 'plans' => [ + 'monthly' => [ + 'price_id' => env('STRIPE_PRICE_MONTHLY'), + 'price' => 25, + 'interval' => 'month', + ], + 'yearly' => [ + 'price_id' => env('STRIPE_PRICE_YEARLY'), + 'price' => 20, + 'interval' => 'year', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Trial Period + |-------------------------------------------------------------------------- + | + | The number of days for the trial period. Set to 0 to disable trials. + | + */ + + 'trial_days' => env('CASHIER_TRIAL_DAYS', 8), + ]; diff --git a/config/fortify.php b/config/fortify.php index ce67e2c3..85178608 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -73,7 +73,7 @@ | */ - 'home' => '/dashboard', + 'home' => '/calendar', /* |-------------------------------------------------------------------------- diff --git a/config/mail.php b/config/mail.php index 522b284b..9ee0d369 100644 --- a/config/mail.php +++ b/config/mail.php @@ -14,7 +14,7 @@ | */ - 'default' => env('MAIL_MAILER', 'log'), + 'default' => env('MAIL_MAILER', 'smtp'), /* |-------------------------------------------------------------------------- diff --git a/config/services.php b/config/services.php index fe4534e7..a838345e 100644 --- a/config/services.php +++ b/config/services.php @@ -88,9 +88,4 @@ 'redirect' => env('THREADS_CLIENT_REDIRECT'), ], - 'stripe' => [ - 'price_id' => env('STRIPE_PRICE_ID'), - 'workspace_price' => env('STRIPE_WORKSPACE_PRICE', 2000), // $20.00 in cents - ], - ]; diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index f2c3d713..ec245af1 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -20,7 +20,14 @@ public function up(): void $table->text('two_factor_secret')->nullable(); $table->text('two_factor_recovery_codes')->nullable(); $table->timestamp('two_factor_confirmed_at')->nullable(); + $table->string('stripe_id')->nullable()->index(); + $table->string('pm_type')->nullable(); + $table->string('pm_last_four', 4)->nullable(); + $table->timestamp('trial_ends_at')->nullable(); $table->rememberToken(); + $table->string('setup')->nullable(); + $table->string('persona')->nullable(); + $table->uuid('current_workspace_id')->nullable(); $table->timestamps(); }); diff --git a/database/migrations/2026_01_14_232315_create_workspaces_table.php b/database/migrations/2026_01_14_232315_create_workspaces_table.php index 9994b973..18356a2b 100644 --- a/database/migrations/2026_01_14_232315_create_workspaces_table.php +++ b/database/migrations/2026_01_14_232315_create_workspaces_table.php @@ -15,6 +15,7 @@ public function up(): void $table->uuid('id')->primary(); $table->uuid('user_id'); $table->string('name'); + $table->string('timezone'); $table->timestamps(); $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); diff --git a/database/migrations/2026_01_14_232317_create_social_accounts_table.php b/database/migrations/2026_01_14_232317_create_social_accounts_table.php index 57c842f9..30fbc2d0 100644 --- a/database/migrations/2026_01_14_232317_create_social_accounts_table.php +++ b/database/migrations/2026_01_14_232317_create_social_accounts_table.php @@ -24,6 +24,9 @@ public function up(): void $table->timestamp('token_expires_at')->nullable(); $table->json('scopes')->nullable(); $table->json('meta')->nullable(); + $table->string('status'); + $table->text('error_message')->nullable(); + $table->timestamp('disconnected_at')->nullable(); $table->timestamps(); $table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete(); diff --git a/database/migrations/2026_01_14_232319_create_post_platforms_table.php b/database/migrations/2026_01_14_232319_create_post_platforms_table.php index 979e7df2..093a8009 100644 --- a/database/migrations/2026_01_14_232319_create_post_platforms_table.php +++ b/database/migrations/2026_01_14_232319_create_post_platforms_table.php @@ -19,6 +19,7 @@ public function up(): void $table->text('content')->nullable(); $table->string('status')->default('pending'); $table->string('platform_post_id')->nullable(); + $table->boolean('enabled'); $table->string('platform_url')->nullable(); $table->text('error_message')->nullable(); $table->timestamp('published_at')->nullable(); diff --git a/database/migrations/2026_01_14_232320_create_post_media_table.php b/database/migrations/2026_01_14_232320_create_post_media_table.php index 16b4b5a0..928e64af 100644 --- a/database/migrations/2026_01_14_232320_create_post_media_table.php +++ b/database/migrations/2026_01_14_232320_create_post_media_table.php @@ -11,9 +11,9 @@ */ public function up(): void { - Schema::create('post_media', function (Blueprint $table) { + Schema::create('post_medias', function (Blueprint $table) { $table->uuid('id')->primary(); - $table->uuid('post_platform_id'); + $table->uuid('post_platform_id')->nullable(); $table->string('type'); $table->string('path'); $table->string('original_filename'); @@ -32,6 +32,6 @@ public function up(): void */ public function down(): void { - Schema::dropIfExists('post_media'); + Schema::dropIfExists('post_medias'); } }; diff --git a/database/migrations/2026_01_15_013103_make_post_platform_id_nullable_on_post_media.php b/database/migrations/2026_01_15_013103_make_post_platform_id_nullable_on_post_media.php deleted file mode 100644 index c9c6997d..00000000 --- a/database/migrations/2026_01_15_013103_make_post_platform_id_nullable_on_post_media.php +++ /dev/null @@ -1,28 +0,0 @@ -uuid('post_platform_id')->nullable()->change(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('post_media', function (Blueprint $table) { - $table->uuid('post_platform_id')->nullable(false)->change(); - }); - } -}; diff --git a/database/migrations/2026_01_15_020942_add_timezone_to_workspaces_table.php b/database/migrations/2026_01_15_020942_add_timezone_to_workspaces_table.php deleted file mode 100644 index 4cb795a4..00000000 --- a/database/migrations/2026_01_15_020942_add_timezone_to_workspaces_table.php +++ /dev/null @@ -1,28 +0,0 @@ -string('timezone')->default('America/New_York')->after('name'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('workspaces', function (Blueprint $table) { - $table->dropColumn('timezone'); - }); - } -}; diff --git a/database/migrations/2026_01_15_022545_add_enabled_to_post_platforms_table.php b/database/migrations/2026_01_15_022545_add_enabled_to_post_platforms_table.php deleted file mode 100644 index 6f5a851c..00000000 --- a/database/migrations/2026_01_15_022545_add_enabled_to_post_platforms_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('enabled')->default(true)->after('social_account_id'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('post_platforms', function (Blueprint $table) { - $table->dropColumn('enabled'); - }); - } -}; diff --git a/resources/css/app.css b/resources/css/app.css index 6f9d1f56..b5307aec 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -13,45 +13,31 @@ @theme inline { 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); - --color-background: var(--background); --color-foreground: var(--foreground); - --color-card: var(--card); --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); - - --color-sidebar: var(--sidebar-background); + --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); @@ -59,6 +45,24 @@ @theme inline { --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); } /* @@ -70,6 +74,7 @@ @theme inline { color utility to any element that depends on these defaults. */ @layer base { + *, ::after, ::before, @@ -80,6 +85,7 @@ @layer base { } @layer utilities { + body, html { --font-sans: @@ -90,83 +96,119 @@ @layer utilities { } :root { - --background: hsl(0 0% 100%); - --foreground: hsl(0 0% 3.9%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(0 0% 3.9%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(0 0% 3.9%); - --primary: hsl(0 0% 9%); - --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(0 0% 92.1%); - --secondary-foreground: hsl(0 0% 9%); - --muted: hsl(0 0% 96.1%); - --muted-foreground: hsl(0 0% 45.1%); - --accent: hsl(0 0% 96.1%); - --accent-foreground: hsl(0 0% 9%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 92.8%); - --input: hsl(0 0% 89.8%); - --ring: hsl(0 0% 3.9%); - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); + --background: #f8f9fa; + --foreground: #0c0c1d; + --card: #ffffff; + --card-foreground: #0c0c1d; + --popover: #ffffff; + --popover-foreground: #0c0c1d; + --primary: #ff00c8; + --primary-foreground: #ffffff; + --secondary: #f0f0ff; + --secondary-foreground: #0c0c1d; + --muted: #f0f0ff; + --muted-foreground: #0c0c1d; + --accent: #00ffcc; + --accent-foreground: #0c0c1d; + --destructive: #ff3d00; + --destructive-foreground: #ffffff; + --border: #dfe6e9; + --input: #dfe6e9; + --ring: #ff00c8; + --chart-1: #ff00c8; + --chart-2: #9000ff; + --chart-3: #00e5ff; + --chart-4: #00ffcc; + --chart-5: #ffe600; + --sidebar: #f0f0ff; + --sidebar-foreground: #0c0c1d; + --sidebar-primary: #ff00c8; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #00ffcc; + --sidebar-accent-foreground: #0c0c1d; + --sidebar-border: #dfe6e9; + --sidebar-ring: #ff00c8; + --font-sans: Outfit, sans-serif; + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: Fira Code, monospace; --radius: 0.5rem; - --sidebar-background: hsl(0 0% 98%); - --sidebar-foreground: hsl(240 5.3% 26.1%); - --sidebar-primary: hsl(0 0% 10%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(0 0% 94%); - --sidebar-accent-foreground: hsl(0 0% 30%); - --sidebar-border: hsl(0 0% 91%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - --sidebar: hsl(0 0% 98%); + --shadow-x: 0px; + --shadow-y: 4px; + --shadow-blur: 8px; + --shadow-spread: -2px; + --shadow-opacity: 0.1; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05); + --shadow-xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05); + --shadow-sm: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10); + --shadow: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10); + --shadow-md: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 2px 4px -3px hsl(0 0% 0% / 0.10); + --shadow-lg: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 4px 6px -3px hsl(0 0% 0% / 0.10); + --shadow-xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 8px 10px -3px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; + --spacing: 0.25rem; } .dark { - --background: hsl(0 0% 3.9%); - --foreground: hsl(0 0% 98%); - --card: hsl(0 0% 3.9%); - --card-foreground: hsl(0 0% 98%); - --popover: hsl(0 0% 3.9%); - --popover-foreground: hsl(0 0% 98%); - --primary: hsl(0 0% 98%); - --primary-foreground: hsl(0 0% 9%); - --secondary: hsl(0 0% 14.9%); - --secondary-foreground: hsl(0 0% 98%); - --muted: hsl(0 0% 16.08%); - --muted-foreground: hsl(0 0% 63.9%); - --accent: hsl(0 0% 14.9%); - --accent-foreground: hsl(0 0% 98%); - --destructive: hsl(0 84% 60%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 14.9%); - --input: hsl(0 0% 14.9%); - --ring: hsl(0 0% 83.1%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); - --sidebar-background: hsl(0 0% 7%); - --sidebar-foreground: hsl(0 0% 95.9%); - --sidebar-primary: hsl(360, 100%, 100%); - --sidebar-primary-foreground: hsl(0 0% 100%); - --sidebar-accent: hsl(0 0% 15.9%); - --sidebar-accent-foreground: hsl(240 4.8% 95.9%); - --sidebar-border: hsl(0 0% 15.9%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - --sidebar: hsl(240 5.9% 10%); + --background: #0c0c1d; + --foreground: #eceff4; + --card: #1e1e3f; + --card-foreground: #eceff4; + --popover: #1e1e3f; + --popover-foreground: #eceff4; + --primary: #ff00c8; + --primary-foreground: #ffffff; + --secondary: #1e1e3f; + --secondary-foreground: #eceff4; + --muted: #151530; + --muted-foreground: #8085a6; + --accent: #00ffcc; + --accent-foreground: #0c0c1d; + --destructive: #ff3d00; + --destructive-foreground: #ffffff; + --border: #2e2e5e; + --input: #2e2e5e; + --ring: #ff00c8; + --chart-1: #ff00c8; + --chart-2: #9000ff; + --chart-3: #00e5ff; + --chart-4: #00ffcc; + --chart-5: #ffe600; + --sidebar: #0c0c1d; + --sidebar-foreground: #eceff4; + --sidebar-primary: #ff00c8; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #00ffcc; + --sidebar-accent-foreground: #0c0c1d; + --sidebar-border: #2e2e5e; + --sidebar-ring: #ff00c8; + --font-sans: Outfit, sans-serif; + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: Fira Code, monospace; + --radius: 0.5rem; + --shadow-x: 0px; + --shadow-y: 4px; + --shadow-blur: 8px; + --shadow-spread: -2px; + --shadow-opacity: 0.1; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05); + --shadow-xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05); + --shadow-sm: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10); + --shadow: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10); + --shadow-md: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 2px 4px -3px hsl(0 0% 0% / 0.10); + --shadow-lg: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 4px 6px -3px hsl(0 0% 0% / 0.10); + --shadow-xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 8px 10px -3px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.25); } @layer base { * { @apply border-border outline-ring/50; } + body { @apply bg-background text-foreground; } -} +} \ No newline at end of file diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index 720a2df2..52a4d1a6 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -1,10 +1,11 @@ @@ -62,7 +80,7 @@ const mainNavItems: NavItem[] = [
-
+
- + TryPost + + + -