From ccd78e3c8d18553c78f778ccde237baaca8231c7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 19:00:57 -0300 Subject: [PATCH] fix(posthog): harden usage sync after deletes and workspace removal Use Account::postsCountCacheKey everywhere, avoid findOrFail when syncing post deletes, dispatch SyncAccountUsage after DeleteWorkspace when enabled, and add coverage for the new paths. Co-authored-by: Cursor --- app/Actions/Workspace/DeleteWorkspace.php | 8 +++ app/Jobs/PostHog/SyncAccountUsage.php | 2 +- .../PostHog/SyncUsageOnPostDeleted.php | 6 +- app/Models/Account.php | 9 +++ app/Models/Traits/HasUsage.php | 3 +- .../Actions/Workspace/DeleteWorkspaceTest.php | 60 +++++++++++++++++++ .../Jobs/PostHog/SyncAccountUsageTest.php | 2 +- .../PostHog/SyncUsageOnPostDeletedTest.php | 11 ++++ tests/Feature/Models/HasUsageTraitTest.php | 6 +- 9 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/Actions/Workspace/DeleteWorkspaceTest.php diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php index b9bf224f..84cdacdc 100644 --- a/app/Actions/Workspace/DeleteWorkspace.php +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -4,8 +4,10 @@ namespace App\Actions\Workspace; +use App\Jobs\PostHog\SyncAccountUsage; use App\Models\User; use App\Models\Workspace; +use App\Services\PostHogService; class DeleteWorkspace { @@ -13,6 +15,12 @@ public static function execute(User $user, Workspace $workspace): void { User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]); + $accountId = (string) $workspace->account_id; + $workspace->delete(); + + if (PostHogService::isEnabled()) { + SyncAccountUsage::dispatch($accountId, null); + } } } diff --git a/app/Jobs/PostHog/SyncAccountUsage.php b/app/Jobs/PostHog/SyncAccountUsage.php index dffcabad..d6dfdc8e 100644 --- a/app/Jobs/PostHog/SyncAccountUsage.php +++ b/app/Jobs/PostHog/SyncAccountUsage.php @@ -33,7 +33,7 @@ public function handle(PostHogService $postHog): void return; } - Cache::forget("account:{$this->accountId}:posts_count"); + Cache::forget(Account::postsCountCacheKey($this->accountId)); $account = Account::with('plan')->find($this->accountId); diff --git a/app/Listeners/PostHog/SyncUsageOnPostDeleted.php b/app/Listeners/PostHog/SyncUsageOnPostDeleted.php index 3042ea24..275dde83 100644 --- a/app/Listeners/PostHog/SyncUsageOnPostDeleted.php +++ b/app/Listeners/PostHog/SyncUsageOnPostDeleted.php @@ -17,7 +17,11 @@ public function handle(PostDeleted $event): void return; } - $workspace = Workspace::findOrFail($event->workspaceId); + $workspace = Workspace::find($event->workspaceId); + + if (! $workspace) { + return; + } SyncAccountUsage::dispatch((string) $workspace->account_id, (string) $workspace->id); } diff --git a/app/Models/Account.php b/app/Models/Account.php index 57934b19..632b9682 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -26,6 +26,15 @@ class Account extends Model public const SUBSCRIPTION_NAME = 'default'; + /** + * Redis/cache key for aggregated post counts across the account's workspaces. + * Invalidated by the PostHog usage sync job before re-reading aggregates for analytics. + */ + public static function postsCountCacheKey(string $accountId): string + { + return "account:{$accountId}:posts_count"; + } + protected $fillable = [ 'owner_id', 'name', diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index 256fe888..099a68c5 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -8,6 +8,7 @@ use App\Features\MonthlyCreditsLimit; use App\Features\SocialAccountLimit; use App\Features\WorkspaceLimit; +use App\Models\Account; use App\Models\AiUsageLog; use App\Models\Invite; use App\Models\Post; @@ -78,7 +79,7 @@ private function cachedPostCount(array $workspaceIds): int } return (int) Cache::remember( - "account:{$this->id}:posts_count", + Account::postsCountCacheKey((string) $this->id), self::POST_COUNT_CACHE_TTL, fn () => Post::whereIn('workspace_id', $workspaceIds)->count(), ); diff --git a/tests/Feature/Actions/Workspace/DeleteWorkspaceTest.php b/tests/Feature/Actions/Workspace/DeleteWorkspaceTest.php new file mode 100644 index 00000000..92a2095d --- /dev/null +++ b/tests/Feature/Actions/Workspace/DeleteWorkspaceTest.php @@ -0,0 +1,60 @@ + true, 'services.posthog.api_key' => 'phc_test_key']); + + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + Bus::fake(); + + DeleteWorkspace::execute($user, $workspace); + + expect(Workspace::find($workspace->id))->toBeNull(); + + Bus::assertDispatched(SyncAccountUsage::class, function ($job) use ($account) { + return $job->accountId === (string) $account->id + && $job->workspaceId === null; + }); +}); + +test('delete workspace does not dispatch SyncAccountUsage when PostHog is disabled', function () { + config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]); + + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + Bus::fake(); + + DeleteWorkspace::execute($user, $workspace); + + expect(Workspace::find($workspace->id))->toBeNull(); + + Bus::assertNotDispatched(SyncAccountUsage::class); +}); diff --git a/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php b/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php index 11944d2d..77d4c209 100644 --- a/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php +++ b/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php @@ -107,7 +107,7 @@ 'user_id' => $this->user->id, ]); - Cache::put("account:{$this->account->id}:posts_count", 999, 300); + Cache::put(Account::postsCountCacheKey((string) $this->account->id), 999, 300); Post::factory()->count(2)->create([ 'workspace_id' => $workspace->id, diff --git a/tests/Feature/Listeners/PostHog/SyncUsageOnPostDeletedTest.php b/tests/Feature/Listeners/PostHog/SyncUsageOnPostDeletedTest.php index 47eba113..b74b71fa 100644 --- a/tests/Feature/Listeners/PostHog/SyncUsageOnPostDeletedTest.php +++ b/tests/Feature/Listeners/PostHog/SyncUsageOnPostDeletedTest.php @@ -45,6 +45,17 @@ Bus::assertDispatched(SyncAccountUsage::class); }); +test('listener does not dispatch when workspace no longer exists', function () { + Bus::fake(); + + (new SyncUsageOnPostDeleted)->handle(new PostDeleted( + postId: (string) Str::uuid(), + workspaceId: (string) Str::uuid(), + )); + + Bus::assertNotDispatched(SyncAccountUsage::class); +}); + test('listener does not dispatch when PostHog is disabled', function () { config(['services.posthog.enabled' => false]); diff --git a/tests/Feature/Models/HasUsageTraitTest.php b/tests/Feature/Models/HasUsageTraitTest.php index fdbbcadd..502aae3e 100644 --- a/tests/Feature/Models/HasUsageTraitTest.php +++ b/tests/Feature/Models/HasUsageTraitTest.php @@ -94,7 +94,7 @@ expect($this->account->usage()['postCount'])->toBe(2); // Forgetting the cache key returns the fresh count. - Cache::forget("account:{$this->account->id}:posts_count"); + Cache::forget(Account::postsCountCacheKey((string) $this->account->id)); expect($this->account->usage()['postCount'])->toBe(3); }); @@ -102,7 +102,7 @@ expect($this->account->usage()['postCount'])->toBe(0); // No cache entry should be written for the empty case. - expect(Cache::has("account:{$this->account->id}:posts_count"))->toBeFalse(); + expect(Cache::has(Account::postsCountCacheKey((string) $this->account->id)))->toBeFalse(); }); test('postCount survives a string-typed cache value (Redis serializer quirk)', function () { @@ -116,7 +116,7 @@ 'user_id' => $this->owner->id, ]); - Cache::put("account:{$this->account->id}:posts_count", '42', 300); + Cache::put(Account::postsCountCacheKey((string) $this->account->id), '42', 300); $usage = $this->account->usage();