diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index f2b4f7e0..6bb253c1 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -4,6 +4,7 @@ namespace App\Actions\User; +use App\Jobs\SyncUserToPostHog; use App\Models\Account; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -16,7 +17,7 @@ class CreateUser */ public static function execute(array $data, array $utmParameters = []): User { - return DB::transaction(function () use ($data, $utmParameters): User { + $user = DB::transaction(function () use ($data, $utmParameters): User { $isInviteRegistration = data_get($data, 'is_invite', false); $account = Account::create([ @@ -39,5 +40,9 @@ public static function execute(array $data, array $utmParameters = []): User return $user; }); + + SyncUserToPostHog::dispatch((string) $user->id); + + return $user; } } diff --git a/app/Jobs/SyncUserToPostHog.php b/app/Jobs/SyncUserToPostHog.php new file mode 100644 index 00000000..f9b6fb52 --- /dev/null +++ b/app/Jobs/SyncUserToPostHog.php @@ -0,0 +1,87 @@ +onQueue('posthog'); + } + + public function handle(PostHogService $postHog): void + { + $user = User::with(['account.plan', 'currentWorkspace'])->find($this->userId); + + if (! $user) { + return; + } + + $postHog->identify((string) $user->id, [ + 'email' => $user->email, + 'name' => $user->name, + '$set_once' => ['signed_up_at' => $user->created_at?->toIso8601String()], + ]); + + if ($account = $user->account) { + $usage = $account->usage(); + + $postHog->groupIdentify('account', (string) $account->id, [ + 'name' => $account->name, + 'plan' => $account->plan?->name, + 'plan_slug' => $account->plan?->slug, + 'has_active_subscription' => $account->hasActiveSubscription(), + 'is_on_trial' => $account->isOnTrial(), + 'workspaces_count' => $usage['workspaceCount'], + 'members_count' => $usage['memberCount'], + 'social_accounts_count' => $usage['socialAccountCount'], + 'posts_count' => $usage['postCount'], + 'pending_invites_count' => $usage['pendingInviteCount'], + 'credits_used' => $usage['creditsUsed'], + 'created_at' => $account->created_at?->toIso8601String(), + ]); + } + + if ($workspace = $user->currentWorkspace) { + $postHog->groupIdentify('workspace', (string) $workspace->id, [ + 'name' => $workspace->name, + 'account_id' => (string) $workspace->account_id, + 'social_accounts_count' => $workspace->socialAccounts()->count(), + 'posts_count' => $workspace->posts()->count(), + 'created_at' => $workspace->created_at?->toIso8601String(), + ]); + } + } +} diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 62d75263..38e2b5a7 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -5,7 +5,9 @@ namespace App\Listeners; use App\Events\SubscriptionCreated; +use App\Jobs\SyncUserToPostHog; use App\Models\Account; +use App\Services\PostHogService; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookReceived; @@ -21,16 +23,16 @@ public function handle(WebhookReceived $event): void return; } - $workspace = Account::where('stripe_id', $stripeCustomerId)->first(); + $account = Account::where('stripe_id', $stripeCustomerId)->first(); - if (! $workspace) { + if (! $account) { return; } match ($type) { - 'customer.subscription.created' => $this->handleSubscriptionCreated($workspace, $event->payload), - 'customer.subscription.updated' => $this->handleSubscriptionUpdated($workspace, $event->payload), - 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($workspace, $event->payload), + 'customer.subscription.created' => $this->handleSubscriptionCreated($account, $event->payload), + 'customer.subscription.updated' => $this->handleSubscriptionUpdated($account, $event->payload), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($account, $event->payload), default => null, }; } catch (\Exception $e) { @@ -41,18 +43,58 @@ public function handle(WebhookReceived $event): void } } + /** + * @param array $payload + */ protected function handleSubscriptionCreated(Account $account, array $payload): void { SubscriptionCreated::dispatch($account); + + $this->trackBilling($account, 'subscription.created', $payload); } - protected function handleSubscriptionUpdated(Account $workspace, array $payload): void + /** + * @param array $payload + */ + protected function handleSubscriptionUpdated(Account $account, array $payload): void { - // + $this->trackBilling($account, 'subscription.updated', $payload); } - protected function handleSubscriptionDeleted(Account $workspace, array $payload): void + /** + * @param array $payload + */ + protected function handleSubscriptionDeleted(Account $account, array $payload): void { - // + $this->trackBilling($account, 'subscription.cancelled', $payload); + } + + /** + * Capture the lifecycle event on the account owner's profile and trigger + * a fresh `SyncUserToPostHog` so the account group properties (plan, + * has_active_subscription, is_on_trial) reflect the new Stripe state. + * + * @param array $payload + */ + private function trackBilling(Account $account, string $event, array $payload): void + { + if (! $account->owner_id) { + return; + } + + $properties = [ + 'stripe_status' => data_get($payload, 'data.object.status'), + 'plan' => $account->plan?->name, + 'plan_slug' => $account->plan?->slug, + ]; + + app(PostHogService::class)->capture( + (string) $account->owner_id, + $event, + $properties, + $account, + ); + + SyncUserToPostHog::dispatch((string) $account->owner_id); } } diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index 3cf3e351..b974e855 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -22,20 +22,22 @@ trait HasUsage { /** - * @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int, creditsUsed: int} + * @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int, postCount: int, creditsUsed: int} */ public function usage(): array { + $workspaces = $this->workspaces() + ->withCount(['socialAccounts', 'posts']) + ->get(); + return [ - 'workspaceCount' => $this->workspaces()->count(), - 'socialAccountCount' => $this->workspaces() - ->withCount('socialAccounts') - ->get() - ->sum('social_accounts_count'), + 'workspaceCount' => $workspaces->count(), + 'socialAccountCount' => (int) $workspaces->sum('social_accounts_count'), 'memberCount' => $this->users()->count(), 'pendingInviteCount' => Invite::where('account_id', $this->id) ->whereNull('accepted_at') ->count(), + 'postCount' => (int) $workspaces->sum('posts_count'), 'creditsUsed' => AiUsageLog::monthlyCredits($this->id), ]; } diff --git a/app/Services/PostHogService.php b/app/Services/PostHogService.php index 60d0dcc2..5c23b457 100644 --- a/app/Services/PostHogService.php +++ b/app/Services/PostHogService.php @@ -5,20 +5,37 @@ namespace App\Services; use App\Jobs\SendPostHogEvent; +use App\Models\Account; use Illuminate\Support\Facades\Log; class PostHogService { /** + * Capture an event for the given distinct id. When `$account` is supplied, + * the workspace/plan group is auto-attached so the event is filterable in + * PostHog by `$groups.account` and the `account_id` / `plan` properties. + * * @param array $properties */ - public function capture(string $distinctId, string $event, array $properties = []): void + public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void { - $this->dispatch('capture', [ + if (! config('services.posthog.api_key')) { + return; + } + + $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->dispatch('capture', $payload); } /** @@ -26,6 +43,10 @@ public function capture(string $distinctId, string $event, array $properties = [ */ public function identify(string $distinctId, array $properties = []): void { + if (! config('services.posthog.api_key')) { + return; + } + $this->dispatch('identify', [ 'distinctId' => $distinctId, 'properties' => $properties, @@ -37,6 +58,10 @@ public function identify(string $distinctId, array $properties = []): void */ public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void { + if (! config('services.posthog.api_key')) { + return; + } + $this->dispatch('groupIdentify', [ 'groupType' => $groupType, 'groupKey' => $groupKey, @@ -49,10 +74,6 @@ public function groupIdentify(string $groupType, string $groupKey, array $proper */ private function dispatch(string $method, array $payload): void { - if (! config('services.posthog.api_key')) { - return; - } - try { SendPostHogEvent::dispatch([ ['method' => $method, 'payload' => $payload], diff --git a/config/horizon.php b/config/horizon.php index 3071d096..60ede02f 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -214,7 +214,7 @@ 'defaults' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['default'], + 'queue' => ['default', 'posthog'], 'balance' => 'auto', 'autoScalingStrategy' => 'time', 'maxProcesses' => 1, diff --git a/resources/js/app.ts b/resources/js/app.ts index fdf022c4..231f3389 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,6 +1,7 @@ import '../css/app.css'; import { createInertiaApp, router } from '@inertiajs/vue3'; +import type { Page } from '@inertiajs/core'; import { configureEcho } from '@laravel/echo-vue'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { i18nVue } from 'laravel-vue-i18n'; @@ -12,12 +13,51 @@ import dayjs from './dayjs'; import posthog from './posthog'; import type { Auth } from './types'; +interface Usage { + workspaceCount: number; + socialAccountCount: number; + memberCount: number; + pendingInviteCount: number; + postCount: number; + creditsUsed: number; +} + configureEcho({ broadcaster: 'reverb', }); const appName = import.meta.env.VITE_APP_NAME || 'TryPost.it'; +// Re-applies the PostHog account/workspace group context with fresh metrics. +// Called on every Inertia navigation so `usage` counts (workspaces, social +// accounts, posts, members) stay reactive — the backend already ships these +// counts on every Inertia request, so this leverages props that are already +// loaded without firing extra queries. +const syncPostHogContext = (page: Page): void => { + const auth = page.props.auth as Auth | undefined; + const usage = page.props.usage as Usage | null; + + if (!auth?.user) return; + + if (auth.account) { + posthog.group('account', auth.account.id, { + name: auth.account.name, + workspaces_count: usage?.workspaceCount, + social_accounts_count: usage?.socialAccountCount, + members_count: usage?.memberCount, + posts_count: usage?.postCount, + credits_used: usage?.creditsUsed, + }); + } + + if (auth.currentWorkspace) { + posthog.group('workspace', auth.currentWorkspace.id, { + name: auth.currentWorkspace.name, + account_id: auth.account?.id, + }); + } +}; + createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), resolve: (name) => @@ -49,18 +89,21 @@ createInertiaApp({ $email: auth.user.email, $name: auth.user.name, }); - - if (auth.currentWorkspace) { - posthog.group('workspace', auth.currentWorkspace.id, { - name: auth.currentWorkspace.name, - }); - } } - router.on('navigate', () => { - posthog.capture('$pageview', { - $current_url: window.location.href, - }); + // Initial group context + initial pageview. The backend mirrors this + // hierarchy in app/Jobs/SyncUserToPostHog.php so events emitted from + // the server land on the same person + group identifiers. + syncPostHogContext(props.initialPage); + posthog.capture('$pageview', { $current_url: window.location.href }); + + router.on('navigate', (event) => { + // Re-sync group context on every navigation: refreshes the count + // metrics on the `account` group AND covers workspace switches + // (which update auth.currentWorkspace via Inertia's prop refresh + // without triggering setup() again). + syncPostHogContext(event.detail.page); + posthog.capture('$pageview', { $current_url: window.location.href }); }); createApp({ render: () => h(App, props) }) diff --git a/resources/js/components/UserMenuContent.vue b/resources/js/components/UserMenuContent.vue index 5691101f..008964a4 100644 --- a/resources/js/components/UserMenuContent.vue +++ b/resources/js/components/UserMenuContent.vue @@ -23,6 +23,7 @@ import { } from '@/components/ui/dropdown-menu'; import UserInfo from '@/components/UserInfo.vue'; import dayjs from '@/dayjs'; +import posthog from '@/posthog'; import { logout } from '@/routes'; import { edit } from '@/routes/app/profile'; import type { User } from '@/types'; @@ -59,6 +60,7 @@ const switchLanguage = (code: string) => { }; const handleLogout = () => { + posthog.reset(); router.flushAll(); }; diff --git a/resources/js/composables/useFeatureAccess.ts b/resources/js/composables/useFeatureAccess.ts index eb070478..20b00ffb 100644 --- a/resources/js/composables/useFeatureAccess.ts +++ b/resources/js/composables/useFeatureAccess.ts @@ -12,6 +12,7 @@ interface Usage { socialAccountCount: number; memberCount: number; pendingInviteCount: number; + postCount: number; creditsUsed: number; } diff --git a/tests/Feature/Models/HasUsageTraitTest.php b/tests/Feature/Models/HasUsageTraitTest.php index 4421bd9c..e26d0d57 100644 --- a/tests/Feature/Models/HasUsageTraitTest.php +++ b/tests/Feature/Models/HasUsageTraitTest.php @@ -36,6 +36,7 @@ 'socialAccountCount' => 3, 'memberCount' => 3, 'pendingInviteCount' => 2, + 'postCount' => 0, 'creditsUsed' => 0, ]); });