15 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 (via existing Notification pattern)
- 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
Dev Agent Record
Status
Ready for Review
Agent Model Used
Claude Opus 4.5 (claude-opus-4-5-20251101)
File List
| File | Action | Description |
|---|---|---|
app/Mail/BookingRejectedEmail.php |
Created | Mailable class with bilingual support, ShouldQueue |
resources/views/emails/booking/rejected/ar.blade.php |
Created | Arabic email template (RTL) |
resources/views/emails/booking/rejected/en.blade.php |
Created | English email template (LTR) |
app/Notifications/BookingRejected.php |
Modified | Updated to use new BookingRejectedEmail mailable |
tests/Feature/Mail/BookingRejectedEmailTest.php |
Created | 22 test cases for mailable and notification |
Change Log
- Created
BookingRejectedEmailmailable with bilingual template support (ar/en) - Created Arabic email template with RTL layout, empathetic tone, conditional reason display
- Created English email template with LTR layout, empathetic tone, conditional reason display
- Updated existing
BookingRejectednotification to use the new mailable instead of inline view - Created comprehensive test suite (22 tests) covering:
- Template selection based on language preference
- Subject lines in both languages
- Reason display/hide logic
- Date/time formatting
- Notification integration
- Email rendering
Completion Notes
- Implementation Note: The story suggested Event/Listener pattern, but the codebase already had a
BookingRejectedNotification being dispatched fromadmin/bookings/review.blade.php. For consistency and to avoid duplication, I updated the existing notification to use the new Mailable instead of creating a separate Event/Listener system. - Database Schema: The
preferred_languagecolumn has a default of'ar'and is NOT NULL, so the "null handling" in the code is defensive but the database enforces Arabic as default. - All 22 tests pass, all booking-related tests (141 total) pass, Pint formatting complete.
QA Results
Review Date: 2026-01-02
Reviewed By: Quinn (Test Architect)
Code Quality Assessment
Overall Grade: Excellent
The implementation is clean, well-structured, and follows established codebase patterns. The mailable class properly implements ShouldQueue for asynchronous processing, uses the correct bilingual template strategy, and maintains consistency with the existing BookingApprovedMail implementation.
Strengths:
- Consistent pattern with existing
BookingApprovedMailclass (same constructor style, locale handling, date/time formatting) - Proper use of
ShouldQueueandSerializesModelstraits - Clean separation of concerns with dedicated formatting methods
- Defensive null handling for
preferred_languagedespite database default - Empathetic tone in templates appropriate for rejection scenarios
- Proper RTL/LTR handling via
<div dir="rtl">wrapper in Arabic template
Minor Observations (No Action Required):
- The class does not extend
BaseMailable(likeWelcomeEmaildoes), but this is consistent withBookingApprovedMail- both implement the pattern directly. No change needed for consistency. - Button URL uses
config('app.url')rather thanroute('booking')- this matches the pattern inBookingApprovedMailand is acceptable.
Refactoring Performed
None required. The code is well-structured and follows established patterns.
Compliance Check
- Coding Standards: ✓ Pint passes, follows PSR-12 conventions
- Project Structure: ✓ Files in correct locations (
app/Mail/,resources/views/emails/booking/rejected/) - Testing Strategy: ✓ Comprehensive test coverage with 22 tests covering all acceptance criteria
- All ACs Met: ✓ See traceability matrix below
Requirements Traceability Matrix
| AC | Requirement | Test Coverage |
|---|---|---|
| Trigger | Sent when consultation status changes to 'rejected' | notification sends booking rejected email, notification toMail returns BookingRejectedEmail mailable |
| Content | "Your consultation request could not be approved" | email renders without errors in english, email renders without errors in arabic |
| Content | Original requested date and time | date is formatted as d/m/Y for arabic users, date is formatted as m/d/Y for english users, time is formatted as h:i A, content includes all required data |
| Content | Rejection reason (conditionally shown) | booking rejected email includes reason when provided, booking rejected email handles null reason, booking rejected email handles empty reason, email renders reason section when reason provided, email does not render reason section when reason not provided |
| Content | Invitation to request new consultation | email renders without errors in english (verifies button presence) |
| Content | Contact info for questions | Template contains info@libra.ps contact |
| Tone | Empathetic, professional | Manual review: ✓ Templates use appropriate language ("نأسف", "We regret") |
| Language | Email in client's preferred language | booking rejected email uses arabic template for arabic preference, booking rejected email uses english template for english preference |
| Language | Default to Arabic if no preference | booking rejected email defaults to arabic from database default |
Improvements Checklist
All items satisfied - no improvements required:
- Mailable implements ShouldQueue for async processing
- Bilingual templates with proper RTL/LTR support
- Conditional reason display with
@if($hasReason)directive - Consistent date formatting per locale
- Subject line in user's preferred language
- 22 comprehensive tests covering all scenarios
- Integration with existing Notification pattern
- Pint formatting compliance
Security Review
No security concerns identified:
- No user input is rendered unescaped
- Email content uses Blade's default escaping
- No SQL queries or user-controlled data paths
Performance Considerations
No performance concerns:
- Email is queued via
ShouldQueue(not sent synchronously) SerializesModelsproperly serializes the Consultation model for queue processing- No N+1 queries in template rendering
Files Modified During Review
None - no modifications were required.
Gate Status
Gate: PASS → docs/qa/gates/8.5-booking-rejected-email.yml
Recommended Status
✓ Ready for Done
All acceptance criteria are met, all 22 tests pass, code follows established patterns, and no security or performance concerns exist.