From 393acde340556a92019f340b9da5486eaa6e17cc Mon Sep 17 00:00:00 2001 From: Naser Mansour Date: Sat, 3 Jan 2026 18:54:53 +0200 Subject: [PATCH] checked epic 11 sroeies with scrum masters and applied fixes --- .../stories/story-11.2-public-booking-form.md | 69 +- .../story-11.3-guest-notifications-admin.md | 898 ++++++++++++++---- 2 files changed, 796 insertions(+), 171 deletions(-) diff --git a/docs/stories/story-11.2-public-booking-form.md b/docs/stories/story-11.2-public-booking-form.md index 17604dd..5fad3ba 100644 --- a/docs/stories/story-11.2-public-booking-form.md +++ b/docs/stories/story-11.2-public-booking-form.md @@ -593,10 +593,77 @@ test('invalid captcha prevents submission', function () { ->call('showConfirm') ->assertHasErrors(['captchaAnswer']); }); + +test('rate limiting prevents excessive booking attempts', function () { + $ipKey = 'guest-booking:127.0.0.1'; + + // Exhaust the rate limit (5 attempts) + for ($i = 0; $i < 5; $i++) { + RateLimiter::hit($ipKey, 60 * 60 * 24); + } + + WorkingHour::factory()->create([ + 'day_of_week' => now()->addDay()->dayOfWeek, + 'is_active' => true, + 'start_time' => '09:00', + 'end_time' => '17:00', + ]); + + $date = now()->addDay()->format('Y-m-d'); + + $component = Volt::test('pages.booking') + ->call('selectSlot', $date, '09:00') + ->set('guestName', 'John Doe') + ->set('guestEmail', 'john@example.com') + ->set('guestPhone', '+970599123456') + ->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.') + ->set('captchaAnswer', session('captcha_answer')) + ->call('showConfirm') + ->call('submit') + ->assertHasErrors(['guestEmail']); + + RateLimiter::clear($ipKey); +}); + +test('slot taken during submission shows error', function () { + WorkingHour::factory()->create([ + 'day_of_week' => now()->addDay()->dayOfWeek, + 'is_active' => true, + 'start_time' => '09:00', + 'end_time' => '17:00', + ]); + + $date = now()->addDay()->format('Y-m-d'); + + // Start the booking process + $component = Volt::test('pages.booking') + ->call('selectSlot', $date, '09:00') + ->set('guestName', 'John Doe') + ->set('guestEmail', 'john@example.com') + ->set('guestPhone', '+970599123456') + ->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.') + ->set('captchaAnswer', session('captcha_answer')) + ->call('showConfirm') + ->assertSet('showConfirmation', true); + + // Simulate another booking taking the slot before submission + Consultation::factory()->guest()->create([ + 'booking_date' => $date, + 'booking_time' => '09:00', + 'status' => ConsultationStatus::Pending, + ]); + + // Try to submit - should fail with slot taken error + $component->call('submit') + ->assertHasErrors(['selectedTime']); +}); ``` ## Dependencies -- Story 11.1 (Database Schema & Model Updates) +- Story 11.1 (Database Schema & Model Updates) - provides guest fields on Consultation model +- Story 11.3 (Guest Notifications) - provides `GuestBookingSubmittedMail` and `NewBookingAdminEmail` mailable classes + +**Note:** The mailable classes used in this story (`GuestBookingSubmittedMail`, `NewBookingAdminEmail`) are created in Story 11.3. During implementation, either implement Story 11.3 first or create stub mailable classes temporarily. ## Definition of Done - [ ] Guest booking form functional at `/booking` diff --git a/docs/stories/story-11.3-guest-notifications-admin.md b/docs/stories/story-11.3-guest-notifications-admin.md index 8149040..78e001c 100644 --- a/docs/stories/story-11.3-guest-notifications-admin.md +++ b/docs/stories/story-11.3-guest-notifications-admin.md @@ -44,6 +44,25 @@ So that **I can contact them and manage their appointments**. - [ ] Admin email shows guest contact info for guest bookings - [ ] Admin email shows client info for client bookings +## Technical Context + +### Existing Email Architecture +The current codebase uses two approaches for sending emails: +1. **Notifications** (`app/Notifications/`) - Used for client bookings via `$user->notify()` +2. **Mailables** (`app/Mail/`) - Used for direct email sending via `Mail::to()` + +For **guest bookings**, we use Mailables (not Notifications) because guests don't have User accounts. + +### Existing Admin Email Structure +The `NewBookingAdminEmail` uses locale-specific markdown templates: +- `resources/views/emails/admin/new-booking/en.blade.php` +- `resources/views/emails/admin/new-booking/ar.blade.php` + +### Existing Admin Components +- `pending.blade.php` uses `$booking` variable (not `$consultation`) +- `review.blade.php` uses modal-based approve/reject flow (`openApproveModal()` -> `approve()`) +- Client notifications use `BookingApproved` and `BookingRejected` Notifications + ## Implementation Steps ### Step 1: Create Guest Booking Submitted Email @@ -55,60 +74,103 @@ Create `app/Mail/GuestBookingSubmittedMail.php`: namespace App\Mail; use App\Models\Consultation; +use Carbon\Carbon; 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 GuestBookingSubmittedMail extends Mailable +class GuestBookingSubmittedMail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; public function __construct( - public Consultation $consultation + public Consultation $consultation, + public string $locale = 'en' ) {} public function envelope(): Envelope { return new Envelope( - subject: __('emails.guest_booking_submitted_subject'), + subject: $this->locale === 'ar' + ? 'تم استلام طلب الحجز - مكتب ليبرا للمحاماة' + : 'Booking Request Received - Libra Law Firm', ); } public function content(): Content { return new Content( - view: 'emails.guest-booking-submitted', + markdown: 'emails.guest.booking-submitted.' . $this->locale, with: [ 'consultation' => $this->consultation, 'guestName' => $this->consultation->guest_name, + 'formattedDate' => $this->getFormattedDate(), + 'formattedTime' => $this->getFormattedTime(), ], ); } + + private function getFormattedDate(): string + { + $date = $this->consultation->booking_date; + return $this->locale === 'ar' + ? $date->format('d/m/Y') + : $date->format('m/d/Y'); + } + + private function getFormattedTime(): string + { + return Carbon::parse($this->consultation->booking_time)->format('h:i A'); + } } ``` -### Step 2: Create Guest Booking Submitted Email Template -Create `resources/views/emails/guest-booking-submitted.blade.php`: +### Step 2: Create Guest Booking Submitted Email Templates +Create `resources/views/emails/guest/booking-submitted/en.blade.php`: ```blade -# {{ __('emails.guest_booking_submitted_title') }} +# Your Booking Request Has Been Received -{{ __('emails.guest_booking_submitted_greeting', ['name' => $guestName]) }} +Dear {{ $guestName }}, -{{ __('emails.guest_booking_submitted_body') }} +Thank you for your consultation request. We have received your booking and our team will review it shortly. -**{{ __('booking.date') }}:** {{ $consultation->booking_date->translatedFormat('l, d M Y') }} +**Requested Date:** {{ $formattedDate }} -**{{ __('booking.time') }}:** {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }} +**Requested Time:** {{ $formattedTime }} -**{{ __('booking.duration') }}:** 45 {{ __('common.minutes') }} +**Duration:** 45 minutes -{{ __('emails.guest_booking_submitted_next_steps') }} +You will receive another email once your booking has been reviewed. If approved, you will receive the consultation details and a calendar invitation. -{{ __('emails.signature') }},
+Thanks,
+{{ config('app.name') }} +
+``` + +Create `resources/views/emails/guest/booking-submitted/ar.blade.php`: + +```blade + +# تم استلام طلب الحجز الخاص بك + +عزيزي/عزيزتي {{ $guestName }}، + +شكراً لطلب الاستشارة. لقد تلقينا حجزك وسيقوم فريقنا بمراجعته قريباً. + +**التاريخ المطلوب:** {{ $formattedDate }} + +**الوقت المطلوب:** {{ $formattedTime }} + +**المدة:** 45 دقيقة + +ستتلقى رسالة أخرى عند مراجعة حجزك. في حال الموافقة، ستتلقى تفاصيل الاستشارة ودعوة تقويم. + +شكراً،
{{ config('app.name') }}
``` @@ -123,54 +185,159 @@ namespace App\Mail; use App\Models\Consultation; use App\Services\CalendarService; +use Carbon\Carbon; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; -class GuestBookingApprovedMail extends Mailable +class GuestBookingApprovedMail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; public function __construct( - public Consultation $consultation + public Consultation $consultation, + public string $locale = 'en', + public ?string $paymentInstructions = null ) {} public function envelope(): Envelope { return new Envelope( - subject: __('emails.guest_booking_approved_subject'), + subject: $this->locale === 'ar' + ? 'تأكيد الحجز - مكتب ليبرا للمحاماة' + : 'Booking Confirmed - Libra Law Firm', ); } public function content(): Content { return new Content( - view: 'emails.guest-booking-approved', + markdown: 'emails.guest.booking-approved.' . $this->locale, with: [ 'consultation' => $this->consultation, 'guestName' => $this->consultation->guest_name, + 'formattedDate' => $this->getFormattedDate(), + 'formattedTime' => $this->getFormattedTime(), 'isPaid' => $this->consultation->consultation_type?->value === 'paid', + 'paymentAmount' => $this->consultation->payment_amount, + 'paymentInstructions' => $this->paymentInstructions, ], ); } public function attachments(): array { - $calendarService = app(CalendarService::class); - $icsContent = $calendarService->generateIcs($this->consultation); + try { + $calendarService = app(CalendarService::class); + $icsContent = $calendarService->generateIcs($this->consultation); - return [ - Attachment::fromData(fn () => $icsContent, 'consultation.ics') - ->withMime('text/calendar'), - ]; + return [ + Attachment::fromData(fn () => $icsContent, 'consultation.ics') + ->withMime('text/calendar'), + ]; + } catch (\Exception $e) { + Log::error('Failed to generate calendar attachment for guest email', [ + 'consultation_id' => $this->consultation->id, + 'error' => $e->getMessage(), + ]); + return []; + } + } + + private function getFormattedDate(): string + { + $date = $this->consultation->booking_date; + return $this->locale === 'ar' + ? $date->format('d/m/Y') + : $date->format('m/d/Y'); + } + + private function getFormattedTime(): string + { + return Carbon::parse($this->consultation->booking_time)->format('h:i A'); } } ``` -### Step 4: Create Guest Booking Rejected Email +### Step 4: Create Guest Booking Approved Email Templates +Create `resources/views/emails/guest/booking-approved/en.blade.php`: + +```blade + +# Your Booking Has Been Confirmed + +Dear {{ $guestName }}, + +Great news! Your consultation request has been approved. + +**Date:** {{ $formattedDate }} + +**Time:** {{ $formattedTime }} + +**Duration:** 45 minutes + +**Type:** {{ $isPaid ? 'Paid Consultation' : 'Free Consultation' }} + +@if($isPaid && $paymentAmount) +**Amount Due:** {{ number_format($paymentAmount, 2) }} ILS + +@if($paymentInstructions) +**Payment Instructions:** +{{ $paymentInstructions }} +@endif +@endif + +A calendar invitation (.ics file) is attached to this email. You can add it to your calendar application. + +We look forward to meeting with you. + +Thanks,
+{{ config('app.name') }} +
+``` + +Create `resources/views/emails/guest/booking-approved/ar.blade.php`: + +```blade + +# تم تأكيد حجزك + +عزيزي/عزيزتي {{ $guestName }}، + +أخبار سارة! تمت الموافقة على طلب الاستشارة الخاص بك. + +**التاريخ:** {{ $formattedDate }} + +**الوقت:** {{ $formattedTime }} + +**المدة:** 45 دقيقة + +**النوع:** {{ $isPaid ? 'استشارة مدفوعة' : 'استشارة مجانية' }} + +@if($isPaid && $paymentAmount) +**المبلغ المستحق:** {{ number_format($paymentAmount, 2) }} شيكل + +@if($paymentInstructions) +**تعليمات الدفع:** +{{ $paymentInstructions }} +@endif +@endif + +مرفق بهذه الرسالة ملف دعوة تقويم (.ics). يمكنك إضافته إلى تطبيق التقويم الخاص بك. + +نتطلع للقائك. + +شكراً،
+{{ config('app.name') }} +
+``` + +### Step 5: Create Guest Booking Rejected Email Create `app/Mail/GuestBookingRejectedMail.php`: ```php @@ -179,230 +346,513 @@ Create `app/Mail/GuestBookingRejectedMail.php`: namespace App\Mail; use App\Models\Consultation; +use Carbon\Carbon; 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 GuestBookingRejectedMail extends Mailable +class GuestBookingRejectedMail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; public function __construct( public Consultation $consultation, + public string $locale = 'en', public ?string $reason = null ) {} public function envelope(): Envelope { return new Envelope( - subject: __('emails.guest_booking_rejected_subject'), + subject: $this->locale === 'ar' + ? 'تحديث الحجز - مكتب ليبرا للمحاماة' + : 'Booking Update - Libra Law Firm', ); } public function content(): Content { return new Content( - view: 'emails.guest-booking-rejected', + markdown: 'emails.guest.booking-rejected.' . $this->locale, with: [ 'consultation' => $this->consultation, 'guestName' => $this->consultation->guest_name, + 'formattedDate' => $this->getFormattedDate(), + 'formattedTime' => $this->getFormattedTime(), 'reason' => $this->reason, + 'hasReason' => !empty($this->reason), ], ); } + + private function getFormattedDate(): string + { + $date = $this->consultation->booking_date; + return $this->locale === 'ar' + ? $date->format('d/m/Y') + : $date->format('m/d/Y'); + } + + private function getFormattedTime(): string + { + return Carbon::parse($this->consultation->booking_time)->format('h:i A'); + } } ``` -### Step 5: Update NewBookingAdminEmail -Update `app/Mail/NewBookingAdminEmail.php` to handle guests: +### Step 6: Create Guest Booking Rejected Email Templates +Create `resources/views/emails/guest/booking-rejected/en.blade.php`: + +```blade + +# Booking Update + +Dear {{ $guestName }}, + +We regret to inform you that we are unable to accommodate your consultation request for the following time: + +**Requested Date:** {{ $formattedDate }} + +**Requested Time:** {{ $formattedTime }} + +@if($hasReason) +**Reason:** {{ $reason }} +@endif + +We apologize for any inconvenience. Please feel free to submit a new booking request for a different time slot. + +Thanks,
+{{ config('app.name') }} +
+``` + +Create `resources/views/emails/guest/booking-rejected/ar.blade.php`: + +```blade + +# تحديث الحجز + +عزيزي/عزيزتي {{ $guestName }}، + +نأسف لإبلاغك بأننا غير قادرين على استيعاب طلب الاستشارة الخاص بك في الوقت التالي: + +**التاريخ المطلوب:** {{ $formattedDate }} + +**الوقت المطلوب:** {{ $formattedTime }} + +@if($hasReason) +**السبب:** {{ $reason }} +@endif + +نعتذر عن أي إزعاج. لا تتردد في تقديم طلب حجز جديد لفترة زمنية مختلفة. + +شكراً،
+{{ config('app.name') }} +
+``` + +### Step 7: Update NewBookingAdminEmail +Update `app/Mail/NewBookingAdminEmail.php` to handle guest bookings. + +Modify the `content()` method to use the Consultation model's helper methods: ```php public function content(): Content { + $admin = $this->getAdminUser(); + $locale = $admin?->preferred_language ?? 'en'; + return new Content( - view: 'emails.new-booking-admin', + markdown: 'emails.admin.new-booking.'.$locale, with: [ 'consultation' => $this->consultation, + 'client' => $this->consultation->user, 'clientName' => $this->consultation->getClientName(), 'clientEmail' => $this->consultation->getClientEmail(), 'clientPhone' => $this->consultation->getClientPhone(), 'isGuest' => $this->consultation->isGuest(), + 'formattedDate' => $this->getFormattedDate($locale), + 'formattedTime' => $this->getFormattedTime(), + 'reviewUrl' => $this->getReviewUrl(), ], ); } ``` -### Step 6: Update Admin New Booking Email Template -Update `resources/views/emails/new-booking-admin.blade.php`: +### Step 8: Update Admin New Booking Email Templates +Update `resources/views/emails/admin/new-booking/en.blade.php` to show guest indicator: +Add after the title section: ```blade - -# {{ __('emails.new_booking_admin_title') }} - -{{ __('emails.new_booking_admin_body') }} - @if($isGuest) -**{{ __('emails.booking_type') }}:** {{ __('emails.guest_booking') }} +**Booking Type:** Guest (No Account) + @endif +**Client Name:** {{ $clientName }} -**{{ __('booking.client_name') }}:** {{ $clientName }} +**Client Email:** {{ $clientEmail }} -**{{ __('booking.client_email') }}:** {{ $clientEmail }} - -**{{ __('booking.client_phone') }}:** {{ $clientPhone }} - -**{{ __('booking.date') }}:** {{ $consultation->booking_date->translatedFormat('l, d M Y') }} - -**{{ __('booking.time') }}:** {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }} - -**{{ __('booking.problem_summary') }}:** -{{ $consultation->problem_summary }} - - -{{ __('emails.review_booking') }} - - -{{ __('emails.signature') }},
-{{ config('app.name') }} -
+**Client Phone:** {{ $clientPhone ?? 'Not provided' }} ``` -### Step 7: Update Admin Pending Bookings List -Update `resources/views/livewire/admin/bookings/pending.blade.php` to show guest indicator: - -In the table row, add guest badge: +Update `resources/views/emails/admin/new-booking/ar.blade.php` similarly: ```blade - - @if($consultation->isGuest()) - {{ __('admin.guest') }}
- {{ $consultation->guest_name }} - @else - {{ $consultation->user->name }} - @endif - - - @if($consultation->isGuest()) - - {{ $consultation->guest_email }} - - @else - - {{ $consultation->user->email }} - - @endif - +@if($isGuest) +**نوع الحجز:** زائر (بدون حساب) + +@endif +**اسم العميل:** {{ $clientName }} + +**بريد العميل:** {{ $clientEmail }} + +**هاتف العميل:** {{ $clientPhone ?? 'غير متوفر' }} ``` -### Step 8: Update Admin Booking Review Page -Update `resources/views/livewire/admin/bookings/review.blade.php`: +### Step 9: Update Admin Pending Bookings List +Update `resources/views/livewire/admin/bookings/pending.blade.php`. + +In the booking info section (around line 194), replace the client name display: ```blade -{{-- Client/Guest Information Section --}} -
- - {{ __('admin.client_information') }} - @if($consultation->isGuest()) - {{ __('admin.guest') }} + +
+
+ @if($booking->isGuest()) + {{ __('admin.guest') }} @endif - + + {{ $booking->getClientName() }} + + + {{ $booking->status->label() }} + +
-
-
-
{{ __('booking.client_name') }}
-
{{ $consultation->getClientName() }}
+
+
+ + {{ \Carbon\Carbon::parse($booking->booking_date)->translatedFormat('l, d M Y') }}
-
-
{{ __('booking.client_email') }}
-
- - {{ $consultation->getClientEmail() }} - -
+
+ + {{ \Carbon\Carbon::parse($booking->booking_time)->format('g:i A') }}
-
-
{{ __('booking.client_phone') }}
-
- - {{ $consultation->getClientPhone() }} - -
+ - @unless($consultation->isGuest()) -
-
{{ __('admin.client_type') }}
-
{{ $consultation->user->user_type->label() }}
+
+ + {{ __('admin.submitted') }}: {{ $booking->created_at->translatedFormat('d M Y') }}
- @endunless -
+
+ +

+ {{ Str::limit($booking->problem_summary, 150) }} +

``` -### Step 9: Update Booking Approval Logic -Update the approve method in the booking review component to send guest emails: +Also update the `quickApprove` and `quickReject` methods to handle guest bookings: + +```php +public function quickApprove(int $id): void +{ + $consultation = Consultation::with('user')->findOrFail($id); + + // ... existing validation and update logic ... + + // Send appropriate notification/email based on guest/client + if ($consultation->isGuest()) { + Mail::to($consultation->guest_email)->queue( + new \App\Mail\GuestBookingApprovedMail( + $consultation, + app()->getLocale() + ) + ); + } elseif ($consultation->user) { + $consultation->user->notify( + new BookingApproved($consultation, $icsContent ?? '', null) + ); + } + + // ... rest of logging ... +} + +public function quickReject(int $id): void +{ + $consultation = Consultation::with('user')->findOrFail($id); + + // ... existing validation and update logic ... + + // Send appropriate notification/email based on guest/client + if ($consultation->isGuest()) { + Mail::to($consultation->guest_email)->queue( + new \App\Mail\GuestBookingRejectedMail( + $consultation, + app()->getLocale(), + null + ) + ); + } elseif ($consultation->user) { + $consultation->user->notify( + new BookingRejected($consultation, null) + ); + } + + // ... rest of logging ... +} +``` + +Add the required imports at the top: +```php +use App\Mail\GuestBookingApprovedMail; +use App\Mail\GuestBookingRejectedMail; +use Illuminate\Support\Facades\Mail; +``` + +### Step 10: Update Admin Booking Review Page +Update `resources/views/livewire/admin/bookings/review.blade.php`. + +Replace the "Client Information" section (around line 188-218): + +```blade + +
+
+ {{ __('admin.client_information') }} + @if($consultation->isGuest()) + {{ __('admin.guest') }} + @endif +
+ +
+
+

{{ __('admin.client_name') }}

+

+ {{ $consultation->getClientName() }} +

+
+
+

{{ __('admin.client_email') }}

+

+ + {{ $consultation->getClientEmail() }} + +

+
+
+

{{ __('admin.client_phone') }}

+

+ @if($consultation->getClientPhone()) + + {{ $consultation->getClientPhone() }} + + @else + - + @endif +

+
+ @unless($consultation->isGuest()) +
+

{{ __('admin.client_type') }}

+

+ {{ $consultation->user?->user_type?->label() ?? '-' }} +

+
+ @endunless +
+
+``` + +Update the `approve()` method in the PHP section: ```php public function approve(): void { - // ... existing approval logic ... + if ($this->consultation->status !== ConsultationStatus::Pending) { + session()->flash('error', __('admin.booking_already_processed')); + $this->showApproveModal = false; + return; + } - // Send appropriate email based on guest/client + $this->validate([ + 'consultationType' => ['required', 'in:free,paid'], + 'paymentAmount' => ['required_if:consultationType,paid', 'nullable', 'numeric', 'min:0'], + 'paymentInstructions' => ['nullable', 'string', 'max:1000'], + ]); + + $oldStatus = $this->consultation->status->value; + $type = $this->consultationType === 'paid' ? ConsultationType::Paid : ConsultationType::Free; + + $this->consultation->update([ + 'status' => ConsultationStatus::Approved, + 'consultation_type' => $type, + 'payment_amount' => $type === ConsultationType::Paid ? $this->paymentAmount : null, + 'payment_status' => $type === ConsultationType::Paid ? PaymentStatus::Pending : PaymentStatus::NotApplicable, + ]); + + // Generate calendar file + $icsContent = null; + try { + $calendarService = app(CalendarService::class); + $icsContent = $calendarService->generateIcs($this->consultation); + } catch (\Exception $e) { + Log::error('Failed to generate calendar file', [ + 'consultation_id' => $this->consultation->id, + 'error' => $e->getMessage(), + ]); + } + + // Send appropriate notification/email based on guest/client if ($this->consultation->isGuest()) { Mail::to($this->consultation->guest_email)->queue( - new GuestBookingApprovedMail($this->consultation) + new GuestBookingApprovedMail( + $this->consultation, + app()->getLocale(), + $this->paymentInstructions ?: null + ) ); - } else { - Mail::to($this->consultation->user)->queue( - new BookingApprovedMail($this->consultation) + } elseif ($this->consultation->user) { + $this->consultation->user->notify( + new BookingApproved( + $this->consultation, + $icsContent ?? '', + $this->paymentInstructions ?: null + ) ); } -} + // Log action + AdminLog::create([ + 'admin_id' => auth()->id(), + 'action' => 'approve', + 'target_type' => 'consultation', + 'target_id' => $this->consultation->id, + 'old_values' => ['status' => $oldStatus], + 'new_values' => [ + 'status' => ConsultationStatus::Approved->value, + 'consultation_type' => $type->value, + 'payment_amount' => $this->paymentAmount, + ], + 'ip_address' => request()->ip(), + 'created_at' => now(), + ]); + + session()->flash('success', __('admin.booking_approved')); + $this->redirect(route('admin.bookings.pending'), navigate: true); +} +``` + +Update the `reject()` method similarly: + +```php public function reject(): void { - // ... existing rejection logic ... + if ($this->consultation->status !== ConsultationStatus::Pending) { + session()->flash('error', __('admin.booking_already_processed')); + $this->showRejectModal = false; + return; + } - // Send appropriate email based on guest/client + $this->validate([ + 'rejectionReason' => ['nullable', 'string', 'max:1000'], + ]); + + $oldStatus = $this->consultation->status->value; + + $this->consultation->update([ + 'status' => ConsultationStatus::Rejected, + ]); + + // Send appropriate notification/email based on guest/client if ($this->consultation->isGuest()) { Mail::to($this->consultation->guest_email)->queue( - new GuestBookingRejectedMail($this->consultation, $this->rejectionReason) + new GuestBookingRejectedMail( + $this->consultation, + app()->getLocale(), + $this->rejectionReason ?: null + ) ); - } else { - Mail::to($this->consultation->user)->queue( - new BookingRejectedMail($this->consultation, $this->rejectionReason) + } elseif ($this->consultation->user) { + $this->consultation->user->notify( + new BookingRejected($this->consultation, $this->rejectionReason ?: null) ); } + + // Log action + AdminLog::create([ + 'admin_id' => auth()->id(), + 'action' => 'reject', + 'target_type' => 'consultation', + 'target_id' => $this->consultation->id, + 'old_values' => ['status' => $oldStatus], + 'new_values' => [ + 'status' => ConsultationStatus::Rejected->value, + 'reason' => $this->rejectionReason, + ], + 'ip_address' => request()->ip(), + 'created_at' => now(), + ]); + + session()->flash('success', __('admin.booking_rejected')); + $this->redirect(route('admin.bookings.pending'), navigate: true); } ``` -### Step 10: Add Translation Keys -Add to `lang/en/emails.php`: +Add required imports at the top of the PHP section: ```php -'guest_booking_submitted_subject' => 'Booking Request Received - Libra Law Firm', -'guest_booking_submitted_title' => 'Your Booking Request Has Been Received', -'guest_booking_submitted_greeting' => 'Dear :name,', -'guest_booking_submitted_body' => 'Thank you for your consultation request. We have received your booking and our team will review it shortly.', -'guest_booking_submitted_next_steps' => 'You will receive another email once your booking has been reviewed. If approved, you will receive the consultation details and a calendar invitation.', -'guest_booking_approved_subject' => 'Booking Confirmed - Libra Law Firm', -'guest_booking_rejected_subject' => 'Booking Update - Libra Law Firm', -'booking_type' => 'Booking Type', -'guest_booking' => 'Guest (No Account)', +use App\Mail\GuestBookingApprovedMail; +use App\Mail\GuestBookingRejectedMail; +use Illuminate\Support\Facades\Mail; ``` -Add to `lang/ar/emails.php`: +Update the `with()` method to handle guest consultations (they have no history): ```php -'guest_booking_submitted_subject' => 'تم استلام طلب الحجز - مكتب ليبرا للمحاماة', -'guest_booking_submitted_title' => 'تم استلام طلب الحجز الخاص بك', -'guest_booking_submitted_greeting' => 'عزيزي/عزيزتي :name،', -'guest_booking_submitted_body' => 'شكراً لطلب الاستشارة. لقد تلقينا حجزك وسيقوم فريقنا بمراجعته قريباً.', -'guest_booking_submitted_next_steps' => 'ستتلقى رسالة أخرى عند مراجعة حجزك. في حال الموافقة، ستتلقى تفاصيل الاستشارة ودعوة تقويم.', -'guest_booking_approved_subject' => 'تأكيد الحجز - مكتب ليبرا للمحاماة', -'guest_booking_rejected_subject' => 'تحديث الحجز - مكتب ليبرا للمحاماة', -'booking_type' => 'نوع الحجز', -'guest_booking' => 'زائر (بدون حساب)', +public function with(): array +{ + // Guest bookings don't have consultation history + if ($this->consultation->isGuest()) { + return ['consultationHistory' => collect()]; + } + + return [ + 'consultationHistory' => Consultation::query() + ->where('user_id', $this->consultation->user_id) + ->where('id', '!=', $this->consultation->id) + ->orderBy('booking_date', 'desc') + ->limit(5) + ->get(), + ]; +} ``` +Update the modal summary sections to use helper methods: + +In the Approve Modal (around line 314): +```blade +
+

{{ __('admin.client') }}: {{ $consultation->getClientName() }}

+

{{ __('admin.date') }}: {{ \Carbon\Carbon::parse($consultation->booking_date)->translatedFormat('l, d M Y') }}

+

{{ __('admin.time') }}: {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}

+
+``` + +In the Reject Modal (around line 369): +```blade +
+

{{ __('admin.client') }}: {{ $consultation->getClientName() }}

+

{{ __('admin.date') }}: {{ \Carbon\Carbon::parse($consultation->booking_date)->translatedFormat('l, d M Y') }}

+

{{ __('admin.time') }}: {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}

+
+``` + +### Step 11: Add Translation Keys Add to `lang/en/admin.php`: ```php 'guest' => 'Guest', @@ -420,7 +870,20 @@ Add to `lang/ar/admin.php`: ## Testing Requirements ### Email Tests +Create `tests/Feature/GuestEmailNotificationTest.php`: + ```php +guest_email)->send( - new GuestBookingSubmittedMail($consultation) + new GuestBookingSubmittedMail($consultation, 'en') ); Mail::assertSent(GuestBookingSubmittedMail::class, function ($mail) use ($consultation) { @@ -445,7 +908,7 @@ test('guest receives approval email with calendar attachment', function () { ]); Mail::to($consultation->guest_email)->send( - new GuestBookingApprovedMail($consultation) + new GuestBookingApprovedMail($consultation, 'en') ); Mail::assertSent(GuestBookingApprovedMail::class, function ($mail) { @@ -453,6 +916,22 @@ test('guest receives approval email with calendar attachment', function () { }); }); +test('guest receives rejection email', function () { + Mail::fake(); + + $consultation = Consultation::factory()->guest()->create([ + 'status' => ConsultationStatus::Rejected, + ]); + + Mail::to($consultation->guest_email)->send( + new GuestBookingRejectedMail($consultation, 'en', 'Not available') + ); + + Mail::assertSent(GuestBookingRejectedMail::class, function ($mail) use ($consultation) { + return $mail->hasTo($consultation->guest_email); + }); +}); + test('admin email shows guest indicator for guest bookings', function () { Mail::fake(); @@ -466,74 +945,153 @@ test('admin email shows guest indicator for guest bookings', function () { ``` ### Admin Interface Tests +Create `tests/Feature/Admin/GuestBookingManagementTest.php`: + ```php +admin()->create(); $guestConsultation = Consultation::factory()->guest()->pending()->create(); + $this->actingAs($admin); + Volt::test('admin.bookings.pending') - ->actingAs($admin) ->assertSee($guestConsultation->guest_name) ->assertSee(__('admin.guest')); }); -test('admin can view guest booking details', function () { +test('admin can view guest booking details in review page', function () { $admin = User::factory()->admin()->create(); - $consultation = Consultation::factory()->guest()->create([ + $consultation = Consultation::factory()->guest()->pending()->create([ 'guest_name' => 'Test Guest', 'guest_email' => 'test@example.com', ]); + $this->actingAs($admin); + Volt::test('admin.bookings.review', ['consultation' => $consultation]) - ->actingAs($admin) ->assertSee('Test Guest') ->assertSee('test@example.com') ->assertSee(__('admin.guest')); }); -test('admin can approve guest booking', function () { +test('admin can approve guest booking via modal', function () { Mail::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->guest()->pending()->create(); + $this->actingAs($admin); + Volt::test('admin.bookings.review', ['consultation' => $consultation]) - ->actingAs($admin) + ->call('openApproveModal') + ->assertSet('showApproveModal', true) + ->set('consultationType', 'free') ->call('approve'); expect($consultation->fresh()->status)->toBe(ConsultationStatus::Approved); Mail::assertQueued(GuestBookingApprovedMail::class); }); -test('admin can reject guest booking', function () { +test('admin can reject guest booking via modal', function () { Mail::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->guest()->pending()->create(); + $this->actingAs($admin); + Volt::test('admin.bookings.review', ['consultation' => $consultation]) - ->actingAs($admin) + ->call('openRejectModal') + ->assertSet('showRejectModal', true) ->set('rejectionReason', 'Not available for this time') ->call('reject'); expect($consultation->fresh()->status)->toBe(ConsultationStatus::Rejected); Mail::assertQueued(GuestBookingRejectedMail::class); }); + +test('admin can quick approve guest booking from pending list', function () { + Mail::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->guest()->pending()->create(); + + $this->actingAs($admin); + + Volt::test('admin.bookings.pending') + ->call('quickApprove', $consultation->id); + + expect($consultation->fresh()->status)->toBe(ConsultationStatus::Approved); + Mail::assertQueued(GuestBookingApprovedMail::class); +}); + +test('admin can quick reject guest booking from pending list', function () { + Mail::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->guest()->pending()->create(); + + $this->actingAs($admin); + + Volt::test('admin.bookings.pending') + ->call('quickReject', $consultation->id); + + expect($consultation->fresh()->status)->toBe(ConsultationStatus::Rejected); + Mail::assertQueued(GuestBookingRejectedMail::class); +}); + +test('guest booking review shows no consultation history', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->guest()->pending()->create(); + + $this->actingAs($admin); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->assertDontSee(__('admin.consultation_history')); +}); + +test('client booking still uses notification system', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $client = User::factory()->client()->create(); + $consultation = Consultation::factory()->pending()->create(['user_id' => $client->id]); + + $this->actingAs($admin); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->call('openApproveModal') + ->set('consultationType', 'free') + ->call('approve'); + + Notification::assertSentTo($client, BookingApproved::class); +}); ``` ## Dependencies -- Story 11.1 (Database Schema & Model Updates) -- Story 11.2 (Public Booking Form) +- Story 11.1 (Database Schema & Model Updates) - provides `isGuest()`, `getClientName()`, `getClientEmail()`, `getClientPhone()` methods +- Story 11.2 (Public Booking Form) - uses the guest email classes created in this story ## Definition of Done -- [ ] Guest booking submitted email created and working -- [ ] Guest booking approved email created with calendar attachment -- [ ] Guest booking rejected email created -- [ ] Admin new booking email updated for guests -- [ ] Admin pending bookings shows guest indicator -- [ ] Admin booking review shows guest contact info -- [ ] Approve/reject sends correct email (guest vs client) +- [ ] Guest booking submitted email created and working (en/ar templates) +- [ ] Guest booking approved email created with calendar attachment (en/ar templates) +- [ ] Guest booking rejected email created (en/ar templates) +- [ ] Admin new booking email updated for guests (shows guest indicator) +- [ ] Admin pending bookings shows guest badge and uses helper methods +- [ ] Admin booking review shows guest contact info with mailto/tel links +- [ ] Quick approve/reject sends correct email (guest Mail vs client Notification) +- [ ] Modal approve/reject sends correct email (guest Mail vs client Notification) - [ ] All translations in place (Arabic/English) - [ ] All email tests pass - [ ] All admin interface tests pass -- [ ] Existing client booking emails unchanged +- [ ] Existing client booking notifications unchanged