# Story 3.8: Consultation Reminders ## Epic Reference **Epic 3:** Booking & Consultation System ## User Story As a **client**, I want **to receive reminder emails before my consultation**, So that **I don't forget my appointment and can prepare accordingly**. ## Story Context ### Existing System Integration - **Integrates with:** consultations table, Laravel scheduler, email notifications - **Technology:** Laravel Queue, Scheduler, Mailable - **Follows pattern:** Scheduled job pattern - **Touch points:** Email system, consultation status ## Acceptance Criteria ### 24-Hour Reminder - [x] Sent 24 hours before consultation - [x] Includes: - Consultation date and time - Type (free/paid) - Payment reminder if paid and not received - Calendar file link - Any special instructions ### 2-Hour Reminder - [x] Sent 2 hours before consultation - [x] Includes: - Consultation date and time - Final payment reminder if applicable - Contact information for last-minute issues ### Reminder Logic - [x] Only for approved consultations - [x] Skip cancelled consultations - [x] Skip no-show consultations - [x] Don't send duplicate reminders - [x] Handle timezone correctly ### Language Support - [x] Email in client's preferred language - [x] Arabic template for Arabic preference - [x] English template for English preference ### Quality Requirements - [x] Scheduled jobs run reliably - [x] Retry on failure - [x] Logging for debugging - [x] Tests for reminder logic ## Prerequisites & Assumptions ### Consultation Model (from Story 3.4/3.5) The `Consultation` model must exist with the following structure: - **Fields:** - `status` - enum: `pending`, `approved`, `completed`, `cancelled`, `no_show` - `scheduled_date` - date (cast to Carbon) - `scheduled_time` - time string (H:i:s) - `type` - enum: `free`, `paid` - `payment_status` - enum: `pending`, `received`, `not_applicable` - `payment_amount` - decimal, nullable - `user_id` - foreign key to users table - `reminder_24h_sent_at` - timestamp, nullable (added by this story) - `reminder_2h_sent_at` - timestamp, nullable (added by this story) - **Relationships:** - `user()` - BelongsTo User - **Factory States:** - `approved()` - sets status to 'approved' ### User Model Requirements - `preferred_language` field must exist (values: `ar`, `en`) - This field should be added in Epic 1 or early user stories ### Route Requirements - `client.consultations.calendar` route from Story 3.6 must exist for calendar file download link ## Technical Notes ### Reminder Commands #### 24-Hour Reminder Command ```php addHours(24); $windowStart = $targetTime->copy()->subMinutes(30); $windowEnd = $targetTime->copy()->addMinutes(30); $consultations = Consultation::where('status', 'approved') ->whereNull('reminder_24h_sent_at') ->whereDate('scheduled_date', $targetTime->toDateString()) ->get() ->filter(function ($consultation) use ($windowStart, $windowEnd) { $consultationDateTime = Carbon::parse( $consultation->scheduled_date->format('Y-m-d') . ' ' . $consultation->scheduled_time ); return $consultationDateTime->between($windowStart, $windowEnd); }); $count = 0; foreach ($consultations as $consultation) { try { $consultation->user->notify(new ConsultationReminder24h($consultation)); $consultation->update(['reminder_24h_sent_at' => now()]); $count++; $this->info("Sent 24h reminder for consultation #{$consultation->id}"); } catch (\Exception $e) { $this->error("Failed to send reminder for consultation #{$consultation->id}: {$e->getMessage()}"); \Log::error('24h reminder failed', [ 'consultation_id' => $consultation->id, 'error' => $e->getMessage(), ]); } } $this->info("Sent {$count} 24-hour reminders"); return Command::SUCCESS; } } ``` #### 2-Hour Reminder Command ```php addHours(2); $windowStart = $targetTime->copy()->subMinutes(15); $windowEnd = $targetTime->copy()->addMinutes(15); $consultations = Consultation::where('status', 'approved') ->whereNull('reminder_2h_sent_at') ->whereDate('scheduled_date', $targetTime->toDateString()) ->get() ->filter(function ($consultation) use ($windowStart, $windowEnd) { $consultationDateTime = Carbon::parse( $consultation->scheduled_date->format('Y-m-d') . ' ' . $consultation->scheduled_time ); return $consultationDateTime->between($windowStart, $windowEnd); }); $count = 0; foreach ($consultations as $consultation) { try { $consultation->user->notify(new ConsultationReminder2h($consultation)); $consultation->update(['reminder_2h_sent_at' => now()]); $count++; $this->info("Sent 2h reminder for consultation #{$consultation->id}"); } catch (\Exception $e) { $this->error("Failed to send reminder for consultation #{$consultation->id}: {$e->getMessage()}"); \Log::error('2h reminder failed', [ 'consultation_id' => $consultation->id, 'error' => $e->getMessage(), ]); } } $this->info("Sent {$count} 2-hour reminders"); return Command::SUCCESS; } } ``` ### Scheduler Configuration ```php // In routes/console.php or app/Console/Kernel.php (Laravel 12) use Illuminate\Support\Facades\Schedule; Schedule::command('reminders:send-24h')->hourly(); Schedule::command('reminders:send-2h')->everyFifteenMinutes(); ``` ### Migration for Tracking ```php // Add reminder tracking columns to consultations Schema::table('consultations', function (Blueprint $table) { $table->timestamp('reminder_24h_sent_at')->nullable(); $table->timestamp('reminder_2h_sent_at')->nullable(); }); ``` ### 24-Hour Reminder Notification ```php preferred_language ?? 'ar'; return (new MailMessage) ->subject($this->getSubject($locale)) ->markdown('emails.reminders.24h.' . $locale, [ 'consultation' => $this->consultation, 'user' => $notifiable, 'showPaymentReminder' => $this->shouldShowPaymentReminder(), ]); } private function getSubject(string $locale): string { return $locale === 'ar' ? 'تذكير: موعدك غداً مع مكتب ليبرا للمحاماة' : 'Reminder: Your consultation is tomorrow'; } private function shouldShowPaymentReminder(): bool { return $this->consultation->type === 'paid' && $this->consultation->payment_status === 'pending'; } } ``` ### Email Template (24h Arabic) ```blade # تذكير بموعدك عزيزي {{ $user->name }}، نود تذكيرك بموعد استشارتك غداً: **التاريخ:** {{ $consultation->scheduled_date->translatedFormat('l، d F Y') }} **الوقت:** {{ \Carbon\Carbon::parse($consultation->scheduled_time)->format('g:i A') }} **المدة:** 45 دقيقة **النوع:** {{ $consultation->type === 'free' ? 'مجانية' : 'مدفوعة' }} @if($showPaymentReminder) **تذكير بالدفع:** يرجى إتمام عملية الدفع قبل موعد الاستشارة. المبلغ المطلوب: {{ number_format($consultation->payment_amount, 2) }} شيكل @endif تحميل ملف التقويم إذا كنت بحاجة لإعادة جدولة الموعد، يرجى التواصل معنا في أقرب وقت. مع أطيب التحيات، مكتب ليبرا للمحاماة ``` ### Email Template (2h Arabic) ```blade # موعدك بعد ساعتين عزيزي {{ $user->name }}، تذكير أخير: موعد استشارتك خلال ساعتين. **الوقت:** {{ \Carbon\Carbon::parse($consultation->scheduled_time)->format('g:i A') }} @if($showPaymentReminder) **هام:** لم نستلم الدفعة بعد. يرجى إتمام الدفع قبل بدء الاستشارة. @endif إذا كان لديك أي استفسار طارئ، يرجى التواصل معنا على: [رقم الهاتف] نتطلع للقائك، مكتب ليبرا للمحاماة ``` ### Testing ```php use App\Console\Commands\Send24HourReminders; use App\Models\Consultation; use App\Notifications\ConsultationReminder24h; use Illuminate\Support\Facades\Notification; it('sends 24h reminder for upcoming consultation', function () { Notification::fake(); $consultation = Consultation::factory()->approved()->create([ 'scheduled_date' => now()->addHours(24)->toDateString(), 'scheduled_time' => now()->addHours(24)->format('H:i:s'), 'reminder_24h_sent_at' => null, ]); $this->artisan('reminders:send-24h') ->assertSuccessful(); Notification::assertSentTo( $consultation->user, ConsultationReminder24h::class ); expect($consultation->fresh()->reminder_24h_sent_at)->not->toBeNull(); }); it('does not send reminder for cancelled consultation', function () { Notification::fake(); $consultation = Consultation::factory()->create([ 'status' => 'cancelled', 'scheduled_date' => now()->addHours(24)->toDateString(), 'scheduled_time' => now()->addHours(24)->format('H:i:s'), ]); $this->artisan('reminders:send-24h') ->assertSuccessful(); Notification::assertNotSentTo($consultation->user, ConsultationReminder24h::class); }); it('does not send reminder for no-show consultation', function () { Notification::fake(); $consultation = Consultation::factory()->create([ 'status' => 'no_show', 'scheduled_date' => now()->addHours(24)->toDateString(), 'scheduled_time' => now()->addHours(24)->format('H:i:s'), ]); $this->artisan('reminders:send-24h') ->assertSuccessful(); Notification::assertNotSentTo($consultation->user, ConsultationReminder24h::class); }); it('does not send duplicate 24h reminders', function () { Notification::fake(); $consultation = Consultation::factory()->approved()->create([ 'scheduled_date' => now()->addHours(24)->toDateString(), 'scheduled_time' => now()->addHours(24)->format('H:i:s'), 'reminder_24h_sent_at' => now()->subHour(), // Already sent ]); $this->artisan('reminders:send-24h') ->assertSuccessful(); Notification::assertNotSentTo($consultation->user, ConsultationReminder24h::class); }); it('sends 2h reminder for upcoming consultation', function () { Notification::fake(); $consultation = Consultation::factory()->approved()->create([ 'scheduled_date' => now()->addHours(2)->toDateString(), 'scheduled_time' => now()->addHours(2)->format('H:i:s'), 'reminder_2h_sent_at' => null, ]); $this->artisan('reminders:send-2h') ->assertSuccessful(); Notification::assertSentTo( $consultation->user, ConsultationReminder2h::class ); expect($consultation->fresh()->reminder_2h_sent_at)->not->toBeNull(); }); it('includes payment reminder for unpaid consultations', function () { Notification::fake(); $consultation = Consultation::factory()->approved()->create([ 'scheduled_date' => now()->addHours(24)->toDateString(), 'scheduled_time' => now()->addHours(24)->format('H:i:s'), 'type' => 'paid', 'payment_status' => 'pending', 'payment_amount' => 200.00, ]); $this->artisan('reminders:send-24h') ->assertSuccessful(); Notification::assertSentTo( $consultation->user, ConsultationReminder24h::class, function ($notification) { return $notification->consultation->type === 'paid' && $notification->consultation->payment_status === 'pending'; } ); }); it('respects user language preference for reminders', function () { Notification::fake(); $user = User::factory()->create(['preferred_language' => 'en']); $consultation = Consultation::factory()->approved()->create([ 'user_id' => $user->id, 'scheduled_date' => now()->addHours(24)->toDateString(), 'scheduled_time' => now()->addHours(24)->format('H:i:s'), ]); $this->artisan('reminders:send-24h') ->assertSuccessful(); Notification::assertSentTo($user, ConsultationReminder24h::class); }); ``` ## Definition of Done - [x] 24h reminder command works - [x] 2h reminder command works - [x] Scheduler configured correctly - [x] Only approved consultations receive reminders - [x] Cancelled/no-show don't receive reminders - [x] No duplicate reminders sent - [x] Payment reminder included when applicable - [x] Arabic emails work correctly - [x] English emails work correctly - [x] Logging for debugging - [x] Tests pass for reminder logic - [x] Code formatted with Pint ## Dependencies - **Story 3.4:** Consultation model and booking submission (`app/Models/Consultation.php`, `database/factories/ConsultationFactory.php`) - **Story 3.5:** Booking approval workflow (creates approved consultations) - **Story 3.6:** Calendar file generation (provides `client.consultations.calendar` route) - **Epic 1:** User model with `preferred_language` field (`app/Models/User.php`) - **Epic 8:** Email infrastructure (mail configuration, queue setup) ## Risk Assessment - **Primary Risk:** Scheduler not running - **Mitigation:** Monitor scheduler, alert on failures - **Rollback:** Manual reminder sending if needed ## Estimation **Complexity:** Medium **Estimated Effort:** 3-4 hours --- ## Dev Agent Record ### Status **Ready for Review** ### Agent Model Used Claude Opus 4.5 (claude-opus-4-5-20251101) ### File List | File | Action | |------|--------| | `database/migrations/2025_12_26_180923_add_reminder_columns_to_consultations_table.php` | Created | | `app/Models/Consultation.php` | Modified (added reminder columns to fillable/casts) | | `app/Notifications/ConsultationReminder24h.php` | Created | | `app/Notifications/ConsultationReminder2h.php` | Created | | `app/Console/Commands/Send24HourReminders.php` | Created | | `app/Console/Commands/Send2HourReminders.php` | Created | | `resources/views/emails/reminder-24h.blade.php` | Created | | `resources/views/emails/reminder-2h.blade.php` | Created | | `lang/ar/emails.php` | Modified (added reminder translations) | | `lang/en/emails.php` | Modified (added reminder translations) | | `routes/console.php` | Modified (added scheduler commands) | | `tests/Feature/ConsultationReminderTest.php` | Created | ### Change Log - Added `reminder_24h_sent_at` and `reminder_2h_sent_at` columns to consultations table - Created `ConsultationReminder24h` and `ConsultationReminder2h` notification classes with queue support - Created `Send24HourReminders` and `Send2HourReminders` artisan commands - Created bilingual email templates (Arabic/English) for both reminder types - Configured Laravel scheduler to run 24h reminders hourly and 2h reminders every 15 minutes - Added 11 comprehensive tests covering all acceptance criteria ### Completion Notes - The story specified `scheduled_date`/`scheduled_time` fields but the existing model uses `booking_date`/`booking_time` - implementation adapted accordingly - The story specified `type` field but the existing model uses `consultation_type` - implementation adapted accordingly - Email templates follow the existing project pattern using `@component('mail::message')` with locale-based RTL/LTR support - Notifications implement `ShouldQueue` for async processing via Laravel's queue system - Window-based reminder logic prevents edge cases (24h: ±30min window, 2h: ±15min window) ## QA Results ### Review Date: 2025-12-26 ### Reviewed By: Quinn (Test Architect) ### Code Quality Assessment Implementation is solid and well-structured. The developer correctly adapted the story specifications to match the existing codebase conventions (using `booking_date`/`booking_time` instead of `scheduled_date`/`scheduled_time`, and `consultation_type` instead of `type`). The window-based reminder logic is a smart approach that handles edge cases around scheduler timing. Key highlights: - Clean separation between Commands, Notifications, and Views - Proper use of PHP 8 enums throughout - Bilingual email templates with proper RTL/LTR support - Comprehensive test coverage with 11 tests all passing - Proper queue integration with `ShouldQueue` ### Refactoring Performed None required - implementation meets all quality standards. ### Compliance Check - Coding Standards: ✓ Pint formatting verified - Project Structure: ✓ Files in correct locations per Laravel conventions - Testing Strategy: ✓ Feature tests cover all acceptance criteria - All ACs Met: ✓ All 15+ acceptance criteria validated ### Improvements Checklist - [x] Migration properly adds nullable timestamp columns - [x] Model fillable and casts updated correctly - [x] Commands use enum constants for status comparison - [x] Notifications implement ShouldQueue for async processing - [x] Email templates support both Arabic and English - [x] Translations added to lang/ar and lang/en - [x] Scheduler configured in routes/console.php - [x] Error handling with try/catch and logging - [x] All 11 tests passing - [ ] Consider adding a test for 2h reminder with payment scenario (minor - 24h version covers this logic) ### Security Review No security concerns. The implementation: - Only sends emails to the consultation owner - No sensitive data exposed in email content beyond appointment details - Uses Laravel's notification system with proper escaping ### Performance Considerations - Notifications are queued (`ShouldQueue`) for async processing - DB queries filter by date first, then in-memory filter by time window - For typical consultation volumes (tens to hundreds per day), this is efficient - Scheduler runs hourly (24h) and every 15 minutes (2h) which is appropriate ### Files Modified During Review None - no modifications were necessary. ### Gate Status Gate: PASS → docs/qa/gates/3.8-consultation-reminders.yml ### Recommended Status ✓ Ready for Done All acceptance criteria are met, tests pass, code quality is high, and no blocking issues identified.