From 203b86ef069cceefbc15f36d214d8ad4dd48470d Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Sun, 18 Oct 2026 12:33:33 +0600 Subject: [PATCH] feat: rollup notifications, add in-app announcement broadcasts, and announcement detail modal --- .../Controllers/Admin/EmailController.php | 102 +++++---- app/Http/Controllers/AuthController.php | 2 +- app/Http/Controllers/BlogController.php | 2 +- app/Http/Controllers/ForumVoteController.php | 4 +- app/Http/Controllers/NodeController.php | 2 +- .../Controllers/UserProfileController.php | 2 +- .../BlogReactionNotification.php | 67 +++--- app/Notifications/ForumAnswerNotification.php | 32 +-- app/Notifications/ForumStatusNotification.php | 53 +---- app/Notifications/ForumVoteNotification.php | 46 ++++ app/Notifications/NodeVoteNotification.php | 37 ++++ .../SystemAnnouncementNotification.php | 37 ++++ .../UserAppreciationNotification.php | 69 +++--- .../js/components/NotificationDropdown.vue | 127 ++++++++++- .../js/components/navigation/Navigation.tsx | 4 + resources/js/pages/admin/EmailSend.vue | 204 ++++++++++++++++-- tests/Feature/AdminEmailTest.php | 40 ++-- tests/Feature/BlogNotificationTest.php | 53 +++-- tests/Feature/ForumAnswerNotificationTest.php | 7 +- tests/Feature/ForumNotificationTest.php | 12 +- tests/Feature/ForumVoteNotificationTest.php | 40 ++++ tests/Feature/NodeVoteNotificationTest.php | 38 ++++ .../UserAppreciationNotificationTest.php | 101 +++++++-- 23 files changed, 799 insertions(+), 282 deletions(-) create mode 100644 app/Notifications/SystemAnnouncementNotification.php diff --git a/app/Http/Controllers/Admin/EmailController.php b/app/Http/Controllers/Admin/EmailController.php index c26e45d6..a8c94fba 100644 --- a/app/Http/Controllers/Admin/EmailController.php +++ b/app/Http/Controllers/Admin/EmailController.php @@ -5,11 +5,14 @@ use App\Http\Controllers\Controller; use App\Mail\BulkAnnouncementMail; use App\Models\User; +use App\Notifications\SystemAnnouncementNotification; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; @@ -20,43 +23,32 @@ class EmailController extends Controller */ public function create(): Response { - $allSubscribersCount = User::where('receive_emails', true) - ->whereNotNull('email') - ->count(); - - $studentsCount = User::where('receive_emails', true) - ->whereNotNull('email') - ->doesntHave('roles') - ->count(); - - $staffCount = User::where('receive_emails', true) - ->whereNotNull('email') - ->has('roles') - ->count(); - return Inertia::render('admin/EmailSend', [ - 'recipientCount' => $allSubscribersCount, - 'studentsCount' => $studentsCount, - 'staffCount' => $staffCount, + 'allUsersCount' => User::count(), + 'recipientCount' => User::where('receive_emails', true)->count(), + 'studentsCount' => User::where('is_verified', false)->count(), + 'staffCount' => User::where('is_verified', true)->count(), ]); } /** - * Fetch raw subscribed email list for importing into the editor. + * Fetch raw email list for importing into the editor. */ public function recipients(Request $request): JsonResponse { - $type = $request->query('type', 'all'); + $type = $request->query('type', 'subscribers'); - $query = User::where('receive_emails', true)->whereNotNull('email'); + $query = User::query(); - if ($type === 'students') { - $query->doesntHave('roles'); + if ($type === 'subscribers') { + $query->where('receive_emails', true); + } elseif ($type === 'students') { + $query->where('is_verified', false); } elseif ($type === 'staff') { - $query->has('roles'); + $query->where('is_verified', true); } - $emails = $query->pluck('email')->unique()->values(); + $emails = $query->pluck('email'); return response()->json([ 'emails' => $emails, @@ -65,7 +57,7 @@ public function recipients(Request $request): JsonResponse } /** - * Dispatch emails to a list of raw recipient emails. + * Dispatch emails and/or in-app announcements. */ public function store(Request $request): RedirectResponse { @@ -74,8 +66,20 @@ public function store(Request $request): RedirectResponse 'subject' => ['required', 'string', 'max:255'], 'body' => ['required', 'string'], 'image' => ['sometimes', 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 'send_email' => ['sometimes', 'boolean'], + 'send_in_app' => ['sometimes', 'boolean'], + 'action_url' => ['sometimes', 'nullable', 'string', 'max:255'], ]); + $sendEmail = $request->boolean('send_email', true); + $sendInApp = $request->boolean('send_in_app', false); + + if (! $sendEmail && ! $sendInApp) { + return redirect() + ->route('admin.emails.create') + ->with('error', 'Please select at least one delivery channel (Email or In-App Notification).'); + } + $subject = $validated['subject']; $body = $validated['body']; @@ -95,7 +99,7 @@ public function store(Request $request): RedirectResponse if (empty($uniqueEmails)) { return redirect() ->route('admin.emails.create') - ->with('error', 'No valid recipient email addresses found in the list.'); + ->withErrors(['recipients' => 'Please provide at least one valid recipient email in the list.']); } $imageUrl = null; @@ -105,29 +109,37 @@ public function store(Request $request): RedirectResponse $imageUrl = str_starts_with($relativeUrl, 'http') ? $relativeUrl : url($relativeUrl); } - // Fetch known user names in bulk for personalization - $usersMap = User::whereIn('email', $uniqueEmails) - ->pluck('name', 'email') - ->toArray(); - - foreach ($uniqueEmails as $email) { - $recipientName = $usersMap[$email] ?? null; - - Mail::to($email)->queue( - new BulkAnnouncementMail( - mailSubject: $subject, - mailContent: $body, - recipientName: $recipientName, - imageUrl: $imageUrl, - ) - ); + $targetUsers = User::whereIn('email', $uniqueEmails)->get(); + + if ($sendEmail) { + $usersMap = $targetUsers->pluck('name', 'email'); + + foreach ($uniqueEmails as $email) { + Mail::to($email)->queue( + new BulkAnnouncementMail( + mailSubject: $subject, + mailContent: $body, + recipientName: $usersMap[$email] ?? null, + imageUrl: $imageUrl, + ) + ); + } } - $totalCount = count($uniqueEmails); - $emailPlural = $totalCount === 1 ? 'recipient' : 'recipients'; + if ($sendInApp && $targetUsers->isNotEmpty()) { + $announcement = new SystemAnnouncementNotification( + title: $subject, + snippet: Str::limit(strip_tags($body), 120), + fullContent: $body, + imageUrl: $imageUrl, + url: $validated['action_url'] ?? null, + ); + + Notification::send($targetUsers, $announcement); + } return redirect() ->route('admin.emails.create') - ->with('success', "Email successfully queued for {$totalCount} unique {$emailPlural}."); + ->with('success', 'Announcement successfully dispatched.'); } } diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 6c3ae793..40453d2f 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -220,7 +220,7 @@ public function completeOnboarding(Request $request) ]); $totalAppreciations = $targetUser->appreciationsReceived()->count(); - $targetUser->notify(new UserAppreciationNotification($user, $totalAppreciations)); + UserAppreciationNotification::sendTo($targetUser, $user, $totalAppreciations); } } } diff --git a/app/Http/Controllers/BlogController.php b/app/Http/Controllers/BlogController.php index 792d8592..8894c7e1 100644 --- a/app/Http/Controllers/BlogController.php +++ b/app/Http/Controllers/BlogController.php @@ -185,7 +185,7 @@ public function toggleReaction(Blog $blog) $blog->reactions()->create(['user_id' => $user->id]); if ($blog->user_id !== $user->id) { - $blog->user->notify(new BlogReactionNotification($blog, $user, $blog->reactions()->count())); + BlogReactionNotification::sendTo($blog->user, $blog, $user, $blog->reactions()->count()); } } diff --git a/app/Http/Controllers/ForumVoteController.php b/app/Http/Controllers/ForumVoteController.php index b57441fd..1762cfe3 100644 --- a/app/Http/Controllers/ForumVoteController.php +++ b/app/Http/Controllers/ForumVoteController.php @@ -64,7 +64,7 @@ public function votePost(Request $request, ForumPost $post): RedirectResponse }); if ($isFirstTimeUpvote && $post->user_id !== $userId) { - $post->user->notify(new ForumVoteNotification($post, $user)); + ForumVoteNotification::sendTo($post->user, $post, $user); } return back(); @@ -123,7 +123,7 @@ public function voteAnswer(Request $request, ForumAnswer $answer): RedirectRespo }); if ($isFirstTimeUpvote && $answer->user_id !== $userId) { - $answer->user->notify(new ForumVoteNotification($answer, $user)); + ForumVoteNotification::sendTo($answer->user, $answer, $user); } return back(); diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index d3ce1ee1..f5e5bea6 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -109,7 +109,7 @@ public function vote(Request $request, Node $node) } if ($isFirstTimeUpvote && $node->user_id && $node->user_id !== $user->id) { - $node->user->notify(new NodeVoteNotification($node, $user)); + NodeVoteNotification::sendTo($node->user, $node, $user); } return back(); diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 49cbe3c3..e219951c 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -367,7 +367,7 @@ public function toggleAppreciate(User $user) ]); $totalAppreciations = $user->appreciationsReceived()->count(); - $user->notify(new UserAppreciationNotification($currentAuthUser, $totalAppreciations)); + UserAppreciationNotification::sendTo($user, $currentAuthUser, $totalAppreciations); } return back(); diff --git a/app/Notifications/BlogReactionNotification.php b/app/Notifications/BlogReactionNotification.php index d7c14d20..c5244948 100644 --- a/app/Notifications/BlogReactionNotification.php +++ b/app/Notifications/BlogReactionNotification.php @@ -6,8 +6,8 @@ use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Cache; class BlogReactionNotification extends Notification implements ShouldQueue { @@ -19,44 +19,45 @@ public function __construct( public int $reactionsCount, ) {} - public function isMilestone(): bool + /** + * Send or roll up a blog reaction notification to the blog author. + */ + public static function sendTo(User $targetUser, Blog $blog, User $reactor, int $reactionsCount): void { - $milestones = [1, 10, 25, 50, 100, 250, 500, 1000]; + if ($targetUser->id === $reactor->id) { + return; + } - return in_array($this->reactionsCount, $milestones, true) - || ($this->reactionsCount > 1000 && $this->reactionsCount % 500 === 0); - } + $unread = $targetUser->unreadNotifications() + ->where('type', self::class) + ->get() + ->first(fn ($n) => ($n->data['blog_id'] ?? null) === $blog->id); - public function via(object $notifiable): array - { - $channels = ['database']; + if ($unread) { + $data = $unread->data; + $count = ($data['count'] ?? 1) + 1; + $others = $count - 1; + $othersText = $others === 1 ? '1 other person' : "{$others} other people"; - if ($this->isMilestone() && ! empty($notifiable->email) && ($notifiable->receive_emails ?? true)) { - $channels[] = 'mail'; - } + $data['count'] = $count; + $data['title'] = "{$reactor->name} and {$othersText} loved your blog post"; + $data['message'] = $blog->title; + $data['reactions_count'] = $reactionsCount; - return $channels; + $unread->update([ + 'data' => $data, + 'created_at' => now(), + ]); + + Cache::forever("user:{$targetUser->id}:has_unread_notifs", true); + } else { + $targetUser->notify(new self($blog, $reactor, $reactionsCount)); + } } - public function toMail(object $notifiable): MailMessage + public function via(object $notifiable): array { - $headline = $this->reactionsCount === 1 - ? 'Your blog received its first love reaction! ❤️' - : "🎉 Milestone: {$this->reactionsCount} people loved your blog post!"; - - return (new MailMessage) - ->subject($headline) - ->view('emails.default', [ - 'subject' => $headline, - 'greeting' => "Hello {$notifiable->name},", - 'lines' => [ - "{$this->reactor->name} just loved your blog post \"{$this->blog->title}\".", - "Your article has reached a milestone of {$this->reactionsCount} love reactions!", - 'Thank you for creating content that resonates with our community.', - ], - 'actionText' => 'View Blog Post', - 'actionUrl' => route('blogs.show', $this->blog->slug), - ]); + return ['database']; } public function toArray(object $notifiable): array @@ -66,6 +67,10 @@ public function toArray(object $notifiable): array 'title' => "{$this->reactor->name} loved your blog post", 'message' => $this->blog->title, 'url' => route('blogs.show', $this->blog->slug), + 'blog_id' => $this->blog->id, + 'reactor_id' => $this->reactor->id, + 'reactions_count' => $this->reactionsCount, + 'count' => 1, ]; } } diff --git a/app/Notifications/ForumAnswerNotification.php b/app/Notifications/ForumAnswerNotification.php index 58273cae..f36f13c5 100644 --- a/app/Notifications/ForumAnswerNotification.php +++ b/app/Notifications/ForumAnswerNotification.php @@ -6,7 +6,6 @@ use App\Models\ForumPost; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; use Illuminate\Support\Str; @@ -27,36 +26,7 @@ public function __construct( */ public function via(object $notifiable): array { - $channels = ['database']; - - // Only send email for direct answers to questions (not sub-replies), if user is opted in - if (! $this->isReply && ! empty($notifiable->email) && ($notifiable->receive_emails ?? true)) { - $channels[] = 'mail'; - } - - return $channels; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $authorName = $this->answer->user?->name ?? 'A student'; - $snippet = Str::limit(strip_tags($this->answer->body), 150); - - return (new MailMessage) - ->subject("New answer to your question: \"{$this->post->title}\" 💬") - ->view('emails.default', [ - 'subject' => "New answer on \"{$this->post->title}\"", - 'greeting' => "Hello {$notifiable->name},", - 'lines' => [ - "{$authorName} just answered your question \"{$this->post->title}\":", - "\"{$snippet}\"", - ], - 'actionText' => 'View Answer', - 'actionUrl' => route('forum.show', $this->post->slug), - ]); + return ['database']; } /** diff --git a/app/Notifications/ForumStatusNotification.php b/app/Notifications/ForumStatusNotification.php index 938bf478..457ff1cb 100644 --- a/app/Notifications/ForumStatusNotification.php +++ b/app/Notifications/ForumStatusNotification.php @@ -5,7 +5,6 @@ use App\Models\ForumPost; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; class ForumStatusNotification extends Notification implements ShouldQueue @@ -26,57 +25,7 @@ public function __construct( */ public function via(object $notifiable): array { - $channels = ['database']; - - // Send email on approval, flagged, and rejected if user has emails enabled - if (in_array($this->status, ['approved', 'flagged', 'rejected'], true) - && ! empty($notifiable->email) - && ($notifiable->receive_emails ?? true)) { - $channels[] = 'mail'; - } - - return $channels; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $subject = match ($this->status) { - 'approved' => 'Your question was approved! 🎉', - 'flagged' => 'Important notice regarding your question ⚠️', - 'rejected' => 'Your question was rejected by moderators 🚫', - default => "Update on your discussion: \"{$this->post->title}\"", - }; - - $lines = match ($this->status) { - 'approved' => [ - "Great news! Your question \"{$this->post->title}\" has been approved by our moderation team.", - 'It is now publicly visible in the forum for other students and mentors to answer.', - ], - 'flagged' => [ - "Your question \"{$this->post->title}\" was flagged for review following community reports.", - 'It has been temporarily hidden while our moderation team reviews the content against our community guidelines.', - ], - 'rejected' => [ - "Your question \"{$this->post->title}\" was reviewed and rejected by our moderation team.", - 'Please ensure all questions adhere to our content policy and community guidelines.', - ], - default => [ - "There has been an update on your discussion \"{$this->post->title}\".", - ], - }; - - return (new MailMessage) - ->subject($subject) - ->view('emails.default', [ - 'subject' => $subject, - 'greeting' => "Hello {$notifiable->name},", - 'lines' => $lines, - 'actionText' => $this->status === 'approved' ? 'View Question' : 'Visit HSCStack', - 'actionUrl' => $this->status === 'approved' ? route('forum.show', $this->post->slug) : route('index'), - ]); + return ['database']; } /** diff --git a/app/Notifications/ForumVoteNotification.php b/app/Notifications/ForumVoteNotification.php index 54997df1..2ec0f4ba 100644 --- a/app/Notifications/ForumVoteNotification.php +++ b/app/Notifications/ForumVoteNotification.php @@ -8,6 +8,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Cache; class ForumVoteNotification extends Notification implements ShouldQueue { @@ -18,6 +19,48 @@ public function __construct( public User $voter, ) {} + /** + * Send or roll up a forum vote notification to the author. + */ + public static function sendTo(User $targetUser, ForumPost|ForumAnswer $item, User $voter): void + { + if ($targetUser->id === $voter->id) { + return; + } + + $isQuestion = $item instanceof ForumPost; + $targetType = $isQuestion ? 'question' : 'answer'; + $itemId = $item->id; + $itemType = $isQuestion ? 'post' : 'answer'; + + $unread = $targetUser->unreadNotifications() + ->where('type', self::class) + ->get() + ->first(fn ($n) => ($n->data['item_type'] ?? null) === $itemType && ($n->data['item_id'] ?? null) === $itemId); + + if ($unread) { + $data = $unread->data; + $count = ($data['count'] ?? 1) + 1; + $others = $count - 1; + $othersText = $others === 1 ? '1 other person' : "{$others} other people"; + + $title = $isQuestion ? $item->title : $item->post->title; + + $data['count'] = $count; + $data['title'] = "{$voter->name} and {$othersText} upvoted your {$targetType}"; + $data['message'] = "\"{$title}\""; + + $unread->update([ + 'data' => $data, + 'created_at' => now(), + ]); + + Cache::forever("user:{$targetUser->id}:has_unread_notifs", true); + } else { + $targetUser->notify(new self($item, $voter)); + } + } + public function via(object $notifiable): array { return ['database']; @@ -34,6 +77,9 @@ public function toArray(object $notifiable): array 'title' => "{$this->voter->name} upvoted your ".($isQuestion ? 'question' : 'answer'), 'message' => "\"{$title}\"", 'url' => route('forum.show', $slug), + 'item_type' => $isQuestion ? 'post' : 'answer', + 'item_id' => $this->item->id, + 'count' => 1, ]; } } diff --git a/app/Notifications/NodeVoteNotification.php b/app/Notifications/NodeVoteNotification.php index 22d5404b..896c150a 100644 --- a/app/Notifications/NodeVoteNotification.php +++ b/app/Notifications/NodeVoteNotification.php @@ -7,6 +7,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Cache; class NodeVoteNotification extends Notification implements ShouldQueue { @@ -17,6 +18,41 @@ public function __construct( public User $voter, ) {} + /** + * Send or roll up a node vote notification to the node contributor. + */ + public static function sendTo(User $targetUser, Node $node, User $voter): void + { + if ($targetUser->id === $voter->id) { + return; + } + + $unread = $targetUser->unreadNotifications() + ->where('type', self::class) + ->get() + ->first(fn ($n) => ($n->data['node_id'] ?? null) === $node->id); + + if ($unread) { + $data = $unread->data; + $count = ($data['count'] ?? 1) + 1; + $others = $count - 1; + $othersText = $others === 1 ? '1 other person' : "{$others} other people"; + + $data['count'] = $count; + $data['title'] = "{$voter->name} and {$othersText} upvoted your folder"; + $data['message'] = "\"{$node->name}\""; + + $unread->update([ + 'data' => $data, + 'created_at' => now(), + ]); + + Cache::forever("user:{$targetUser->id}:has_unread_notifs", true); + } else { + $targetUser->notify(new self($node, $voter)); + } + } + public function via(object $notifiable): array { return ['database']; @@ -38,6 +74,7 @@ public function toArray(object $notifiable): array 'message' => "\"{$this->node->name}\"", 'url' => $url, 'node_id' => $this->node->id, + 'count' => 1, ]; } } diff --git a/app/Notifications/SystemAnnouncementNotification.php b/app/Notifications/SystemAnnouncementNotification.php new file mode 100644 index 00000000..7b24ffa2 --- /dev/null +++ b/app/Notifications/SystemAnnouncementNotification.php @@ -0,0 +1,37 @@ + 'system_announcement', + 'title' => $this->title, + 'message' => $this->snippet, + 'full_content' => $this->fullContent, + 'image_url' => $this->imageUrl, + 'url' => $this->url, + ]; + } +} diff --git a/app/Notifications/UserAppreciationNotification.php b/app/Notifications/UserAppreciationNotification.php index b791f881..9d183125 100644 --- a/app/Notifications/UserAppreciationNotification.php +++ b/app/Notifications/UserAppreciationNotification.php @@ -5,8 +5,8 @@ use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Cache; class UserAppreciationNotification extends Notification implements ShouldQueue { @@ -18,14 +18,38 @@ public function __construct( ) {} /** - * Determine if this count represents an achievement milestone. + * Send or roll up an appreciation notification to the target user. */ - public function isMilestone(): bool + public static function sendTo(User $targetUser, User $appreciator, int $totalAppreciations): void { - $milestones = [1, 10, 25, 50, 100, 250, 500, 1000]; + if ($targetUser->id === $appreciator->id) { + return; + } + + $unread = $targetUser->unreadNotifications() + ->where('type', self::class) + ->first(); + + if ($unread) { + $data = $unread->data; + $count = ($data['count'] ?? 1) + 1; + $others = $count - 1; + $othersText = $others === 1 ? '1 other person' : "{$others} other people"; - return in_array($this->totalAppreciations, $milestones, true) - || ($this->totalAppreciations > 1000 && $this->totalAppreciations % 500 === 0); + $data['count'] = $count; + $data['title'] = "{$appreciator->name} and {$othersText} appreciated you!"; + $data['message'] = "You have {$totalAppreciations} community appreciations."; + $data['total_appreciations'] = $totalAppreciations; + + $unread->update([ + 'data' => $data, + 'created_at' => now(), + ]); + + Cache::forever("user:{$targetUser->id}:has_unread_notifs", true); + } else { + $targetUser->notify(new self($appreciator, $totalAppreciations)); + } } /** @@ -35,37 +59,7 @@ public function isMilestone(): bool */ public function via(object $notifiable): array { - $channels = ['database']; - - if ($this->isMilestone() && ! empty($notifiable->email) && ($notifiable->receive_emails ?? true)) { - $channels[] = 'mail'; - } - - return $channels; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $subject = $this->totalAppreciations === 1 - ? '🎉 You received your first community appreciation!' - : "⭐ Milestone: {$this->totalAppreciations} students appreciated your profile!"; - - return (new MailMessage) - ->subject($subject) - ->view('emails.default', [ - 'subject' => $subject, - 'greeting' => "Hello {$notifiable->name},", - 'lines' => [ - "{$this->appreciator->name} (@{$this->appreciator->username}) just appreciated your contributions on HSCStack!", - "You have now received a total of {$this->totalAppreciations} appreciation".($this->totalAppreciations === 1 ? '' : 's').' from the community.', - 'Thank you for your active participation and helpful presence.', - ], - 'actionText' => 'View Profile', - 'actionUrl' => route('user.profile', $notifiable->username), - ]); + return ['database']; } /** @@ -82,6 +76,7 @@ public function toArray(object $notifiable): array 'url' => route('user.profile', $notifiable->username), 'appreciator_id' => $this->appreciator->id, 'total_appreciations' => $this->totalAppreciations, + 'count' => 1, ]; } } diff --git a/resources/js/components/NotificationDropdown.vue b/resources/js/components/NotificationDropdown.vue index 3f3594ee..24f72bae 100644 --- a/resources/js/components/NotificationDropdown.vue +++ b/resources/js/components/NotificationDropdown.vue @@ -16,6 +16,8 @@ import { Clock, LifeBuoy, Zap, + Megaphone, + X, } from 'lucide-vue-next'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { getCsrfToken } from '@/lib/useCsrf'; @@ -53,6 +55,8 @@ const notifications = ref([]); const hasMore = ref(false); const currentPage = ref(1); const dropdownRef = ref(null); +const showAnnouncementModal = ref(false); +const activeAnnouncement = ref(null); // Get initial unread state from Inertia shared prop const hasUnread = ref( @@ -255,6 +259,19 @@ const markAsRead = async (notification: NotificationItem) => { closeDropdown(); + if ( + notification.data?.full_content || + (!notification.data?.url && + (notification.data?.image_url || + notification.data?.type === 'system_announcement' || + notification.data?.type === 'system_notice')) + ) { + activeAnnouncement.value = notification; + showAnnouncementModal.value = true; + + return; + } + if (notification.data?.url) { router.visit(notification.data.url); } @@ -367,7 +384,7 @@ onBeforeUnmount(() => { + + +

+ {{ activeAnnouncement.data?.title }} +

+ +
+ +
+ + View Link → + + +
+
+ + + + diff --git a/resources/js/components/navigation/Navigation.tsx b/resources/js/components/navigation/Navigation.tsx index 6bd25cf2..e52efc1a 100644 --- a/resources/js/components/navigation/Navigation.tsx +++ b/resources/js/components/navigation/Navigation.tsx @@ -97,6 +97,10 @@ function openNotificationsFromLabel( e: MouseEvent | KeyboardEvent, scope: Ref, ) { + if ((e.target as HTMLElement | null)?.closest('button')) { + return; + } + e.stopPropagation(); e.preventDefault(); scope.value?.querySelector('button')?.click(); diff --git a/resources/js/pages/admin/EmailSend.vue b/resources/js/pages/admin/EmailSend.vue index 022f4313..3d4ace42 100644 --- a/resources/js/pages/admin/EmailSend.vue +++ b/resources/js/pages/admin/EmailSend.vue @@ -15,12 +15,18 @@ import { Trash2, Download, ChevronDown, + Bell, + Megaphone, } from 'lucide-vue-next'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import HTMLEditor from '@/components/HTMLEditor.vue'; defineProps({ + allUsersCount: { + type: Number, + default: 0, + }, recipientCount: { type: Number, default: 0, @@ -52,6 +58,9 @@ const form = useForm({ subject: '', body: '', image: null as File | null, + send_email: true, + send_in_app: false, + action_url: '', }); const formattedCurrentDate = computed(() => { @@ -149,7 +158,9 @@ const handleRemoveImage = () => { }; // Import helper -const importSubscribers = async (type: 'all' | 'students' | 'staff') => { +const importSubscribers = async ( + type: 'subscribers' | 'all' | 'students' | 'staff', +) => { if (isImporting.value) { return; } @@ -191,6 +202,14 @@ const cleanAndFormatRecipients = () => { }; const handleSendClick = () => { + if (!form.send_email && !form.send_in_app) { + alert( + 'Please select at least one delivery channel (Email or In-App Notification).', + ); + + return; + } + if (recipientStats.value.validEmails.length === 0) { form.setError( 'recipients', @@ -280,6 +299,124 @@ const submitForm = () => {
+ +
+
+ +

+ Choose whether to broadcast this message via Email, + In-App Notification (Bell icon), or both. +

+
+ +
+ + + + + +
+ + +
+
+ + Optional In-App Action Link +
+ +
+ +

+ If left blank, clicking the bell notification opens + the full announcement details in an interactive + modal. +

+
+
+
+
{ v-if="isImportDropdownOpen" class="absolute right-0 z-30 mt-1.5 w-60 overflow-hidden rounded-2xl border border-slate-200 bg-white p-1.5 shadow-xl dark:border-gray-800 dark:bg-gray-900" > + + + +
- {{ recipientCount }} + {{ allUsersCount }} + +