libra/docs/stories/story-8.5-booking-rejected-...

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

  • Consultation model exists with user relationship (belongsTo User)
  • User model has preferred_language field (defaults to 'ar' if null)
  • Admin rejection action captures optional reason field
  • 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

  • BookingRejectedEmail mailable 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