diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index ead5eca0..d54e5a4f 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -17,11 +17,11 @@ class CreateUser { /** * @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null} $data - * @param array $utmParameters + * @param array $attributionParameters UTM parameters and ad click IDs (gclid, fbclid, etc.) captured before signup */ - public static function execute(array $data, array $utmParameters = []): User + public static function execute(array $data, array $attributionParameters = []): User { - $user = DB::transaction(function () use ($data, $utmParameters): User { + $user = DB::transaction(function () use ($data, $attributionParameters): User { $isInviteRegistration = data_get($data, 'is_invite', false); $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); $accountAttributes = [ @@ -45,7 +45,7 @@ public static function execute(array $data, array $utmParameters = []): User 'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null), 'account_id' => $account->id, 'registration_ip' => data_get($data, 'registration_ip'), - ], $utmParameters)); + ], $attributionParameters)); $account->update(['owner_id' => $user->id]); diff --git a/app/Http/Controllers/Auth/Concerns/PreservesAttributionParameters.php b/app/Http/Controllers/Auth/Concerns/PreservesAttributionParameters.php new file mode 100644 index 00000000..27e2f1cf --- /dev/null +++ b/app/Http/Controllers/Auth/Concerns/PreservesAttributionParameters.php @@ -0,0 +1,50 @@ + + */ + private function extractAttributionParameters(Request $request): array + { + $utm = collect($request->only(AttributionKeys::UTM)) + ->filter(fn ($value) => is_string($value)) + ->map(fn (string $value) => Str::limit($value, 255, '')); + + $clickIds = collect($request->only(AttributionKeys::CLICK_ID)) + ->filter(fn ($value) => is_string($value)); + + return $utm->merge($clickIds)->filter()->all(); + } + + private function storeAttributionParameters(Request $request): void + { + $request->session()->put('attribution_parameters', $this->extractAttributionParameters($request)); + } + + /** + * @return array + */ + private function retrieveAttributionParameters(): array + { + return session()->pull('attribution_parameters', []); + } +} diff --git a/app/Http/Controllers/Auth/Concerns/PreservesUtmParameters.php b/app/Http/Controllers/Auth/Concerns/PreservesUtmParameters.php deleted file mode 100644 index 90b174c4..00000000 --- a/app/Http/Controllers/Auth/Concerns/PreservesUtmParameters.php +++ /dev/null @@ -1,48 +0,0 @@ - - */ - private function extractUtmParameters(Request $request): array - { - return array_filter( - array_map( - fn (string $value) => mb_substr($value, 0, 255), - array_filter($request->only(self::UTM_KEYS), 'is_string'), - ), - ); - } - - private function storeUtmParameters(Request $request): void - { - $utms = $this->extractUtmParameters($request); - - if ($utms !== []) { - $request->session()->put('utm_parameters', $utms); - } - } - - /** - * @return array - */ - private function retrieveUtmParameters(): array - { - return session()->pull('utm_parameters', []); - } -} diff --git a/app/Http/Controllers/Auth/GitHubController.php b/app/Http/Controllers/Auth/GitHubController.php index 3b3f09ba..5b11104d 100644 --- a/app/Http/Controllers/Auth/GitHubController.php +++ b/app/Http/Controllers/Auth/GitHubController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Actions\User\CreateUser; -use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters; +use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters; use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Auth\Events\Registered; @@ -16,11 +16,11 @@ class GitHubController extends Controller { - use PreservesUtmParameters; + use PreservesAttributionParameters; public function redirect(Request $request): RedirectResponse { - $this->storeUtmParameters($request); + $this->storeAttributionParameters($request); return Socialite::driver('github') ->scopes(['read:user', 'user:email']) @@ -90,14 +90,14 @@ private function loginExistingUser(User $user, string $githubId): RedirectRespon Auth::login($user, remember: true); - $this->retrieveUtmParameters(); + $this->retrieveAttributionParameters(); return redirect()->route('app.home'); } private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): RedirectResponse { - $utmParameters = $this->retrieveUtmParameters(); + $attributionParameters = $this->retrieveAttributionParameters(); $user = CreateUser::execute([ 'name' => $githubUser->getName() ?? $githubUser->getNickname() ?? explode('@', $githubUser->getEmail())[0], @@ -105,7 +105,7 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): 'github_id' => (string) $githubUser->getId(), 'email_verified_at' => now(), 'registration_ip' => request()->ip(), - ], $utmParameters); + ], $attributionParameters); event(new Registered($user)); @@ -113,6 +113,6 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): session()->flash('auth_provider', 'github'); - return redirect()->route('register.success', $utmParameters); + return redirect()->route('register.success', $attributionParameters); } } diff --git a/app/Http/Controllers/Auth/GoogleController.php b/app/Http/Controllers/Auth/GoogleController.php index f7e1b5da..378ab9b9 100644 --- a/app/Http/Controllers/Auth/GoogleController.php +++ b/app/Http/Controllers/Auth/GoogleController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Actions\User\CreateUser; -use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters; +use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters; use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Auth\Events\Registered; @@ -16,11 +16,11 @@ class GoogleController extends Controller { - use PreservesUtmParameters; + use PreservesAttributionParameters; public function redirect(Request $request): RedirectResponse { - $this->storeUtmParameters($request); + $this->storeAttributionParameters($request); return Socialite::driver('google-auth')->redirect(); } @@ -82,14 +82,14 @@ private function loginExistingUser(User $user, string $googleId): RedirectRespon Auth::login($user, remember: true); - $this->retrieveUtmParameters(); + $this->retrieveAttributionParameters(); return redirect()->route('app.home'); } private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse { - $utmParameters = $this->retrieveUtmParameters(); + $attributionParameters = $this->retrieveAttributionParameters(); $user = CreateUser::execute([ 'name' => $googleUser->getName(), @@ -97,7 +97,7 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): 'google_id' => $googleUser->getId(), 'email_verified_at' => now(), 'registration_ip' => request()->ip(), - ], $utmParameters); + ], $attributionParameters); event(new Registered($user)); @@ -105,6 +105,6 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): session()->flash('auth_provider', 'google'); - return redirect()->route('register.success', $utmParameters); + return redirect()->route('register.success', $attributionParameters); } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 8b860a90..273f9f56 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Actions\User\CreateUser; -use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters; +use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters; use App\Http\Controllers\Controller; use App\Http\Requests\App\Auth\RegisterRequest; use Illuminate\Auth\Events\Registered; @@ -17,11 +17,11 @@ class RegisteredUserController extends Controller { - use PreservesUtmParameters; + use PreservesAttributionParameters; public function create(Request $request): Response { - $this->storeUtmParameters($request); + $this->storeAttributionParameters($request); return Inertia::render('auth/Register', [ 'email' => $request->query('email'), @@ -32,7 +32,7 @@ public function create(Request $request): Response public function store(RegisterRequest $request): RedirectResponse { - $utmParameters = $this->retrieveUtmParameters(); + $attributionParameters = $this->retrieveAttributionParameters(); $user = CreateUser::execute([ 'name' => $request->validated('name'), @@ -40,7 +40,7 @@ public function store(RegisterRequest $request): RedirectResponse 'password' => $request->validated('password'), 'is_invite' => $request->isInviteRegistration(), 'registration_ip' => $request->ip(), - ], $utmParameters); + ], $attributionParameters); event(new Registered($user)); @@ -56,6 +56,6 @@ public function store(RegisterRequest $request): RedirectResponse session()->flash('auth_provider', 'email'); - return redirect()->route('register.success', $utmParameters); + return redirect()->route('register.success', $attributionParameters); } } diff --git a/app/Jobs/PostHog/SyncUser.php b/app/Jobs/PostHog/SyncUser.php index c1d355c3..64816807 100644 --- a/app/Jobs/PostHog/SyncUser.php +++ b/app/Jobs/PostHog/SyncUser.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Services\PostHogService; +use App\Support\AttributionKeys; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -37,10 +38,18 @@ public function handle(PostHogService $postHog): void return; } + $attribution = collect(AttributionKeys::all()) + ->mapWithKeys(fn (string $key) => [$key => $user->{$key}]) + ->filter() + ->all(); + $postHog->identify($user->id, [ '$email' => $user->email, '$name' => $user->name, - '$set_once' => ['signed_up_at' => $user->created_at?->toIso8601String()], + '$set_once' => [ + 'signed_up_at' => $user->created_at?->toIso8601String(), + ...$attribution, + ], ]); if ($user->account_id) { diff --git a/app/Models/User.php b/app/Models/User.php index dd9795db..95788b56 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -44,6 +44,12 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable 'utm_campaign', 'utm_term', 'utm_content', + 'gclid', + 'fbclid', + 'li_fat_id', + 'ttclid', + 'rdt_cid', + 'epik', 'registration_ip', 'persona', 'goals', diff --git a/app/Support/AttributionKeys.php b/app/Support/AttributionKeys.php new file mode 100644 index 00000000..c7e2e854 --- /dev/null +++ b/app/Support/AttributionKeys.php @@ -0,0 +1,44 @@ + + * session -> persisted on the users row), shared by + * App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters (capture) + * and App\Jobs\PostHog\SyncUser (forwarding to PostHog). + */ +final class AttributionKeys +{ + public const array UTM = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + ]; + + /** + * Per-platform ad click IDs (Google, Meta, LinkedIn, TikTok, Reddit, + * Pinterest). Adding a new ad network's click ID is just one more key + * here. + */ + public const array CLICK_ID = [ + 'gclid', + 'fbclid', + 'li_fat_id', + 'ttclid', + 'rdt_cid', + 'epik', + ]; + + /** + * @return list + */ + public static function all(): array + { + return [...self::UTM, ...self::CLICK_ID]; + } +} diff --git a/database/migrations/2026_08_11_171700_add_click_id_columns_to_users_table.php b/database/migrations/2026_08_11_171700_add_click_id_columns_to_users_table.php new file mode 100644 index 00000000..ff97aa22 --- /dev/null +++ b/database/migrations/2026_08_11_171700_add_click_id_columns_to_users_table.php @@ -0,0 +1,29 @@ +text('gclid')->nullable()->after('utm_content'); + $table->text('fbclid')->nullable()->after('gclid'); + $table->text('li_fat_id')->nullable()->after('fbclid'); + $table->text('ttclid')->nullable()->after('li_fat_id'); + $table->text('rdt_cid')->nullable()->after('ttclid'); + $table->text('epik')->nullable()->after('rdt_cid'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn(['gclid', 'fbclid', 'li_fat_id', 'ttclid', 'rdt_cid', 'epik']); + }); + } +}; diff --git a/tests/Feature/Auth/SignupClickIdTrackingTest.php b/tests/Feature/Auth/SignupClickIdTrackingTest.php new file mode 100644 index 00000000..e6fa5541 --- /dev/null +++ b/tests/Feature/Auth/SignupClickIdTrackingTest.php @@ -0,0 +1,215 @@ + config()->set('trypost.self_hosted', false)); + +test('email registration saves ad click ids from the register page query string', function () { + $clickIds = [ + 'gclid' => 'gclid-value', + 'fbclid' => 'fbclid-value', + 'li_fat_id' => 'li-fat-id-value', + 'ttclid' => 'ttclid-value', + 'rdt_cid' => 'rdt-cid-value', + 'epik' => 'epik-value', + ]; + + $this->get(route('register', $clickIds)); + + $this->post(route('register.store'), [ + 'name' => 'Click User', + 'email' => 'click@example.com', + 'password' => 'Password123!', + ]) + ->assertRedirect(route('register.success', $clickIds, absolute: false)); + + $this->assertDatabaseHas('users', [ + 'email' => 'click@example.com', + ...$clickIds, + ]); +}); + +test('email registration without click ids saves null columns', function () { + $this->post(route('register.store'), [ + 'name' => 'No Click User', + 'email' => 'no-click@example.com', + 'password' => 'Password123!', + ]); + + $this->assertDatabaseHas('users', [ + 'email' => 'no-click@example.com', + 'gclid' => null, + 'fbclid' => null, + 'li_fat_id' => null, + 'ttclid' => null, + 'rdt_cid' => null, + 'epik' => null, + ]); +}); + +test('click id values longer than 255 characters are stored in full, unlike utm parameters', function () { + $longValue = str_repeat('a', 300); + + $this->get(route('register', ['gclid' => $longValue])); + + $this->post(route('register.store'), [ + 'name' => 'Long Click User', + 'email' => 'long-click@example.com', + 'password' => 'Password123!', + ]); + + $user = User::where('email', 'long-click@example.com')->first(); + + expect($user->gclid)->toBe($longValue); +}); + +test('google registration saves ad click ids captured before the oauth round-trip', function () { + $clickIds = ['gclid' => 'g-click', 'fbclid' => 'fb-click']; + + $this->get(route('auth.google.redirect', $clickIds)); + + $socialiteUser = new SocialiteUser; + $socialiteUser->id = 'g-click-user'; + $socialiteUser->name = 'Google Click'; + $socialiteUser->email = 'google-click@example.com'; + + Socialite::shouldReceive('driver') + ->with('google-auth') + ->andReturn($driver = Mockery::mock()); + + $driver->shouldReceive('user') + ->andReturn($socialiteUser); + + $this->get(route('auth.google.callback')) + ->assertRedirect(route('register.success', $clickIds, absolute: false)); + + $this->assertDatabaseHas('users', [ + 'email' => 'google-click@example.com', + ...$clickIds, + ]); +}); + +test('github registration saves ad click ids captured before the oauth round-trip', function () { + $clickIds = ['li_fat_id' => 'li-click', 'ttclid' => 'tt-click']; + + $this->get(route('auth.github.redirect', $clickIds)); + + $socialiteUser = new SocialiteUser; + $socialiteUser->id = 'gh-click-user'; + $socialiteUser->name = 'GitHub Click'; + $socialiteUser->email = 'github-click@example.com'; + + Socialite::shouldReceive('driver') + ->with('github') + ->andReturn($driver = Mockery::mock()); + + $driver->shouldReceive('scopes') + ->andReturnSelf(); + + $driver->shouldReceive('user') + ->andReturn($socialiteUser); + + $this->get(route('auth.github.callback')) + ->assertRedirect(route('register.success', $clickIds, absolute: false)); + + $this->assertDatabaseHas('users', [ + 'email' => 'github-click@example.com', + ...$clickIds, + ]); +}); + +test('existing google user login consumes the click id session so it does not leak to a later signup', function () { + User::factory()->create([ + 'email' => 'existing-click@example.com', + 'google_id' => 'g-existing-click', + ]); + + $this->get(route('auth.google.redirect', ['gclid' => 'stale-click'])); + + $socialiteUser = new SocialiteUser; + $socialiteUser->id = 'g-existing-click'; + $socialiteUser->name = 'Existing Click User'; + $socialiteUser->email = 'existing-click@example.com'; + + Socialite::shouldReceive('driver') + ->with('google-auth') + ->andReturn($driver = Mockery::mock()); + + $driver->shouldReceive('user') + ->andReturn($socialiteUser); + + $this->get(route('auth.google.callback')) + ->assertRedirect(route('app.home')); + + expect(session()->get('attribution_parameters'))->toBeNull(); +}); + +test('existing github user login consumes the click id session so it does not leak to a later signup', function () { + User::factory()->create([ + 'email' => 'existing-gh-click@example.com', + 'github_id' => 'gh-existing-click', + ]); + + $this->get(route('auth.github.redirect', ['gclid' => 'stale-gh-click'])); + + $socialiteUser = new SocialiteUser; + $socialiteUser->id = 'gh-existing-click'; + $socialiteUser->name = 'Existing GitHub Click User'; + $socialiteUser->email = 'existing-gh-click@example.com'; + + Socialite::shouldReceive('driver') + ->with('github') + ->andReturn($driver = Mockery::mock()); + + $driver->shouldReceive('scopes') + ->andReturnSelf(); + + $driver->shouldReceive('user') + ->andReturn($socialiteUser); + + $this->get(route('auth.github.callback')) + ->assertRedirect(route('app.home')); + + expect(session()->get('attribution_parameters'))->toBeNull(); +}); + +test('an empty query string value is treated the same as an absent one, not stored as an empty string', function () { + // Some ad/email templates always append every tracking key, leaving + // unfilled slots blank (?utm_source=&gclid=) instead of omitting them. + $this->get(route('register', ['utm_source' => '', 'gclid' => ''])); + + $this->post(route('register.store'), [ + 'name' => 'Empty Param User', + 'email' => 'empty-param@example.com', + 'password' => 'Password123!', + ]); + + $this->assertDatabaseHas('users', [ + 'email' => 'empty-param@example.com', + 'utm_source' => null, + 'gclid' => null, + ]); +}); + +test('click ids and utm parameters are both saved when present together', function () { + $this->get(route('register', [ + 'utm_source' => 'google', + 'gclid' => 'mixed-click', + ])); + + $this->post(route('register.store'), [ + 'name' => 'Mixed User', + 'email' => 'mixed@example.com', + 'password' => 'Password123!', + ]); + + $this->assertDatabaseHas('users', [ + 'email' => 'mixed@example.com', + 'utm_source' => 'google', + 'gclid' => 'mixed-click', + ]); +}); diff --git a/tests/Feature/Auth/SignupUtmTrackingTest.php b/tests/Feature/Auth/SignupUtmTrackingTest.php index 9a80e10e..075099af 100644 --- a/tests/Feature/Auth/SignupUtmTrackingTest.php +++ b/tests/Feature/Auth/SignupUtmTrackingTest.php @@ -146,7 +146,7 @@ $this->get(route('auth.google.callback')) ->assertRedirect(route('app.home')); - expect(session()->get('utm_parameters'))->toBeNull(); + expect(session()->get('attribution_parameters'))->toBeNull(); }); test('invitation registration does not include utm parameters in its redirect', function () { @@ -317,5 +317,5 @@ $this->get(route('auth.github.callback')) ->assertRedirect(route('app.home')); - expect(session()->get('utm_parameters'))->toBeNull(); + expect(session()->get('attribution_parameters'))->toBeNull(); }); diff --git a/tests/Feature/Jobs/PostHog/SyncUserTest.php b/tests/Feature/Jobs/PostHog/SyncUserTest.php index 9e5dc252..075ea845 100644 --- a/tests/Feature/Jobs/PostHog/SyncUserTest.php +++ b/tests/Feature/Jobs/PostHog/SyncUserTest.php @@ -54,6 +54,53 @@ }); }); +test('handle forwards ad click ids as first-touch person properties when present', function () { + $this->user->update(['gclid' => 'g-123', 'fbclid' => 'fb-456']); + Queue::fake(); + + (new SyncUser((string) $this->user->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function ($job) { + return $job->payload['properties']['$set_once']['gclid'] === 'g-123' + && $job->payload['properties']['$set_once']['fbclid'] === 'fb-456'; + }); +}); + +test('handle forwards utm parameters as first-touch person properties when present', function () { + $this->user->update(['utm_source' => 'google', 'utm_medium' => 'cpc', 'utm_campaign' => 'spring-launch']); + Queue::fake(); + + (new SyncUser((string) $this->user->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function ($job) { + return $job->payload['properties']['$set_once']['utm_source'] === 'google' + && $job->payload['properties']['$set_once']['utm_medium'] === 'cpc' + && $job->payload['properties']['$set_once']['utm_campaign'] === 'spring-launch'; + }); +}); + +test('handle omits attribution properties when none are set', function () { + Queue::fake(); + + (new SyncUser((string) $this->user->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function ($job) { + $setOnce = $job->payload['properties']['$set_once']; + + return ! array_key_exists('utm_source', $setOnce) + && ! array_key_exists('utm_medium', $setOnce) + && ! array_key_exists('utm_campaign', $setOnce) + && ! array_key_exists('utm_term', $setOnce) + && ! array_key_exists('utm_content', $setOnce) + && ! array_key_exists('gclid', $setOnce) + && ! array_key_exists('fbclid', $setOnce) + && ! array_key_exists('li_fat_id', $setOnce) + && ! array_key_exists('ttclid', $setOnce) + && ! array_key_exists('rdt_cid', $setOnce) + && ! array_key_exists('epik', $setOnce); + }); +}); + test('handle dispatches SyncAccountUsage with the user account id', function () { Queue::fake();