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
@@ -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;
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<int, string>
*/
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()),
],
];
}
@@ -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<int, string>
*/
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}"),
];
}
}
+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'),
];
}
}