From df4f523089b77c3d422acc80a8b634b6255cf0ce Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Tue, 11 Aug 2026 16:46:05 +0200 Subject: [PATCH] feat: add persistent mobile notifications --- app/Actions/ReviewIdentityVerification.php | 6 + .../SendEngagementReminderNotifications.php | 3 +- app/Http/Controllers/FollowController.php | 10 +- .../Controllers/NotificationController.php | 70 +++++++++ .../NotificationPreferenceController.php | 44 ++++++ .../UpdateNotificationPreferencesRequest.php | 29 ++++ app/Http/Resources/NotificationResource.php | 30 ++++ app/Listeners/HandleStripeWebhook.php | 47 +++++- app/Models/User.php | 9 ++ .../CertificationReviewedNotification.php | 39 +++++ .../EngagementReminderNotification.php | 37 ++--- .../MealPostCommentedNotification.php | 29 ++-- app/Notifications/MobileNotification.php | 99 ++++++++++++ app/Notifications/NewFollowerNotification.php | 31 ++++ .../PaymentFailedNotification.php | 23 +++ ...otification_preferences_to_users_table.php | 32 ++++ ...ication_indexes_to_notifications_table.php | 36 +++++ lang/en/api.php | 11 ++ lang/fr/api.php | 11 ++ routes/api.php | 8 + .../EngagementReminderNotificationsTest.php | 9 ++ tests/Feature/NotificationApiTest.php | 144 ++++++++++++++++++ tests/Feature/NotificationDeliveryTest.php | 95 ++++++++++++ tests/Feature/PostReviewsControllerTest.php | 45 +++--- 24 files changed, 822 insertions(+), 75 deletions(-) create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Http/Controllers/NotificationPreferenceController.php create mode 100644 app/Http/Requests/UpdateNotificationPreferencesRequest.php create mode 100644 app/Http/Resources/NotificationResource.php create mode 100644 app/Notifications/CertificationReviewedNotification.php create mode 100644 app/Notifications/MobileNotification.php create mode 100644 app/Notifications/NewFollowerNotification.php create mode 100644 app/Notifications/PaymentFailedNotification.php create mode 100644 database/migrations/2026_08_11_142757_add_notification_preferences_to_users_table.php create mode 100644 database/migrations/2026_08_11_143000_add_mobile_notification_indexes_to_notifications_table.php create mode 100644 tests/Feature/NotificationApiTest.php create mode 100644 tests/Feature/NotificationDeliveryTest.php diff --git a/app/Actions/ReviewIdentityVerification.php b/app/Actions/ReviewIdentityVerification.php index 4622b74..f0f8299 100644 --- a/app/Actions/ReviewIdentityVerification.php +++ b/app/Actions/ReviewIdentityVerification.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Enums\IdentityVerificationStatus; use App\Models\IdentityVerificationRequest; use App\Models\User; +use App\Notifications\CertificationReviewedNotification; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -60,6 +61,11 @@ class ReviewIdentityVerification $this->deleteReviewedDocument($reviewedRequest); + $reviewedRequest->user->notify( + (new CertificationReviewedNotification($reviewedRequest)) + ->locale($reviewedRequest->user->preferredLocale()), + ); + return $reviewedRequest->fresh(['user', 'reviewer']); } diff --git a/app/Console/Commands/SendEngagementReminderNotifications.php b/app/Console/Commands/SendEngagementReminderNotifications.php index 94f1f3b..52dd687 100644 --- a/app/Console/Commands/SendEngagementReminderNotifications.php +++ b/app/Console/Commands/SendEngagementReminderNotifications.php @@ -55,8 +55,9 @@ class SendEngagementReminderNotifications extends Command private function eligibleUsersQuery(): Builder { return User::query() - ->select(['id', 'locale', 'suspended_at']) + ->select(['id', 'locale', 'suspended_at', 'engagement_reminders_enabled']) ->whereNull('suspended_at') + ->where('engagement_reminders_enabled', true) ->whereHas('deviceTokens') ->whereDoesntHave('mealPosts', fn (Builder $query): Builder => $query ->where('created_at', '>=', now()->subDay())); diff --git a/app/Http/Controllers/FollowController.php b/app/Http/Controllers/FollowController.php index b663c37..32bd944 100644 --- a/app/Http/Controllers/FollowController.php +++ b/app/Http/Controllers/FollowController.php @@ -6,6 +6,7 @@ use App\Enums\FollowStatus; use App\Http\Requests\FollowRequest; use App\Http\Resources\FollowResource; use App\Models\User; +use App\Notifications\NewFollowerNotification; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -27,7 +28,14 @@ class FollowController extends Controller // Default to Accepted as is_private doesn't exist in the DB yet $status = FollowStatus::Accepted; - $authUser->following()->syncWithPivotValues([$user->id], ['status' => $status], false); + $changes = $authUser->following()->syncWithPivotValues([$user->id], ['status' => $status], false); + + if ($changes['attached'] !== []) { + $user->notify( + (new NewFollowerNotification($authUser)) + ->locale($user->preferredLocale()), + ); + } return response()->json([ 'message' => __('api.follows.followed'), diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..c54a8e3 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,70 @@ +integer('per_page', 20), 60)); + $notifications = $request->user() + ->notifications() + ->whereIn('type', MobileNotification::TYPES) + ->latest() + ->paginate($perPage); + + return NotificationResource::collection($notifications)->additional([ + 'unreadCount' => $this->unreadCountFor($request), + ]); + } + + public function unreadCount(Request $request): JsonResponse + { + return response()->json([ + 'data' => [ + 'count' => $this->unreadCountFor($request), + ], + ]); + } + + public function markAsRead(Request $request, string $notification): NotificationResource + { + $databaseNotification = $request->user() + ->notifications() + ->whereIn('type', MobileNotification::TYPES) + ->findOrFail($notification); + + $databaseNotification->markAsRead(); + + return NotificationResource::make($databaseNotification->fresh()); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $updated = $request->user() + ->unreadNotifications() + ->whereIn('type', MobileNotification::TYPES) + ->update(['read_at' => now()]); + + return response()->json([ + 'data' => [ + 'updated' => $updated, + ], + 'message' => __('api.notifications.all_read'), + ]); + } + + private function unreadCountFor(Request $request): int + { + return $request->user() + ->unreadNotifications() + ->whereIn('type', MobileNotification::TYPES) + ->count(); + } +} diff --git a/app/Http/Controllers/NotificationPreferenceController.php b/app/Http/Controllers/NotificationPreferenceController.php new file mode 100644 index 0000000..24604fc --- /dev/null +++ b/app/Http/Controllers/NotificationPreferenceController.php @@ -0,0 +1,44 @@ +json([ + 'data' => $this->preferences($request), + ]); + } + + public function update(UpdateNotificationPreferencesRequest $request): JsonResponse + { + $user = $request->user(); + $validated = $request->validated(); + + $user->forceFill([ + 'social_notifications_enabled' => $validated['socialNotificationsEnabled'] + ?? $user->social_notifications_enabled, + 'engagement_reminders_enabled' => $validated['engagementRemindersEnabled'] + ?? $user->engagement_reminders_enabled, + ])->save(); + + return response()->json([ + 'data' => $this->preferences($request), + 'message' => __('api.notifications.preferences_updated'), + ]); + } + + /** @return array{socialNotificationsEnabled: bool, engagementRemindersEnabled: bool} */ + private function preferences(Request $request): array + { + return [ + 'socialNotificationsEnabled' => (bool) $request->user()->social_notifications_enabled, + 'engagementRemindersEnabled' => (bool) $request->user()->engagement_reminders_enabled, + ]; + } +} diff --git a/app/Http/Requests/UpdateNotificationPreferencesRequest.php b/app/Http/Requests/UpdateNotificationPreferencesRequest.php new file mode 100644 index 0000000..8e9be03 --- /dev/null +++ b/app/Http/Requests/UpdateNotificationPreferencesRequest.php @@ -0,0 +1,29 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'socialNotificationsEnabled' => ['sometimes', 'required', 'boolean'], + 'engagementRemindersEnabled' => ['sometimes', 'required', 'boolean'], + ]; + } +} diff --git a/app/Http/Resources/NotificationResource.php b/app/Http/Resources/NotificationResource.php new file mode 100644 index 0000000..a1c1558 --- /dev/null +++ b/app/Http/Resources/NotificationResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + $data = is_array($this->data) ? $this->data : []; + + return [ + 'id' => $this->id, + 'type' => $data['type'] ?? $this->type, + 'category' => $data['category'] ?? 'account', + 'title' => $data['title'] ?? __('api.notifications.default_title'), + 'body' => $data['body'] ?? '', + 'url' => $data['url'] ?? null, + 'readAt' => $this->read_at?->toISOString(), + 'createdAt' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/app/Listeners/HandleStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php index 7ca7778..cfddec1 100644 --- a/app/Listeners/HandleStripeWebhook.php +++ b/app/Listeners/HandleStripeWebhook.php @@ -5,7 +5,9 @@ namespace App\Listeners; use App\Enums\BillingProductPurpose; use App\Enums\PaymentTransactionStatus; use App\Models\BillingProduct; +use App\Models\PaymentTransaction; use App\Models\User; +use App\Notifications\PaymentFailedNotification; use App\Services\PaymentInvoiceEmailer; use App\Services\PaymentTransactionRecorder; use Laravel\Cashier\Cashier; @@ -112,7 +114,7 @@ class HandleStripeWebhook return; } - $this->transactions->recordCheckoutSession( + $transaction = $this->transactions->recordCheckoutSession( user: $this->userFromCustomer($session['customer'] ?? null), product: $this->productFromMetadata($session['metadata'] ?? null), stripeCheckoutSessionId: $sessionId, @@ -126,6 +128,8 @@ class HandleStripeWebhook errorMessage: 'Checkout session expired before payment.', payload: $session, ); + + $this->notifyPaymentFailure($transaction); } /** @@ -210,6 +214,8 @@ class HandleStripeWebhook } $lines = $invoice['lines']['data'] ?? [null]; + $notificationUser = null; + $shouldNotify = false; foreach ($lines ?: [null] as $line) { $priceId = is_array($line) ? $this->linePriceId($line) : null; @@ -217,7 +223,7 @@ class HandleStripeWebhook ? BillingProduct::query()->where('stripe_price_id', $priceId)->first() : null; - $this->transactions->recordInvoice( + $transaction = $this->transactions->recordInvoice( user: $this->userFromCustomer($invoice['customer'] ?? null), product: $product, stripeInvoiceId: $invoiceId, @@ -237,7 +243,44 @@ class HandleStripeWebhook errorMessage: 'Invoice payment failed.', payload: $invoice, ); + + if ($this->shouldNotifyPaymentFailure($transaction)) { + $notificationUser ??= $transaction->user; + $shouldNotify = true; + } } + + if ($shouldNotify && $notificationUser) { + $notificationUser->notify( + (new PaymentFailedNotification) + ->locale($notificationUser->preferredLocale()), + ); + } + } + + private function notifyPaymentFailure(PaymentTransaction $transaction): void + { + if (! $this->shouldNotifyPaymentFailure($transaction)) { + return; + } + + $user = $transaction->user; + + if (! $user) { + return; + } + + $user->notify( + (new PaymentFailedNotification) + ->locale($user->preferredLocale()), + ); + } + + private function shouldNotifyPaymentFailure(PaymentTransaction $transaction): bool + { + return $transaction->wasRecentlyCreated + || $transaction->wasChanged('status') + || $transaction->wasChanged('user_id'); } private function userFromCustomer(mixed $customer): ?User diff --git a/app/Models/User.php b/app/Models/User.php index 7f2afdd..0d7521b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -33,6 +33,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale /** @use HasFactory<\Database\Factories\UserFactory> */ use Billable, HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; + protected $attributes = [ + 'social_notifications_enabled' => true, + 'engagement_reminders_enabled' => true, + ]; + /** * The attributes that are mass assignable. * @@ -61,6 +66,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'sex', 'date_of_birth', 'terms_accepted_at', + 'social_notifications_enabled', + 'engagement_reminders_enabled', 'suspended_at', 'suspended_by', 'suspended_reason', @@ -99,6 +106,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'role' => UserRole::class, 'date_of_birth' => 'immutable_date', 'terms_accepted_at' => 'datetime', + 'social_notifications_enabled' => 'boolean', + 'engagement_reminders_enabled' => 'boolean', 'suspended_at' => 'datetime', 'trial_ends_at' => 'datetime', ]; diff --git a/app/Notifications/CertificationReviewedNotification.php b/app/Notifications/CertificationReviewedNotification.php new file mode 100644 index 0000000..483d12d --- /dev/null +++ b/app/Notifications/CertificationReviewedNotification.php @@ -0,0 +1,39 @@ +verificationRequest->status === IdentityVerificationStatus::APPROVED; + + return [ + 'type' => $approved ? 'certification_approved' : 'certification_rejected', + 'title' => $approved + ? __('api.notifications.certification_approved_title') + : __('api.notifications.certification_rejected_title'), + 'body' => $approved + ? __('api.notifications.certification_approved_body') + : __('api.notifications.certification_rejected_body', [ + 'reason' => $this->verificationRequest->rejection_reason, + ]), + 'url' => MobileDeepLink::to('profile/settings/certification'), + ]; + } +} diff --git a/app/Notifications/EngagementReminderNotification.php b/app/Notifications/EngagementReminderNotification.php index 6d52743..1240f6d 100644 --- a/app/Notifications/EngagementReminderNotification.php +++ b/app/Notifications/EngagementReminderNotification.php @@ -3,14 +3,10 @@ namespace App\Notifications; use App\Services\MobileDeepLink; -use Illuminate\Bus\Queueable; -use Illuminate\Notifications\Notification; use Illuminate\Support\Arr; -class EngagementReminderNotification extends Notification +class EngagementReminderNotification extends MobileNotification { - use Queueable; - private const MESSAGE_KEYS = [ 'publish_meal', 'meal_photo', @@ -19,7 +15,10 @@ class EngagementReminderNotification extends Notification 'workout_checkin', ]; - public function __construct(private readonly string $messageKey) {} + public function __construct(private readonly string $messageKey) + { + parent::__construct(); + } public static function randomMessageKey(): string { @@ -34,38 +33,22 @@ class EngagementReminderNotification extends Notification return self::MESSAGE_KEYS; } - /** - * Get the notification's delivery channels. - * - * @return array - */ - public function via(object $notifiable): array + protected function category(): string { - return ['expo']; + return self::CATEGORY_ENGAGEMENT; } - /** - * @return array{ - * title: string, - * body: string, - * sound: string, - * channelId: string, - * data: array{type: string, message_key: string, url: string} - * } - */ - public function toExpoPush(object $notifiable): array + protected function payload(object $notifiable): array { $message = $this->message(); return [ + 'type' => 'engagement_reminder', 'title' => $message['title'], 'body' => $message['body'], - 'sound' => 'default', - 'channelId' => 'default', + 'url' => MobileDeepLink::to($this->path()), 'data' => [ - 'type' => 'engagement_reminder', 'message_key' => $this->messageKey, - 'url' => MobileDeepLink::to($this->path()), ], ]; } diff --git a/app/Notifications/MealPostCommentedNotification.php b/app/Notifications/MealPostCommentedNotification.php index cb0f0b2..cdf1ada 100644 --- a/app/Notifications/MealPostCommentedNotification.php +++ b/app/Notifications/MealPostCommentedNotification.php @@ -4,25 +4,21 @@ namespace App\Notifications; use App\Models\PostReviews; use App\Services\MobileDeepLink; -use Illuminate\Bus\Queueable; -use Illuminate\Notifications\Notification; use Illuminate\Support\Str; -class MealPostCommentedNotification extends Notification +class MealPostCommentedNotification extends MobileNotification { - use Queueable; - - public function __construct(private readonly PostReviews $postReview) {} - - /** - * @return array - */ - public function via(object $notifiable): array + public function __construct(private readonly PostReviews $postReview) { - return ['expo']; + parent::__construct(); } - public function toExpoPush(object $notifiable): array + protected function category(): string + { + return self::CATEGORY_SOCIAL; + } + + protected function payload(object $notifiable): array { $this->postReview->loadMissing( 'mealPost:id,title,user_id', @@ -35,16 +31,13 @@ class MealPostCommentedNotification extends Notification $commenterName = $this->postReview->user?->name ?: __('api.notifications.someone'); return [ + 'type' => 'meal_commented', 'title' => __('api.notifications.meal_post_commented_title'), 'body' => __('api.notifications.meal_post_commented_body', [ 'meal' => $mealTitle, 'name' => $commenterName, ]), - 'sound' => 'default', - 'channelId' => 'default', - 'data' => [ - 'url' => MobileDeepLink::to("meals/{$mealPostId}"), - ], + 'url' => MobileDeepLink::to("meals/{$mealPostId}"), ]; } } diff --git a/app/Notifications/MobileNotification.php b/app/Notifications/MobileNotification.php new file mode 100644 index 0000000..aa094f2 --- /dev/null +++ b/app/Notifications/MobileNotification.php @@ -0,0 +1,99 @@ +afterCommit(); + } + + /** @return list */ + public function via(object $notifiable): array + { + if ($this->category() === self::CATEGORY_ENGAGEMENT + && $notifiable instanceof User + && ! $notifiable->engagement_reminders_enabled) { + return []; + } + + $channels = ['database']; + + if ($this->shouldSendPush($notifiable)) { + $channels[] = 'expo'; + } + + return $channels; + } + + /** @return array */ + public function toDatabase(object $notifiable): array + { + return [ + ...$this->payload($notifiable), + 'category' => $this->category(), + ]; + } + + public function databaseType(object $notifiable): string + { + return $this->payload($notifiable)['type']; + } + + /** @return array */ + public function toExpoPush(object $notifiable): array + { + $payload = $this->payload($notifiable); + + return [ + 'title' => $payload['title'], + 'body' => $payload['body'], + 'sound' => 'default', + 'channelId' => 'default', + 'data' => [ + 'type' => $payload['type'], + 'url' => $payload['url'], + ...($this->id ? ['notificationId' => $this->id] : []), + ...($payload['data'] ?? []), + ], + ]; + } + + abstract protected function category(): string; + + /** @return array{type: string, title: string, body: string, url: string, data?: array} */ + abstract protected function payload(object $notifiable): array; + + private function shouldSendPush(object $notifiable): bool + { + if (! $notifiable instanceof User) { + return false; + } + + return $this->category() !== self::CATEGORY_SOCIAL + || $notifiable->social_notifications_enabled; + } +} diff --git a/app/Notifications/NewFollowerNotification.php b/app/Notifications/NewFollowerNotification.php new file mode 100644 index 0000000..2484333 --- /dev/null +++ b/app/Notifications/NewFollowerNotification.php @@ -0,0 +1,31 @@ + 'new_follower', + 'title' => __('api.notifications.new_follower_title'), + 'body' => __('api.notifications.new_follower_body', [ + 'name' => $this->follower->name ?: __('api.notifications.someone'), + ]), + 'url' => MobileDeepLink::to("users/{$this->follower->getKey()}"), + ]; + } +} diff --git a/app/Notifications/PaymentFailedNotification.php b/app/Notifications/PaymentFailedNotification.php new file mode 100644 index 0000000..4e22b7a --- /dev/null +++ b/app/Notifications/PaymentFailedNotification.php @@ -0,0 +1,23 @@ + 'payment_failed', + 'title' => __('api.notifications.payment_failed_title'), + 'body' => __('api.notifications.payment_failed_body'), + 'url' => MobileDeepLink::to('profile/settings/billing'), + ]; + } +} diff --git a/database/migrations/2026_08_11_142757_add_notification_preferences_to_users_table.php b/database/migrations/2026_08_11_142757_add_notification_preferences_to_users_table.php new file mode 100644 index 0000000..a97328b --- /dev/null +++ b/database/migrations/2026_08_11_142757_add_notification_preferences_to_users_table.php @@ -0,0 +1,32 @@ +boolean('social_notifications_enabled')->default(true); + $table->boolean('engagement_reminders_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'social_notifications_enabled', + 'engagement_reminders_enabled', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_11_143000_add_mobile_notification_indexes_to_notifications_table.php b/database/migrations/2026_08_11_143000_add_mobile_notification_indexes_to_notifications_table.php new file mode 100644 index 0000000..f608180 --- /dev/null +++ b/database/migrations/2026_08_11_143000_add_mobile_notification_indexes_to_notifications_table.php @@ -0,0 +1,36 @@ +index( + ['notifiable_type', 'notifiable_id', 'created_at'], + 'notifications_notifiable_created_index', + ); + $table->index( + ['notifiable_type', 'notifiable_id', 'read_at'], + 'notifications_notifiable_read_index', + ); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('notifications', function (Blueprint $table) { + $table->dropIndex('notifications_notifiable_created_index'); + $table->dropIndex('notifications_notifiable_read_index'); + }); + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index c35c895..bf27602 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -44,10 +44,21 @@ return [ 'rejection_reason_required' => 'A rejection reason is required.', ], 'notifications' => [ + 'all_read' => 'All notifications have been marked as read.', + 'preferences_updated' => 'Notification preferences updated.', + 'default_title' => 'Bowli', '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".', + 'new_follower_title' => 'New follower', + 'new_follower_body' => ':name is now following you.', + 'certification_approved_title' => 'Profile certified', + 'certification_approved_body' => 'Your identity was approved and your badge is now active.', + 'certification_rejected_title' => 'Certification rejected', + 'certification_rejected_body' => 'Your request was rejected. Reason: :reason', + 'payment_failed_title' => 'Payment failed', + 'payment_failed_body' => 'Your payment did not go through. Please check your payment method.', 'someone' => 'Someone', 'your_meal' => 'your meal', 'moderation_threshold_title' => 'Reports need review', diff --git a/lang/fr/api.php b/lang/fr/api.php index fb6bcac..ef7e72c 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -44,10 +44,21 @@ return [ 'rejection_reason_required' => 'Un motif de refus est obligatoire.', ], 'notifications' => [ + 'all_read' => 'Toutes les notifications ont été marquées comme lues.', + 'preferences_updated' => 'Préférences de notifications mises à jour.', + 'default_title' => 'Bowli', '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".', + 'new_follower_title' => 'Nouvel abonné', + 'new_follower_body' => ':name vous suit maintenant.', + 'certification_approved_title' => 'Profil certifié', + 'certification_approved_body' => 'Votre identité a été validée et votre badge est maintenant actif.', + 'certification_rejected_title' => 'Certification refusée', + 'certification_rejected_body' => 'Votre demande a été refusée. Motif : :reason', + 'payment_failed_title' => 'Paiement échoué', + 'payment_failed_body' => 'Votre paiement n’a pas abouti. Vérifiez votre moyen de paiement.', 'someone' => 'Quelqu’un', 'your_meal' => 'votre plat', 'moderation_threshold_title' => 'Signalements à vérifier', diff --git a/routes/api.php b/routes/api.php index 77ab3d2..f1ea71e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,8 @@ use App\Http\Controllers\FollowController; use App\Http\Controllers\LegalDocumentController; use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; +use App\Http\Controllers\NotificationController; +use App\Http\Controllers\NotificationPreferenceController; use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PublicUserProfileController; use App\Http\Controllers\ReportController; @@ -58,6 +60,12 @@ Route::prefix('legal-documents')->group(function (): void { Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token'); Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->middleware('throttle:device-token'); + Route::get('notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::get('notifications/unread-count', [NotificationController::class, 'unreadCount'])->name('notifications.unread-count'); + Route::patch('notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('notifications.read-all'); + Route::patch('notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('notifications.read'); + Route::get('notification-preferences', [NotificationPreferenceController::class, 'show'])->name('notification-preferences.show'); + Route::patch('notification-preferences', [NotificationPreferenceController::class, 'update'])->middleware('throttle:account-action')->name('notification-preferences.update'); }); // Billing diff --git a/tests/Feature/EngagementReminderNotificationsTest.php b/tests/Feature/EngagementReminderNotificationsTest.php index ab0042f..cd6b3e8 100644 --- a/tests/Feature/EngagementReminderNotificationsTest.php +++ b/tests/Feature/EngagementReminderNotificationsTest.php @@ -50,6 +50,15 @@ it('sends engagement reminders only to eligible mobile users', function () { 'platform' => 'ios', ]); + $optedOutUser = User::factory()->create([ + 'locale' => 'fr', + 'engagement_reminders_enabled' => false, + ]); + $optedOutUser->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[opted-out]', + 'platform' => 'ios', + ]); + $this->artisan('notifications:send-engagement-reminders') ->expectsOutput('1 engagement reminder notification(s) sent.') ->assertSuccessful(); diff --git a/tests/Feature/NotificationApiTest.php b/tests/Feature/NotificationApiTest.php new file mode 100644 index 0000000..4dd6887 --- /dev/null +++ b/tests/Feature/NotificationApiTest.php @@ -0,0 +1,144 @@ +insert([ + 'id' => $id, + 'type' => $data['type'], + 'notifiable_type' => User::class, + 'notifiable_id' => $user->getKey(), + 'data' => json_encode($data, JSON_THROW_ON_ERROR), + 'read_at' => $readAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $id; +} + +it('lists paginated mobile notifications with the unread count', function () { + $user = User::factory()->create(); + $readNotificationId = storeMobileNotification($user, [ + 'type' => 'new_follower', + 'category' => 'social', + 'title' => 'Nouvel abonné', + 'body' => 'Alex vous suit maintenant.', + 'url' => 'bowli://users/123', + ], now()->toISOString()); + $unreadNotificationId = storeMobileNotification($user, [ + 'type' => 'payment_failed', + 'category' => 'account', + 'title' => 'Paiement échoué', + 'body' => 'Vérifiez votre moyen de paiement.', + 'url' => 'bowli://profile/settings/billing', + ]); + + DB::table('notifications')->insert([ + 'id' => (string) Str::uuid(), + 'type' => 'filament', + 'notifiable_type' => User::class, + 'notifiable_id' => $user->getKey(), + 'data' => json_encode(['format' => 'filament'], JSON_THROW_ON_ERROR), + 'read_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Sanctum::actingAs($user); + + $this->getJson('/api/notifications?per_page=1') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $unreadNotificationId) + ->assertJsonPath('data.0.readAt', null) + ->assertJsonPath('meta.total', 2) + ->assertJsonPath('unreadCount', 1); + + $this->getJson('/api/notifications?per_page=1&page=2') + ->assertOk() + ->assertJsonPath('data.0.id', $readNotificationId); +}); + +it('marks only the authenticated users notifications as read', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $firstId = storeMobileNotification($user, [ + 'type' => 'new_follower', + 'category' => 'social', + 'title' => 'Nouvel abonné', + 'body' => 'Alex vous suit maintenant.', + 'url' => 'bowli://users/123', + ]); + storeMobileNotification($user, [ + 'type' => 'meal_commented', + 'category' => 'social', + 'title' => 'Nouveau commentaire', + 'body' => 'Un commentaire.', + 'url' => 'bowli://meals/123', + ]); + $otherId = storeMobileNotification($otherUser, [ + 'type' => 'payment_failed', + 'category' => 'account', + 'title' => 'Paiement échoué', + 'body' => 'Paiement échoué.', + 'url' => 'bowli://profile/settings/billing', + ]); + + Sanctum::actingAs($user); + + $this->patchJson("/api/notifications/{$otherId}/read")->assertNotFound(); + + $this->patchJson("/api/notifications/{$firstId}/read") + ->assertOk() + ->assertJsonPath('data.id', $firstId) + ->assertJsonPath('data.readAt', fn ($value): bool => is_string($value)); + + $this->getJson('/api/notifications/unread-count') + ->assertOk() + ->assertJsonPath('data.count', 1); + + $this->patchJson('/api/notifications/read-all') + ->assertOk() + ->assertJsonPath('data.updated', 1); + + $this->getJson('/api/notifications/unread-count') + ->assertOk() + ->assertJsonPath('data.count', 0); +}); + +it('shows and updates notification preferences', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + + $this->getJson('/api/notification-preferences') + ->assertOk() + ->assertJsonPath('data.socialNotificationsEnabled', true) + ->assertJsonPath('data.engagementRemindersEnabled', true); + + $this->patchJson('/api/notification-preferences', [ + 'socialNotificationsEnabled' => false, + 'engagementRemindersEnabled' => false, + ]) + ->assertOk() + ->assertJsonPath('data.socialNotificationsEnabled', false) + ->assertJsonPath('data.engagementRemindersEnabled', false); + + expect($user->fresh()) + ->social_notifications_enabled->toBeFalse() + ->engagement_reminders_enabled->toBeFalse(); + + $this->patchJson('/api/notification-preferences', [ + 'socialNotificationsEnabled' => 'no', + ])->assertUnprocessable(); +}); diff --git a/tests/Feature/NotificationDeliveryTest.php b/tests/Feature/NotificationDeliveryTest.php new file mode 100644 index 0000000..c50c301 --- /dev/null +++ b/tests/Feature/NotificationDeliveryTest.php @@ -0,0 +1,95 @@ +create(); + $user = User::factory()->create([ + 'social_notifications_enabled' => false, + 'engagement_reminders_enabled' => false, + ]); + + expect((new NewFollowerNotification($follower))->via($user)) + ->toBe(['database']) + ->and((new EngagementReminderNotification('publish_meal'))->via($user)) + ->toBe([]) + ->and((new PaymentFailedNotification)->via($user)) + ->toBe(['database', 'expo']); + + $user->notifyNow(new NewFollowerNotification($follower)); + + $this->assertDatabaseHas('notifications', [ + 'type' => 'new_follower', + 'notifiable_id' => $user->getKey(), + ]); +}); + +it('notifies a user only once when a new follower is attached', function () { + Notification::fake(); + $follower = User::factory()->create(); + $followed = User::factory()->create(); + Sanctum::actingAs($follower); + + $this->postJson("/api/users/{$followed->id}/follow")->assertOk(); + $this->postJson("/api/users/{$followed->id}/follow")->assertOk(); + + Notification::assertSentToTimes($followed, NewFollowerNotification::class, 1); +}); + +it('notifies the user when certification is reviewed', function () { + Notification::fake(); + Storage::fake('identity_documents'); + $user = User::factory()->create(['certification_purchased_at' => now()]); + $admin = User::factory()->create(['role' => UserRole::ADMIN]); + $verificationRequest = IdentityVerificationRequest::factory()->for($user)->create(); + Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity'); + + app(ReviewIdentityVerification::class)->execute( + $verificationRequest, + IdentityVerificationStatus::APPROVED, + $admin, + ); + + Notification::assertSentTo($user, CertificationReviewedNotification::class); +}); + +it('notifies a user only once when a failed invoice webhook is replayed', function () { + Notification::fake(); + $user = User::factory()->create(['stripe_id' => 'cus_failed_payment']); + $event = (object) [ + 'payload' => [ + 'id' => 'evt_failed_payment', + 'type' => 'invoice.payment_failed', + 'data' => [ + 'object' => [ + 'id' => 'in_failed_payment', + 'customer' => 'cus_failed_payment', + 'amount_due' => 999, + 'currency' => 'eur', + 'lines' => ['data' => []], + ], + ], + ], + ]; + + $listener = app(HandleStripeWebhook::class); + $listener->handle($event); + $listener->handle($event); + + Notification::assertSentToTimes($user, PaymentFailedNotification::class, 1); +}); diff --git a/tests/Feature/PostReviewsControllerTest.php b/tests/Feature/PostReviewsControllerTest.php index 6a138db..99cb17d 100644 --- a/tests/Feature/PostReviewsControllerTest.php +++ b/tests/Feature/PostReviewsControllerTest.php @@ -3,9 +3,9 @@ use App\Models\MealPosts; use App\Models\PostReviews; use App\Models\User; +use App\Notifications\MealPostCommentedNotification; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Http\Client\Request; -use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Notification; use Laravel\Sanctum\Sanctum; uses(RefreshDatabase::class); @@ -43,13 +43,7 @@ it('creates one review per user for a meal post', function () { }); 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'], - ], - ]), - ]); + Notification::fake(); $owner = User::factory()->create(); $owner->deviceTokens()->create([ @@ -68,23 +62,22 @@ it('sends a push notification to the meal owner when another user comments on th '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' => "bowli://meals/{$mealPost->id}", - ], - ], - ]); + Notification::assertSentTo( + $owner, + MealPostCommentedNotification::class, + function (MealPostCommentedNotification $notification) use ($mealPost, $owner): bool { + $payload = $notification->toExpoPush($owner); + + return $payload['title'] === 'New comment' + && $payload['body'] === 'Alex commented on your meal "Recovery bowl".' + && $payload['data']['type'] === 'meal_commented' + && $payload['data']['url'] === "bowli://meals/{$mealPost->id}"; + }, + ); }); it('does not send a push notification for rating-only reviews', function () { - Http::fake(); + Notification::fake(); $owner = User::factory()->create(); $owner->deviceTokens()->create([ @@ -100,11 +93,11 @@ it('does not send a push notification for rating-only reviews', function () { 'rating' => 5, ])->assertCreated(); - Http::assertNothingSent(); + Notification::assertNothingSent(); }); it('does not notify the owner when they comment on their own meal', function () { - Http::fake(); + Notification::fake(); $owner = User::factory()->create(); $owner->deviceTokens()->create([ @@ -120,7 +113,7 @@ it('does not notify the owner when they comment on their own meal', function () 'comment' => 'Note personnelle.', ])->assertCreated(); - Http::assertNothingSent(); + Notification::assertNothingSent(); }); it('lists reviews for a single meal post', function () {