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\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']);
}
@@ -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()));
+9 -1
View File
@@ -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'),
@@ -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\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
+9
View File
@@ -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',
];
@@ -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'),
];
}
}