Repository navigation
feat: notification rollups, in-app announcement broadcasts, and announcement detail modal #426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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'], | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: set -o pipefail
printf '%s\n' '--- changed hunk ---'
git diff --unified=40 3b48544088a692f9e1ef18352216d0998554fb6b 203b86ef069cceefbc15f36d214d8ad4dd48470d -- app/Http/Controllers/Admin/EmailController.php
printf '%s\n' '--- controller ---'
nl -ba app/Http/Controllers/Admin/EmailController.php | sed -n '1,180p'
printf '%s\n' '--- action_url consumers ---'
rg -n -F --glob '*.vue' --glob '*.php' -e 'activeAnnouncement.data.url' -e 'data.url' -e 'SystemAnnouncementNotification' resources app routes || test "$?" -eq 1
printf '%s\n' '--- notification dropdown context ---'
rg -n -F --glob '*.vue' 'activeAnnouncement.data.url' resources || test "$?" -eq 1
hit=$(rg -l -F --glob '*.vue' 'activeAnnouncement.data.url' resources | head -n 1)
if [ -n "$hit" ]; then
nl -ba "$hit" | sed -n '800,870p'
fi
printf '%s\n' '--- permission role definitions ---'
rg -n -F --glob '*.php' -e "'send email'" -e '"send email"' app config database routes tests || test "$?" -eq 1Repository: hscstack/platform Length of output: 18497 XSS Reachability: External Validate
Proposed fix--- "a/app/Http/Controllers/Admin/EmailController.php"
+++ "b/app/Http/Controllers/Admin/EmailController.php"
@@ -68,7 +68,7 @@
'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'],
+ 'action_url' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:#^(/(?!/)|https?://)#i'],
]);
$sendEmail = $request->boolean('send_email', true);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| ]); | ||||||
|
|
||||||
| $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.'); | ||||||
|
Comment on lines
+129
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win An in-app-only send can report success when it sends nothing. Assume Proposed fix $targetUsers = User::whereIn('email', $uniqueEmails)->get();
+ if (! $sendEmail && $targetUsers->isEmpty()) {
+ return redirect()
+ ->route('admin.emails.create')
+ ->withErrors(['recipients' => 'None of the recipients are registered users, so no in-app notification can be sent.']);
+ }
+🤖 Prompt for AI Agents |
||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+36
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win The rollup counts events, not distinct reactors.
Fix: store the IDs of distinct actors in the notification data. Increment only when the actor is new. 🐛 Proposed fix (same pattern applies to forum/node)--- "a/app/Notifications/BlogReactionNotification.php"
+++ "b/app/Notifications/BlogReactionNotification.php"
@@ -33,13 +33,20 @@
->get()
->first(fn ($n) => ($n->data['blog_id'] ?? null) === $blog->id);
if ($unread) {
$data = $unread->data;
- $count = ($data['count'] ?? 1) + 1;
+ $actorIds = $data['actor_ids'] ?? [($data['reactor_id'] ?? null)];
+ if (in_array($reactor->id, $actorIds, true)) {
+ return;
+ }
+ $actorIds[] = $reactor->id;
+ $count = count($actorIds);
$others = $count - 1;
$othersText = $others === 1 ? '1 other person' : "{$others} other people";
+ $data['actor_ids'] = $actorIds;
+ $data['reactor_id'] = $reactor->id;
$data['count'] = $count;
$data['title'] = "{$reactor->name} and {$othersText} loved your blog post";
$data['message'] = $blog->title;
$data['reactions_count'] = $reactionsCount;Also add 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+53
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
rg -n "QUEUE_CONNECTION" -g '.env*' -g 'phpunit.xml*' -g 'config/queue.php'Repository: hscstack/platform Length of output: 504 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- BlogReactionNotification ---'
nl -ba app/Notifications/BlogReactionNotification.php
printf '%s\n' '--- related notification classes ---'
for f in app/Notifications/ForumVoteNotification.php app/Notifications/NodeVoteNotification.php; do
if [ -f "$f" ]; then nl -ba "$f"; fi
done
printf '%s\n' '--- notification call sites ---'
rg -n -F --glob '*.php' -e 'BlogReactionNotification' -e 'sendTo(' app tests || test "$?" -eq 1
printf '%s\n' '--- relevant notification configuration and dependency versions ---'
nl -ba config/queue.php
rg -n -F --glob 'composer.json' -e '"laravel/framework"' -e '"illuminate/notifications"' .
rg -n -F --glob '*.php' -e 'unreadNotifications()' -e 'implements ShouldQueue' app tests || test "$?" -eq 1
printf '%s\n' '--- focused tests ---'
rg -n -F --glob '*Test.php' -e 'BlogReactionNotification' -e 'ForumVoteNotification' -e 'NodeVoteNotification' tests || test "$?" -eq 1Repository: hscstack/platform Length of output: 26245 🏁 Script executed: #!/bin/bash
set -eu
nl -ba tests/Feature/BlogNotificationTest.php
printf '%s\n' '--- test bootstrap queue setup ---'
rg -n -F --glob '*.php' -e 'QUEUE_CONNECTION' -e 'Notification::fake' -e 'Queue::fake' tests phpunit.xml
printf '%s\n' '--- PR diff for the affected notification ---'
git diff --no-ext-diff --unified=20 3b48544088a692f9e1ef18352216d0998554fb6b 203b86ef069cceefbc15f36d214d8ad4dd48470d -- app/Notifications/BlogReactionNotification.php tests/Feature/BlogNotificationTest.phpRepository: hscstack/platform Length of output: 17354 Keep rollups synchronous for database notifications. When an asynchronous queue worker handles Use synchronous delivery for this database-only rollup. Apply the same correction to The aggregation test runs with 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win
Reachability: Internal
Exploitability: Trivial
CWE: CWE-359
View Security blast radius
Opted-out users are imported for bulk email.
Before this change, every import type required
receive_emails = true. Now theall,students, andstafftypes skip that filter. "All Registered Users", "All Students" and "All Staff" therefore load users who unsubscribed into the recipient list.store()then queuesBulkAnnouncementMailto each of those addresses. The test attests/Feature/AdminEmailTest.phpLine 67 expectsunsub@example.comin this list. The result is marketing-style email to users who opted out, which is a CAN-SPAM/GDPR consent risk.Two other cases also need fixing:
typevalue applies no filter and returns every email.Proposed fix: enforce opt-out when sending email
if ($sendEmail) { $usersMap = $targetUsers->pluck('name', 'email'); + $optedOut = $targetUsers->where('receive_emails', false)->pluck('email')->flip(); foreach ($uniqueEmails as $email) { + if ($optedOut->has($email)) { + continue; + } Mail::to($email)->queue(📝 Committable suggestion
🤖 Prompt for AI Agents