feat: add persistent mobile notifications
CI / 🧪 Tests Laravel (push) Successful in 2m7s
CI / 🐳 Build & Push Image (push) Successful in 42s

This commit is contained in:
2026-08-11 16:46:05 +02:00
parent bd1b4bba20
commit df4f523089
24 changed files with 822 additions and 75 deletions
@@ -5,6 +5,7 @@ namespace App\Actions;
use App\Enums\IdentityVerificationStatus; use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest; use App\Models\IdentityVerificationRequest;
use App\Models\User; use App\Models\User;
use App\Notifications\CertificationReviewedNotification;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -60,6 +61,11 @@ class ReviewIdentityVerification
$this->deleteReviewedDocument($reviewedRequest); $this->deleteReviewedDocument($reviewedRequest);
$reviewedRequest->user->notify(
(new CertificationReviewedNotification($reviewedRequest))
->locale($reviewedRequest->user->preferredLocale()),
);
return $reviewedRequest->fresh(['user', 'reviewer']); return $reviewedRequest->fresh(['user', 'reviewer']);
} }
@@ -55,8 +55,9 @@ class SendEngagementReminderNotifications extends Command
private function eligibleUsersQuery(): Builder private function eligibleUsersQuery(): Builder
{ {
return User::query() return User::query()
->select(['id', 'locale', 'suspended_at']) ->select(['id', 'locale', 'suspended_at', 'engagement_reminders_enabled'])
->whereNull('suspended_at') ->whereNull('suspended_at')
->where('engagement_reminders_enabled', true)
->whereHas('deviceTokens') ->whereHas('deviceTokens')
->whereDoesntHave('mealPosts', fn (Builder $query): Builder => $query ->whereDoesntHave('mealPosts', fn (Builder $query): Builder => $query
->where('created_at', '>=', now()->subDay())); ->where('created_at', '>=', now()->subDay()));
+9 -1
View File
@@ -6,6 +6,7 @@ use App\Enums\FollowStatus;
use App\Http\Requests\FollowRequest; use App\Http\Requests\FollowRequest;
use App\Http\Resources\FollowResource; use App\Http\Resources\FollowResource;
use App\Models\User; use App\Models\User;
use App\Notifications\NewFollowerNotification;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; 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 // Default to Accepted as is_private doesn't exist in the DB yet
$status = FollowStatus::Accepted; $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([ return response()->json([
'message' => __('api.follows.followed'), 'message' => __('api.follows.followed'),
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\NotificationResource;
use App\Notifications\MobileNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class NotificationController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$perPage = max(1, min($request->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();
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateNotificationPreferencesRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationPreferenceController extends Controller
{
public function show(Request $request): JsonResponse
{
return response()->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,
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationPreferencesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'socialNotificationsEnabled' => ['sometimes', 'required', 'boolean'],
'engagementRemindersEnabled' => ['sometimes', 'required', 'boolean'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class NotificationResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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(),
];
}
}
+45 -2
View File
@@ -5,7 +5,9 @@ namespace App\Listeners;
use App\Enums\BillingProductPurpose; use App\Enums\BillingProductPurpose;
use App\Enums\PaymentTransactionStatus; use App\Enums\PaymentTransactionStatus;
use App\Models\BillingProduct; use App\Models\BillingProduct;
use App\Models\PaymentTransaction;
use App\Models\User; use App\Models\User;
use App\Notifications\PaymentFailedNotification;
use App\Services\PaymentInvoiceEmailer; use App\Services\PaymentInvoiceEmailer;
use App\Services\PaymentTransactionRecorder; use App\Services\PaymentTransactionRecorder;
use Laravel\Cashier\Cashier; use Laravel\Cashier\Cashier;
@@ -112,7 +114,7 @@ class HandleStripeWebhook
return; return;
} }
$this->transactions->recordCheckoutSession( $transaction = $this->transactions->recordCheckoutSession(
user: $this->userFromCustomer($session['customer'] ?? null), user: $this->userFromCustomer($session['customer'] ?? null),
product: $this->productFromMetadata($session['metadata'] ?? null), product: $this->productFromMetadata($session['metadata'] ?? null),
stripeCheckoutSessionId: $sessionId, stripeCheckoutSessionId: $sessionId,
@@ -126,6 +128,8 @@ class HandleStripeWebhook
errorMessage: 'Checkout session expired before payment.', errorMessage: 'Checkout session expired before payment.',
payload: $session, payload: $session,
); );
$this->notifyPaymentFailure($transaction);
} }
/** /**
@@ -210,6 +214,8 @@ class HandleStripeWebhook
} }
$lines = $invoice['lines']['data'] ?? [null]; $lines = $invoice['lines']['data'] ?? [null];
$notificationUser = null;
$shouldNotify = false;
foreach ($lines ?: [null] as $line) { foreach ($lines ?: [null] as $line) {
$priceId = is_array($line) ? $this->linePriceId($line) : null; $priceId = is_array($line) ? $this->linePriceId($line) : null;
@@ -217,7 +223,7 @@ class HandleStripeWebhook
? BillingProduct::query()->where('stripe_price_id', $priceId)->first() ? BillingProduct::query()->where('stripe_price_id', $priceId)->first()
: null; : null;
$this->transactions->recordInvoice( $transaction = $this->transactions->recordInvoice(
user: $this->userFromCustomer($invoice['customer'] ?? null), user: $this->userFromCustomer($invoice['customer'] ?? null),
product: $product, product: $product,
stripeInvoiceId: $invoiceId, stripeInvoiceId: $invoiceId,
@@ -237,7 +243,44 @@ class HandleStripeWebhook
errorMessage: 'Invoice payment failed.', errorMessage: 'Invoice payment failed.',
payload: $invoice, 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 private function userFromCustomer(mixed $customer): ?User
+9
View File
@@ -33,6 +33,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use Billable, HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; use Billable, HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
protected $attributes = [
'social_notifications_enabled' => true,
'engagement_reminders_enabled' => true,
];
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
@@ -61,6 +66,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
'sex', 'sex',
'date_of_birth', 'date_of_birth',
'terms_accepted_at', 'terms_accepted_at',
'social_notifications_enabled',
'engagement_reminders_enabled',
'suspended_at', 'suspended_at',
'suspended_by', 'suspended_by',
'suspended_reason', 'suspended_reason',
@@ -99,6 +106,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
'role' => UserRole::class, 'role' => UserRole::class,
'date_of_birth' => 'immutable_date', 'date_of_birth' => 'immutable_date',
'terms_accepted_at' => 'datetime', 'terms_accepted_at' => 'datetime',
'social_notifications_enabled' => 'boolean',
'engagement_reminders_enabled' => 'boolean',
'suspended_at' => 'datetime', 'suspended_at' => 'datetime',
'trial_ends_at' => 'datetime', 'trial_ends_at' => 'datetime',
]; ];
@@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Services\MobileDeepLink;
class CertificationReviewedNotification extends MobileNotification
{
public function __construct(
private readonly IdentityVerificationRequest $verificationRequest,
) {
parent::__construct();
}
protected function category(): string
{
return self::CATEGORY_ACCOUNT;
}
protected function payload(object $notifiable): array
{
$approved = $this->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'),
];
}
}
@@ -3,14 +3,10 @@
namespace App\Notifications; namespace App\Notifications;
use App\Services\MobileDeepLink; use App\Services\MobileDeepLink;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class EngagementReminderNotification extends Notification class EngagementReminderNotification extends MobileNotification
{ {
use Queueable;
private const MESSAGE_KEYS = [ private const MESSAGE_KEYS = [
'publish_meal', 'publish_meal',
'meal_photo', 'meal_photo',
@@ -19,7 +15,10 @@ class EngagementReminderNotification extends Notification
'workout_checkin', 'workout_checkin',
]; ];
public function __construct(private readonly string $messageKey) {} public function __construct(private readonly string $messageKey)
{
parent::__construct();
}
public static function randomMessageKey(): string public static function randomMessageKey(): string
{ {
@@ -34,38 +33,22 @@ class EngagementReminderNotification extends Notification
return self::MESSAGE_KEYS; return self::MESSAGE_KEYS;
} }
/** protected function category(): string
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{ {
return ['expo']; return self::CATEGORY_ENGAGEMENT;
} }
/** protected function payload(object $notifiable): array
* @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
{ {
$message = $this->message(); $message = $this->message();
return [ return [
'type' => 'engagement_reminder',
'title' => $message['title'], 'title' => $message['title'],
'body' => $message['body'], 'body' => $message['body'],
'sound' => 'default', 'url' => MobileDeepLink::to($this->path()),
'channelId' => 'default',
'data' => [ 'data' => [
'type' => 'engagement_reminder',
'message_key' => $this->messageKey, 'message_key' => $this->messageKey,
'url' => MobileDeepLink::to($this->path()),
], ],
]; ];
} }
@@ -4,25 +4,21 @@ namespace App\Notifications;
use App\Models\PostReviews; use App\Models\PostReviews;
use App\Services\MobileDeepLink; use App\Services\MobileDeepLink;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class MealPostCommentedNotification extends Notification class MealPostCommentedNotification extends MobileNotification
{ {
use Queueable; public function __construct(private readonly PostReviews $postReview)
public function __construct(private readonly PostReviews $postReview) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{ {
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( $this->postReview->loadMissing(
'mealPost:id,title,user_id', 'mealPost:id,title,user_id',
@@ -35,16 +31,13 @@ class MealPostCommentedNotification extends Notification
$commenterName = $this->postReview->user?->name ?: __('api.notifications.someone'); $commenterName = $this->postReview->user?->name ?: __('api.notifications.someone');
return [ return [
'type' => 'meal_commented',
'title' => __('api.notifications.meal_post_commented_title'), 'title' => __('api.notifications.meal_post_commented_title'),
'body' => __('api.notifications.meal_post_commented_body', [ 'body' => __('api.notifications.meal_post_commented_body', [
'meal' => $mealTitle, 'meal' => $mealTitle,
'name' => $commenterName, 'name' => $commenterName,
]), ]),
'sound' => 'default', 'url' => MobileDeepLink::to("meals/{$mealPostId}"),
'channelId' => 'default',
'data' => [
'url' => MobileDeepLink::to("meals/{$mealPostId}"),
],
]; ];
} }
} }
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
abstract class MobileNotification extends Notification implements ShouldQueue
{
use Queueable;
public const array TYPES = [
'certification_approved',
'certification_rejected',
'engagement_reminder',
'meal_commented',
'new_follower',
'payment_failed',
];
protected const string CATEGORY_ACCOUNT = 'account';
protected const string CATEGORY_ENGAGEMENT = 'engagement';
protected const string CATEGORY_SOCIAL = 'social';
public function __construct()
{
$this->afterCommit();
}
/** @return list<string> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed>} */
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;
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Notifications;
use App\Models\User;
use App\Services\MobileDeepLink;
class NewFollowerNotification extends MobileNotification
{
public function __construct(private readonly User $follower)
{
parent::__construct();
}
protected function category(): string
{
return self::CATEGORY_SOCIAL;
}
protected function payload(object $notifiable): array
{
return [
'type' => '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()}"),
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Notifications;
use App\Services\MobileDeepLink;
class PaymentFailedNotification extends MobileNotification
{
protected function category(): string
{
return self::CATEGORY_ACCOUNT;
}
protected function payload(object $notifiable): array
{
return [
'type' => 'payment_failed',
'title' => __('api.notifications.payment_failed_title'),
'body' => __('api.notifications.payment_failed_body'),
'url' => MobileDeepLink::to('profile/settings/billing'),
];
}
}
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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',
]);
});
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('notifications', function (Blueprint $table) {
$table->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');
});
}
};
+11
View File
@@ -44,10 +44,21 @@ return [
'rejection_reason_required' => 'A rejection reason is required.', 'rejection_reason_required' => 'A rejection reason is required.',
], ],
'notifications' => [ 'notifications' => [
'all_read' => 'All notifications have been marked as read.',
'preferences_updated' => 'Notification preferences updated.',
'default_title' => 'Bowli',
'test_title' => 'Test', 'test_title' => 'Test',
'test_body' => 'Test notification from the Laravel API.', 'test_body' => 'Test notification from the Laravel API.',
'meal_post_commented_title' => 'New comment', 'meal_post_commented_title' => 'New comment',
'meal_post_commented_body' => ':name commented on your meal ":meal".', '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', 'someone' => 'Someone',
'your_meal' => 'your meal', 'your_meal' => 'your meal',
'moderation_threshold_title' => 'Reports need review', 'moderation_threshold_title' => 'Reports need review',
+11
View File
@@ -44,10 +44,21 @@ return [
'rejection_reason_required' => 'Un motif de refus est obligatoire.', 'rejection_reason_required' => 'Un motif de refus est obligatoire.',
], ],
'notifications' => [ '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_title' => 'Test',
'test_body' => 'Notification test depuis Laravel API.', 'test_body' => 'Notification test depuis Laravel API.',
'meal_post_commented_title' => 'Nouveau commentaire', 'meal_post_commented_title' => 'Nouveau commentaire',
'meal_post_commented_body' => ':name a commenté votre plat ":meal".', '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 na pas abouti. Vérifiez votre moyen de paiement.',
'someone' => 'Quelquun', 'someone' => 'Quelquun',
'your_meal' => 'votre plat', 'your_meal' => 'votre plat',
'moderation_threshold_title' => 'Signalements à vérifier', 'moderation_threshold_title' => 'Signalements à vérifier',
+8
View File
@@ -8,6 +8,8 @@ use App\Http\Controllers\FollowController;
use App\Http\Controllers\LegalDocumentController; use App\Http\Controllers\LegalDocumentController;
use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealImageAnalysisController;
use App\Http\Controllers\MealPostController; use App\Http\Controllers\MealPostController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\NotificationPreferenceController;
use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PostReviewsController;
use App\Http\Controllers\PublicUserProfileController; use App\Http\Controllers\PublicUserProfileController;
use App\Http\Controllers\ReportController; 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::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token'); Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token');
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->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 // Billing
@@ -50,6 +50,15 @@ it('sends engagement reminders only to eligible mobile users', function () {
'platform' => 'ios', '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') $this->artisan('notifications:send-engagement-reminders')
->expectsOutput('1 engagement reminder notification(s) sent.') ->expectsOutput('1 engagement reminder notification(s) sent.')
->assertSuccessful(); ->assertSuccessful();
+144
View File
@@ -0,0 +1,144 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
function storeMobileNotification(User $user, array $data, ?string $readAt = null): string
{
$id = (string) Str::uuid();
DB::table('notifications')->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();
});
@@ -0,0 +1,95 @@
<?php
use App\Actions\ReviewIdentityVerification;
use App\Enums\IdentityVerificationStatus;
use App\Enums\UserRole;
use App\Listeners\HandleStripeWebhook;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use App\Notifications\CertificationReviewedNotification;
use App\Notifications\EngagementReminderNotification;
use App\Notifications\NewFollowerNotification;
use App\Notifications\PaymentFailedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('respects social and engagement push preferences', function () {
$follower = User::factory()->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);
});
+19 -26
View File
@@ -3,9 +3,9 @@
use App\Models\MealPosts; use App\Models\MealPosts;
use App\Models\PostReviews; use App\Models\PostReviews;
use App\Models\User; use App\Models\User;
use App\Notifications\MealPostCommentedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Http;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class); 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 () { it('sends a push notification to the meal owner when another user comments on their meal', function () {
Http::fake([ Notification::fake();
'https://exp.host/*' => Http::response([
'data' => [
['status' => 'ok', 'id' => 'ticket-one'],
],
]),
]);
$owner = User::factory()->create(); $owner = User::factory()->create();
$owner->deviceTokens()->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.', 'comment' => 'Très bon repas.',
])->assertCreated(); ])->assertCreated();
Http::assertSent(fn (Request $request) => $request->url() === 'https://exp.host/--/api/v2/push/send' Notification::assertSentTo(
&& $request->data() === [ $owner,
[ MealPostCommentedNotification::class,
'to' => 'ExponentPushToken[owner]', function (MealPostCommentedNotification $notification) use ($mealPost, $owner): bool {
'title' => 'New comment', $payload = $notification->toExpoPush($owner);
'body' => 'Alex commented on your meal "Recovery bowl".',
'sound' => 'default', return $payload['title'] === 'New comment'
'channelId' => 'default', && $payload['body'] === 'Alex commented on your meal "Recovery bowl".'
'data' => [ && $payload['data']['type'] === 'meal_commented'
'url' => "bowli://meals/{$mealPost->id}", && $payload['data']['url'] === "bowli://meals/{$mealPost->id}";
], },
], );
]);
}); });
it('does not send a push notification for rating-only reviews', function () { it('does not send a push notification for rating-only reviews', function () {
Http::fake(); Notification::fake();
$owner = User::factory()->create(); $owner = User::factory()->create();
$owner->deviceTokens()->create([ $owner->deviceTokens()->create([
@@ -100,11 +93,11 @@ it('does not send a push notification for rating-only reviews', function () {
'rating' => 5, 'rating' => 5,
])->assertCreated(); ])->assertCreated();
Http::assertNothingSent(); Notification::assertNothingSent();
}); });
it('does not notify the owner when they comment on their own meal', function () { it('does not notify the owner when they comment on their own meal', function () {
Http::fake(); Notification::fake();
$owner = User::factory()->create(); $owner = User::factory()->create();
$owner->deviceTokens()->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.', 'comment' => 'Note personnelle.',
])->assertCreated(); ])->assertCreated();
Http::assertNothingSent(); Notification::assertNothingSent();
}); });
it('lists reviews for a single meal post', function () { it('lists reviews for a single meal post', function () {