# 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 - [x] Sent when consultation status changes to `'rejected'` ### Content - [x] "Your consultation request could not be approved" - [x] Original requested date and time - [x] Rejection reason (conditionally shown if provided by admin) - [x] Invitation to request new consultation - [x] Contact info for questions ### Tone - [x] Empathetic, professional ### Language - [x] Email in client's preferred language (Arabic or English) - [x] 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 ```php 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 ```php // 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) ```blade {{-- resources/views/emails/booking/rejected/ar.blade.php --}} # تعذر الموافقة على طلب الاستشارة عزيزي/عزيزتي {{ $consultation->user->name }}, نأسف لإبلاغك بأنه تعذر علينا الموافقة على طلب الاستشارة الخاص بك. **التاريخ المطلوب:** {{ $consultation->scheduled_at->format('Y-m-d') }} **الوقت المطلوب:** {{ $consultation->scheduled_at->format('H:i') }} @if($hasReason) **السبب:** {{ $reason }} @endif نرحب بك لتقديم طلب استشارة جديد في وقت آخر يناسبك. طلب استشارة جديدة للاستفسارات، تواصل معنا على: info@libra.ps مع أطيب التحيات, مكتب ليبرا للمحاماة ``` ## 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 ```php // 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 ```php // 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 - [x] `BookingRejectedEmail` mailable class created - [x] Arabic template created with RTL layout and empathetic tone - [x] English template created with LTR layout and empathetic tone - [x] Event and listener wired for consultation rejection (via existing Notification pattern) - [x] Reason conditionally displayed when provided - [x] Defaults to Arabic when no language preference - [x] Email queued (not sent synchronously) - [x] All unit tests pass - [x] All feature tests pass - [x] 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 `
` 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: - [x] Mailable implements ShouldQueue for async processing - [x] Bilingual templates with proper RTL/LTR support - [x] Conditional reason display with `@if($hasReason)` directive - [x] Consistent date formatting per locale - [x] Subject line in user's preferred language - [x] 22 comprehensive tests covering all scenarios - [x] Integration with existing Notification pattern - [x] 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 ### 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.