608 lines
20 KiB
Markdown
608 lines
20 KiB
Markdown
# 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
|
|
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Consultation;
|
|
use App\Notifications\ConsultationReminder24h;
|
|
use Illuminate\Console\Command;
|
|
use Carbon\Carbon;
|
|
|
|
class Send24HourReminders extends Command
|
|
{
|
|
protected $signature = 'reminders:send-24h';
|
|
protected $description = 'Send consultation reminders 24 hours before appointment';
|
|
|
|
public function handle(): int
|
|
{
|
|
$targetTime = now()->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
|
|
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Consultation;
|
|
use App\Notifications\ConsultationReminder2h;
|
|
use Illuminate\Console\Command;
|
|
use Carbon\Carbon;
|
|
|
|
class Send2HourReminders extends Command
|
|
{
|
|
protected $signature = 'reminders:send-2h';
|
|
protected $description = 'Send consultation reminders 2 hours before appointment';
|
|
|
|
public function handle(): int
|
|
{
|
|
$targetTime = now()->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
|
|
<?php
|
|
|
|
namespace App\Notifications;
|
|
|
|
use App\Models\Consultation;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Notifications\Notification;
|
|
use Illuminate\Notifications\Messages\MailMessage;
|
|
|
|
class ConsultationReminder24h extends Notification
|
|
{
|
|
use Queueable;
|
|
|
|
public function __construct(
|
|
public Consultation $consultation
|
|
) {}
|
|
|
|
public function via(object $notifiable): array
|
|
{
|
|
return ['mail'];
|
|
}
|
|
|
|
public function toMail(object $notifiable): MailMessage
|
|
{
|
|
$locale = $notifiable->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
|
|
<!-- resources/views/emails/reminders/24h/ar.blade.php -->
|
|
<x-mail::message>
|
|
# تذكير بموعدك
|
|
|
|
عزيزي {{ $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)
|
|
<x-mail::panel>
|
|
**تذكير بالدفع:** يرجى إتمام عملية الدفع قبل موعد الاستشارة.
|
|
المبلغ المطلوب: {{ number_format($consultation->payment_amount, 2) }} شيكل
|
|
</x-mail::panel>
|
|
@endif
|
|
|
|
<x-mail::button :url="route('client.consultations.calendar', $consultation)">
|
|
تحميل ملف التقويم
|
|
</x-mail::button>
|
|
|
|
إذا كنت بحاجة لإعادة جدولة الموعد، يرجى التواصل معنا في أقرب وقت.
|
|
|
|
مع أطيب التحيات،
|
|
مكتب ليبرا للمحاماة
|
|
</x-mail::message>
|
|
```
|
|
|
|
### Email Template (2h Arabic)
|
|
```blade
|
|
<!-- resources/views/emails/reminders/2h/ar.blade.php -->
|
|
<x-mail::message>
|
|
# موعدك بعد ساعتين
|
|
|
|
عزيزي {{ $user->name }}،
|
|
|
|
تذكير أخير: موعد استشارتك خلال ساعتين.
|
|
|
|
**الوقت:** {{ \Carbon\Carbon::parse($consultation->scheduled_time)->format('g:i A') }}
|
|
|
|
@if($showPaymentReminder)
|
|
<x-mail::panel>
|
|
**هام:** لم نستلم الدفعة بعد. يرجى إتمام الدفع قبل بدء الاستشارة.
|
|
</x-mail::panel>
|
|
@endif
|
|
|
|
إذا كان لديك أي استفسار طارئ، يرجى التواصل معنا على:
|
|
[رقم الهاتف]
|
|
|
|
نتطلع للقائك،
|
|
مكتب ليبرا للمحاماة
|
|
</x-mail::message>
|
|
```
|
|
|
|
### 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.
|