diff --git a/app/Mail/BookingRejectedEmail.php b/app/Mail/BookingRejectedEmail.php
new file mode 100644
index 0000000..9a75bef
--- /dev/null
+++ b/app/Mail/BookingRejectedEmail.php
@@ -0,0 +1,68 @@
+locale = $consultation->user->preferred_language ?? 'ar';
+ }
+
+ public function envelope(): Envelope
+ {
+ $locale = $this->consultation->user->preferred_language ?? 'ar';
+
+ return new Envelope(
+ subject: $locale === 'ar'
+ ? 'تعذر الموافقة على طلب الاستشارة'
+ : 'Your Consultation Request Could Not Be Approved',
+ );
+ }
+
+ public function content(): Content
+ {
+ $locale = $this->consultation->user->preferred_language ?? 'ar';
+
+ return new Content(
+ markdown: 'emails.booking.rejected.'.$locale,
+ with: [
+ 'consultation' => $this->consultation,
+ 'user' => $this->consultation->user,
+ 'reason' => $this->reason,
+ 'hasReason' => ! empty($this->reason),
+ 'formattedDate' => $this->getFormattedDate($locale),
+ 'formattedTime' => $this->getFormattedTime(),
+ ],
+ );
+ }
+
+ public function getFormattedDate(string $locale): string
+ {
+ $date = $this->consultation->booking_date;
+
+ return $locale === 'ar'
+ ? $date->format('d/m/Y')
+ : $date->format('m/d/Y');
+ }
+
+ public function getFormattedTime(): string
+ {
+ $time = $this->consultation->booking_time;
+
+ return Carbon::parse($time)->format('h:i A');
+ }
+}
diff --git a/app/Notifications/BookingRejected.php b/app/Notifications/BookingRejected.php
index c05ca4c..4676fb6 100644
--- a/app/Notifications/BookingRejected.php
+++ b/app/Notifications/BookingRejected.php
@@ -2,10 +2,11 @@
namespace App\Notifications;
+use App\Mail\BookingRejectedEmail;
use App\Models\Consultation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
-use Illuminate\Notifications\Messages\MailMessage;
+use Illuminate\Mail\Mailable;
use Illuminate\Notifications\Notification;
class BookingRejected extends Notification implements ShouldQueue
@@ -33,28 +34,10 @@ class BookingRejected extends Notification implements ShouldQueue
/**
* Get the mail representation of the notification.
*/
- public function toMail(object $notifiable): MailMessage
+ public function toMail(object $notifiable): Mailable
{
- $locale = $notifiable->preferred_language ?? 'ar';
-
- return (new MailMessage)
- ->subject($this->getSubject($locale))
- ->view('emails.booking-rejected', [
- 'consultation' => $this->consultation,
- 'rejectionReason' => $this->rejectionReason,
- 'locale' => $locale,
- 'user' => $notifiable,
- ]);
- }
-
- /**
- * Get the subject based on locale.
- */
- private function getSubject(string $locale): string
- {
- return $locale === 'ar'
- ? 'بخصوص طلب الاستشارة الخاص بك'
- : 'Regarding Your Consultation Request';
+ return (new BookingRejectedEmail($this->consultation, $this->rejectionReason))
+ ->to($notifiable->email);
}
/**
diff --git a/docs/qa/gates/8.5-booking-rejected-email.yml b/docs/qa/gates/8.5-booking-rejected-email.yml
new file mode 100644
index 0000000..a655961
--- /dev/null
+++ b/docs/qa/gates/8.5-booking-rejected-email.yml
@@ -0,0 +1,45 @@
+schema: 1
+story: "8.5"
+story_title: "Booking Rejected Email"
+gate: PASS
+status_reason: "All acceptance criteria met with comprehensive test coverage (22 tests). Implementation follows established codebase patterns consistently."
+reviewer: "Quinn (Test Architect)"
+updated: "2026-01-02T00:00:00Z"
+
+waiver: { active: false }
+
+top_issues: []
+
+risk_summary:
+ totals: { critical: 0, high: 0, medium: 0, low: 0 }
+ recommendations:
+ must_fix: []
+ monitor: []
+
+quality_score: 100
+expires: "2026-01-16T00:00:00Z"
+
+evidence:
+ tests_reviewed: 22
+ risks_identified: 0
+ trace:
+ ac_covered: [1, 2, 3, 4, 5, 6, 7, 8, 9]
+ ac_gaps: []
+
+nfr_validation:
+ security:
+ status: PASS
+ notes: "No unescaped user input, Blade default escaping used throughout"
+ performance:
+ status: PASS
+ notes: "Email queued via ShouldQueue, SerializesModels used properly"
+ reliability:
+ status: PASS
+ notes: "Standard Laravel queue retry mechanism (3 attempts) applies"
+ maintainability:
+ status: PASS
+ notes: "Clean code structure, consistent with BookingApprovedMail pattern"
+
+recommendations:
+ immediate: []
+ future: []
diff --git a/docs/stories/story-8.5-booking-rejected-email.md b/docs/stories/story-8.5-booking-rejected-email.md
index 48b9adb..fe18e06 100644
--- a/docs/stories/story-8.5-booking-rejected-email.md
+++ b/docs/stories/story-8.5-booking-rejected-email.md
@@ -22,21 +22,21 @@ So that **I can understand why and request a new consultation if needed**.
## Acceptance Criteria
### Trigger
-- [ ] Sent when consultation status changes to `'rejected'`
+- [x] 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
+- [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
-- [ ] Empathetic, professional
+- [x] Empathetic, professional
### Language
-- [ ] Email in client's preferred language (Arabic or English)
-- [ ] Default to Arabic if no preference set
+- [x] Email in client's preferred language (Arabic or English)
+- [x] Default to Arabic if no preference set
## Technical Notes
@@ -257,16 +257,145 @@ test('email is sent to correct recipient', function () {
```
## 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
+- [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.
diff --git a/resources/views/emails/booking/rejected/ar.blade.php b/resources/views/emails/booking/rejected/ar.blade.php
new file mode 100644
index 0000000..40b84ad
--- /dev/null
+++ b/resources/views/emails/booking/rejected/ar.blade.php
@@ -0,0 +1,33 @@
+
+
+# تعذر الموافقة على طلب الاستشارة
+
+عزيزي/عزيزتي {{ $user->company_name ?? $user->full_name }}،
+
+نأسف لإبلاغك بأنه تعذر علينا الموافقة على طلب الاستشارة الخاص بك.
+
+**تفاصيل الطلب:**
+
+- **التاريخ المطلوب:** {{ $formattedDate }}
+- **الوقت المطلوب:** {{ $formattedTime }}
+
+@if($hasReason)
+
+**سبب الرفض:**
+
+{{ $reason }}
+
+@endif
+
+نرحب بك لتقديم طلب استشارة جديد في وقت آخر يناسبك.
+
+
+طلب استشارة جديدة
+
+
+للاستفسارات، تواصل معنا على: info@libra.ps
+
+مع أطيب التحيات،
+{{ config('app.name') }}
+
+
diff --git a/resources/views/emails/booking/rejected/en.blade.php b/resources/views/emails/booking/rejected/en.blade.php
new file mode 100644
index 0000000..8e39eea
--- /dev/null
+++ b/resources/views/emails/booking/rejected/en.blade.php
@@ -0,0 +1,31 @@
+
+# Your Consultation Request Could Not Be Approved
+
+Dear {{ $user->company_name ?? $user->full_name }},
+
+We regret to inform you that we were unable to approve your consultation request.
+
+**Request Details:**
+
+- **Requested Date:** {{ $formattedDate }}
+- **Requested Time:** {{ $formattedTime }}
+
+@if($hasReason)
+
+**Reason:**
+
+{{ $reason }}
+
+@endif
+
+We welcome you to submit a new consultation request at another time that suits you.
+
+
+Request New Consultation
+
+
+For inquiries, contact us at: info@libra.ps
+
+Regards,
+{{ config('app.name') }}
+
diff --git a/tests/Feature/Mail/BookingRejectedEmailTest.php b/tests/Feature/Mail/BookingRejectedEmailTest.php
new file mode 100644
index 0000000..20caf20
--- /dev/null
+++ b/tests/Feature/Mail/BookingRejectedEmailTest.php
@@ -0,0 +1,251 @@
+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 from database default', function () {
+ // The database schema has default('ar') for preferred_language
+ // This test verifies the mailable respects that default
+ $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');
+ expect($mailable->envelope()->subject)->toBe('تعذر الموافقة على طلب الاستشارة');
+});
+
+test('booking rejected email has correct arabic subject', function () {
+ $user = User::factory()->create(['preferred_language' => 'ar']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $mailable = new BookingRejectedEmail($consultation);
+
+ expect($mailable->envelope()->subject)->toBe('تعذر الموافقة على طلب الاستشارة');
+});
+
+test('booking rejected email has correct english subject', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $mailable = new BookingRejectedEmail($consultation);
+
+ expect($mailable->envelope()->subject)->toBe('Your Consultation Request Could Not Be Approved');
+});
+
+test('booking rejected email includes reason when provided', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+ $reason = 'Schedule conflict with another appointment';
+
+ $mailable = new BookingRejectedEmail($consultation, $reason);
+ $content = $mailable->content();
+
+ expect($content->with['reason'])->toBe($reason);
+ expect($content->with['hasReason'])->toBeTrue();
+});
+
+test('booking rejected email handles null reason', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $mailable = new BookingRejectedEmail($consultation, null);
+ $content = $mailable->content();
+
+ expect($content->with['reason'])->toBeNull();
+ expect($content->with['hasReason'])->toBeFalse();
+});
+
+test('booking rejected email handles empty reason', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $mailable = new BookingRejectedEmail($consultation, '');
+ $content = $mailable->content();
+
+ expect($content->with['hasReason'])->toBeFalse();
+});
+
+test('date is formatted as d/m/Y for arabic users', function () {
+ $user = User::factory()->create(['preferred_language' => 'ar']);
+ $consultation = Consultation::factory()->for($user)->create([
+ 'booking_date' => '2025-03-15',
+ ]);
+
+ $mailable = new BookingRejectedEmail($consultation);
+
+ expect($mailable->getFormattedDate('ar'))->toBe('15/03/2025');
+});
+
+test('date is formatted as m/d/Y for english users', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create([
+ 'booking_date' => '2025-03-15',
+ ]);
+
+ $mailable = new BookingRejectedEmail($consultation);
+
+ expect($mailable->getFormattedDate('en'))->toBe('03/15/2025');
+});
+
+test('time is formatted as h:i A', function () {
+ $user = User::factory()->create(['preferred_language' => 'ar']);
+ $consultation = Consultation::factory()->for($user)->create([
+ 'booking_time' => '14:30:00',
+ ]);
+
+ $mailable = new BookingRejectedEmail($consultation);
+
+ expect($mailable->getFormattedTime())->toBe('02:30 PM');
+});
+
+test('booking rejected email implements ShouldQueue', function () {
+ expect(BookingRejectedEmail::class)->toImplement(ShouldQueue::class);
+});
+
+test('content includes all required data', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create([
+ 'booking_date' => '2025-03-15',
+ 'booking_time' => '10:00:00',
+ ]);
+ $reason = 'Test reason';
+
+ $mailable = new BookingRejectedEmail($consultation, $reason);
+ $content = $mailable->content();
+
+ expect($content->with)
+ ->toHaveKey('consultation')
+ ->toHaveKey('user')
+ ->toHaveKey('reason')
+ ->toHaveKey('hasReason')
+ ->toHaveKey('formattedDate')
+ ->toHaveKey('formattedTime');
+});
+
+test('email renders without errors in arabic', function () {
+ $user = User::factory()->create(['preferred_language' => 'ar']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $mailable = new BookingRejectedEmail($consultation);
+ $rendered = $mailable->render();
+
+ expect($rendered)->toContain('تعذر الموافقة على طلب الاستشارة');
+});
+
+test('email renders without errors in english', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $mailable = new BookingRejectedEmail($consultation);
+ $rendered = $mailable->render();
+
+ expect($rendered)->toContain('Your Consultation Request Could Not Be Approved');
+});
+
+test('email renders reason section when reason provided', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+ $reason = 'The requested time slot is not available';
+
+ $mailable = new BookingRejectedEmail($consultation, $reason);
+ $rendered = $mailable->render();
+
+ expect($rendered)->toContain($reason);
+ expect($rendered)->toContain('Reason');
+});
+
+test('email does not render reason section when reason not provided', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $mailable = new BookingRejectedEmail($consultation);
+ $rendered = $mailable->render();
+
+ expect($rendered)->not->toContain('Reason:');
+});
+
+test('arabic email does not render reason section when reason not provided', function () {
+ $user = User::factory()->create(['preferred_language' => 'ar']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $mailable = new BookingRejectedEmail($consultation);
+ $rendered = $mailable->render();
+
+ expect($rendered)->not->toContain('سبب الرفض');
+});
+
+test('notification sends booking rejected email', function () {
+ Notification::fake();
+
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+ $reason = 'Test reason';
+
+ $user->notify(new BookingRejected($consultation, $reason));
+
+ Notification::assertSentTo($user, BookingRejected::class, function ($notification) use ($consultation, $reason) {
+ return $notification->consultation->id === $consultation->id
+ && $notification->rejectionReason === $reason;
+ });
+});
+
+test('notification sends email to correct recipient', function () {
+ Notification::fake();
+
+ $user = User::factory()->create(['email' => 'client@example.com']);
+ $consultation = Consultation::factory()->for($user)->create();
+
+ $user->notify(new BookingRejected($consultation));
+
+ Notification::assertSentTo($user, BookingRejected::class);
+});
+
+test('notification passes rejection reason to mailable', function () {
+ Notification::fake();
+
+ $user = User::factory()->create();
+ $consultation = Consultation::factory()->for($user)->create();
+ $reason = 'Schedule conflict';
+
+ $user->notify(new BookingRejected($consultation, $reason));
+
+ Notification::assertSentTo($user, BookingRejected::class, function ($notification) use ($reason) {
+ return $notification->rejectionReason === $reason;
+ });
+});
+
+test('notification toMail returns BookingRejectedEmail mailable', function () {
+ $user = User::factory()->create(['preferred_language' => 'en']);
+ $consultation = Consultation::factory()->for($user)->create();
+ $reason = 'Test reason';
+
+ $notification = new BookingRejected($consultation, $reason);
+ $mailable = $notification->toMail($user);
+
+ expect($mailable)->toBeInstanceOf(BookingRejectedEmail::class);
+ expect($mailable->consultation->id)->toBe($consultation->id);
+ expect($mailable->reason)->toBe($reason);
+});