Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions app/Http/Controllers/Admin/EmailController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,28 @@ class EmailController extends Controller
*/
public function create(): Response
{
$recipientCount = User::where('receive_emails', true)
$allSubscribersCount = User::where('receive_emails', true)
->whereNotNull('email')
->count();

$studentsCount = User::where('receive_emails', true)
->whereNotNull('email')
->doesntHave('roles')
->count();

return Inertia::render('admin/EmailSend', [
'recipientCount' => $recipientCount,
'recipientCount' => $allSubscribersCount,
'studentsCount' => $studentsCount,
]);
}

/**
* Dispatch emails to either a single user or all subscribed users in bulk.
* Dispatch emails to either a single user or all/student subscribed users in bulk.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'recipient_type' => ['required', 'in:all,single'],
'recipient_type' => ['required', 'in:all,students,single'],
'recipient_email' => ['required_if:recipient_type,single', 'nullable', 'email', 'max:255'],
'subject' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
Expand Down Expand Up @@ -70,16 +76,20 @@ public function store(Request $request): RedirectResponse
->with('success', "Email successfully queued for {$recipientEmail}.");
}

// Bulk broadcast to all subscribed users
// Bulk broadcast query
$usersQuery = User::where('receive_emails', true)
->whereNotNull('email');

if ($validated['recipient_type'] === 'students') {
$usersQuery->doesntHave('roles');
}

$totalRecipients = $usersQuery->count();

if ($totalRecipients === 0) {
return redirect()
->route('admin.emails.create')
->with('error', 'No subscribed recipients found.');
->with('error', 'No subscribed recipients found for the selected target.');
}

$usersQuery->chunkById(100, function ($users) use ($subject, $body, $imageUrl) {
Expand All @@ -95,8 +105,10 @@ public function store(Request $request): RedirectResponse
}
});

$targetLabel = $validated['recipient_type'] === 'students' ? 'students (non-staff)' : 'all subscribed';

return redirect()
->route('admin.emails.create')
->with('success', "Email broadcast successfully queued for {$totalRecipients} recipients.");
->with('success', "Email broadcast successfully queued for {$totalRecipients} {$targetLabel} recipients.");
}
}
65 changes: 57 additions & 8 deletions resources/js/pages/admin/EmailSend.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Send,
Users,
User,
GraduationCap,
AlertCircle,
AtSign,
Eye,
Expand All @@ -22,6 +23,10 @@ const props = defineProps({
type: Number,
default: 0,
},
studentsCount: {
type: Number,
default: 0,
},
});

const page = usePage();
Expand All @@ -34,7 +39,7 @@ const imagePreview = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);

const form = useForm({
recipient_type: 'all' as 'all' | 'single',
recipient_type: 'all' as 'all' | 'students' | 'single',
recipient_email: '',
subject: '',
body: '',
Expand Down Expand Up @@ -168,7 +173,7 @@ const submitForm = () => {
Recipient Target
</label>

<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<!-- Bulk to all subscribed -->
<button
type="button"
Expand All @@ -194,13 +199,47 @@ const submitForm = () => {
<p
class="text-sm font-bold text-slate-900 dark:text-gray-100"
>
All Subscribed Users
All Subscribed
</p>
<p
class="text-xs text-slate-500 dark:text-gray-400"
>
Bulk broadcast ({{ recipientCount }}
recipients)
{{ recipientCount }} recipients
</p>
</div>
</button>

<!-- Students (Non-Staff) -->
<button
type="button"
@click="form.recipient_type = 'students'"
class="flex items-center gap-3 rounded-2xl border p-4 text-left transition"
:class="
form.recipient_type === 'students'
? 'border-indigo-600 bg-indigo-50/40 ring-2 ring-indigo-600/10 dark:border-indigo-500 dark:bg-indigo-500/10'
: 'border-slate-200 bg-white hover:border-slate-300 dark:border-gray-700 dark:bg-gray-950/40 dark:hover:border-gray-600'
"
>
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
:class="
form.recipient_type === 'students'
? 'bg-indigo-600 text-white dark:bg-indigo-500'
: 'bg-slate-100 text-slate-500 dark:bg-gray-800 dark:text-gray-400'
"
>
<GraduationCap class="h-4.5 w-4.5" />
</div>
<div>
<p
class="text-sm font-bold text-slate-900 dark:text-gray-100"
>
Students (Non-Staff)
</p>
<p
class="text-xs text-slate-500 dark:text-gray-400"
>
{{ studentsCount }} public accounts
</p>
</div>
</button>
Expand Down Expand Up @@ -235,7 +274,7 @@ const submitForm = () => {
<p
class="text-xs text-slate-500 dark:text-gray-400"
>
Send to an individual email address
Specific email address
</p>
</div>
</button>
Expand Down Expand Up @@ -716,14 +755,18 @@ const submitForm = () => {
{{
form.recipient_type === 'single'
? 'Confirm Single Email'
: 'Confirm Email Broadcast'
: form.recipient_type === 'students'
? 'Confirm Students Broadcast'
: 'Confirm Email Broadcast'
}}
</h3>
<p class="text-xs text-slate-500 dark:text-gray-400">
{{
form.recipient_type === 'single'
? `Direct email to ${form.recipient_email}`
: `Queue broadcast to ${props.recipientCount} subscribed users`
: form.recipient_type === 'students'
? `Queue broadcast to ${props.studentsCount} students (non-staff)`
: `Queue broadcast to ${props.recipientCount} subscribed users`
}}
</p>
</div>
Expand All @@ -737,6 +780,12 @@ const submitForm = () => {
<strong>{{ form.recipient_email }}</strong
>?
</span>
<span v-else-if="form.recipient_type === 'students'">
Are you sure you want to send this broadcast? The emails
will be dispatched to
<strong>{{ props.studentsCount }}</strong> students
(public non-staff users) who have enabled email updates.
</span>
<span v-else>
Are you sure you want to send this broadcast? The emails
will be dispatched to all
Expand Down
44 changes: 44 additions & 0 deletions tests/Feature/AdminEmailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,47 @@
! empty($mail->imageUrl);
});
});

test('admin can queue broadcast emails specifically to students non-staff users', function () {
Mail::fake();

$admin = User::factory()->create(['email' => 'admin@example.com']);
$admin->assignRole('admin');

$editor = User::factory()->create(['email' => 'editor@example.com', 'receive_emails' => true]);
$editorRole = Role::findOrCreate('editor', 'web');
$editor->assignRole($editorRole);

$student1 = User::factory()->create(['email' => 'student1@example.com', 'receive_emails' => true]);
$student2 = User::factory()->create(['email' => 'student2@example.com', 'receive_emails' => true]);
$unsubStudent = User::factory()->create(['email' => 'unsub_student@example.com', 'receive_emails' => false]);

$response = $this->actingAs($admin)->post(route('admin.emails.store'), [
'recipient_type' => 'students',
'subject' => 'Student Community Update',
'body' => '<p>Special notice for all students.</p>',
]);

$response->assertRedirect(route('admin.emails.create'));
$response->assertSessionHas('success');

// Students with receive_emails=true should receive the email
Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) use ($student1) {
return $mail->hasTo($student1->email) &&
$mail->mailSubject === 'Student Community Update';
});

Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) use ($student2) {
return $mail->hasTo($student2->email);
});

// Staff/role-assigned users should NOT receive it
Mail::assertNotQueued(BulkAnnouncementMail::class, function ($mail) use ($editor) {
return $mail->hasTo($editor->email);
});

// Unsubscribed students should NOT receive it
Mail::assertNotQueued(BulkAnnouncementMail::class, function ($mail) use ($unsubStudent) {
return $mail->hasTo($unsubStudent->email);
});
});