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 <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-05-19 19:00:57 -03:00
parent a659a9eadf
commit ccd78e3c8d
9 changed files with 100 additions and 7 deletions

View file

@ -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);
}
}
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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',

View file

@ -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(),
);

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Actions\Workspace\DeleteWorkspace;
use App\Enums\UserWorkspace\Role;
use App\Jobs\PostHog\SyncAccountUsage;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Bus;
test('delete workspace dispatches SyncAccountUsage when PostHog is enabled', function () {
config(['services.posthog.enabled' => 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);
});

View file

@ -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,

View file

@ -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]);

View file

@ -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();