Add full notification preferences system

Users can now control which in-app and email notifications they receive
from their Settings tab on their channel page.

Bell (in-app) preferences:
- New comment on my video (default: on)
- Reply to my comment (default: on)
- Comment liked (default: on)
- Video liked (default: on)
- New subscriber (default: on)
- New video from channels I follow (default: on)
- New post from channels I follow (default: on)
- New user registration — super admins only (default: on)

Email preferences (same set plus):
- Comment liked (default: off — too noisy)
- Video liked (default: off — too noisy)
- New post from channels I follow (default: off)
- My video finished processing (default: on)
- Weekly activity digest every Monday (default: on)
- New user registration — super admins only (default: on)

Implementation:
- Migration: notification_preferences JSON column on users table
- User::notificationPref($key) helper with typed defaults
- All existing notification classes updated to check prefs in via()
- 4 new notification classes: NewSubscriberNotification,
  VideoLikedNotification, NewPostNotification, WeeklyDigestNotification
- 8 new email views matching existing dark theme
- SendWeeklyDigest artisan command, scheduled every Monday 09:00
- NewSubscriberNotification wired into UserController::toggleSubscribe
- VideoLikedNotification wired into UserController::toggleLike
- NewPostNotification wired into PostController::store (to all subscribers)
- Bell renderer updated for new_subscriber, video_like, new_post types
- Preferences saved via AJAX (POST /settings/notifications) — instant
  toggle with automatic revert on failure

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-16 23:47:28 +03:00
1 parent 3fe167e33f
commit f8d13457fa
26 files changed
+808 -18

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Notifications\WeeklyDigestNotification;
use Illuminate\Console\Command;
class SendWeeklyDigest extends Command
{
protected $signature = 'digest:weekly';
protected $description = 'Send weekly activity digest emails to creators who have it enabled';
public function handle(): void
{
$users = User::whereHas('videos')->get();
$sent = 0;
foreach ($users as $user) {
if (! $user->notificationPref('email_weekly_digest')) {
continue;
}
try {
$user->notify(new WeeklyDigestNotification($user));
$sent++;
} catch (\Throwable $e) {
\Log::error('Weekly digest failed for user ' . $user->id . ': ' . $e->getMessage());
}
}
$this->info("Sent weekly digest to {$sent} users.");
}
}
+6
View File
@@ -25,6 +25,12 @@ class Kernel extends ConsoleKernel
$nas->clearNasCache(24);
}
})->daily()->name('nas-cache-evict')->withoutOverlapping();
// Weekly activity digest — every Monday at 9:00 AM (Bahrain time)
$schedule->command('digest:weekly')
->weeklyOn(1, '09:00')
->withoutOverlapping()
->runInBackground();
}
/**
+7
View File
@@ -6,6 +6,7 @@ use App\Models\Post;
use App\Models\PostImage;
use App\Models\PostVideo;
use App\Models\User;
use App\Notifications\NewPostNotification;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -115,6 +116,12 @@ class PostController extends Controller
}
}
// Notify subscribers of new post
$author = $user->fresh();
$author->subscribers()->each(function (User $subscriber) use ($post, $author) {
try { $subscriber->notify(new NewPostNotification($post, $author)); } catch (\Throwable) {}
});
return back()->with('toast_success', 'Post shared!');
}
+22
View File
@@ -7,6 +7,8 @@ use App\Models\AuditLog;
use App\Models\Post;
use App\Models\User;
use App\Models\Video;
use App\Notifications\NewSubscriberNotification;
use App\Notifications\VideoLikedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -151,6 +153,22 @@ class UserController extends Controller
return redirect()->route('channel')->with('toast_success', 'Password updated!')->with('_open_tab', 'settings');
}
// Save a single notification preference toggle (AJAX)
public function updateNotificationPreferences(Request $request)
{
$request->validate([
'key' => ['required', 'string', 'in:' . implode(',', array_keys(User::notifDefaults()))],
'value' => ['required', 'boolean'],
]);
$user = Auth::user();
$prefs = $user->notification_preferences ?? [];
$prefs[$request->key] = (bool) $request->value;
$user->update(['notification_preferences' => $prefs]);
return response()->json(['ok' => true]);
}
// Logout all other devices
public function logoutAllDevices(Request $request)
{
@@ -348,6 +366,9 @@ class UserController extends Controller
} else {
$video->likes()->attach($user->id);
$liked = true;
if ($video->user_id && $video->user_id !== $user->id) {
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
}
}
return response()->json([
@@ -370,6 +391,7 @@ class UserController extends Controller
} else {
$me->subscriptions()->attach($user->id);
$subscribed = true;
try { $user->notify(new NewSubscriberNotification($me)); } catch (\Throwable) {}
}
return response()->json([
+39 -3
View File
@@ -43,6 +43,7 @@ class User extends Authenticatable implements MustVerifyEmail
'two_factor_secret',
'two_factor_enabled',
'banner',
'notification_preferences',
];
protected $hidden = [
@@ -51,11 +52,46 @@ class User extends Authenticatable implements MustVerifyEmail
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
'notification_preferences' => 'array',
];
// Defaults for each preference key (true = on, false = off)
private const NOTIF_DEFAULTS = [
'notif_new_comment' => true,
'notif_new_reply' => true,
'notif_comment_like' => true,
'notif_video_like' => true,
'notif_new_subscriber' => true,
'notif_new_video_from_sub' => true,
'notif_new_post_from_sub' => true,
'notif_new_user_reg' => true,
'email_new_comment' => true,
'email_new_reply' => true,
'email_comment_like' => false,
'email_video_like' => false,
'email_new_subscriber' => true,
'email_new_video_from_sub' => true,
'email_new_post_from_sub' => false,
'email_video_processed' => true,
'email_new_user_reg' => true,
'email_weekly_digest' => true,
];
public function notificationPref(string $key): bool
{
$prefs = $this->notification_preferences ?? [];
$default = self::NOTIF_DEFAULTS[$key] ?? true;
return isset($prefs[$key]) ? (bool) $prefs[$key] : $default;
}
public static function notifDefaults(): array
{
return self::NOTIF_DEFAULTS;
}
protected $appends = ['avatar_url', 'banner_url'];
// Auto-generate a unique slug-based username when creating a user without one
@@ -5,6 +5,7 @@ namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
@@ -18,7 +19,10 @@ class NewCommentLikeNotification extends Notification
public function via(object $notifiable): array
{
return ['database'];
$ch = [];
if ($notifiable->notificationPref('notif_comment_like')) $ch[] = 'database';
if ($notifiable->notificationPref('email_comment_like')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
@@ -35,4 +39,16 @@ class NewCommentLikeNotification extends Notification
'comment_preview' => Str::limit($this->comment->body, 80),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->liker->name . ' liked your comment')
->view('emails.comment-liked', [
'video' => $this->video,
'comment' => $this->comment,
'liker' => $this->liker,
'recipient'=> $notifiable,
]);
}
}
+17 -1
View File
@@ -5,6 +5,7 @@ namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
@@ -18,7 +19,10 @@ class NewCommentNotification extends Notification
public function via(object $notifiable): array
{
return ['database'];
$ch = [];
if ($notifiable->notificationPref('notif_new_comment')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_comment')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
@@ -35,4 +39,16 @@ class NewCommentNotification extends Notification
'comment_preview' => Str::limit($this->comment->body, 80),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->commenter->name . ' commented on your video')
->view('emails.new-comment', [
'video' => $this->video,
'comment' => $this->comment,
'commenter'=> $this->commenter,
'recipient'=> $notifiable,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Notifications;
use App\Models\Post;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewPostNotification extends Notification
{
public function __construct(public Post $post, public User $author) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_new_post_from_sub')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_post_from_sub')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_post',
'post_id' => $this->post->id,
'author_id' => $this->author->id,
'author_name' => $this->author->name,
'author_avatar' => $this->author->avatar_url,
'author_channel' => $this->author->channel,
'post_preview' => Str::limit(strip_tags($this->post->body ?? ''), 100),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->author->name . ' posted something new')
->view('emails.new-post-from-sub', [
'post' => $this->post,
'author' => $this->author,
'recipient' => $notifiable,
]);
}
}
+17 -1
View File
@@ -5,6 +5,7 @@ namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
@@ -18,7 +19,10 @@ class NewReplyNotification extends Notification
public function via(object $notifiable): array
{
return ['database'];
$ch = [];
if ($notifiable->notificationPref('notif_new_reply')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_reply')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
@@ -35,4 +39,16 @@ class NewReplyNotification extends Notification
'comment_preview' => Str::limit($this->reply->body, 80),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->replier->name . ' replied to your comment')
->view('emails.new-reply', [
'video' => $this->video,
'reply' => $this->reply,
'replier' => $this->replier,
'recipient'=> $notifiable,
]);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewSubscriberNotification extends Notification
{
public function __construct(public User $subscriber) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_new_subscriber')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_subscriber')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_subscriber',
'actor_id' => $this->subscriber->id,
'actor_name' => $this->subscriber->name,
'actor_avatar' => $this->subscriber->avatar_url,
'actor_channel' => $this->subscriber->channel,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->subscriber->name . ' subscribed to your channel')
->view('emails.new-subscriber', [
'subscriber' => $this->subscriber,
'recipient' => $notifiable,
]);
}
}
+5 -2
View File
@@ -3,8 +3,8 @@
namespace App\Notifications;
use App\Models\User;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewUserRegistered extends Notification
{
@@ -12,7 +12,10 @@ class NewUserRegistered extends Notification
public function via(object $notifiable): array
{
return ['database', 'mail'];
$ch = [];
if ($notifiable->notificationPref('notif_new_user_reg')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_user_reg')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
+17 -4
View File
@@ -4,17 +4,19 @@ namespace App\Notifications;
use App\Models\Video;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewVideoUploaded extends Notification
{
public function __construct(public Video $video, public User $uploader)
{
}
public function __construct(public Video $video, public User $uploader) {}
public function via(object $notifiable): array
{
return ['database'];
$ch = [];
if ($notifiable->notificationPref('notif_new_video_from_sub')) $ch[] = 'database';
if ($notifiable->notificationPref('email_new_video_from_sub')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
@@ -30,4 +32,15 @@ class NewVideoUploaded extends Notification
'uploader_avatar' => $this->uploader->avatar_url,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->uploader->name . ' uploaded a new video')
->view('emails.new-video-from-sub', [
'video' => $this->video,
'uploader' => $this->uploader,
'recipient'=> $notifiable,
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Notifications;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class VideoLikedNotification extends Notification
{
public function __construct(public Video $video, public User $liker) {}
public function via(object $notifiable): array
{
$ch = [];
if ($notifiable->notificationPref('notif_video_like')) $ch[] = 'database';
if ($notifiable->notificationPref('email_video_like')) $ch[] = 'mail';
return $ch;
}
public function toArray(object $notifiable): array
{
return [
'type' => 'video_like',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->liker->id,
'actor_name' => $this->liker->name,
'actor_avatar' => $this->liker->avatar_url,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject($this->liker->name . ' liked your video')
->view('emails.video-liked', [
'video' => $this->video,
'liker' => $this->liker,
'recipient'=> $notifiable,
]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Notifications;
use App\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\DB;
class WeeklyDigestNotification extends Notification
{
public array $stats;
public function __construct(public User $creator)
{
$videoIds = $creator->videos()->pluck('id');
$views = DB::table('video_views')
->whereIn('video_id', $videoIds)
->where('created_at', '>=', now()->subWeek())
->count();
$likes = DB::table('video_likes')
->whereIn('video_id', $videoIds)
->where('created_at', '>=', now()->subWeek())
->count();
$newSubs = DB::table('user_subscriptions')
->where('channel_id', $creator->id)
->where('created_at', '>=', now()->subWeek())
->count();
$topVideo = $creator->videos()
->withCount(['viewers as week_views' => fn($q) => $q->where('video_views.created_at', '>=', now()->subWeek())])
->orderByDesc('week_views')
->first();
$this->stats = [
'views' => $views,
'likes' => $likes,
'new_subs' => $newSubs,
'top_video' => $topVideo,
];
}
public function via(object $notifiable): array
{
return $notifiable->notificationPref('email_weekly_digest') ? ['mail'] : [];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Your weekly TAKEONE summary 📊')
->view('emails.weekly-digest', [
'creator' => $this->creator,
'stats' => $this->stats,
]);
}
}