feat: add persistent mobile notifications
This commit is contained in:
@@ -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()));
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 +243,46 @@ 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
|
||||||
{
|
{
|
||||||
if (! is_string($customer) || $customer === '') {
|
if (! is_string($customer) || $customer === '') {
|
||||||
|
|||||||
@@ -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',
|
|
||||||
'channelId' => 'default',
|
|
||||||
'data' => [
|
|
||||||
'type' => 'engagement_reminder',
|
|
||||||
'message_key' => $this->messageKey,
|
|
||||||
'url' => MobileDeepLink::to($this->path()),
|
'url' => MobileDeepLink::to($this->path()),
|
||||||
|
'data' => [
|
||||||
|
'message_key' => $this->messageKey,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
'channelId' => 'default',
|
|
||||||
'data' => [
|
|
||||||
'url' => MobileDeepLink::to("meals/{$mealPostId}"),
|
'url' => MobileDeepLink::to("meals/{$mealPostId}"),
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+36
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 n’a pas abouti. Vérifiez votre moyen de paiement.',
|
||||||
'someone' => 'Quelqu’un',
|
'someone' => 'Quelqu’un',
|
||||||
'your_meal' => 'votre plat',
|
'your_meal' => 'votre plat',
|
||||||
'moderation_threshold_title' => 'Signalements à vérifier',
|
'moderation_threshold_title' => 'Signalements à vérifier',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 () {
|
||||||
|
|||||||
Reference in New Issue
Block a user