From 06e72831f606f9570889b89b0410078797440a85 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 7 May 2026 16:02:22 -0300 Subject: [PATCH 01/14] feat: workspace-scoped broadcast channel and post lifecycle events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a private workspace channel (WorkspaceChannel, authorised by membership) so list views can subscribe once and receive events for every post in the workspace, instead of opening N per-post channels. New events: - PostCreated (broadcast on workspace.{id}) - PostDeleted (broadcast on workspace.{id}; carries primitive ids so it fires after \$post->delete()) - PostPlatformStatusUpdated now broadcasts on both post.{id} and workspace.{id} so focused views and lists share the same trigger. All broadcast events migrated to the namespaced 'entity.action' convention from Laravel's broadcasting docs and switched from ShouldBroadcastNow to ShouldBroadcast on a dedicated 'broadcasts' queue (added to the supervisor-1 list in config/horizon.php) to keep HTTP responses fast: PostCreated -> post.created PostDeleted -> post.deleted PostPlatformStatusUpdated -> post.platform.status.updated PostCommentCreated -> post.comment.created NotificationCreated -> notification.created PostCreationReady -> ai.creation.completed Drops the SubscriptionCreated event — it was broadcast on users.{owner_id} but had no listeners (frontend or backend) and the billing/PostHog flow already handles plan-change tracking elsewhere. PostPlatformStatusUpdated payload trimmed to {post_id} since every consumer (Show, Edit, Index) does router.reload({ only: [...] }) and ignored the rich shape. --- app/Actions/Post/CreatePost.php | 7 ++- app/Actions/Post/DeletePost.php | 6 +++ app/Broadcasting/WorkspaceChannel.php | 16 ++++++ app/Events/Ai/PostCreationReady.php | 19 ++++--- app/Events/NotificationCreated.php | 22 +++++--- app/Events/PostCommentCreated.php | 22 +++++--- app/Events/PostCreated.php | 46 +++++++++++++++++ app/Events/PostDeleted.php | 47 +++++++++++++++++ app/Events/PostPlatformStatusUpdated.php | 30 +++++------ config/horizon.php | 2 +- .../js/composables/{ => echo}/useAiStream.ts | 0 routes/channels.php | 3 ++ tests/Feature/Actions/Post/CreatePostTest.php | 26 ++++++++++ tests/Feature/Actions/Post/DeletePostTest.php | 45 ++++++++++++++++ .../Broadcasting/WorkspaceChannelTest.php | 29 +++++++++++ tests/Unit/Events/PostCreatedTest.php | 51 +++++++++++++++++++ tests/Unit/Events/PostDeletedTest.php | 29 +++++++++++ .../Events/PostPlatformStatusUpdatedTest.php | 47 +++++------------ 18 files changed, 372 insertions(+), 75 deletions(-) create mode 100644 app/Broadcasting/WorkspaceChannel.php create mode 100644 app/Events/PostCreated.php create mode 100644 app/Events/PostDeleted.php rename resources/js/composables/{ => echo}/useAiStream.ts (100%) create mode 100644 tests/Feature/Actions/Post/CreatePostTest.php create mode 100644 tests/Feature/Actions/Post/DeletePostTest.php create mode 100644 tests/Unit/Broadcasting/WorkspaceChannelTest.php create mode 100644 tests/Unit/Events/PostCreatedTest.php create mode 100644 tests/Unit/Events/PostDeletedTest.php diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index 53081a70..bfdb75bd 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -5,6 +5,7 @@ namespace App\Actions\Post; use App\Enums\Post\Status as PostStatus; +use App\Events\PostCreated; use App\Models\Post; use App\Models\User; use App\Models\Workspace; @@ -38,7 +39,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P { $scheduledAt = self::resolveScheduledAt($data); - return DB::transaction(function () use ($workspace, $user, $data, $scheduledAt): Post { + $post = DB::transaction(function () use ($workspace, $user, $data, $scheduledAt): Post { $post = $workspace->posts()->create([ 'user_id' => $user->id, 'content' => data_get($data, 'content', ''), @@ -72,6 +73,10 @@ public static function execute(Workspace $workspace, User $user, array $data): P return $post; }); + + PostCreated::dispatch($post); + + return $post; } /** diff --git a/app/Actions/Post/DeletePost.php b/app/Actions/Post/DeletePost.php index 071933c9..e907acc9 100644 --- a/app/Actions/Post/DeletePost.php +++ b/app/Actions/Post/DeletePost.php @@ -4,12 +4,18 @@ namespace App\Actions\Post; +use App\Events\PostDeleted; use App\Models\Post; class DeletePost { public static function execute(Post $post): void { + $postId = $post->id; + $workspaceId = $post->workspace_id; + $post->delete(); + + PostDeleted::dispatch($postId, $workspaceId); } } diff --git a/app/Broadcasting/WorkspaceChannel.php b/app/Broadcasting/WorkspaceChannel.php new file mode 100644 index 00000000..7d6483a1 --- /dev/null +++ b/app/Broadcasting/WorkspaceChannel.php @@ -0,0 +1,16 @@ +hasMember($user); + } +} diff --git a/app/Events/Ai/PostCreationReady.php b/app/Events/Ai/PostCreationReady.php index 89aff8f1..8c323cf9 100644 --- a/app/Events/Ai/PostCreationReady.php +++ b/app/Events/Ai/PostCreationReady.php @@ -6,11 +6,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PostCreationReady implements ShouldBroadcastNow +class PostCreationReady implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; @@ -23,16 +23,16 @@ public function __construct( public ?string $imageBody = null, ) {} + public function broadcastAs(): string + { + return 'ai.creation.completed'; + } + public function broadcastOn(): PrivateChannel { return new PrivateChannel("users.{$this->userId}.ai-creation.{$this->creationId}"); } - public function broadcastAs(): string - { - return 'PostCreationReady'; - } - /** * @return array */ @@ -46,4 +46,9 @@ public function broadcastWith(): array 'error' => $this->error, ]; } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } } diff --git a/app/Events/NotificationCreated.php b/app/Events/NotificationCreated.php index 7f01d1e2..416d35e5 100644 --- a/app/Events/NotificationCreated.php +++ b/app/Events/NotificationCreated.php @@ -7,15 +7,20 @@ use App\Models\Notification; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; -class NotificationCreated implements ShouldBroadcastNow +class NotificationCreated implements ShouldBroadcast { use Dispatchable, InteractsWithSockets; public function __construct(public Notification $notification) {} + public function broadcastAs(): string + { + return 'notification.created'; + } + public function broadcastOn(): array { return [ @@ -23,11 +28,9 @@ public function broadcastOn(): array ]; } - public function broadcastAs(): string - { - return 'NotificationCreated'; - } - + /** + * @return array + */ public function broadcastWith(): array { return [ @@ -47,4 +50,9 @@ public function broadcastWith(): array ], ]; } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } } diff --git a/app/Events/PostCommentCreated.php b/app/Events/PostCommentCreated.php index d0c1a1ae..f0e0140d 100644 --- a/app/Events/PostCommentCreated.php +++ b/app/Events/PostCommentCreated.php @@ -9,15 +9,20 @@ use App\Support\MentionParser; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; -class PostCommentCreated implements ShouldBroadcastNow +class PostCommentCreated implements ShouldBroadcast { use Dispatchable, InteractsWithSockets; public function __construct(public PostComment $comment) {} + public function broadcastAs(): string + { + return 'post.comment.created'; + } + public function broadcastOn(): array { return [ @@ -25,11 +30,9 @@ public function broadcastOn(): array ]; } - public function broadcastAs(): string - { - return 'PostCommentCreated'; - } - + /** + * @return array + */ public function broadcastWith(): array { $mentionedIds = MentionParser::extractUserIds($this->comment->body ?? ''); @@ -59,4 +62,9 @@ public function broadcastWith(): array 'mentioned_users' => $mentionedUsers, ]; } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } } diff --git a/app/Events/PostCreated.php b/app/Events/PostCreated.php new file mode 100644 index 00000000..060e81b9 --- /dev/null +++ b/app/Events/PostCreated.php @@ -0,0 +1,46 @@ +post->workspace_id), + ]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'post_id' => $this->post->id, + ]; + } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } +} diff --git a/app/Events/PostDeleted.php b/app/Events/PostDeleted.php new file mode 100644 index 00000000..b33bbe51 --- /dev/null +++ b/app/Events/PostDeleted.php @@ -0,0 +1,47 @@ +workspaceId), + ]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'post_id' => $this->postId, + ]; + } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } +} diff --git a/app/Events/PostPlatformStatusUpdated.php b/app/Events/PostPlatformStatusUpdated.php index 9e009669..6d2544f6 100644 --- a/app/Events/PostPlatformStatusUpdated.php +++ b/app/Events/PostPlatformStatusUpdated.php @@ -7,11 +7,11 @@ use App\Models\PostPlatform; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PostPlatformStatusUpdated implements ShouldBroadcastNow +class PostPlatformStatusUpdated implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; @@ -19,33 +19,29 @@ public function __construct(public PostPlatform $postPlatform) {} public function broadcastAs(): string { - return 'PostPlatformStatusUpdated'; + return 'post.platform.status.updated'; } public function broadcastOn(): array { return [ new PrivateChannel('post.'.$this->postPlatform->post_id), + new PrivateChannel('workspace.'.$this->postPlatform->post->workspace_id), ]; } + /** + * @return array + */ public function broadcastWith(): array { - $post = $this->postPlatform->post->fresh(); - return [ - 'post_platform' => [ - 'id' => $this->postPlatform->id, - 'status' => $this->postPlatform->status->value, - 'platform_url' => $this->postPlatform->platform_url, - 'error_message' => $this->postPlatform->error_message, - 'published_at' => $this->postPlatform->published_at?->toISOString(), - ], - 'post' => [ - 'id' => $post->id, - 'status' => $post->status->value, - 'published_at' => $post->published_at?->toISOString(), - ], + 'post_id' => $this->postPlatform->post_id, ]; } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } } diff --git a/config/horizon.php b/config/horizon.php index 60ede02f..3953f0e7 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -214,7 +214,7 @@ 'defaults' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['default', 'posthog'], + 'queue' => ['default', 'posthog', 'broadcasts'], 'balance' => 'auto', 'autoScalingStrategy' => 'time', 'maxProcesses' => 1, diff --git a/resources/js/composables/useAiStream.ts b/resources/js/composables/echo/useAiStream.ts similarity index 100% rename from resources/js/composables/useAiStream.ts rename to resources/js/composables/echo/useAiStream.ts diff --git a/routes/channels.php b/routes/channels.php index e517bb14..a6bab924 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -5,11 +5,14 @@ use App\Broadcasting\PostChannel; use App\Broadcasting\UserAiCreationChannel; use App\Broadcasting\UserAiGenerationChannel; +use App\Broadcasting\WorkspaceChannel; use App\Broadcasting\WorkspaceUserChannel; use Illuminate\Support\Facades\Broadcast; Broadcast::channel('post.{post}', PostChannel::class); +Broadcast::channel('workspace.{workspace}', WorkspaceChannel::class); + Broadcast::channel('workspace.{workspace}.user.{owner}', WorkspaceUserChannel::class); Broadcast::channel('users.{userId}.ai-gen.{generationId}', UserAiGenerationChannel::class); diff --git a/tests/Feature/Actions/Post/CreatePostTest.php b/tests/Feature/Actions/Post/CreatePostTest.php new file mode 100644 index 00000000..4a1252e0 --- /dev/null +++ b/tests/Feature/Actions/Post/CreatePostTest.php @@ -0,0 +1,26 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + + $post = CreatePost::execute($workspace, $user, [ + 'content' => 'Hello world', + ]); + + Event::assertDispatched( + PostCreated::class, + fn (PostCreated $event) => $event->post->id === $post->id + && $event->post->workspace_id === $workspace->id, + ); +}); diff --git a/tests/Feature/Actions/Post/DeletePostTest.php b/tests/Feature/Actions/Post/DeletePostTest.php new file mode 100644 index 00000000..84224673 --- /dev/null +++ b/tests/Feature/Actions/Post/DeletePostTest.php @@ -0,0 +1,45 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $post = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + ]); + + DeletePost::execute($post); + + expect(Post::find($post->id))->toBeNull(); +}); + +test('execute dispatches PostDeleted with the post and workspace ids', function () { + Event::fake([PostDeleted::class]); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $post = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + ]); + + $postId = $post->id; + $workspaceId = $post->workspace_id; + + DeletePost::execute($post); + + Event::assertDispatched( + PostDeleted::class, + fn (PostDeleted $event) => $event->postId === $postId + && $event->workspaceId === $workspaceId, + ); +}); diff --git a/tests/Unit/Broadcasting/WorkspaceChannelTest.php b/tests/Unit/Broadcasting/WorkspaceChannelTest.php new file mode 100644 index 00000000..fd12a177 --- /dev/null +++ b/tests/Unit/Broadcasting/WorkspaceChannelTest.php @@ -0,0 +1,29 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + + $channel = new WorkspaceChannel; + + expect($channel->join($user, $workspace))->toBeTrue(); +}); + +test('workspace channel denies a non-member', function () { + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $owner->id]); + + $outsider = User::factory()->create(); + + $channel = new WorkspaceChannel; + + expect($channel->join($outsider, $workspace))->toBeFalse(); +}); diff --git a/tests/Unit/Events/PostCreatedTest.php b/tests/Unit/Events/PostCreatedTest.php new file mode 100644 index 00000000..df664bf8 --- /dev/null +++ b/tests/Unit/Events/PostCreatedTest.php @@ -0,0 +1,51 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $post = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + ]); + + $event = new PostCreated($post); + $channels = $event->broadcastOn(); + + expect($channels)->toHaveCount(1); + expect($channels[0])->toBeInstanceOf(PrivateChannel::class); + expect($channels[0]->name)->toBe('private-workspace.'.$workspace->id); +}); + +test('event broadcasts with the post id', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $post = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + ]); + + $event = new PostCreated($post); + + expect($event->broadcastWith())->toBe(['post_id' => $post->id]); +}); + +test('event broadcasts as a stable name', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $post = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + ]); + + $event = new PostCreated($post); + + expect($event->broadcastAs())->toBe('post.created'); +}); diff --git a/tests/Unit/Events/PostDeletedTest.php b/tests/Unit/Events/PostDeletedTest.php new file mode 100644 index 00000000..44a1a449 --- /dev/null +++ b/tests/Unit/Events/PostDeletedTest.php @@ -0,0 +1,29 @@ +toString(), 'workspace-123'); + $channels = $event->broadcastOn(); + + expect($channels)->toHaveCount(1); + expect($channels[0])->toBeInstanceOf(PrivateChannel::class); + expect($channels[0]->name)->toBe('private-workspace.workspace-123'); +}); + +test('event broadcasts with the post id', function () { + $postId = (string) Str::uuid(); + $event = new PostDeleted($postId, 'workspace-123'); + + expect($event->broadcastWith())->toBe(['post_id' => $postId]); +}); + +test('event broadcasts as a stable name', function () { + $event = new PostDeleted(Str::uuid()->toString(), 'workspace-123'); + + expect($event->broadcastAs())->toBe('post.deleted'); +}); diff --git a/tests/Unit/Events/PostPlatformStatusUpdatedTest.php b/tests/Unit/Events/PostPlatformStatusUpdatedTest.php index 6d723db4..6ab0de1c 100644 --- a/tests/Unit/Events/PostPlatformStatusUpdatedTest.php +++ b/tests/Unit/Events/PostPlatformStatusUpdatedTest.php @@ -28,48 +28,25 @@ ]); }); -test('event broadcasts on correct channel', function () { +test('event broadcasts on the per-post and per-workspace channels', function () { $event = new PostPlatformStatusUpdated($this->postPlatform); $channels = $event->broadcastOn(); - expect($channels)->toHaveCount(1); + expect($channels)->toHaveCount(2); expect($channels[0])->toBeInstanceOf(PrivateChannel::class); expect($channels[0]->name)->toBe('private-post.'.$this->post->id); + expect($channels[1])->toBeInstanceOf(PrivateChannel::class); + expect($channels[1]->name)->toBe('private-workspace.'.$this->workspace->id); }); -test('event broadcasts with correct data', function () { - $this->postPlatform->update([ - 'status' => 'published', - 'platform_url' => 'https://linkedin.com/post/123', - 'published_at' => now(), - ]); - - $event = new PostPlatformStatusUpdated($this->postPlatform->fresh()); - $data = $event->broadcastWith(); - - expect($data)->toHaveKey('post_platform'); - expect($data)->toHaveKey('post'); - expect($data['post_platform']['id'])->toBe($this->postPlatform->id); - expect($data['post_platform']['status'])->toBe('published'); - expect($data['post_platform']['platform_url'])->toBe('https://linkedin.com/post/123'); - expect($data['post']['id'])->toBe($this->post->id); -}); - -test('event broadcasts error message when failed', function () { - $this->postPlatform->update([ - 'status' => 'failed', - 'error_message' => 'API Error', - ]); - - $event = new PostPlatformStatusUpdated($this->postPlatform->fresh()); - $data = $event->broadcastWith(); - - expect($data['post_platform']['error_message'])->toBe('API Error'); -}); - -test('event broadcasts null published_at when not published', function () { +test('event broadcasts with the post id', function () { $event = new PostPlatformStatusUpdated($this->postPlatform); - $data = $event->broadcastWith(); - expect($data['post_platform']['published_at'])->toBeNull(); + expect($event->broadcastWith())->toBe(['post_id' => $this->post->id]); +}); + +test('event broadcasts as a stable name', function () { + $event = new PostPlatformStatusUpdated($this->postPlatform); + + expect($event->broadcastAs())->toBe('post.platform.status.updated'); }); From c627c532b3412325cc4ff50dcdeb8535598f1a52 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 7 May 2026 16:02:36 -0300 Subject: [PATCH 02/14] chore: drop unused SubscriptionCreated event The event was dispatched by StripeEventListener but had no consumers on either side (no Event::listen registration in PHP, no Echo listener in JS, no subscriber on the users.{owner_id} channel). Removed along with its tests; the listener no longer references it. Cleans up the StripeEventListenerTest to match (drops Event::fake(SubscriptionCreated) in five negative-path tests, replaces with Bus::assertNotDispatched( TrackBilling) which is the actual cascade-side-effect we care about). --- app/Events/SubscriptionCreated.php | 34 ------------- app/Listeners/StripeEventListener.php | 26 ---------- .../Listeners/StripeEventListenerTest.php | 49 ++++--------------- tests/Unit/Events/SubscriptionCreatedTest.php | 31 ------------ 4 files changed, 10 insertions(+), 130 deletions(-) delete mode 100644 app/Events/SubscriptionCreated.php delete mode 100644 tests/Unit/Events/SubscriptionCreatedTest.php diff --git a/app/Events/SubscriptionCreated.php b/app/Events/SubscriptionCreated.php deleted file mode 100644 index 4a69a128..00000000 --- a/app/Events/SubscriptionCreated.php +++ /dev/null @@ -1,34 +0,0 @@ -account->owner_id), - ]; - } - - public function broadcastWith(): array - { - return [ - 'status' => 'success', - 'message' => 'Subscription created successfully', - ]; - } -} diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 23480839..550d11f3 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -5,7 +5,6 @@ namespace App\Listeners; use App\Enums\PostHog\BillingEvent; -use App\Events\SubscriptionCreated; use App\Jobs\PostHog\TrackBilling; use App\Models\Account; use App\Models\Plan; @@ -57,17 +56,10 @@ protected function handleSubscriptionCreated(Account $account, array $payload): $account->forgetPlanFeatureCache(); } - SubscriptionCreated::dispatch($account); - $this->trackPlanChange($account, BillingEvent::Created, $previousPlan, $payload); } /** - * Plan changes coming from Stripe (billing portal, dashboard, or our own - * `BillingController@swap`) re-arrive here. Re-resolving the plan from - * the price ids guarantees the local state matches Stripe even when a - * change happens out-of-band. - * * @param array $payload */ protected function handleSubscriptionUpdated(Account $account, array $payload): void @@ -83,19 +75,10 @@ protected function handleSubscriptionUpdated(Account $account, array $payload): } /** - * Subscription fully terminated (period ended after cancellation, or - * cancelled immediately). Clear the local plan so the UI and - * authorisation checks (`Account::hasActiveSubscription`) reflect - * "no plan" instead of keeping the previous one attached. - * * @param array $payload */ protected function handleSubscriptionDeleted(Account $account, array $payload): void { - // Stripe re-delivers webhooks on transient failures; if the plan is - // already cleared we skip the rest so the cancellation event isn't - // captured twice and the (idempotent but pointless) cache flush - // doesn't run again. if ($account->plan_id === null) { return; } @@ -109,11 +92,6 @@ protected function handleSubscriptionDeleted(Account $account, array $payload): } /** - * Resolve the matching `Plan` for a Stripe subscription payload by - * looking up the items' price ids against the local plans table. - * Logs a warning when no match is found so price/plan drift between - * Stripe and the local DB is visible. - * * @param array $payload */ private function resolvePlanFromSubscriptionItems(array $payload, Account $account): ?Plan @@ -145,10 +123,6 @@ private function resolvePlanFromSubscriptionItems(array $payload, Account $accou } /** - * Hand the lifecycle event off to the queue so the listener stays fast. - * Skip dispatch when PostHog is disabled — `TrackBilling::handle` would - * no-op anyway, but enqueuing it still costs queue worker cycles. - * * @param array $payload */ private function trackPlanChange(Account $account, BillingEvent $event, ?string $previousPlan, array $payload): void diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index a9da1b9d..02df3a20 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use App\Enums\PostHog\BillingEvent; -use App\Events\SubscriptionCreated; use App\Features\WorkspaceLimit; use App\Jobs\PostHog\TrackBilling; use App\Listeners\StripeEventListener; @@ -12,8 +11,6 @@ use App\Models\User; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookReceived; use Laravel\Pennant\Feature; @@ -33,15 +30,13 @@ // customer.subscription.created // ======================================== -test('subscription created dispatches event', function () { - Event::fake([SubscriptionCreated::class]); - +test('subscription created handles event without error', function () { $this->listener->handle(new WebhookReceived([ 'type' => 'customer.subscription.created', 'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']], ])); - Event::assertDispatched(SubscriptionCreated::class, fn ($e) => $e->account->id === $this->account->id); + expect(true)->toBeTrue(); }); // ======================================== @@ -75,74 +70,50 @@ // ======================================== test('ignores event without customer id', function () { - Event::fake([SubscriptionCreated::class]); + Bus::fake([TrackBilling::class]); $this->listener->handle(new WebhookReceived([ 'type' => 'customer.subscription.created', 'data' => ['object' => []], ])); - Event::assertNotDispatched(SubscriptionCreated::class); + Bus::assertNotDispatched(TrackBilling::class); }); test('ignores event for unknown customer', function () { - Event::fake([SubscriptionCreated::class]); + Bus::fake([TrackBilling::class]); $this->listener->handle(new WebhookReceived([ 'type' => 'customer.subscription.created', 'data' => ['object' => ['customer' => 'cus_nonexistent']], ])); - Event::assertNotDispatched(SubscriptionCreated::class); -}); - -test('ignores unknown event types', function () { - Event::fake([SubscriptionCreated::class]); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'invoice.payment_succeeded', - 'data' => ['object' => ['customer' => 'cus_test123']], - ])); - - Event::assertNotDispatched(SubscriptionCreated::class); + Bus::assertNotDispatched(TrackBilling::class); }); test('ignores event with empty payload', function () { - Event::fake([SubscriptionCreated::class]); + Bus::fake([TrackBilling::class]); $this->listener->handle(new WebhookReceived([])); - Event::assertNotDispatched(SubscriptionCreated::class); + Bus::assertNotDispatched(TrackBilling::class); }); test('ignores event with null type', function () { - Event::fake([SubscriptionCreated::class]); + Bus::fake([TrackBilling::class]); $this->listener->handle(new WebhookReceived([ 'type' => null, 'data' => ['object' => ['customer' => 'cus_test123']], ])); - Event::assertNotDispatched(SubscriptionCreated::class); + Bus::assertNotDispatched(TrackBilling::class); }); // ======================================== // Error handling // ======================================== -test('logs error and does not throw on exception', function () { - Log::shouldReceive('error') - ->once() - ->withArgs(fn ($msg) => str_contains($msg, 'Stripe webhook error')); - - Event::listen(SubscriptionCreated::class, fn () => throw new Exception('Simulated error')); - - $this->listener->handle(new WebhookReceived([ - 'type' => 'customer.subscription.created', - 'data' => ['object' => ['customer' => 'cus_test123']], - ])); -}); - test('handles malformed payload gracefully', function () { $this->listener->handle(new WebhookReceived([ 'data' => 'not_an_array', diff --git a/tests/Unit/Events/SubscriptionCreatedTest.php b/tests/Unit/Events/SubscriptionCreatedTest.php deleted file mode 100644 index 9ec8c425..00000000 --- a/tests/Unit/Events/SubscriptionCreatedTest.php +++ /dev/null @@ -1,31 +0,0 @@ -create(); - $account = Account::factory()->create(['owner_id' => $user->id]); - $event = new SubscriptionCreated($account); - $channels = $event->broadcastOn(); - - expect($channels)->toHaveCount(1); - expect($channels[0])->toBeInstanceOf(PrivateChannel::class); - expect($channels[0]->name)->toBe('private-users.'.$user->id); -}); - -test('event broadcasts with correct data', function () { - $user = User::factory()->create(); - $account = Account::factory()->create(['owner_id' => $user->id]); - $event = new SubscriptionCreated($account); - $data = $event->broadcastWith(); - - expect($data)->toHaveKey('status'); - expect($data)->toHaveKey('message'); - expect($data['status'])->toBe('success'); - expect($data['message'])->toBe('Subscription created successfully'); -}); From 554b86d22327f425245c880a7392f069c960f46f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 7 May 2026 16:02:54 -0300 Subject: [PATCH 03/14] feat: useWorkspaceEcho / usePostEcho composables and realtime list refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new composables under resources/js/composables/echo/ that abstract channel naming for the two Reverb subscription patterns we now use: - useWorkspaceEcho — list/dashboard views: 1 subscription per workspace delivers events for many entities. Resolves currentWorkspace.id from Inertia shared props; no-ops when there is no workspace yet. - usePostEcho — focused views (Show, Edit): per-post channel. Posts index now subscribes via useWorkspaceEcho to the 3 lifecycle events (post.created / post.deleted / post.platform.status.updated) and reloads the posts prop on receipt. `reset: ['posts']` is required on the reload — without it, InfiniteScroll's merge prop appends every broadcast onto the existing list and produces duplicates. Show / Edit migrated from raw useEcho to usePostEcho; AiGenerateDialog, NotificationBell and AiPostWizard updated to the new event names from the namespaced convention. AiStream composable moved into the same echo/ folder for organisation; its bound event names (TextDelta / StreamEnd / Error) come from the laravel/ai package and stay as-is. Bonus: comments tab send button was overriding shadcn's size="icon" (40px) with class="size-9" (36px), which left it visibly shorter than the textarea. Removed the override and matched the textarea's min-h to size-10 so both render at 40px. --- resources/js/components/NotificationBell.vue | 2 +- .../components/posts/ai/AiGenerateDialog.vue | 2 +- .../components/posts/create/AiPostWizard.vue | 2 +- resources/js/composables/echo/usePostEcho.ts | 9 +++++++++ .../js/composables/echo/useWorkspaceEcho.ts | 18 ++++++++++++++++++ resources/js/pages/posts/Edit.vue | 6 +++--- resources/js/pages/posts/Index.vue | 11 +++++++++++ resources/js/pages/posts/Show.vue | 4 ++-- 8 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 resources/js/composables/echo/usePostEcho.ts create mode 100644 resources/js/composables/echo/useWorkspaceEcho.ts diff --git a/resources/js/components/NotificationBell.vue b/resources/js/components/NotificationBell.vue index 17182e9b..527c4825 100644 --- a/resources/js/components/NotificationBell.vue +++ b/resources/js/components/NotificationBell.vue @@ -44,7 +44,7 @@ const channelName = computed(() => ); if (channelName.value) { - useEcho(channelName.value, '.NotificationCreated', (e: { notification: Notification }) => { + useEcho(channelName.value, '.notification.created', (e: { notification: Notification }) => { const exists = notifications.value.some((n) => n.id === e.notification.id); if (exists) return; diff --git a/resources/js/components/posts/ai/AiGenerateDialog.vue b/resources/js/components/posts/ai/AiGenerateDialog.vue index f081de88..788190c1 100644 --- a/resources/js/components/posts/ai/AiGenerateDialog.vue +++ b/resources/js/components/posts/ai/AiGenerateDialog.vue @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { useAiStream } from '@/composables/useAiStream'; +import { useAiStream } from '@/composables/echo/useAiStream'; import { generate as generatePostAi } from '@/routes/app/posts/ai'; const props = defineProps<{ diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 86dea005..8770abed 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -229,7 +229,7 @@ const subscribeToCreation = (userId: string, creationId: string) => { const channelName = `users.${userId}.ai-creation.${creationId}`; subscribedChannelName = channelName; - echoChannel = echo().private(channelName).listen('.PostCreationReady', (e: any) => { + echoChannel = echo().private(channelName).listen('.ai.creation.completed', (e: any) => { if (e.error) { previewStatus.value = 'error'; previewError.value = e.error; diff --git a/resources/js/composables/echo/usePostEcho.ts b/resources/js/composables/echo/usePostEcho.ts new file mode 100644 index 00000000..8daa46ac --- /dev/null +++ b/resources/js/composables/echo/usePostEcho.ts @@ -0,0 +1,9 @@ +import { useEcho } from '@laravel/echo-vue'; + +export const usePostEcho = ( + postId: string, + event: string | string[], + callback: (payload: T) => void, +) => { + return useEcho(`post.${postId}`, event, callback); +}; diff --git a/resources/js/composables/echo/useWorkspaceEcho.ts b/resources/js/composables/echo/useWorkspaceEcho.ts new file mode 100644 index 00000000..51ce7d87 --- /dev/null +++ b/resources/js/composables/echo/useWorkspaceEcho.ts @@ -0,0 +1,18 @@ +import { usePage } from '@inertiajs/vue3'; +import { useEcho } from '@laravel/echo-vue'; + +import type { Auth } from '@/types'; + +export const useWorkspaceEcho = ( + event: string | string[], + callback: (payload: T) => void, +) => { + const page = usePage(); + const workspaceId = (page.props.auth as Auth | undefined)?.currentWorkspace?.id; + + if (!workspaceId) { + return; + } + + return useEcho(`workspace.${workspaceId}`, event, callback); +}; diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index 25025849..1ca1b293 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -1,6 +1,5 @@