From da45562e06fbb045fd7bc32ad8ade031eb4b1ca7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 17:09:03 -0300 Subject: [PATCH 01/19] feat: add social connect step to welcome before Stripe checkout Ask new owners to connect a network after referral source so we can track welcome.connect in PostHog and still let them continue to checkout without a connection. Co-authored-by: Cursor --- app/Enums/PostHog/WelcomeEvent.php | 1 + .../Controllers/App/WelcomeController.php | 114 ++++++++++-- lang/ar/welcome.php | 2 + lang/de/welcome.php | 2 + lang/el/welcome.php | 2 + lang/en/welcome.php | 2 + lang/es/welcome.php | 2 + lang/fr/welcome.php | 2 + lang/it/welcome.php | 2 + lang/ja/welcome.php | 2 + lang/ko/welcome.php | 2 + lang/nl/welcome.php | 2 + lang/pl/welcome.php | 2 + lang/pt-BR/welcome.php | 2 + lang/ru/welcome.php | 2 + lang/tr/welcome.php | 2 + lang/uk/welcome.php | 2 + lang/zh/welcome.php | 2 + resources/js/layouts/WelcomeLayout.vue | 4 +- resources/js/pages/welcome/Connect.vue | 57 ++++++ resources/js/pages/welcome/ReferralSource.vue | 2 +- routes/app.php | 6 +- .../Feature/Welcome/WelcomeControllerTest.php | 176 ++++++++++++++---- 23 files changed, 335 insertions(+), 57 deletions(-) create mode 100644 resources/js/pages/welcome/Connect.vue diff --git a/app/Enums/PostHog/WelcomeEvent.php b/app/Enums/PostHog/WelcomeEvent.php index 934937def..e9d474b84 100644 --- a/app/Enums/PostHog/WelcomeEvent.php +++ b/app/Enums/PostHog/WelcomeEvent.php @@ -9,4 +9,5 @@ enum WelcomeEvent: string case Persona = 'welcome.persona'; case Goals = 'welcome.goals'; case Referral = 'welcome.referral'; + case Connect = 'welcome.connect'; } diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index 485e1c3a8..8fb0aafc6 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -8,13 +8,17 @@ use App\Enums\Plan\Slug; use App\Enums\PostHog\CheckoutEvent; use App\Enums\PostHog\WelcomeEvent; +use App\Enums\SocialAccount\Platform as SocialPlatform; +use App\Enums\SocialAccount\Status; use App\Enums\User\Goal; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; use App\Http\Requests\App\Welcome\StoreWelcomeGoalsRequest; use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest; use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest; +use App\Http\Resources\App\SocialAccountResource; use App\Models\Plan; +use App\Models\SocialAccount; use App\Models\User; use App\Services\PostHogService; use Illuminate\Http\RedirectResponse; @@ -120,9 +124,8 @@ public function referralSource(Request $request): InertiaResponse|RedirectRespon public function storeReferralSource( StoreWelcomeReferralSourceRequest $request, - StartSubscriptionCheckout $checkout, PostHogService $postHog, - ): Response|RedirectResponse { + ): RedirectResponse { if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) { return $redirect; } @@ -145,25 +148,56 @@ public function storeReferralSource( $user->account, ); - $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); - $priceId = $plan->stripe_monthly_price_id; + return redirect()->route('app.welcome.connect'); + } - abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Monthly price is not configured.'); + public function connect(Request $request): InertiaResponse|RedirectResponse + { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { + return $redirect; + } - $response = $checkout->redirect( - $user->account, - $priceId, - route('app.welcome.referral-source'), - ); + $workspace = $request->user()->currentWorkspace; + return Inertia::render('welcome/Connect', [ + 'platforms' => SocialPlatform::connectableOptions(), + 'accounts' => $workspace + ? SocialAccountResource::collection( + $workspace->socialAccounts()->orderBy('id')->get(), + )->resolve() + : [], + ]); + } + + public function storeConnect( + Request $request, + StartSubscriptionCheckout $checkout, + PostHogService $postHog, + ): Response|RedirectResponse { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { + return $redirect; + } + + $user = $request->user(); + + abort_unless($user->isAccountOwner(), Response::HTTP_FORBIDDEN); + + $platforms = $this->connectedPlatforms($user); + + $postHog->identify($user->id, [ + 'connected_platforms' => $platforms, + ]); $postHog->capture( $user->id, - CheckoutEvent::Started->value, - ['plan_name' => $plan->name, 'interval' => 'monthly'], + WelcomeEvent::Connect->value, + [ + 'connected' => $platforms !== [], + 'platforms' => $platforms, + ], $user->account, ); - return $response; + return $this->startCheckout($user, $checkout, $postHog); } public function subscriptionRequired(Request $request): InertiaResponse|RedirectResponse @@ -183,8 +217,11 @@ public function subscriptionRequired(Request $request): InertiaResponse|Redirect ]); } - private function redirectIfStepIncomplete(Request $request, bool $requireGoals = false): ?RedirectResponse - { + private function redirectIfStepIncomplete( + Request $request, + bool $requireGoals = false, + bool $requireReferral = false, + ): ?RedirectResponse { if ($redirect = $this->redirectIfUnavailable($request)) { return $redirect; } @@ -199,6 +236,10 @@ private function redirectIfStepIncomplete(Request $request, bool $requireGoals = return redirect()->route('app.welcome.goals'); } + if ($requireReferral && ! $user->referral_source) { + return redirect()->route('app.welcome.referral-source'); + } + return null; } @@ -220,6 +261,49 @@ private function hasCurrentGoals(User $user): bool return array_intersect($goals, $allowed) !== []; } + /** + * @return list + */ + private function connectedPlatforms(User $user): array + { + $workspace = $user->currentWorkspace; + + if ($workspace === null) { + return []; + } + + return $workspace->socialAccounts() + ->where('status', Status::Connected) + ->orderBy('id') + ->get() + ->map(fn (SocialAccount $account): string => $account->platform->value) + ->values() + ->all(); + } + + private function startCheckout(User $user, StartSubscriptionCheckout $checkout, PostHogService $postHog): Response + { + $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); + $priceId = $plan->stripe_monthly_price_id; + + abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Monthly price is not configured.'); + + $response = $checkout->redirect( + $user->account, + $priceId, + route('app.welcome.connect'), + ); + + $postHog->capture( + $user->id, + CheckoutEvent::Started->value, + ['plan_name' => $plan->name, 'interval' => 'monthly'], + $user->account, + ); + + return $response; + } + private function redirectIfUnavailable(Request $request): ?RedirectResponse { $user = $request->user(); diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php index fd9e5616a..47b9d52cd 100644 --- a/lang/ar/welcome.php +++ b/lang/ar/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'شيء آخر', ], + 'connect_title' => 'اربط حسابًا اجتماعيًا', + 'connect_description' => 'اختر شبكة واحدة على الأقل يمكن لـ TryPost النشر عليها. يمكنك المتابعة وفعل ذلك لاحقًا.', ]; diff --git a/lang/de/welcome.php b/lang/de/welcome.php index e4f6f39de..f89ff97ee 100644 --- a/lang/de/welcome.php +++ b/lang/de/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Etwas anderes', ], + 'connect_title' => 'Verbinde ein soziales Konto', + 'connect_description' => 'Wähle mindestens ein Netzwerk, auf dem TryPost deine Inhalte veröffentlichen kann. Du kannst auch fortfahren und das später erledigen.', ]; diff --git a/lang/el/welcome.php b/lang/el/welcome.php index a2801c25f..e50e3e1cb 100644 --- a/lang/el/welcome.php +++ b/lang/el/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Κάτι άλλο', ], + 'connect_title' => 'Σύνδεσε έναν λογαριασμό κοινωνικής δικτύωσης', + 'connect_description' => 'Επίλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου. Μπορείς να συνεχίσεις και να το κάνεις αργότερα.', ]; diff --git a/lang/en/welcome.php b/lang/en/welcome.php index d83a8e377..f983537bb 100644 --- a/lang/en/welcome.php +++ b/lang/en/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Something else', ], + 'connect_title' => 'Connect a social account', + 'connect_description' => 'Choose at least one network where TryPost can publish your content. You can continue and do this later.', ]; diff --git a/lang/es/welcome.php b/lang/es/welcome.php index be2ee737b..20c9be9fd 100644 --- a/lang/es/welcome.php +++ b/lang/es/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Otra cosa', ], + 'connect_title' => 'Conecta una red social', + 'connect_description' => 'Elige al menos una red donde TryPost pueda publicar tu contenido. Puedes continuar y hacerlo más tarde.', ]; diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php index f7648989f..6c500ec76 100644 --- a/lang/fr/welcome.php +++ b/lang/fr/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Autre chose', ], + 'connect_title' => 'Connectez un réseau social', + 'connect_description' => 'Choisissez au moins un réseau sur lequel TryPost peut publier votre contenu. Vous pouvez continuer et le faire plus tard.', ]; diff --git a/lang/it/welcome.php b/lang/it/welcome.php index e6f2667a3..d3d04838e 100644 --- a/lang/it/welcome.php +++ b/lang/it/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Qualcos\'altro', ], + 'connect_title' => 'Collega un account social', + 'connect_description' => 'Scegli almeno una rete su cui TryPost può pubblicare i tuoi contenuti. Puoi continuare e farlo più tardi.', ]; diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php index 8507a5d41..64765da27 100644 --- a/lang/ja/welcome.php +++ b/lang/ja/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'その他', ], + 'connect_title' => 'SNSアカウントを接続', + 'connect_description' => 'TryPostが投稿できるネットワークを少なくとも1つ選んでください。今は進めて、あとから接続することもできます。', ]; diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php index 15a081213..b520081c2 100644 --- a/lang/ko/welcome.php +++ b/lang/ko/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => '기타', ], + 'connect_title' => '소셜 계정을 연결하세요', + 'connect_description' => 'TryPost가 콘텐츠를 게시할 네트워크를 하나 이상 선택하세요. 지금은 계속하고 나중에 연결해도 됩니다.', ]; diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php index b51349ff6..ac6b191e8 100644 --- a/lang/nl/welcome.php +++ b/lang/nl/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Iets anders', ], + 'connect_title' => 'Verbind een social account', + 'connect_description' => 'Kies minstens één netwerk waarop TryPost je content kan plaatsen. Je kunt ook doorgaan en dit later doen.', ]; diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php index 87f4bba0f..7a17a94ad 100644 --- a/lang/pl/welcome.php +++ b/lang/pl/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Coś innego', ], + 'connect_title' => 'Połącz konto społecznościowe', + 'connect_description' => 'Wybierz co najmniej jedną sieć, na której TryPost może publikować Twoje treści. Możesz kontynuować i zrobić to później.', ]; diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php index 3e4744360..6d90b43af 100644 --- a/lang/pt-BR/welcome.php +++ b/lang/pt-BR/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Outra coisa', ], + 'connect_title' => 'Conecte uma rede social', + 'connect_description' => 'Escolha pelo menos uma rede onde o TryPost possa publicar seu conteúdo. Você pode continuar e fazer isso depois.', ]; diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php index 41c419100..f24d30fef 100644 --- a/lang/ru/welcome.php +++ b/lang/ru/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Что-то другое', ], + 'connect_title' => 'Подключите соцсеть', + 'connect_description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент. Можно продолжить и сделать это позже.', ]; diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php index 74f9dc6f6..f5b1f1574 100644 --- a/lang/tr/welcome.php +++ b/lang/tr/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Başka bir şey', ], + 'connect_title' => 'Bir sosyal hesap bağla', + 'connect_description' => 'TryPost’un içeriğini yayınlayabileceği en az bir ağ seç. Şimdi devam edip bunu daha sonra da yapabilirsin.', ]; diff --git a/lang/uk/welcome.php b/lang/uk/welcome.php index 80967c87c..4c5643157 100644 --- a/lang/uk/welcome.php +++ b/lang/uk/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => 'Щось інше', ], + 'connect_title' => 'Підключіть соцмережу', + 'connect_description' => 'Оберіть принаймні одну мережу, де TryPost зможе публікувати ваш контент. Можете продовжити і зробити це пізніше.', ]; diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php index 06781f02f..eb60b35f0 100644 --- a/lang/zh/welcome.php +++ b/lang/zh/welcome.php @@ -59,4 +59,6 @@ 'blog' => 'Blog / newsletter', 'other' => '其他', ], + 'connect_title' => '连接社交账号', + 'connect_description' => '选择至少一个 TryPost 可以发布内容的平台。你也可以先继续,稍后再连接。', ]; diff --git a/resources/js/layouts/WelcomeLayout.vue b/resources/js/layouts/WelcomeLayout.vue index 375d2bb4f..dcdf38534 100644 --- a/resources/js/layouts/WelcomeLayout.vue +++ b/resources/js/layouts/WelcomeLayout.vue @@ -3,6 +3,7 @@ import { Link } from '@inertiajs/vue3'; import { computed } from 'vue'; import { + connect as connectRoute, goals as goalsRoute, persona as personaRoute, referralSource as referralSourceRoute, @@ -20,7 +21,7 @@ const props = withDefaults( title: undefined, description: undefined, step: undefined, - totalSteps: 3, + totalSteps: 4, wide: false, }, ); @@ -29,6 +30,7 @@ const stepRoutes = computed(() => [ personaRoute(), goalsRoute(), referralSourceRoute(), + connectRoute(), ]); const canNavigateTo = (stepNumber: number): boolean => diff --git a/resources/js/pages/welcome/Connect.vue b/resources/js/pages/welcome/Connect.vue new file mode 100644 index 000000000..1b5a9455c --- /dev/null +++ b/resources/js/pages/welcome/Connect.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/pages/welcome/ReferralSource.vue b/resources/js/pages/welcome/ReferralSource.vue index 4d1a6acbf..9ad809f8b 100644 --- a/resources/js/pages/welcome/ReferralSource.vue +++ b/resources/js/pages/welcome/ReferralSource.vue @@ -222,7 +222,7 @@ const submit = (): void => { size="lg" class="w-full rounded-full" :disabled="form.referral_source === '' || form.processing" - data-testid="welcome-start-checkout" + data-testid="welcome-referral-continue" @click="submit" > {{ $t('welcome.continue') }} diff --git a/routes/app.php b/routes/app.php index a6742a769..779d4770b 100644 --- a/routes/app.php +++ b/routes/app.php @@ -65,10 +65,14 @@ Route::get('welcome/goals', [WelcomeController::class, 'goals'])->name('app.welcome.goals'); Route::post('welcome/goals', [WelcomeController::class, 'storeGoals'])->name('app.welcome.goals.store'); Route::get('welcome/referral-source', [WelcomeController::class, 'referralSource'])->name('app.welcome.referral-source'); - Route::get('welcome/subscription-required', [WelcomeController::class, 'subscriptionRequired'])->name('app.welcome.subscription-required'); Route::post('welcome/referral-source', [WelcomeController::class, 'storeReferralSource']) ->middleware('throttle:6,1') ->name('app.welcome.referral-source.store'); + Route::get('welcome/connect', [WelcomeController::class, 'connect'])->name('app.welcome.connect'); + Route::post('welcome/connect', [WelcomeController::class, 'storeConnect']) + ->middleware('throttle:6,1') + ->name('app.welcome.connect.store'); + Route::get('welcome/subscription-required', [WelcomeController::class, 'subscriptionRequired'])->name('app.welcome.subscription-required'); Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing'); Route::get('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create'); diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index 8352490f6..9555371e6 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -6,13 +6,17 @@ use App\Enums\Plan\Slug; use App\Enums\PostHog\CheckoutEvent; use App\Enums\PostHog\WelcomeEvent; +use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\User\Goal; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; +use App\Enums\Workspace\ContentLanguage; use App\Jobs\PostHog\SendEvent; use App\Models\Account; use App\Models\Plan; +use App\Models\SocialAccount; use App\Models\User; +use App\Models\Workspace; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Route; @@ -115,6 +119,7 @@ $this->user->update([ 'persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], + 'referral_source' => ReferralSource::Google->value, ]); $this->actingAs($this->user->fresh()) @@ -126,6 +131,16 @@ ->get(route('app.welcome.goals')) ->assertOk() ->assertInertia(fn ($page) => $page->component('welcome/Goals', false)); + + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.referral-source')) + ->assertOk() + ->assertInertia(fn ($page) => $page->component('welcome/ReferralSource', false)); + + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.connect')) + ->assertOk() + ->assertInertia(fn ($page) => $page->component('welcome/Connect', false)); }); test('referral source redirects through incomplete prior steps', function (array $attributes, string $routeName) { @@ -197,7 +212,7 @@ 'invalid' => [['referral_source' => 'not-a-source']], ]); -test('referral source store saves the source and starts Stripe checkout without a social account', function () { +test('referral source store saves the source mirrors it to PostHog and advances to connect', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); $this->user->update([ @@ -205,6 +220,65 @@ 'goals' => [Goal::SaveTime->value], ]); + $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.referral-source.store'), [ + 'referral_source' => ReferralSource::ProductHunt->value, + ]) + ->assertRedirect(route('app.welcome.connect')); + + expect($this->user->fresh()->referral_source)->toBe(ReferralSource::ProductHunt); + Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' + && data_get($event->payload, 'event') === WelcomeEvent::Referral->value + && data_get($event->payload, 'properties.referral_source') === ReferralSource::ProductHunt->value); + Bus::assertNotDispatched( + SendEvent::class, + fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value, + ); +}); + +test('connect redirects through incomplete prior steps', function (array $attributes, string $routeName) { + $this->user->update($attributes); + + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.connect')) + ->assertRedirect(route($routeName)); +})->with([ + 'missing persona' => [[], 'app.welcome.persona'], + 'missing goals' => [['persona' => Persona::Agency->value], 'app.welcome.goals'], + 'missing referral' => [ + [ + 'persona' => Persona::Agency->value, + 'goals' => [Goal::SaveTime->value], + ], + 'app.welcome.referral-source', + ], +]); + +test('connect renders after prior steps are complete', function () { + completeWelcomeThroughReferral($this->user); + + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.connect')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('welcome/Connect', false) + ->has('platforms', count(SocialPlatform::connectableOptions())) + ->has('accounts') + ); +}); + +test('connect copy exists in every locale', function (string $locale) { + expect(__('welcome.connect_title', [], $locale))->not->toBe('welcome.connect_title') + ->and(__('welcome.connect_description', [], $locale))->not->toBe('welcome.connect_description'); +})->with(ContentLanguage::values()); + +test('connect store starts Stripe checkout without a social account', function () { + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); + Bus::fake(); + completeWelcomeThroughReferral($this->user); + Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ 'stripe_monthly_price_id' => 'price_monthly_test', ]); @@ -214,29 +288,55 @@ ->once() ->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $account->is($this->user->account) && $priceId === 'price_monthly_test' - && $cancelUrl === route('app.welcome.referral-source')) + && $cancelUrl === route('app.welcome.connect')) ->andReturn(redirect('https://checkout.stripe.test/session')); $this->actingAs($this->user->fresh()) - ->post(route('app.welcome.referral-source.store'), [ - 'referral_source' => ReferralSource::ProductHunt->value, - ]) + ->post(route('app.welcome.connect.store')) ->assertRedirect('https://checkout.stripe.test/session'); - expect($this->user->fresh()->referral_source)->toBe(ReferralSource::ProductHunt); Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' - && data_get($event->payload, 'event') === WelcomeEvent::Referral->value - && data_get($event->payload, 'properties.referral_source') === ReferralSource::ProductHunt->value); + && data_get($event->payload, 'event') === WelcomeEvent::Connect->value + && data_get($event->payload, 'properties.connected') === false + && data_get($event->payload, 'properties.platforms') === []); }); -test('referral source store captures checkout.started with the plan name and interval', function () { +test('connect store captures connected platforms when a social account exists', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); - $this->user->update([ - 'persona' => Persona::Agency->value, - 'goals' => [Goal::SaveTime->value], + completeWelcomeThroughReferral($this->user); + + $workspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $this->user->update(['current_workspace_id' => $workspace->id]); + SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); + + Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ + 'stripe_monthly_price_id' => 'price_monthly_test', ]); + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->once() + ->andReturn(redirect('https://checkout.stripe.test/session')); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.connect.store')) + ->assertRedirect('https://checkout.stripe.test/session'); + + Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' + && data_get($event->payload, 'event') === WelcomeEvent::Connect->value + && data_get($event->payload, 'properties.connected') === true + && data_get($event->payload, 'properties.platforms') === [SocialPlatform::LinkedIn->value]); +}); + +test('connect store captures checkout.started with the plan name and interval', function () { + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); + Bus::fake(); + completeWelcomeThroughReferral($this->user); + $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); $plan->update(['stripe_monthly_price_id' => 'price_monthly_test']); @@ -246,9 +346,7 @@ ->andReturn(redirect('https://checkout.stripe.test/session')); $this->actingAs($this->user->fresh()) - ->post(route('app.welcome.referral-source.store'), [ - 'referral_source' => ReferralSource::ProductHunt->value, - ]); + ->post(route('app.welcome.connect.store')); Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' && data_get($event->payload, 'event') === CheckoutEvent::Started->value @@ -256,13 +354,10 @@ && data_get($event->payload, 'properties.interval') === 'monthly'); }); -test('referral source store does not capture checkout.started when Stripe checkout creation fails', function () { +test('connect store does not capture checkout.started when Stripe checkout creation fails', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); - $this->user->update([ - 'persona' => Persona::Agency->value, - 'goals' => [Goal::SaveTime->value], - ]); + completeWelcomeThroughReferral($this->user); Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ 'stripe_monthly_price_id' => 'price_monthly_test', @@ -274,9 +369,7 @@ ->andThrow(new RuntimeException('Stripe checkout could not be created.')); $this->actingAs($this->user->fresh()) - ->post(route('app.welcome.referral-source.store'), [ - 'referral_source' => ReferralSource::ProductHunt->value, - ]); + ->post(route('app.welcome.connect.store')); Bus::assertNotDispatched( SendEvent::class, @@ -301,6 +394,8 @@ 'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]], 'referral source' => ['app.welcome.referral-source', 'get'], 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]], + 'connect' => ['app.welcome.connect', 'get'], + 'connect store' => ['app.welcome.connect.store', 'post'], ]); test('welcome redirects generic-trial accounts with app access to calendar', function () { @@ -335,6 +430,8 @@ 'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]], 'referral source' => ['app.welcome.referral-source', 'get'], 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]], + 'connect' => ['app.welcome.connect', 'get'], + 'connect store' => ['app.welcome.connect.store', 'post'], ]); test('old onboarding icp routes are not registered', function (string $routeName) { @@ -355,23 +452,18 @@ $member->update([ 'persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], + 'referral_source' => ReferralSource::Google->value, ]); $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); - // Members never reach the referral step — they are held on the - // subscription-required screen before any checkout attempt. $this->actingAs($member->fresh()) - ->get(route('app.welcome.referral-source')) + ->get(route('app.welcome.connect')) ->assertRedirect(route('app.welcome.subscription-required')); $this->actingAs($member->fresh()) - ->post(route('app.welcome.referral-source.store'), [ - 'referral_source' => ReferralSource::Google->value, - ]) + ->post(route('app.welcome.connect.store')) ->assertRedirect(route('app.welcome.subscription-required')); - - expect($member->fresh()->referral_source)->toBeNull(); }); test('members without app access are held on the subscription required screen', function (string $routeName, string $method, array $payload = []) { @@ -391,6 +483,8 @@ 'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]], 'referral source' => ['app.welcome.referral-source', 'get'], 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]], + 'connect' => ['app.welcome.connect', 'get'], + 'connect store' => ['app.welcome.connect.store', 'post'], ]); test('subscription required screen renders for members without app access', function () { @@ -447,21 +541,16 @@ ->assertRedirect(route('app.calendar')); }); -test('referral source store fails loudly when the monthly price is not configured', function () { +test('connect store fails loudly when the monthly price is not configured', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); - $this->user->update([ - 'persona' => Persona::Agency->value, - 'goals' => [Goal::SaveTime->value], - ]); + completeWelcomeThroughReferral($this->user); Plan::where('slug', Slug::Workspace)->update(['stripe_monthly_price_id' => null]); $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); $this->actingAs($this->user->fresh()) - ->post(route('app.welcome.referral-source.store'), [ - 'referral_source' => ReferralSource::Google->value, - ]) + ->post(route('app.welcome.connect.store')) ->assertServerError(); Bus::assertNotDispatched( @@ -469,3 +558,12 @@ fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value, ); }); + +function completeWelcomeThroughReferral(User $user): void +{ + $user->update([ + 'persona' => Persona::Agency->value, + 'goals' => [Goal::SaveTime->value], + 'referral_source' => ReferralSource::ProductHunt->value, + ]); +} From cac2d087d14929c8356a35a5f30ac00f8d6751a0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 17:12:02 -0300 Subject: [PATCH 02/19] Nest welcome connect copy under a connect array. Co-authored-by: Cursor --- lang/ar/welcome.php | 6 ++++-- lang/de/welcome.php | 6 ++++-- lang/el/welcome.php | 6 ++++-- lang/en/welcome.php | 6 ++++-- lang/es/welcome.php | 6 ++++-- lang/fr/welcome.php | 6 ++++-- lang/it/welcome.php | 6 ++++-- lang/ja/welcome.php | 6 ++++-- lang/ko/welcome.php | 6 ++++-- lang/nl/welcome.php | 6 ++++-- lang/pl/welcome.php | 6 ++++-- lang/pt-BR/welcome.php | 6 ++++-- lang/ru/welcome.php | 6 ++++-- lang/tr/welcome.php | 6 ++++-- lang/uk/welcome.php | 6 ++++-- lang/zh/welcome.php | 6 ++++-- resources/js/pages/welcome/Connect.vue | 6 +++--- tests/Feature/Welcome/WelcomeControllerTest.php | 4 ++-- 18 files changed, 69 insertions(+), 37 deletions(-) diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php index 47b9d52cd..0d9b87f79 100644 --- a/lang/ar/welcome.php +++ b/lang/ar/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'شيء آخر', ], - 'connect_title' => 'اربط حسابًا اجتماعيًا', - 'connect_description' => 'اختر شبكة واحدة على الأقل يمكن لـ TryPost النشر عليها. يمكنك المتابعة وفعل ذلك لاحقًا.', + 'connect' => [ + 'title' => 'اربط حسابًا اجتماعيًا', + 'description' => 'اختر شبكة واحدة على الأقل يمكن لـ TryPost النشر عليها. يمكنك المتابعة وفعل ذلك لاحقًا.', + ], ]; diff --git a/lang/de/welcome.php b/lang/de/welcome.php index f89ff97ee..f24a750f9 100644 --- a/lang/de/welcome.php +++ b/lang/de/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Etwas anderes', ], - 'connect_title' => 'Verbinde ein soziales Konto', - 'connect_description' => 'Wähle mindestens ein Netzwerk, auf dem TryPost deine Inhalte veröffentlichen kann. Du kannst auch fortfahren und das später erledigen.', + 'connect' => [ + 'title' => 'Verbinde ein soziales Konto', + 'description' => 'Wähle mindestens ein Netzwerk, auf dem TryPost deine Inhalte veröffentlichen kann. Du kannst auch fortfahren und das später erledigen.', + ], ]; diff --git a/lang/el/welcome.php b/lang/el/welcome.php index e50e3e1cb..3012ccd06 100644 --- a/lang/el/welcome.php +++ b/lang/el/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Κάτι άλλο', ], - 'connect_title' => 'Σύνδεσε έναν λογαριασμό κοινωνικής δικτύωσης', - 'connect_description' => 'Επίλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου. Μπορείς να συνεχίσεις και να το κάνεις αργότερα.', + 'connect' => [ + 'title' => 'Σύνδεσε έναν λογαριασμό κοινωνικής δικτύωσης', + 'description' => 'Επίλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου. Μπορείς να συνεχίσεις και να το κάνεις αργότερα.', + ], ]; diff --git a/lang/en/welcome.php b/lang/en/welcome.php index f983537bb..02afad550 100644 --- a/lang/en/welcome.php +++ b/lang/en/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Something else', ], - 'connect_title' => 'Connect a social account', - 'connect_description' => 'Choose at least one network where TryPost can publish your content. You can continue and do this later.', + 'connect' => [ + 'title' => 'Connect a social account', + 'description' => 'Choose at least one network where TryPost can publish your content. You can continue and do this later.', + ], ]; diff --git a/lang/es/welcome.php b/lang/es/welcome.php index 20c9be9fd..b3856fbc2 100644 --- a/lang/es/welcome.php +++ b/lang/es/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Otra cosa', ], - 'connect_title' => 'Conecta una red social', - 'connect_description' => 'Elige al menos una red donde TryPost pueda publicar tu contenido. Puedes continuar y hacerlo más tarde.', + 'connect' => [ + 'title' => 'Conecta una red social', + 'description' => 'Elige al menos una red donde TryPost pueda publicar tu contenido. Puedes continuar y hacerlo más tarde.', + ], ]; diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php index 6c500ec76..2ddff074b 100644 --- a/lang/fr/welcome.php +++ b/lang/fr/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Autre chose', ], - 'connect_title' => 'Connectez un réseau social', - 'connect_description' => 'Choisissez au moins un réseau sur lequel TryPost peut publier votre contenu. Vous pouvez continuer et le faire plus tard.', + 'connect' => [ + 'title' => 'Connectez un réseau social', + 'description' => 'Choisissez au moins un réseau sur lequel TryPost peut publier votre contenu. Vous pouvez continuer et le faire plus tard.', + ], ]; diff --git a/lang/it/welcome.php b/lang/it/welcome.php index d3d04838e..176341d32 100644 --- a/lang/it/welcome.php +++ b/lang/it/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Qualcos\'altro', ], - 'connect_title' => 'Collega un account social', - 'connect_description' => 'Scegli almeno una rete su cui TryPost può pubblicare i tuoi contenuti. Puoi continuare e farlo più tardi.', + 'connect' => [ + 'title' => 'Collega un account social', + 'description' => 'Scegli almeno una rete su cui TryPost può pubblicare i tuoi contenuti. Puoi continuare e farlo più tardi.', + ], ]; diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php index 64765da27..7e32429e3 100644 --- a/lang/ja/welcome.php +++ b/lang/ja/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'その他', ], - 'connect_title' => 'SNSアカウントを接続', - 'connect_description' => 'TryPostが投稿できるネットワークを少なくとも1つ選んでください。今は進めて、あとから接続することもできます。', + 'connect' => [ + 'title' => 'SNSアカウントを接続', + 'description' => 'TryPostが投稿できるネットワークを少なくとも1つ選んでください。今は進めて、あとから接続することもできます。', + ], ]; diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php index b520081c2..7ba337676 100644 --- a/lang/ko/welcome.php +++ b/lang/ko/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => '기타', ], - 'connect_title' => '소셜 계정을 연결하세요', - 'connect_description' => 'TryPost가 콘텐츠를 게시할 네트워크를 하나 이상 선택하세요. 지금은 계속하고 나중에 연결해도 됩니다.', + 'connect' => [ + 'title' => '소셜 계정을 연결하세요', + 'description' => 'TryPost가 콘텐츠를 게시할 네트워크를 하나 이상 선택하세요. 지금은 계속하고 나중에 연결해도 됩니다.', + ], ]; diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php index ac6b191e8..80f92f3e5 100644 --- a/lang/nl/welcome.php +++ b/lang/nl/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Iets anders', ], - 'connect_title' => 'Verbind een social account', - 'connect_description' => 'Kies minstens één netwerk waarop TryPost je content kan plaatsen. Je kunt ook doorgaan en dit later doen.', + 'connect' => [ + 'title' => 'Verbind een social account', + 'description' => 'Kies minstens één netwerk waarop TryPost je content kan plaatsen. Je kunt ook doorgaan en dit later doen.', + ], ]; diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php index 7a17a94ad..a4e517fd6 100644 --- a/lang/pl/welcome.php +++ b/lang/pl/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Coś innego', ], - 'connect_title' => 'Połącz konto społecznościowe', - 'connect_description' => 'Wybierz co najmniej jedną sieć, na której TryPost może publikować Twoje treści. Możesz kontynuować i zrobić to później.', + 'connect' => [ + 'title' => 'Połącz konto społecznościowe', + 'description' => 'Wybierz co najmniej jedną sieć, na której TryPost może publikować Twoje treści. Możesz kontynuować i zrobić to później.', + ], ]; diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php index 6d90b43af..98ba4573c 100644 --- a/lang/pt-BR/welcome.php +++ b/lang/pt-BR/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Outra coisa', ], - 'connect_title' => 'Conecte uma rede social', - 'connect_description' => 'Escolha pelo menos uma rede onde o TryPost possa publicar seu conteúdo. Você pode continuar e fazer isso depois.', + 'connect' => [ + 'title' => 'Conecte uma rede social', + 'description' => 'Escolha pelo menos uma rede onde o TryPost possa publicar seu conteúdo. Você pode continuar e fazer isso depois.', + ], ]; diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php index f24d30fef..d94b68e4f 100644 --- a/lang/ru/welcome.php +++ b/lang/ru/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Что-то другое', ], - 'connect_title' => 'Подключите соцсеть', - 'connect_description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент. Можно продолжить и сделать это позже.', + 'connect' => [ + 'title' => 'Подключите соцсеть', + 'description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент. Можно продолжить и сделать это позже.', + ], ]; diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php index f5b1f1574..a3601e29c 100644 --- a/lang/tr/welcome.php +++ b/lang/tr/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Başka bir şey', ], - 'connect_title' => 'Bir sosyal hesap bağla', - 'connect_description' => 'TryPost’un içeriğini yayınlayabileceği en az bir ağ seç. Şimdi devam edip bunu daha sonra da yapabilirsin.', + 'connect' => [ + 'title' => 'Bir sosyal hesap bağla', + 'description' => 'TryPost’un içeriğini yayınlayabileceği en az bir ağ seç. Şimdi devam edip bunu daha sonra da yapabilirsin.', + ], ]; diff --git a/lang/uk/welcome.php b/lang/uk/welcome.php index 4c5643157..892c460a6 100644 --- a/lang/uk/welcome.php +++ b/lang/uk/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => 'Щось інше', ], - 'connect_title' => 'Підключіть соцмережу', - 'connect_description' => 'Оберіть принаймні одну мережу, де TryPost зможе публікувати ваш контент. Можете продовжити і зробити це пізніше.', + 'connect' => [ + 'title' => 'Підключіть соцмережу', + 'description' => 'Оберіть принаймні одну мережу, де TryPost зможе публікувати ваш контент. Можете продовжити і зробити це пізніше.', + ], ]; diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php index eb60b35f0..43e96bd7f 100644 --- a/lang/zh/welcome.php +++ b/lang/zh/welcome.php @@ -59,6 +59,8 @@ 'blog' => 'Blog / newsletter', 'other' => '其他', ], - 'connect_title' => '连接社交账号', - 'connect_description' => '选择至少一个 TryPost 可以发布内容的平台。你也可以先继续,稍后再连接。', + 'connect' => [ + 'title' => '连接社交账号', + 'description' => '选择至少一个 TryPost 可以发布内容的平台。你也可以先继续,稍后再连接。', + ], ]; diff --git a/resources/js/pages/welcome/Connect.vue b/resources/js/pages/welcome/Connect.vue index 1b5a9455c..17220ff87 100644 --- a/resources/js/pages/welcome/Connect.vue +++ b/resources/js/pages/welcome/Connect.vue @@ -26,11 +26,11 @@ const submit = (): void => { diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index ebcdfef4f..a283a731c 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -271,6 +271,24 @@ 'app.welcome.referral-source', 'post', ], + 'get only removed goals' => [ + [ + 'persona' => Persona::Agency->value, + 'goals' => ['team_collaboration', 'automate_api', 'track_performance'], + 'referral_source' => ReferralSource::Google->value, + ], + 'app.welcome.goals', + 'get', + ], + 'post only removed goals' => [ + [ + 'persona' => Persona::Agency->value, + 'goals' => ['team_collaboration', 'automate_api', 'track_performance'], + 'referral_source' => ReferralSource::Google->value, + ], + 'app.welcome.goals', + 'post', + ], ]); test('connect hides the network grid when the user has no workspace', function () { @@ -286,6 +304,20 @@ ); }); +test('connect renders the network grid when the workspace has no accounts', function () { + completeWelcomeThroughReferral($this->user); + attachCurrentWorkspace($this->user); + + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.connect')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('welcome/Connect', false) + ->has('platforms', count(SocialPlatform::connectableOptions())) + ->where('accounts', []) + ); +}); + test('connect renders connected accounts for the current workspace', function () { completeWelcomeThroughReferral($this->user); $workspace = attachCurrentWorkspace($this->user); @@ -344,6 +376,23 @@ ->assertSessionHasErrors('connect'); }); +test('connect store ignores social accounts on another workspace', function () { + completeWelcomeThroughReferral($this->user); + attachCurrentWorkspace($this->user); + + $otherWorkspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + SocialAccount::factory()->linkedin()->create(['workspace_id' => $otherWorkspace->id]); + + $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.connect.store')) + ->assertSessionHasErrors('connect'); +}); + test('connect store starts Stripe checkout when a social account is connected', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); @@ -418,6 +467,10 @@ $this->actingAs($this->user->fresh()) ->post(route('app.welcome.connect.store')); + Bus::assertNotDispatched( + SendEvent::class, + fn (SendEvent $event): bool => $event->method === 'identify', + ); Bus::assertNotDispatched( SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === WelcomeEvent::Connect->value, From fe2e967888aa40d46d3d7860e15f62a5dc3c8fc9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 18:45:28 -0300 Subject: [PATCH 11/19] Assume a welcome workspace, validate connect in the FormRequest, and add browser tests. Co-authored-by: Cursor --- app/Enums/PostHog/WelcomeEvent.php | 17 +++ .../Controllers/App/WelcomeController.php | 33 +---- .../Welcome/StoreWelcomeConnectRequest.php | 59 +++++++++ resources/js/layouts/WelcomeLayout.vue | 21 ++- resources/js/pages/welcome/Connect.vue | 25 ++-- resources/js/pages/welcome/ReferralSource.vue | 1 + tests/Browser/WelcomeConnectTest.php | 120 ++++++++++++++++++ .../Feature/Welcome/WelcomeControllerTest.php | 60 +++++++-- tests/Unit/Enums/WelcomeEventTest.php | 16 +++ 9 files changed, 296 insertions(+), 56 deletions(-) create mode 100644 app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php create mode 100644 tests/Browser/WelcomeConnectTest.php create mode 100644 tests/Unit/Enums/WelcomeEventTest.php diff --git a/app/Enums/PostHog/WelcomeEvent.php b/app/Enums/PostHog/WelcomeEvent.php index e9d474b84..bd893d9df 100644 --- a/app/Enums/PostHog/WelcomeEvent.php +++ b/app/Enums/PostHog/WelcomeEvent.php @@ -10,4 +10,21 @@ enum WelcomeEvent: string case Goals = 'welcome.goals'; case Referral = 'welcome.referral'; case Connect = 'welcome.connect'; + + /** + * Capture events in dashboard funnel order. Connect sits between + * Referral and checkout.started — do not jump those two steps. + * + * @return list + */ + public static function dashboardFunnel(): array + { + return [ + self::Persona->value, + self::Goals->value, + self::Referral->value, + self::Connect->value, + CheckoutEvent::Started->value, + ]; + } } diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index 0111315cb..e50a788c6 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -9,16 +9,15 @@ use App\Enums\PostHog\CheckoutEvent; use App\Enums\PostHog\WelcomeEvent; use App\Enums\SocialAccount\Platform as SocialPlatform; -use App\Enums\SocialAccount\Status; use App\Enums\User\Goal; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; +use App\Http\Requests\App\Welcome\StoreWelcomeConnectRequest; use App\Http\Requests\App\Welcome\StoreWelcomeGoalsRequest; use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest; use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest; use App\Http\Resources\App\SocialAccountResource; use App\Models\Plan; -use App\Models\SocialAccount; use App\Models\User; use App\Services\PostHogService; use Illuminate\Http\RedirectResponse; @@ -152,17 +151,15 @@ public function connect(Request $request): InertiaResponse|RedirectResponse $workspace = $request->user()->currentWorkspace; return Inertia::render('welcome/Connect', [ - 'platforms' => $workspace ? SocialPlatform::connectableOptions() : [], - 'accounts' => $workspace - ? SocialAccountResource::collection( - $workspace->socialAccounts()->orderBy('id')->get(), - )->resolve() - : [], + 'platforms' => SocialPlatform::connectableOptions(), + 'accounts' => SocialAccountResource::collection( + $workspace->socialAccounts()->orderBy('id')->get(), + )->resolve(), ]); } public function storeConnect( - Request $request, + StoreWelcomeConnectRequest $request, StartSubscriptionCheckout $checkout, PostHogService $postHog, ): Response|RedirectResponse { @@ -171,23 +168,7 @@ public function storeConnect( } $user = $request->user(); - - $platforms = $user->currentWorkspace - ? $user->currentWorkspace->socialAccounts() - ->where('status', Status::Connected) - ->orderBy('id') - ->get() - ->map(fn (SocialAccount $account): string => $account->platform->value) - ->unique() - ->values() - ->all() - : []; - - if ($platforms === []) { - return back()->withErrors([ - 'connect' => __('welcome.connect.required'), - ]); - } + $platforms = $request->connectedPlatforms(); $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); $priceId = $plan->stripe_monthly_price_id; diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php new file mode 100644 index 000000000..d3d435057 --- /dev/null +++ b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php @@ -0,0 +1,59 @@ +|null + */ + private ?array $connectedPlatforms = null; + + public function authorize(): bool + { + return true; + } + + /** + * @return array + */ + public function rules(): array + { + return []; + } + + /** + * @return list + */ + public function connectedPlatforms(): array + { + return $this->connectedPlatforms ??= $this->user()->currentWorkspace->socialAccounts() + ->where('status', Status::Connected) + ->orderBy('id') + ->get() + ->map(fn (SocialAccount $account): string => $account->platform->value) + ->unique() + ->values() + ->all(); + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($this->user()->currentWorkspace === null) { + return; + } + + if ($this->connectedPlatforms() === []) { + $validator->errors()->add('connect', __('welcome.connect.required')); + } + }); + } +} diff --git a/resources/js/layouts/WelcomeLayout.vue b/resources/js/layouts/WelcomeLayout.vue index a3c6af79f..24e50195b 100644 --- a/resources/js/layouts/WelcomeLayout.vue +++ b/resources/js/layouts/WelcomeLayout.vue @@ -95,6 +95,7 @@ const canNavigateTo = (stepNumber: number): boolean => }) " :data-testid="`welcome-step-${stepNumber}`" + :dusk="`welcome-step-${stepNumber}`" >
}) : undefined " - /> + > + +
diff --git a/resources/js/pages/welcome/Connect.vue b/resources/js/pages/welcome/Connect.vue index 602ae77c0..c40c6912f 100644 --- a/resources/js/pages/welcome/Connect.vue +++ b/resources/js/pages/welcome/Connect.vue @@ -49,19 +49,24 @@ const submit = (): void => { :connected-accounts="accounts" grid-class="grid-cols-2 sm:grid-cols-3 xl:grid-cols-6" data-testid="welcome-connect-grid" + dusk="welcome-connect-grid" />
- -
diff --git a/resources/js/pages/welcome/ReferralSource.vue b/resources/js/pages/welcome/ReferralSource.vue index e3bcdea13..278d7de7f 100644 --- a/resources/js/pages/welcome/ReferralSource.vue +++ b/resources/js/pages/welcome/ReferralSource.vue @@ -222,6 +222,7 @@ const submit = (): void => { class="w-full rounded-full" :disabled="form.referral_source === '' || form.processing" data-testid="welcome-referral-continue" + dusk="welcome-referral-continue" @click="submit" > {{ $t('welcome.continue') }} diff --git a/tests/Browser/WelcomeConnectTest.php b/tests/Browser/WelcomeConnectTest.php new file mode 100644 index 000000000..5a88b2674 --- /dev/null +++ b/tests/Browser/WelcomeConnectTest.php @@ -0,0 +1,120 @@ +script(<< { + const sel = '[data-testid="{$testId}"]'; + for (let i = 0; i < 100; i++) { + const el = document.querySelector(sel); + if (el && el.getBoundingClientRect().height > 0) return; + await new Promise((r) => setTimeout(r, 50)); + } + })(); + JS); +} + +function welcomeOwnerOnConnectStep(): User +{ + $user = User::factory()->create(); + $user->update([ + 'persona' => Persona::Agency->value, + 'goals' => [Goal::SaveTime->value], + 'referral_source' => ReferralSource::ProductHunt->value, + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + return $user->fresh(); +} + +test('connect step shows the grid and keeps continue disabled without a social account', function () { + config(['trypost.self_hosted' => false]); + + $user = welcomeOwnerOnConnectStep(); + + $this->actingAs($user); + + $page = visit(route('app.welcome.connect')); + + waitForWelcomeTestId($page, 'welcome-start-checkout'); + + $page->assertRoute('app.welcome.connect') + ->assertVisible('@welcome-connect-grid') + ->assertVisible('@welcome-start-checkout') + ->assertDisabled('@welcome-start-checkout') + ->assertVisible('@welcome-step-4') + ->assertNoJavaScriptErrors(); +}); + +test('connect step enables continue when a social account is connected', function () { + config(['trypost.self_hosted' => false]); + + $user = welcomeOwnerOnConnectStep(); + SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $user->current_workspace_id, + ]); + + $this->actingAs($user->fresh()); + + $page = visit(route('app.welcome.connect')); + + waitForWelcomeTestId($page, 'welcome-start-checkout'); + + $page->assertRoute('app.welcome.connect') + ->assertVisible('@welcome-connect-grid') + ->assertEnabled('@welcome-start-checkout') + ->assertNoJavaScriptErrors(); +}); + +test('connect step can go back to referral', function () { + config(['trypost.self_hosted' => false]); + + $user = welcomeOwnerOnConnectStep(); + + $this->actingAs($user); + + $page = visit(route('app.welcome.connect')); + + waitForWelcomeTestId($page, 'welcome-step-3'); + + $page->click('@welcome-step-3'); + + waitForWelcomeTestId($page, 'welcome-referral-continue'); + + $page->assertRoute('app.welcome.referral-source') + ->assertVisible('@welcome-referral-continue') + ->assertNoJavaScriptErrors(); +}); + +test('connect step redirects to persona when prior steps are missing', function () { + config(['trypost.self_hosted' => false]); + + $user = User::factory()->create(); + + $this->actingAs($user); + + $page = visit(route('app.welcome.connect')); + + $page->assertRoute('app.welcome.persona') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index a283a731c..9a6262cdd 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -118,6 +118,7 @@ }); test('completed welcome steps remain reachable when going back', function () { + attachCurrentWorkspace($this->user); $this->user->update([ 'persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], @@ -212,6 +213,51 @@ 'invalid' => [['referral_source' => 'not-a-source']], ]); +test('welcome funnel captures connect between referral and checkout.started', function () { + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); + Bus::fake(); + $workspace = attachCurrentWorkspace($this->user); + SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); + + Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ + 'stripe_monthly_price_id' => 'price_monthly_test', + ]); + + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->once() + ->andReturn(redirect('https://checkout.stripe.test/session')); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.persona.store'), ['persona' => Persona::Agency->value]) + ->assertRedirect(route('app.welcome.goals')); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.goals.store'), ['goals' => [Goal::SaveTime->value]]) + ->assertRedirect(route('app.welcome.referral-source')); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.referral-source.store'), [ + 'referral_source' => ReferralSource::ProductHunt->value, + ]) + ->assertRedirect(route('app.welcome.connect')); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.connect.store')) + ->assertRedirect('https://checkout.stripe.test/session'); + + $funnel = WelcomeEvent::dashboardFunnel(); + + $captured = collect(Bus::dispatched(SendEvent::class)) + ->filter(fn (SendEvent $event): bool => $event->method === 'capture') + ->map(fn (SendEvent $event): string => (string) data_get($event->payload, 'event')) + ->filter(fn (string $event): bool => in_array($event, $funnel, true)) + ->values() + ->all(); + + expect($captured)->toBe($funnel); +}); + test('referral source store saves the source mirrors it to PostHog and advances to connect', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); @@ -291,19 +337,6 @@ ], ]); -test('connect hides the network grid when the user has no workspace', function () { - completeWelcomeThroughReferral($this->user); - - $this->actingAs($this->user->fresh()) - ->get(route('app.welcome.connect')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->component('welcome/Connect', false) - ->where('platforms', []) - ->where('accounts', []) - ); -}); - test('connect renders the network grid when the workspace has no accounts', function () { completeWelcomeThroughReferral($this->user); attachCurrentWorkspace($this->user); @@ -346,6 +379,7 @@ config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); completeWelcomeThroughReferral($this->user); + attachCurrentWorkspace($this->user); $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); diff --git a/tests/Unit/Enums/WelcomeEventTest.php b/tests/Unit/Enums/WelcomeEventTest.php new file mode 100644 index 000000000..8d7c7638b --- /dev/null +++ b/tests/Unit/Enums/WelcomeEventTest.php @@ -0,0 +1,16 @@ +toBe([ + WelcomeEvent::Persona->value, + WelcomeEvent::Goals->value, + WelcomeEvent::Referral->value, + WelcomeEvent::Connect->value, + CheckoutEvent::Started->value, + ]); +}); From 60f905ce8281d4e3ac971ee27012f16c643f65e5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 18:48:51 -0300 Subject: [PATCH 12/19] Rename WelcomeEvent::dashboardFunnel() to funnel(). Co-authored-by: Cursor --- app/Enums/PostHog/WelcomeEvent.php | 5 ++--- tests/Feature/Welcome/WelcomeControllerTest.php | 2 +- tests/Unit/Enums/WelcomeEventTest.php | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/Enums/PostHog/WelcomeEvent.php b/app/Enums/PostHog/WelcomeEvent.php index bd893d9df..bab4a07ca 100644 --- a/app/Enums/PostHog/WelcomeEvent.php +++ b/app/Enums/PostHog/WelcomeEvent.php @@ -12,12 +12,11 @@ enum WelcomeEvent: string case Connect = 'welcome.connect'; /** - * Capture events in dashboard funnel order. Connect sits between - * Referral and checkout.started — do not jump those two steps. + * Welcome capture order through Stripe Checkout. * * @return list */ - public static function dashboardFunnel(): array + public static function funnel(): array { return [ self::Persona->value, diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index 9a6262cdd..331e239f7 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -246,7 +246,7 @@ ->post(route('app.welcome.connect.store')) ->assertRedirect('https://checkout.stripe.test/session'); - $funnel = WelcomeEvent::dashboardFunnel(); + $funnel = WelcomeEvent::funnel(); $captured = collect(Bus::dispatched(SendEvent::class)) ->filter(fn (SendEvent $event): bool => $event->method === 'capture') diff --git a/tests/Unit/Enums/WelcomeEventTest.php b/tests/Unit/Enums/WelcomeEventTest.php index 8d7c7638b..17ce3d9b5 100644 --- a/tests/Unit/Enums/WelcomeEventTest.php +++ b/tests/Unit/Enums/WelcomeEventTest.php @@ -5,8 +5,8 @@ use App\Enums\PostHog\CheckoutEvent; use App\Enums\PostHog\WelcomeEvent; -test('welcome dashboard funnel puts connect between referral and checkout.started', function () { - expect(WelcomeEvent::dashboardFunnel())->toBe([ +test('welcome funnel puts connect between referral and checkout.started', function () { + expect(WelcomeEvent::funnel())->toBe([ WelcomeEvent::Persona->value, WelcomeEvent::Goals->value, WelcomeEvent::Referral->value, From 84a007cc316c6be5c1b9b29c2409dac43a43586d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 18:54:05 -0300 Subject: [PATCH 13/19] Identify connected platforms from the social account observer. Co-authored-by: Cursor --- .../Controllers/App/WelcomeController.php | 3 - app/Observers/SocialAccountObserver.php | 32 ++++++++++ .../Observers/SocialAccountObserverTest.php | 62 +++++++++++++++++++ .../Feature/Welcome/WelcomeControllerTest.php | 7 --- 4 files changed, 94 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index e50a788c6..936ebd911 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -181,9 +181,6 @@ public function storeConnect( route('app.welcome.connect'), ); - $postHog->identify($user->id, [ - 'connected_platforms' => $platforms, - ]); $postHog->capture( $user->id, WelcomeEvent::Connect->value, diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index 1a8f95fc2..4d5b4a06e 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -14,6 +14,8 @@ class SocialAccountObserver { + public function __construct(private readonly PostHogService $postHog) {} + /** * Enforce one connected account per social network per workspace. Variants * of the same network (LinkedIn profile/page, Instagram standalone/Facebook) @@ -57,6 +59,7 @@ public function updated(SocialAccount $socialAccount): void $isConnected = $socialAccount->status === Status::Connected; if ($wasConnected !== $isConnected) { + $this->identifyConnectedPlatforms($socialAccount); $this->notifyOnboarding($socialAccount); } } @@ -64,12 +67,41 @@ public function updated(SocialAccount $socialAccount): void private function syncUsageAndOnboarding(SocialAccount $socialAccount): void { $this->syncUsage($socialAccount); + $this->identifyConnectedPlatforms($socialAccount); if ($socialAccount->status === Status::Connected) { $this->notifyOnboarding($socialAccount); } } + private function identifyConnectedPlatforms(SocialAccount $socialAccount): void + { + if (! PostHogService::isEnabled()) { + return; + } + + $socialAccount->loadMissing('workspace.account.owner'); + + $owner = $socialAccount->workspace?->account?->owner; + + if ($owner === null) { + return; + } + + $platforms = $socialAccount->workspace->socialAccounts() + ->where('status', Status::Connected) + ->orderBy('id') + ->get() + ->map(fn (SocialAccount $account): string => $account->platform->value) + ->unique() + ->values() + ->all(); + + $this->postHog->identify($owner->id, [ + 'connected_platforms' => $platforms, + ]); + } + /** * First usable connect / last disconnect for the account. * Actor-less → syncAndNotify falls back to the account owner. diff --git a/tests/Feature/Observers/SocialAccountObserverTest.php b/tests/Feature/Observers/SocialAccountObserverTest.php index a2fad5485..2bd429b58 100644 --- a/tests/Feature/Observers/SocialAccountObserverTest.php +++ b/tests/Feature/Observers/SocialAccountObserverTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; +use App\Jobs\PostHog\SendEvent; use App\Jobs\PostHog\SyncAccountUsage; use App\Models\Account; use App\Models\SocialAccount; @@ -22,6 +24,46 @@ ]); }); +test('creating a social account identifies the owner with connected platforms', function () { + Bus::fake(); + + SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); + + Bus::assertDispatched(SendEvent::class, function (SendEvent $event): bool { + return $event->method === 'identify' + && data_get($event->payload, 'distinctId') === $this->user->id + && data_get($event->payload, 'properties.connected_platforms') === [Platform::LinkedIn->value]; + }); +}); + +test('deleting a social account identifies the owner without that platform', function () { + $socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); + + Bus::fake(); + + $socialAccount->delete(); + + Bus::assertDispatched(SendEvent::class, function (SendEvent $event): bool { + return $event->method === 'identify' + && data_get($event->payload, 'distinctId') === $this->user->id + && data_get($event->payload, 'properties.connected_platforms') === []; + }); +}); + +test('disconnecting a social account identifies the owner without that platform', function () { + $socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); + + Bus::fake(); + + $socialAccount->update(['status' => Status::Disconnected]); + + Bus::assertDispatched(SendEvent::class, function (SendEvent $event): bool { + return $event->method === 'identify' + && data_get($event->payload, 'distinctId') === $this->user->id + && data_get($event->payload, 'properties.connected_platforms') === []; + }); +}); + test('creating a social account dispatches SyncAccountUsage', function () { Bus::fake(); @@ -54,6 +96,7 @@ $socialAccount->update(['is_active' => false]); Bus::assertNotDispatched(SyncAccountUsage::class); + Bus::assertNotDispatched(SendEvent::class); }); test('does not dispatch when PostHog is disabled', function () { @@ -64,6 +107,25 @@ SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); Bus::assertNotDispatched(SyncAccountUsage::class); + Bus::assertNotDispatched(SendEvent::class); +}); + +test('does not identify connected platforms when self-hosted without PostHog', function () { + config([ + 'trypost.self_hosted' => true, + 'services.posthog.enabled' => false, + 'services.posthog.api_key' => null, + ]); + + Bus::fake(); + + $socialAccount = SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->assertModelExists($socialAccount); + Bus::assertNotDispatched(SyncAccountUsage::class); + Bus::assertNotDispatched(SendEvent::class); }); test('updating status on multiple batch-hydrated social accounts does not throw a lazy loading violation', function () { diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index 331e239f7..d9af663a7 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -450,9 +450,6 @@ ->post(route('app.welcome.connect.store')) ->assertRedirect('https://checkout.stripe.test/session'); - Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'identify' - && data_get($event->payload, 'distinctId') === $this->user->id - && data_get($event->payload, 'properties.connected_platforms') === [SocialPlatform::LinkedIn->value]); Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' && data_get($event->payload, 'event') === WelcomeEvent::Connect->value && data_get($event->payload, 'properties.platforms') === [SocialPlatform::LinkedIn->value]); @@ -501,10 +498,6 @@ $this->actingAs($this->user->fresh()) ->post(route('app.welcome.connect.store')); - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => $event->method === 'identify', - ); Bus::assertNotDispatched( SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === WelcomeEvent::Connect->value, From 5427eb1f5fbbf5b7d3a3b1ed98fc578577512814 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 19:04:49 -0300 Subject: [PATCH 14/19] Queue connected-platform identify on the posthog queue. Co-authored-by: Cursor --- .../PostHog/IdentifyConnectedPlatforms.php | 59 +++++++++++++ app/Observers/SocialAccountObserver.php | 24 +----- .../IdentifyConnectedPlatformsTest.php | 83 +++++++++++++++++++ .../Observers/SocialAccountObserverTest.php | 29 +++---- 4 files changed, 157 insertions(+), 38 deletions(-) create mode 100644 app/Jobs/PostHog/IdentifyConnectedPlatforms.php create mode 100644 tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php diff --git a/app/Jobs/PostHog/IdentifyConnectedPlatforms.php b/app/Jobs/PostHog/IdentifyConnectedPlatforms.php new file mode 100644 index 000000000..fcb4ecded --- /dev/null +++ b/app/Jobs/PostHog/IdentifyConnectedPlatforms.php @@ -0,0 +1,59 @@ +onQueue('posthog'); + } + + public function handle(PostHogService $postHog): void + { + if (! PostHogService::isEnabled()) { + return; + } + + $workspace = Workspace::query() + ->with('account.owner') + ->find($this->workspaceId); + + $owner = $workspace?->account?->owner; + + if ($owner === null) { + return; + } + + $platforms = $workspace->socialAccounts() + ->where('status', Status::Connected) + ->orderBy('id') + ->get() + ->map(fn (SocialAccount $account): string => $account->platform->value) + ->unique() + ->values() + ->all(); + + $postHog->identify($owner->id, [ + 'connected_platforms' => $platforms, + ]); + } +} diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index 4d5b4a06e..13e5c9a6e 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -8,14 +8,13 @@ use App\Enums\SocialAccount\Status; use App\Events\OnboardingStatusUpdated; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; +use App\Jobs\PostHog\IdentifyConnectedPlatforms; use App\Jobs\PostHog\SyncAccountUsage; use App\Models\SocialAccount; use App\Services\PostHogService; class SocialAccountObserver { - public function __construct(private readonly PostHogService $postHog) {} - /** * Enforce one connected account per social network per workspace. Variants * of the same network (LinkedIn profile/page, Instagram standalone/Facebook) @@ -80,26 +79,7 @@ private function identifyConnectedPlatforms(SocialAccount $socialAccount): void return; } - $socialAccount->loadMissing('workspace.account.owner'); - - $owner = $socialAccount->workspace?->account?->owner; - - if ($owner === null) { - return; - } - - $platforms = $socialAccount->workspace->socialAccounts() - ->where('status', Status::Connected) - ->orderBy('id') - ->get() - ->map(fn (SocialAccount $account): string => $account->platform->value) - ->unique() - ->values() - ->all(); - - $this->postHog->identify($owner->id, [ - 'connected_platforms' => $platforms, - ]); + IdentifyConnectedPlatforms::dispatch((string) $socialAccount->workspace_id); } /** diff --git a/tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php b/tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php new file mode 100644 index 000000000..5201606fd --- /dev/null +++ b/tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php @@ -0,0 +1,83 @@ + true, 'services.posthog.api_key' => 'phc_test_key']); + + $this->account = Account::factory()->create(); + $this->user = User::factory()->create(['account_id' => $this->account->id]); + $this->account->update(['owner_id' => $this->user->id]); + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->user->id, + ]); +}); + +test('job is queued on the posthog queue', function () { + $job = new IdentifyConnectedPlatforms((string) Str::uuid()); + + expect($job->queue)->toBe('posthog'); +}); + +test('handle identifies the owner with connected platforms', function () { + Queue::fake(); + + SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); + + (new IdentifyConnectedPlatforms((string) $this->workspace->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { + return $job->method === 'identify' + && data_get($job->payload, 'distinctId') === $this->user->id + && data_get($job->payload, 'properties.connected_platforms') === [Platform::LinkedIn->value]; + }); +}); + +test('handle identifies the owner without disconnected platforms', function () { + Queue::fake(); + + SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => Status::Disconnected, + ]); + + (new IdentifyConnectedPlatforms((string) $this->workspace->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { + return $job->method === 'identify' + && data_get($job->payload, 'distinctId') === $this->user->id + && data_get($job->payload, 'properties.connected_platforms') === []; + }); +}); + +test('handle returns silently when the workspace does not exist', function () { + Queue::fake(); + + (new IdentifyConnectedPlatforms((string) Str::uuid()))->handle(app(PostHogService::class)); + + Queue::assertNothingPushed(); +}); + +test('handle does not push when PostHog is disabled', function () { + config(['services.posthog.api_key' => null]); + Queue::fake(); + + SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); + + (new IdentifyConnectedPlatforms((string) $this->workspace->id))->handle(app(PostHogService::class)); + + Queue::assertNotPushed(SendEvent::class); +}); diff --git a/tests/Feature/Observers/SocialAccountObserverTest.php b/tests/Feature/Observers/SocialAccountObserverTest.php index 2bd429b58..bb166d6db 100644 --- a/tests/Feature/Observers/SocialAccountObserverTest.php +++ b/tests/Feature/Observers/SocialAccountObserverTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; +use App\Jobs\PostHog\IdentifyConnectedPlatforms; use App\Jobs\PostHog\SendEvent; use App\Jobs\PostHog\SyncAccountUsage; use App\Models\Account; @@ -24,43 +24,37 @@ ]); }); -test('creating a social account identifies the owner with connected platforms', function () { +test('creating a social account dispatches IdentifyConnectedPlatforms', function () { Bus::fake(); SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); - Bus::assertDispatched(SendEvent::class, function (SendEvent $event): bool { - return $event->method === 'identify' - && data_get($event->payload, 'distinctId') === $this->user->id - && data_get($event->payload, 'properties.connected_platforms') === [Platform::LinkedIn->value]; + Bus::assertDispatched(IdentifyConnectedPlatforms::class, function (IdentifyConnectedPlatforms $job): bool { + return $job->workspaceId === (string) $this->workspace->id; }); }); -test('deleting a social account identifies the owner without that platform', function () { +test('deleting a social account dispatches IdentifyConnectedPlatforms', function () { $socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); Bus::fake(); $socialAccount->delete(); - Bus::assertDispatched(SendEvent::class, function (SendEvent $event): bool { - return $event->method === 'identify' - && data_get($event->payload, 'distinctId') === $this->user->id - && data_get($event->payload, 'properties.connected_platforms') === []; + Bus::assertDispatched(IdentifyConnectedPlatforms::class, function (IdentifyConnectedPlatforms $job): bool { + return $job->workspaceId === (string) $this->workspace->id; }); }); -test('disconnecting a social account identifies the owner without that platform', function () { +test('disconnecting a social account dispatches IdentifyConnectedPlatforms', function () { $socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); Bus::fake(); $socialAccount->update(['status' => Status::Disconnected]); - Bus::assertDispatched(SendEvent::class, function (SendEvent $event): bool { - return $event->method === 'identify' - && data_get($event->payload, 'distinctId') === $this->user->id - && data_get($event->payload, 'properties.connected_platforms') === []; + Bus::assertDispatched(IdentifyConnectedPlatforms::class, function (IdentifyConnectedPlatforms $job): bool { + return $job->workspaceId === (string) $this->workspace->id; }); }); @@ -96,6 +90,7 @@ $socialAccount->update(['is_active' => false]); Bus::assertNotDispatched(SyncAccountUsage::class); + Bus::assertNotDispatched(IdentifyConnectedPlatforms::class); Bus::assertNotDispatched(SendEvent::class); }); @@ -107,6 +102,7 @@ SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); Bus::assertNotDispatched(SyncAccountUsage::class); + Bus::assertNotDispatched(IdentifyConnectedPlatforms::class); Bus::assertNotDispatched(SendEvent::class); }); @@ -125,6 +121,7 @@ $this->assertModelExists($socialAccount); Bus::assertNotDispatched(SyncAccountUsage::class); + Bus::assertNotDispatched(IdentifyConnectedPlatforms::class); Bus::assertNotDispatched(SendEvent::class); }); From 13670400bdca37022d12d4d98ca342cabc5845d0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 19:11:31 -0300 Subject: [PATCH 15/19] Harden welcome connect: 404 without a workspace, and keep step redirects ahead of connect validation. Co-authored-by: Cursor --- .../Controllers/App/WelcomeController.php | 4 +++ .../Welcome/StoreWelcomeConnectRequest.php | 27 ++++++++++++++++++- .../Feature/Welcome/WelcomeControllerTest.php | 24 ++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index 936ebd911..bee49d336 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -150,6 +150,8 @@ public function connect(Request $request): InertiaResponse|RedirectResponse $workspace = $request->user()->currentWorkspace; + abort_unless($workspace !== null, Response::HTTP_NOT_FOUND); + return Inertia::render('welcome/Connect', [ 'platforms' => SocialPlatform::connectableOptions(), 'accounts' => SocialAccountResource::collection( @@ -167,6 +169,8 @@ public function storeConnect( return $redirect; } + abort_unless($request->user()->currentWorkspace !== null, Response::HTTP_NOT_FOUND); + $user = $request->user(); $platforms = $request->connectedPlatforms(); diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php index d3d435057..7fa6b7da0 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php +++ b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php @@ -5,7 +5,9 @@ namespace App\Http\Requests\App\Welcome; use App\Enums\SocialAccount\Status; +use App\Enums\User\Goal; use App\Models\SocialAccount; +use App\Models\User; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; @@ -47,7 +49,13 @@ public function connectedPlatforms(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - if ($this->user()->currentWorkspace === null) { + $user = $this->user(); + + if ($user->currentWorkspace === null) { + return; + } + + if (! $user->persona || ! $this->hasCurrentGoals($user) || ! $user->referral_source) { return; } @@ -56,4 +64,21 @@ public function withValidator(Validator $validator): void } }); } + + /** + * Mirrors WelcomeController::hasCurrentGoals — dropped enum values + * must not count as a completed goals step. + */ + private function hasCurrentGoals(User $user): bool + { + $goals = $user->goals; + + if (! is_array($goals) || $goals === []) { + return false; + } + + $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); + + return array_intersect($goals, $allowed) !== []; + } } diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index d9af663a7..26e558022 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -284,9 +284,13 @@ ); }); -test('connect redirects through incomplete prior steps', function (array $attributes, string $routeName, string $method) { +test('connect redirects through incomplete prior steps', function (array $attributes, string $routeName, string $method, bool $withWorkspace) { $this->user->update($attributes); + if ($withWorkspace) { + attachCurrentWorkspace($this->user); + } + $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); $this->actingAs($this->user->fresh()); @@ -335,8 +339,26 @@ 'app.welcome.goals', 'post', ], +])->with([ + 'without workspace' => [false], + 'with empty workspace' => [true], ]); +test('connect returns 404 when prior steps are complete but the user has no workspace', function () { + completeWelcomeThroughReferral($this->user); + + $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); + + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.connect')) + ->assertNotFound(); + + $this->actingAs($this->user->fresh()) + ->from(route('app.welcome.connect')) + ->post(route('app.welcome.connect.store')) + ->assertNotFound(); +}); + test('connect renders the network grid when the workspace has no accounts', function () { completeWelcomeThroughReferral($this->user); attachCurrentWorkspace($this->user); From 155889b461ec65b2ada4e0ec55589de9e52d211f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 19:18:20 -0300 Subject: [PATCH 16/19] Identify connected platforms on workspace and account groups, and keep the account union on the owner. Co-authored-by: Cursor --- .../PostHog/IdentifyConnectedPlatforms.php | 40 ++++++++++-- .../IdentifyConnectedPlatformsTest.php | 62 ++++++++++++++++++- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/app/Jobs/PostHog/IdentifyConnectedPlatforms.php b/app/Jobs/PostHog/IdentifyConnectedPlatforms.php index fcb4ecded..d5200fc09 100644 --- a/app/Jobs/PostHog/IdentifyConnectedPlatforms.php +++ b/app/Jobs/PostHog/IdentifyConnectedPlatforms.php @@ -10,6 +10,7 @@ use App\Services\PostHogService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; @@ -37,13 +38,44 @@ public function handle(PostHogService $postHog): void ->with('account.owner') ->find($this->workspaceId); - $owner = $workspace?->account?->owner; + $account = $workspace?->account; + + if ($account === null) { + return; + } + + $workspacePlatforms = $this->connectedPlatformSlugs( + SocialAccount::query()->where('workspace_id', $workspace->id), + ); + $accountPlatforms = $this->connectedPlatformSlugs( + SocialAccount::query()->whereIn('workspace_id', $account->workspaces()->select('id')), + ); + + $postHog->groupIdentify('workspace', (string) $workspace->id, [ + 'connected_platforms' => $workspacePlatforms, + ]); + $postHog->groupIdentify('account', (string) $account->id, [ + 'connected_platforms' => $accountPlatforms, + ]); + + $owner = $account->owner; if ($owner === null) { return; } - $platforms = $workspace->socialAccounts() + $postHog->identify($owner->id, [ + 'connected_platforms' => $accountPlatforms, + ]); + } + + /** + * @param Builder $query + * @return list + */ + private function connectedPlatformSlugs(Builder $query): array + { + return $query ->where('status', Status::Connected) ->orderBy('id') ->get() @@ -51,9 +83,5 @@ public function handle(PostHogService $postHog): void ->unique() ->values() ->all(); - - $postHog->identify($owner->id, [ - 'connected_platforms' => $platforms, - ]); } } diff --git a/tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php b/tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php index 5201606fd..d901ba1df 100644 --- a/tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php +++ b/tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php @@ -32,13 +32,25 @@ expect($job->queue)->toBe('posthog'); }); -test('handle identifies the owner with connected platforms', function () { +test('handle identifies the owner and groups with connected platforms', function () { Queue::fake(); SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); (new IdentifyConnectedPlatforms((string) $this->workspace->id))->handle(app(PostHogService::class)); + Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { + return $job->method === 'groupIdentify' + && data_get($job->payload, 'groupType') === 'workspace' + && data_get($job->payload, 'groupKey') === (string) $this->workspace->id + && data_get($job->payload, 'properties.connected_platforms') === [Platform::LinkedIn->value]; + }); + Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { + return $job->method === 'groupIdentify' + && data_get($job->payload, 'groupType') === 'account' + && data_get($job->payload, 'groupKey') === (string) $this->account->id + && data_get($job->payload, 'properties.connected_platforms') === [Platform::LinkedIn->value]; + }); Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { return $job->method === 'identify' && data_get($job->payload, 'distinctId') === $this->user->id @@ -46,6 +58,44 @@ }); }); +test('handle keeps the account union when another workspace connects', function () { + Queue::fake(); + + SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); + + $otherWorkspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->user->id, + ]); + SocialAccount::factory()->x()->create(['workspace_id' => $otherWorkspace->id]); + + (new IdentifyConnectedPlatforms((string) $otherWorkspace->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function (SendEvent $job) use ($otherWorkspace): bool { + return $job->method === 'groupIdentify' + && data_get($job->payload, 'groupType') === 'workspace' + && data_get($job->payload, 'groupKey') === (string) $otherWorkspace->id + && data_get($job->payload, 'properties.connected_platforms') === [Platform::X->value]; + }); + Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { + return $job->method === 'groupIdentify' + && data_get($job->payload, 'groupType') === 'account' + && data_get($job->payload, 'groupKey') === (string) $this->account->id + && data_get($job->payload, 'properties.connected_platforms') === [ + Platform::LinkedIn->value, + Platform::X->value, + ]; + }); + Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { + return $job->method === 'identify' + && data_get($job->payload, 'distinctId') === $this->user->id + && data_get($job->payload, 'properties.connected_platforms') === [ + Platform::LinkedIn->value, + Platform::X->value, + ]; + }); +}); + test('handle identifies the owner without disconnected platforms', function () { Queue::fake(); @@ -61,6 +111,16 @@ && data_get($job->payload, 'distinctId') === $this->user->id && data_get($job->payload, 'properties.connected_platforms') === []; }); + Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { + return $job->method === 'groupIdentify' + && data_get($job->payload, 'groupType') === 'workspace' + && data_get($job->payload, 'properties.connected_platforms') === []; + }); + Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool { + return $job->method === 'groupIdentify' + && data_get($job->payload, 'groupType') === 'account' + && data_get($job->payload, 'properties.connected_platforms') === []; + }); }); test('handle returns silently when the workspace does not exist', function () { From ea58c23b58d3b74af6190b985f85f58e62598475 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 19:23:16 -0300 Subject: [PATCH 17/19] Share hasCurrentGoals on User and keep Stripe checkout when PostHog capture fails. Co-authored-by: Cursor --- .../Controllers/App/WelcomeController.php | 50 +++++------- .../Welcome/StoreWelcomeConnectRequest.php | 21 +---- app/Models/User.php | 19 +++++ app/Services/PostHogService.php | 81 ++++++++++++------- .../Feature/Welcome/WelcomeControllerTest.php | 28 +++++++ tests/Unit/Models/UserTest.php | 11 +++ 6 files changed, 127 insertions(+), 83 deletions(-) diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index bee49d336..deff3ae21 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -18,13 +18,13 @@ use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest; use App\Http\Resources\App\SocialAccountResource; use App\Models\Plan; -use App\Models\User; use App\Services\PostHogService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response as InertiaResponse; use Symfony\Component\HttpFoundation\Response; +use Throwable; class WelcomeController extends Controller { @@ -185,18 +185,22 @@ public function storeConnect( route('app.welcome.connect'), ); - $postHog->capture( - $user->id, - WelcomeEvent::Connect->value, - ['platforms' => $platforms], - $user->account, - ); - $postHog->capture( - $user->id, - CheckoutEvent::Started->value, - ['plan_name' => $plan->name, 'interval' => 'monthly'], - $user->account, - ); + try { + $postHog->capture( + $user->id, + WelcomeEvent::Connect->value, + ['platforms' => $platforms], + $user->account, + ); + $postHog->capture( + $user->id, + CheckoutEvent::Started->value, + ['plan_name' => $plan->name, 'interval' => 'monthly'], + $user->account, + ); + } catch (Throwable $e) { + report($e); + } return $response; } @@ -233,7 +237,7 @@ private function redirectIfStepIncomplete( return redirect()->route('app.welcome.persona'); } - if ($requireGoals && ! $this->hasCurrentGoals($user)) { + if ($requireGoals && ! $user->hasCurrentGoals()) { return redirect()->route('app.welcome.goals'); } @@ -244,24 +248,6 @@ private function redirectIfStepIncomplete( return null; } - /** - * True when the user has at least one goal that still exists in Goal. - * Dropped enum values must not satisfy the gate or users mid-funnel can - * skip re-selecting after we slim the list. - */ - private function hasCurrentGoals(User $user): bool - { - $goals = $user->goals; - - if (! is_array($goals) || $goals === []) { - return false; - } - - $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); - - return array_intersect($goals, $allowed) !== []; - } - private function redirectIfUnavailable(Request $request): ?RedirectResponse { $user = $request->user(); diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php index 7fa6b7da0..1195e5f16 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php +++ b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php @@ -5,9 +5,7 @@ namespace App\Http\Requests\App\Welcome; use App\Enums\SocialAccount\Status; -use App\Enums\User\Goal; use App\Models\SocialAccount; -use App\Models\User; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; @@ -55,7 +53,7 @@ public function withValidator(Validator $validator): void return; } - if (! $user->persona || ! $this->hasCurrentGoals($user) || ! $user->referral_source) { + if (! $user->persona || ! $user->hasCurrentGoals() || ! $user->referral_source) { return; } @@ -64,21 +62,4 @@ public function withValidator(Validator $validator): void } }); } - - /** - * Mirrors WelcomeController::hasCurrentGoals — dropped enum values - * must not count as a completed goals step. - */ - private function hasCurrentGoals(User $user): bool - { - $goals = $user->goals; - - if (! is_array($goals) || $goals === []) { - return false; - } - - $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); - - return array_intersect($goals, $allowed) !== []; - } } diff --git a/app/Models/User.php b/app/Models/User.php index 7557cb44c..d79f0671b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,7 @@ use App\Enums\Auth\SocialAuthProvider; use App\Enums\Notification\Type as NotificationType; +use App\Enums\User\Goal; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; use App\Models\Traits\HasAccount; @@ -133,4 +134,22 @@ public function isConnectedTo(SocialAuthProvider $provider): bool { return (bool) $this->{"{$provider->value}_id"}; } + + /** + * True when the user has at least one goal that still exists in Goal. + * Dropped enum values must not satisfy the welcome gate or users + * mid-funnel can skip re-selecting after we slim the list. + */ + public function hasCurrentGoals(): bool + { + $goals = $this->goals; + + if (! is_array($goals) || $goals === []) { + return false; + } + + $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); + + return array_intersect($goals, $allowed) !== []; + } } diff --git a/app/Services/PostHogService.php b/app/Services/PostHogService.php index 86ced557d..1f6753df8 100644 --- a/app/Services/PostHogService.php +++ b/app/Services/PostHogService.php @@ -33,22 +33,29 @@ public static function shouldTrack(): bool */ public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void { - $payload = [ - 'distinctId' => $distinctId, - 'event' => $event, - 'properties' => $properties, - ]; - - if ($account) { - $payload['properties']['$groups'] = ['account' => (string) $account->id]; - $payload['properties']['account_id'] = (string) $account->id; - $payload['properties']['plan'] = $account->plan?->name; - } - - $this->logLocally('capture', $payload); - - if (self::isEnabled()) { - $this->dispatch('capture', $payload); + try { + $payload = [ + 'distinctId' => $distinctId, + 'event' => $event, + 'properties' => $properties, + ]; + + if ($account) { + $payload['properties']['$groups'] = ['account' => (string) $account->id]; + $payload['properties']['account_id'] = (string) $account->id; + $payload['properties']['plan'] = $account->plan?->name; + } + + $this->logLocally('capture', $payload); + + if (self::isEnabled()) { + $this->dispatch('capture', $payload); + } + } catch (Throwable $e) { + Log::warning('PostHogService: failed to capture event', [ + 'event' => $event, + 'error' => $e->getMessage(), + ]); } } @@ -57,15 +64,21 @@ public function capture(string $distinctId, string $event, array $properties = [ */ public function identify(string $distinctId, array $properties = []): void { - $payload = [ - 'distinctId' => $distinctId, - 'properties' => $properties, - ]; + try { + $payload = [ + 'distinctId' => $distinctId, + 'properties' => $properties, + ]; - $this->logLocally('identify', $payload); + $this->logLocally('identify', $payload); - if (self::isEnabled()) { - $this->dispatch('identify', $payload); + if (self::isEnabled()) { + $this->dispatch('identify', $payload); + } + } catch (Throwable $e) { + Log::warning('PostHogService: failed to identify', [ + 'error' => $e->getMessage(), + ]); } } @@ -74,16 +87,22 @@ public function identify(string $distinctId, array $properties = []): void */ public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void { - $payload = [ - 'groupType' => $groupType, - 'groupKey' => $groupKey, - 'properties' => $properties, - ]; + try { + $payload = [ + 'groupType' => $groupType, + 'groupKey' => $groupKey, + 'properties' => $properties, + ]; - $this->logLocally('groupIdentify', $payload); + $this->logLocally('groupIdentify', $payload); - if (self::isEnabled()) { - $this->dispatch('groupIdentify', $payload); + if (self::isEnabled()) { + $this->dispatch('groupIdentify', $payload); + } + } catch (Throwable $e) { + Log::warning('PostHogService: failed to group identify', [ + 'error' => $e->getMessage(), + ]); } } diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index 26e558022..8fadc6b5a 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -19,7 +19,9 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\PostHogService; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Route; beforeEach(function () { @@ -530,6 +532,32 @@ ); }); +test('connect store still redirects to stripe when posthog capture fails', function () { + Exceptions::fake(); + completeWelcomeThroughReferral($this->user); + $workspace = attachCurrentWorkspace($this->user); + SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); + + Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ + 'stripe_monthly_price_id' => 'price_monthly_test', + ]); + + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->once() + ->andReturn(redirect('https://checkout.stripe.test/session')); + + $this->mock(PostHogService::class) + ->shouldReceive('capture') + ->andThrow(new RuntimeException('PostHog is down.')); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.connect.store')) + ->assertRedirect('https://checkout.stripe.test/session'); + + Exceptions::assertReported(RuntimeException::class); +}); + test('welcome steps redirect to calendar for subscribed accounts', function (string $routeName, string $method, array $payload = []) { subscribeAccount($this->user->account); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index e3b49088e..001d00983 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\Auth\SocialAuthProvider; +use App\Enums\User\Goal; use App\Models\User; test('isConnectedTo reflects whether the provider id column is set', function () { @@ -21,3 +22,13 @@ ['', ''], [' ', ''], ]); + +test('hasCurrentGoals is true only when at least one stored goal still exists', function (?array $goals, bool $expected) { + expect(User::factory()->make(['goals' => $goals])->hasCurrentGoals())->toBe($expected); +})->with([ + 'null' => [null, false], + 'empty' => [[], false], + 'current' => [[Goal::SaveTime->value], true], + 'removed only' => [['team_collaboration', 'automate_api'], false], + 'mixed' => [['team_collaboration', Goal::SaveTime->value], true], +]); From c5c35209e2dee2d9ca3a791fec0420b041c1318f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 19:28:35 -0300 Subject: [PATCH 18/19] Skip welcome connect validation when the controller would redirect the user away. Co-authored-by: Cursor --- .../Welcome/StoreWelcomeConnectRequest.php | 4 +++ .../Feature/Welcome/WelcomeControllerTest.php | 27 ++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php index 1195e5f16..2389bb21e 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php +++ b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php @@ -53,6 +53,10 @@ public function withValidator(Validator $validator): void return; } + if ($user->account?->hasAppAccess() || ! $user->isAccountOwner()) { + return; + } + if (! $user->persona || ! $user->hasCurrentGoals() || ! $user->referral_source) { return; } diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index 8fadc6b5a..61fa27ae8 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -628,13 +628,13 @@ 'checkout' => 'app.onboarding.checkout', ]); -test('members cannot start Stripe checkout from welcome', function () { +test('members cannot start Stripe checkout from welcome', function (bool $withWorkspace) { $member = User::factory()->create(['account_id' => $this->user->account_id]); - $member->update([ - 'persona' => Persona::Agency->value, - 'goals' => [Goal::SaveTime->value], - 'referral_source' => ReferralSource::Google->value, - ]); + completeWelcomeThroughReferral($member); + + if ($withWorkspace) { + attachCurrentWorkspace($member); + } $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); @@ -645,6 +645,21 @@ $this->actingAs($member->fresh()) ->post(route('app.welcome.connect.store')) ->assertRedirect(route('app.welcome.subscription-required')); +})->with([ + 'without workspace' => [false], + 'with empty workspace' => [true], +]); + +test('subscribed owners skip connect validation and go to calendar', function () { + subscribeAccount($this->user->account); + completeWelcomeThroughReferral($this->user); + attachCurrentWorkspace($this->user); + + $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.connect.store')) + ->assertRedirect(route('app.calendar')); }); test('members without app access are held on the subscription required screen', function (string $routeName, string $method, array $payload = []) { From f72664ccc626576731968070c7d774fcb0b348fa Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 19:34:10 -0300 Subject: [PATCH 19/19] Move current-goal membership onto the Goal enum. Co-authored-by: Cursor --- app/Enums/User/Goal.php | 18 ++++++++++++++++++ .../Controllers/App/WelcomeController.php | 2 +- .../Welcome/StoreWelcomeConnectRequest.php | 3 ++- app/Models/User.php | 19 ------------------- tests/Unit/Enums/GoalTest.php | 15 +++++++++++++++ tests/Unit/Models/UserTest.php | 11 ----------- 6 files changed, 36 insertions(+), 32 deletions(-) create mode 100644 tests/Unit/Enums/GoalTest.php diff --git a/app/Enums/User/Goal.php b/app/Enums/User/Goal.php index a2b600b23..93205f8fe 100644 --- a/app/Enums/User/Goal.php +++ b/app/Enums/User/Goal.php @@ -16,4 +16,22 @@ enum Goal: string case ManageClients = 'manage_clients'; case JustExploring = 'just_exploring'; case Other = 'other'; + + /** + * True when at least one stored goal still exists as a Goal case. + * Dropped values must not count — users mid-funnel would otherwise + * skip re-selecting after we slim the list. + * + * @param list|null $goals + */ + public static function containsCurrent(?array $goals): bool + { + if (! is_array($goals) || $goals === []) { + return false; + } + + $allowed = array_map(fn (self $goal): string => $goal->value, self::cases()); + + return array_intersect($goals, $allowed) !== []; + } } diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index deff3ae21..d4267c7a8 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -237,7 +237,7 @@ private function redirectIfStepIncomplete( return redirect()->route('app.welcome.persona'); } - if ($requireGoals && ! $user->hasCurrentGoals()) { + if ($requireGoals && ! Goal::containsCurrent($user->goals)) { return redirect()->route('app.welcome.goals'); } diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php index 2389bb21e..5cde55a62 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php +++ b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php @@ -5,6 +5,7 @@ namespace App\Http\Requests\App\Welcome; use App\Enums\SocialAccount\Status; +use App\Enums\User\Goal; use App\Models\SocialAccount; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; @@ -57,7 +58,7 @@ public function withValidator(Validator $validator): void return; } - if (! $user->persona || ! $user->hasCurrentGoals() || ! $user->referral_source) { + if (! $user->persona || ! Goal::containsCurrent($user->goals) || ! $user->referral_source) { return; } diff --git a/app/Models/User.php b/app/Models/User.php index d79f0671b..7557cb44c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,7 +6,6 @@ use App\Enums\Auth\SocialAuthProvider; use App\Enums\Notification\Type as NotificationType; -use App\Enums\User\Goal; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; use App\Models\Traits\HasAccount; @@ -134,22 +133,4 @@ public function isConnectedTo(SocialAuthProvider $provider): bool { return (bool) $this->{"{$provider->value}_id"}; } - - /** - * True when the user has at least one goal that still exists in Goal. - * Dropped enum values must not satisfy the welcome gate or users - * mid-funnel can skip re-selecting after we slim the list. - */ - public function hasCurrentGoals(): bool - { - $goals = $this->goals; - - if (! is_array($goals) || $goals === []) { - return false; - } - - $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); - - return array_intersect($goals, $allowed) !== []; - } } diff --git a/tests/Unit/Enums/GoalTest.php b/tests/Unit/Enums/GoalTest.php new file mode 100644 index 000000000..77bf8faa0 --- /dev/null +++ b/tests/Unit/Enums/GoalTest.php @@ -0,0 +1,15 @@ +toBe($expected); +})->with([ + 'null' => [null, false], + 'empty' => [[], false], + 'current' => [[Goal::SaveTime->value], true], + 'removed only' => [['team_collaboration', 'automate_api'], false], + 'mixed' => [['team_collaboration', Goal::SaveTime->value], true], +]); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 001d00983..e3b49088e 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use App\Enums\Auth\SocialAuthProvider; -use App\Enums\User\Goal; use App\Models\User; test('isConnectedTo reflects whether the provider id column is set', function () { @@ -22,13 +21,3 @@ ['', ''], [' ', ''], ]); - -test('hasCurrentGoals is true only when at least one stored goal still exists', function (?array $goals, bool $expected) { - expect(User::factory()->make(['goals' => $goals])->hasCurrentGoals())->toBe($expected); -})->with([ - 'null' => [null, false], - 'empty' => [[], false], - 'current' => [[Goal::SaveTime->value], true], - 'removed only' => [['team_collaboration', 'automate_api'], false], - 'mixed' => [['team_collaboration', Goal::SaveTime->value], true], -]);