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

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

  • 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 (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 BookingRejectedEmail mailable 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 BookingRejected notification 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 BookingRejected Notification being dispatched from admin/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_language column 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 BookingApprovedMail class (same constructor style, locale handling, date/time formatting)
  • Proper use of ShouldQueue and SerializesModels traits
  • Clean separation of concerns with dedicated formatting methods
  • Defensive null handling for preferred_language despite 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 (like WelcomeEmail does), but this is consistent with BookingApprovedMail - both implement the pattern directly. No change needed for consistency.
  • Button URL uses config('app.url') rather than route('booking') - this matches the pattern in BookingApprovedMail and 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)
  • SerializesModels properly 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

Ready for Done

All acceptance criteria are met, all 22 tests pass, code follows established patterns, and no security or performance concerns exist.