8.4 KiB
8.4 KiB
Story 8.5: Booking Rejected Email
Epic Reference
Epic 8: Email Notification System
User Story
As a client, I want to be notified when my booking is rejected, So that I can understand why and request a new consultation if needed.
Dependencies
- Story 8.1: Email Infrastructure Setup (provides base template, branding, queue configuration)
- Epic 3: Consultation/booking system with status management
Assumptions
Consultationmodel exists withuserrelationship (belongsTo User)Usermodel haspreferred_languagefield (defaults to'ar'if null)- Admin rejection action captures optional
reasonfield - Consultation status changes to
'rejected'when admin rejects - Base email template and branding from Story 8.1 are available
Acceptance Criteria
Trigger
- Sent when consultation status changes to
'rejected'
Content
- "Your consultation request could not be approved"
- Original requested date and time
- Rejection reason (conditionally shown if provided by admin)
- Invitation to request new consultation
- Contact info for questions
Tone
- Empathetic, professional
Language
- Email in client's preferred language (Arabic or English)
- Default to Arabic if no preference set
Technical Notes
Files to Create/Modify
| File | Action | Description |
|---|---|---|
app/Mail/BookingRejectedEmail.php |
Create | Mailable class |
resources/views/emails/booking/rejected/ar.blade.php |
Create | Arabic template (RTL) |
resources/views/emails/booking/rejected/en.blade.php |
Create | English template (LTR) |
app/Listeners/SendBookingRejectedEmail.php |
Create | Event listener |
app/Events/ConsultationRejected.php |
Create | Event class (if not exists) |
Mailable Implementation
namespace App\Mail;
use App\Models\Consultation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class BookingRejectedEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Consultation $consultation,
public ?string $reason = null
) {}
public function envelope(): Envelope
{
$locale = $this->consultation->user->preferred_language ?? 'ar';
$subject = $locale === 'ar'
? 'تعذر الموافقة على طلب الاستشارة'
: 'Your Consultation Request Could Not Be Approved';
return new Envelope(subject: $subject);
}
public function content(): Content
{
$locale = $this->consultation->user->preferred_language ?? 'ar';
return new Content(
markdown: "emails.booking.rejected.{$locale}",
with: [
'consultation' => $this->consultation,
'reason' => $this->reason,
'hasReason' => !empty($this->reason),
],
);
}
}
Event/Listener Trigger
// In admin controller or service when rejecting consultation:
use App\Events\ConsultationRejected;
$consultation->update(['status' => 'rejected']);
event(new ConsultationRejected($consultation, $reason));
// app/Events/ConsultationRejected.php
class ConsultationRejected
{
public function __construct(
public Consultation $consultation,
public ?string $reason = null
) {}
}
// app/Listeners/SendBookingRejectedEmail.php
class SendBookingRejectedEmail
{
public function handle(ConsultationRejected $event): void
{
Mail::to($event->consultation->user->email)
->send(new BookingRejectedEmail(
$event->consultation,
$event->reason
));
}
}
// Register in EventServiceProvider boot() or use event discovery
Template Structure (Arabic Example)
{{-- resources/views/emails/booking/rejected/ar.blade.php --}}
<x-mail::message>
# تعذر الموافقة على طلب الاستشارة
عزيزي/عزيزتي {{ $consultation->user->name }},
نأسف لإبلاغك بأنه تعذر علينا الموافقة على طلب الاستشارة الخاص بك.
**التاريخ المطلوب:** {{ $consultation->scheduled_at->format('Y-m-d') }}
**الوقت المطلوب:** {{ $consultation->scheduled_at->format('H:i') }}
@if($hasReason)
**السبب:** {{ $reason }}
@endif
نرحب بك لتقديم طلب استشارة جديد في وقت آخر يناسبك.
<x-mail::button :url="route('client.consultations.create')">
طلب استشارة جديدة
</x-mail::button>
للاستفسارات، تواصل معنا على: info@libra.ps
مع أطيب التحيات,
مكتب ليبرا للمحاماة
</x-mail::message>
Edge Cases
| Scenario | Handling |
|---|---|
| Reason is null/empty | Hide reason section in template using @if($hasReason) |
| User has no preferred_language | Default to Arabic ('ar') |
| Queue failure | Standard Laravel queue retry (3 attempts) |
| User email invalid | Queue will fail, logged for admin review |
Testing Requirements
Unit Tests
// tests/Unit/Mail/BookingRejectedEmailTest.php
test('booking rejected email renders with reason', function () {
$consultation = Consultation::factory()->create();
$reason = 'Schedule conflict';
$mailable = new BookingRejectedEmail($consultation, $reason);
$mailable->assertSeeInHtml($reason);
$mailable->assertSeeInHtml($consultation->scheduled_at->format('Y-m-d'));
});
test('booking rejected email renders without reason', function () {
$consultation = Consultation::factory()->create();
$mailable = new BookingRejectedEmail($consultation, null);
$mailable->assertDontSeeInHtml('السبب:');
$mailable->assertDontSeeInHtml('Reason:');
});
test('booking rejected email uses arabic template for arabic preference', function () {
$user = User::factory()->create(['preferred_language' => 'ar']);
$consultation = Consultation::factory()->for($user)->create();
$mailable = new BookingRejectedEmail($consultation);
expect($mailable->content()->markdown)->toBe('emails.booking.rejected.ar');
});
test('booking rejected email uses english template for english preference', function () {
$user = User::factory()->create(['preferred_language' => 'en']);
$consultation = Consultation::factory()->for($user)->create();
$mailable = new BookingRejectedEmail($consultation);
expect($mailable->content()->markdown)->toBe('emails.booking.rejected.en');
});
test('booking rejected email defaults to arabic when no language preference', function () {
$user = User::factory()->create(['preferred_language' => null]);
$consultation = Consultation::factory()->for($user)->create();
$mailable = new BookingRejectedEmail($consultation);
expect($mailable->content()->markdown)->toBe('emails.booking.rejected.ar');
});
Feature Tests
// tests/Feature/Mail/BookingRejectedEmailTest.php
test('email is queued when consultation is rejected', function () {
Mail::fake();
$consultation = Consultation::factory()->create(['status' => 'pending']);
$reason = 'Not available';
event(new ConsultationRejected($consultation, $reason));
Mail::assertQueued(BookingRejectedEmail::class, function ($mail) use ($consultation) {
return $mail->consultation->id === $consultation->id;
});
});
test('email is sent to correct recipient', function () {
Mail::fake();
$user = User::factory()->create(['email' => 'client@example.com']);
$consultation = Consultation::factory()->for($user)->create();
event(new ConsultationRejected($consultation));
Mail::assertQueued(BookingRejectedEmail::class, function ($mail) {
return $mail->hasTo('client@example.com');
});
});
Definition of Done
BookingRejectedEmailmailable class created- Arabic template created with RTL layout and empathetic tone
- English template created with LTR layout and empathetic tone
- Event and listener wired for consultation rejection
- Reason conditionally displayed when provided
- Defaults to Arabic when no language preference
- Email queued (not sent synchronously)
- All unit tests pass
- All feature tests pass
- Code formatted with Pint
Estimation
Complexity: Low | Effort: 2-3 hours