diff --git a/app/Http/Controllers/Admin/NotificationController.php b/app/Http/Controllers/Admin/NotificationController.php new file mode 100644 index 0000000..1c63b9e --- /dev/null +++ b/app/Http/Controllers/Admin/NotificationController.php @@ -0,0 +1,81 @@ +user(); + + $notifications = Notification::query() + ->where('user_id', $user->id) + ->latest() + ->limit(20) + ->get() + ->map(fn (Notification $n) => [ + 'id' => $n->id, + 'title' => $n->title, + 'body' => $n->body, + 'url' => $n->url, + 'is_read' => $n->is_read, + 'created_at' => $n->created_at->toIso8601String(), + ]); + + $unreadCount = Notification::query() + ->where('user_id', $user->id) + ->unread() + ->count(); + + return response()->json([ + 'notifications' => $notifications, + 'unread_count' => $unreadCount, + ]); + } + + public function markAsRead(Request $request, Notification $notification): JsonResponse + { + if ($notification->user_id !== $request->user()->id) { + abort(403); + } + + $notification->markAsRead(); + + return response()->json(['success' => true]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + Notification::query() + ->where('user_id', $request->user()->id) + ->unread() + ->update(['is_read' => true, 'read_at' => now()]); + + return response()->json(['success' => true]); + } + + public function destroy(Request $request, Notification $notification): JsonResponse + { + if ($notification->user_id !== $request->user()->id) { + abort(403); + } + + $notification->delete(); + + return response()->json(['success' => true]); + } + + public function destroyAll(Request $request): JsonResponse + { + Notification::query() + ->where('user_id', $request->user()->id) + ->delete(); + + return response()->json(['success' => true]); + } +} diff --git a/app/Jobs/SendPushNotificationJob.php b/app/Jobs/SendPushNotificationJob.php index 4387169..9603125 100644 --- a/app/Jobs/SendPushNotificationJob.php +++ b/app/Jobs/SendPushNotificationJob.php @@ -2,8 +2,10 @@ namespace App\Jobs; +use App\Models\Notification; use App\Models\PushSubscription; use App\Models\SystemConfiguration; +use App\Models\User; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\Builder; @@ -24,6 +26,26 @@ public function __construct( ) {} public function handle(): void + { + $this->persistNotifications(); + $this->sendWebPush(); + } + + private function persistNotifications(): void + { + $userIds = $this->resolveUserIds(); + + foreach ($userIds as $uid) { + Notification::create([ + 'user_id' => $uid, + 'title' => $this->title, + 'body' => $this->body, + 'url' => $this->url, + ]); + } + } + + private function sendWebPush(): void { $subscriptions = $this->resolveSubscriptions(); @@ -99,4 +121,23 @@ private function resolveSubscriptions() return $query->get(); } + + /** + * @return list + */ + private function resolveUserIds(): array + { + if ($this->userId !== null) { + return [$this->userId]; + } + + if (! empty($this->roles)) { + return User::query() + ->whereHas('roles', fn (Builder $q) => $q->whereIn('name', $this->roles)) + ->pluck('id') + ->all(); + } + + return User::query()->pluck('id')->all(); + } } diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..288a06a --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,39 @@ + 'boolean', + 'read_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function markAsRead(): void + { + if (! $this->is_read) { + $this->update(['is_read' => true, 'read_at' => now()]); + } + } + + #[Scope] + public function unread(Builder $query): void + { + $query->where('is_read', false); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index d624ad0..516f6bf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -105,6 +105,11 @@ public function purchases(): HasMany return $this->hasMany(Purchase::class, 'created_by_id'); } + public function notifications(): HasMany + { + return $this->hasMany(Notification::class); + } + public function pushSubscriptions(): HasMany { return $this->hasMany(PushSubscription::class); diff --git a/database/migrations/2026_06_27_130700_create_notifications_table.php b/database/migrations/2026_06_27_130700_create_notifications_table.php new file mode 100644 index 0000000..a5b27b3 --- /dev/null +++ b/database/migrations/2026_06_27_130700_create_notifications_table.php @@ -0,0 +1,31 @@ +id(); + + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + + $table->string('title'); + $table->text('body')->nullable(); + $table->string('url')->nullable(); + $table->boolean('is_read')->default(false); + $table->timestamp('read_at')->nullable(); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/js/components/NotificationBell.vue b/resources/js/components/NotificationBell.vue new file mode 100644 index 0000000..518889c --- /dev/null +++ b/resources/js/components/NotificationBell.vue @@ -0,0 +1,291 @@ + + + diff --git a/resources/js/components/UserNav.vue b/resources/js/components/UserNav.vue index e4184a5..c897049 100644 --- a/resources/js/components/UserNav.vue +++ b/resources/js/components/UserNav.vue @@ -1,4 +1,8 @@