libra/docs/stories/story-3.8-consultation-remi...

15 KiB

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

  • Sent 24 hours before consultation
  • Includes:
    • Consultation date and time
    • Type (free/paid)
    • Payment reminder if paid and not received
    • Calendar file link
    • Any special instructions

2-Hour Reminder

  • Sent 2 hours before consultation
  • Includes:
    • Consultation date and time
    • Final payment reminder if applicable
    • Contact information for last-minute issues

Reminder Logic

  • Only for approved consultations
  • Skip cancelled consultations
  • Skip no-show consultations
  • Don't send duplicate reminders
  • Handle timezone correctly

Language Support

  • Email in client's preferred language
  • Arabic template for Arabic preference
  • English template for English preference

Quality Requirements

  • Scheduled jobs run reliably
  • Retry on failure
  • Logging for debugging
  • 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

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

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

// 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

// 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

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)

<!-- 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)

<!-- 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

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

  • 24h reminder command works
  • 2h reminder command works
  • Scheduler configured correctly
  • Only approved consultations receive reminders
  • Cancelled/no-show don't receive reminders
  • No duplicate reminders sent
  • Payment reminder included when applicable
  • Arabic emails work correctly
  • English emails work correctly
  • Logging for debugging
  • Tests pass for reminder logic
  • 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