From 49419c1e7b4d4ba3bdcd5e7cb335ba34a52fb6b0 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Sat, 23 May 2026 10:50:34 +0200 Subject: [PATCH] feat: notifications on comments --- .../Controllers/PostReviewsController.php | 25 +++++- .../MealPostCommentedNotification.php | 49 +++++++++++ lang/en/api.php | 4 + lang/fr/api.php | 4 + tests/Feature/PostReviewsControllerTest.php | 83 +++++++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 app/Notifications/MealPostCommentedNotification.php diff --git a/app/Http/Controllers/PostReviewsController.php b/app/Http/Controllers/PostReviewsController.php index 608b711..60a7edb 100644 --- a/app/Http/Controllers/PostReviewsController.php +++ b/app/Http/Controllers/PostReviewsController.php @@ -6,6 +6,7 @@ use App\Http\Requests\PostReviewsRequest; use App\Http\Resources\PostReviewsResource; use App\Models\MealPosts; use App\Models\PostReviews; +use App\Notifications\MealPostCommentedNotification; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -49,7 +50,10 @@ class PostReviewsController extends Controller 'user_id' => $userId, ]); - return (new PostReviewsResource($postReview->load('user:id,name,avatar_url'))) + $postReview->load('user:id,name,avatar_url'); + $this->notifyMealOwner($mealPost, $postReview, $userId); + + return (new PostReviewsResource($postReview)) ->response() ->setStatusCode(201); } @@ -97,4 +101,23 @@ class PostReviewsController extends Controller 404, ); } + + private function notifyMealOwner(MealPosts $mealPost, PostReviews $postReview, string $reviewerId): void + { + if ($mealPost->user_id === $reviewerId || blank($postReview->comment)) { + return; + } + + $mealPost->loadMissing('user'); + $owner = $mealPost->user; + + if (! $owner) { + return; + } + + $owner->notify( + (new MealPostCommentedNotification($postReview)) + ->locale($owner->preferredLocale()) + ); + } } diff --git a/app/Notifications/MealPostCommentedNotification.php b/app/Notifications/MealPostCommentedNotification.php new file mode 100644 index 0000000..aa9a7d8 --- /dev/null +++ b/app/Notifications/MealPostCommentedNotification.php @@ -0,0 +1,49 @@ + + */ + public function via(object $notifiable): array + { + return ['expo']; + } + + public function toExpoPush(object $notifiable): array + { + $this->postReview->loadMissing( + 'mealPost:id,title,user_id', + 'user:id,name', + ); + + $mealPost = $this->postReview->mealPost; + $mealPostId = $mealPost?->getKey() ?? $this->postReview->meal_post_id; + $mealTitle = Str::limit((string) ($mealPost?->title ?: __('api.notifications.your_meal')), 60); + $commenterName = $this->postReview->user?->name ?: __('api.notifications.someone'); + + return [ + 'title' => __('api.notifications.meal_post_commented_title'), + 'body' => __('api.notifications.meal_post_commented_body', [ + 'meal' => $mealTitle, + 'name' => $commenterName, + ]), + 'sound' => 'default', + 'channelId' => 'default', + 'data' => [ + 'url' => "bowly://meals/{$mealPostId}", + ], + ]; + } +} diff --git a/lang/en/api.php b/lang/en/api.php index 337c4fd..b919ddf 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -29,6 +29,10 @@ return [ 'notifications' => [ 'test_title' => 'Test', 'test_body' => 'Test notification from the Laravel API.', + 'meal_post_commented_title' => 'New comment', + 'meal_post_commented_body' => ':name commented on your meal ":meal".', + 'someone' => 'Someone', + 'your_meal' => 'your meal', 'moderation_threshold_title' => 'Reports need review', 'moderation_threshold_body' => ':count reports received for :type.', ], diff --git a/lang/fr/api.php b/lang/fr/api.php index a37c8ca..810b24c 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -29,6 +29,10 @@ return [ 'notifications' => [ 'test_title' => 'Test', 'test_body' => 'Notification test depuis Laravel API.', + 'meal_post_commented_title' => 'Nouveau commentaire', + 'meal_post_commented_body' => ':name a commenté votre plat ":meal".', + 'someone' => 'Quelqu’un', + 'your_meal' => 'votre plat', 'moderation_threshold_title' => 'Signalements à vérifier', 'moderation_threshold_body' => ':count signalements reçus pour :type.', ], diff --git a/tests/Feature/PostReviewsControllerTest.php b/tests/Feature/PostReviewsControllerTest.php index 7168f7d..bd02dc4 100644 --- a/tests/Feature/PostReviewsControllerTest.php +++ b/tests/Feature/PostReviewsControllerTest.php @@ -4,6 +4,8 @@ use App\Models\MealPosts; use App\Models\PostReviews; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; use Laravel\Sanctum\Sanctum; uses(RefreshDatabase::class); @@ -40,6 +42,87 @@ it('creates one review per user for a meal post', function () { expect(PostReviews::query()->count())->toBe(1); }); +it('sends a push notification to the meal owner when another user comments on their meal', function () { + Http::fake([ + 'https://exp.host/*' => Http::response([ + 'data' => [ + ['status' => 'ok', 'id' => 'ticket-one'], + ], + ]), + ]); + + $owner = User::factory()->create(); + $owner->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[owner]', + 'platform' => 'ios', + ]); + $reviewer = User::factory()->create(['name' => 'Alex']); + $mealPost = MealPosts::factory() + ->for($owner, 'user') + ->create(['title' => 'Recovery bowl']); + + Sanctum::actingAs($reviewer); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reviews", [ + 'rating' => 5, + 'comment' => 'Très bon repas.', + ])->assertCreated(); + + Http::assertSent(fn (Request $request) => $request->url() === 'https://exp.host/--/api/v2/push/send' + && $request->data() === [ + [ + 'to' => 'ExponentPushToken[owner]', + 'title' => 'New comment', + 'body' => 'Alex commented on your meal "Recovery bowl".', + 'sound' => 'default', + 'channelId' => 'default', + 'data' => [ + 'url' => "bowly://meals/{$mealPost->id}", + ], + ], + ]); +}); + +it('does not send a push notification for rating-only reviews', function () { + Http::fake(); + + $owner = User::factory()->create(); + $owner->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[owner]', + 'platform' => 'ios', + ]); + $reviewer = User::factory()->create(); + $mealPost = MealPosts::factory()->for($owner, 'user')->create(); + + Sanctum::actingAs($reviewer); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reviews", [ + 'rating' => 5, + ])->assertCreated(); + + Http::assertNothingSent(); +}); + +it('does not notify the owner when they comment on their own meal', function () { + Http::fake(); + + $owner = User::factory()->create(); + $owner->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[owner]', + 'platform' => 'ios', + ]); + $mealPost = MealPosts::factory()->for($owner, 'user')->create(); + + Sanctum::actingAs($owner); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reviews", [ + 'rating' => 5, + 'comment' => 'Note personnelle.', + ])->assertCreated(); + + Http::assertNothingSent(); +}); + it('lists reviews for a single meal post', function () { $user = User::factory()->create(); $mealPost = MealPosts::factory()->create();