feature(send push notification): send push notification to broker's followers

This commit is contained in:
kusowl 2026-02-09 12:13:27 +05:30
parent 82715973dc
commit 0d0818baf3
15 changed files with 114 additions and 43 deletions

View File

@ -73,3 +73,6 @@ OTP_LIFESPAN=10
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
# Same as the VAPID_PUBLIC_KEY
VITE_VAPID_PUBLIC_KEY=BOBjjU2E-h8pDCV13yPwvMDR_WZwEhFmQY90gr16oJ5L1mpJ5qc7-0WzXcD1Z9D0Ozz0cLZxTe0_7nnDK3VFMP4

View File

@ -5,7 +5,6 @@
use App\Models\Broker; use App\Models\Broker;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Deal; use App\Models\Deal;
use App\Models\Follow;
use App\Notifications\NewDealNotification; use App\Notifications\NewDealNotification;
final readonly class SendDealCreatedNotificationCustomerAction final readonly class SendDealCreatedNotificationCustomerAction
@ -17,10 +16,12 @@ public function execute(Deal $deal): void
*/ */
$broker = $deal->broker->type; $broker = $deal->broker->type;
$followers = $broker->followers()->with('user')->get(); $followers = $broker->followers()->with('user')->get();
$followers->map(function (Customer $customer) use ($deal) { $followers->map(function (Customer $follower) use ($deal) {
\Log::info('dump', [$customer]); $user = $follower->user;
\Log::info("Sending notification to {$customer->user->name}");
$customer->user->notify(new NewDealNotification($deal)); \Log::info("Sending notification to {$follower->user->name}", [$deal, $follower->user]);
$user->notifyNow(new NewDealNotification($deal));
}); });
} }
} }

View File

@ -19,11 +19,12 @@ public function index()
public function approve(Deal $deal, SendDealCreatedNotificationCustomerAction $notificationCustomerAction) public function approve(Deal $deal, SendDealCreatedNotificationCustomerAction $notificationCustomerAction)
{ {
try { try {
\DB::transaction(function () use ($notificationCustomerAction, $deal) { \DB::transaction(function () use ($deal) {
$deal->active = true; $deal->active = true;
$deal->save(); $deal->save();
$notificationCustomerAction->execute($deal);
}); });
$notificationCustomerAction->execute($deal);
return back()->with('success', 'Deal activated successfully.'); return back()->with('success', 'Deal activated successfully.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Deal activation Failed: ', [$e->getMessage(), $e->getTrace()]); Log::error('Deal activation Failed: ', [$e->getMessage(), $e->getTrace()]);

View File

@ -3,14 +3,15 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\PushSubscriptionRequest; use App\Http\Requests\PushSubscriptionRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class PushSubscriptionController extends Controller class PushSubscriptionController extends Controller
{ {
public function __invoke(PushSubscriptionRequest $request): JsonResponse public function __invoke(#[CurrentUser] User $user, PushSubscriptionRequest $request): JsonResponse
{ {
$data = $request->validated(); $data = $request->validated();
$user = $request->user();
$user->updatePushSubscription($data['endpoint'], $data['keys']['p256dh'], $data['keys']['auth']); $user->updatePushSubscription($data['endpoint'], $data['keys']['p256dh'], $data['keys']['auth']);
return response()->json(['message' => 'Push subscription updated successfully.']); return response()->json(['message' => 'Push subscription updated successfully.']);

View File

@ -22,7 +22,7 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
return [ return [
'endpoint' => 'required|string|max:255', 'endpoint' => 'required|string|max:500',
'keys.auth' => 'required|string|max:255', 'keys.auth' => 'required|string|max:255',
'keys.p256dh' => 'required|string|max:255', 'keys.p256dh' => 'required|string|max:255',
]; ];

View File

@ -27,7 +27,7 @@ public function toArray(Request $request): array
'totalRedirection' => $this->total_redirection, 'totalRedirection' => $this->total_redirection,
'isLiked' => $this->is_liked, 'isLiked' => $this->is_liked,
'isFavorite' => $this->is_favorite, 'isFavorite' => $this->is_favorite,
'isFollowed' => $this->is_followed 'isFollowed' => $this->is_followed,
]; ];
} }
} }

View File

@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Relations\Pivot;

View File

@ -11,6 +11,7 @@
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use NotificationChannels\WebPush\HasPushSubscriptions; use NotificationChannels\WebPush\HasPushSubscriptions;
/** /**
* @property int $id * @property int $id
* @property string $name * @property string $name
@ -62,7 +63,7 @@
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasPushSubscriptions; use HasFactory, HasPushSubscriptions, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.

View File

@ -3,15 +3,13 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\Deal; use App\Models\Deal;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
use NotificationChannels\WebPush\WebPushChannel; use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage; use NotificationChannels\WebPush\WebPushMessage;
class NewDealNotification extends Notification class NewDealNotification extends Notification
{ {
use Queueable;
/** /**
* Create a new notification instance. * Create a new notification instance.
*/ */
@ -32,9 +30,11 @@ public function via(object $notifiable): array
public function toWebPush($notifiable, $notification): WebPushMessage public function toWebPush($notifiable, $notification): WebPushMessage
{ {
\Log::info('Building WebPush for user: '.$notifiable->id);
return (new WebPushMessage) return (new WebPushMessage)
->title("New deal from {$this->deal->broker->name}") ->title("New deal from {$this->deal->broker->name}")
->body("Check out this deal: {$this->deal->title}") ->body('Check out this deal:'.Str::limit($this->deal->title, 30, '...'))
->action('View deal', route('explore', ['show' => $this->deal->id])); ->action('View deal', route('explore', ['show' => $this->deal->id]));
} }
} }

44
config/webpush.php Normal file
View File

@ -0,0 +1,44 @@
<?php
return [
/**
* These are the keys for authentication (VAPID).
* These keys must be safely stored and should not change.
*/
'vapid' => [
'subject' => env('VAPID_SUBJECT'),
'public_key' => env('VAPID_PUBLIC_KEY'),
'private_key' => env('VAPID_PRIVATE_KEY'),
'pem_file' => env('VAPID_PEM_FILE'),
],
/**
* This is model that will be used to for push subscriptions.
*/
'model' => \NotificationChannels\WebPush\PushSubscription::class,
/**
* This is the name of the table that will be created by the migration and
* used by the PushSubscription model shipped with this package.
*/
'table_name' => env('WEBPUSH_DB_TABLE', 'push_subscriptions'),
/**
* This is the database connection that will be used by the migration and
* the PushSubscription model shipped with this package.
*/
'database_connection' => env('WEBPUSH_DB_CONNECTION', env('DB_CONNECTION', 'mysql')),
/**
* The Guzzle client options used by Minishlink\WebPush.
*/
'client_options' => [],
/**
* The automatic padding in bytes used by Minishlink\WebPush.
* Set to false to support Firefox Android with v1 endpoint.
*/
'automatic_padding' => env('WEBPUSH_AUTOMATIC_PADDING', true),
];

View File

@ -1,8 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
{ {

View File

@ -71,6 +71,9 @@ export async function favorite(button) {
*/ */
export async function follow(button, brokerId) { export async function follow(button, brokerId) {
let isFollowed = button.dataset.followed === 'true'; let isFollowed = button.dataset.followed === 'true';
if (!isFollowed) {
await initSw();
}
try { try {
button.dataset.followed = isFollowed ? 'false' : 'true'; button.dataset.followed = isFollowed ? 'false' : 'true';
// Update other buttons // Update other buttons
@ -83,9 +86,7 @@ export async function follow(button, brokerId) {
let response = await axios.post(`/api/follow/${brokerId}`); let response = await axios.post(`/api/follow/${brokerId}`);
showToast(response.data.message); showToast(response.data.message);
if (!isFollowed) {
initSw();
}
} catch (e) { } catch (e) {
button.dataset.followed = isFollowed ? 'true' : 'false'; button.dataset.followed = isFollowed ? 'true' : 'false';
showToast('Something went wrong!') showToast('Something went wrong!')

View File

@ -20,11 +20,16 @@ export const initSw = async () => {
* Request permission and trigger subscription * Request permission and trigger subscription
*/ */
const initPush = async () => { const initPush = async () => {
// Request permission if (Notification.permission === 'denied') {
alert('You have blocked notifications. Please click the Lock icon in your address bar to enable them.');
return;
}
// 2. Request permission (this will only pop up if status is 'default')
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
if (permission !== 'granted') { if (permission !== 'granted') {
console.warn('Notification permission denied.'); console.warn('Notification permission denied or dismissed.');
return; return;
} }
@ -39,27 +44,28 @@ const subscribeUser = async () => {
// Check if a subscription already exists // Check if a subscription already exists
let pushSubscription = await registration.pushManager.getSubscription(); let pushSubscription = await registration.pushManager.getSubscription();
if (pushSubscription) {
if (!pushSubscription) { await pushSubscription.unsubscribe();
const subscribeOptions = { console.log('Unsubscribed old record...');
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
import.meta.env.VITE_VAPID_PUBLIC_KEY
)
};
try {
pushSubscription = await registration.pushManager.subscribe(subscribeOptions);
console.log('New Subscription created.');
} catch (err) {
console.error('Failed to subscribe user:', err);
return;
}
} else {
console.log('User is already subscribed.');
} }
// Send subscription to Laravel backend const subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
import.meta.env.VITE_VAPID_PUBLIC_KEY
)
};
try {
pushSubscription = await registration.pushManager.subscribe(subscribeOptions);
console.log('New Subscription created.');
} catch (err) {
console.error('Failed to subscribe user:', err);
return;
}
// Send a subscription to Laravel backend
if (pushSubscription) { if (pushSubscription) {
console.log('Received PushSubscription:', JSON.stringify(pushSubscription)); console.log('Received PushSubscription:', JSON.stringify(pushSubscription));
await storePushSubscription(pushSubscription); await storePushSubscription(pushSubscription);

View File

@ -14,6 +14,20 @@
Route::get('/', HomeController::class)->name('home'); Route::get('/', HomeController::class)->name('home');
Route::get('/explore', ExplorePageController::class)->name('explore'); Route::get('/explore', ExplorePageController::class)->name('explore');
Route::post('/contact', ContactController::class)->name('contact'); Route::post('/contact', ContactController::class)->name('contact');
Route::get('/test-openssl', function () {
$res = openssl_pkey_new([
'curve_name' => 'prime256v1',
'private_key_type' => OPENSSL_KEYTYPE_EC,
]);
if ($res === false) {
return 'ERROR: '.openssl_error_string(); // likely "error:02001003:system library:fopen:No such process"
}
return 'SUCCESS: OpenSSL is configured correctly.';
});
/** /**
* This routes are accessed by JS XHR requests, and is loaded here cause * This routes are accessed by JS XHR requests, and is loaded here cause
* we do not want to use sanctum for web requests * we do not want to use sanctum for web requests

0
storage/logs/.gitignore vendored Normal file → Executable file
View File