diff --git a/docs/stories/story-3.1-working-hours-configuration.md b/docs/stories/story-3.1-working-hours-configuration.md index 8565495..a94995e 100644 --- a/docs/stories/story-3.1-working-hours-configuration.md +++ b/docs/stories/story-3.1-working-hours-configuration.md @@ -66,10 +66,14 @@ Schema::create('working_hours', function (Blueprint $table) { namespace App\Models; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class WorkingHour extends Model { + use HasFactory; + protected $fillable = [ 'day_of_week', 'start_time', @@ -107,10 +111,28 @@ class WorkingHour extends Model } ``` +### AdminLog Schema Reference +The `AdminLog` model is used for audit logging (defined in Epic 1). Schema: +```php +// admin_logs table +Schema::create('admin_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('admin_id')->constrained('users')->cascadeOnDelete(); + $table->string('action_type'); // 'create', 'update', 'delete' + $table->string('target_type'); // 'working_hours', 'user', 'consultation', etc. + $table->unsignedBigInteger('target_id')->nullable(); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->timestamps(); +}); +``` + ### Volt Component ```php schedule as $day => $config) { + if ($config['is_active'] && $config['end_time'] <= $config['start_time']) { + $this->addError("schedule.{$day}.end_time", __('validation.end_time_after_start')); + return; + } + } + + // Check for pending bookings on days being modified (warning only) + $warnings = $this->checkPendingBookings(); + + // Store old values for audit log + $oldValues = WorkingHour::all()->keyBy('day_of_week')->toArray(); + foreach ($this->schedule as $day => $config) { WorkingHour::updateOrCreate( ['day_of_week' => $day], @@ -144,16 +180,39 @@ new class extends Component { ); } - // Log action + // Log action with old and new values AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => 'update', 'target_type' => 'working_hours', + 'old_values' => $oldValues, 'new_values' => $this->schedule, 'ip_address' => request()->ip(), ]); - session()->flash('success', __('messages.working_hours_saved')); + $message = __('messages.working_hours_saved'); + if (!empty($warnings)) { + $message .= ' ' . __('messages.pending_bookings_warning', ['count' => count($warnings)]); + } + + session()->flash('success', $message); + } + + /** + * Check if there are pending bookings on days being disabled or with changed hours. + * Returns array of affected booking info for warning display. + */ + private function checkPendingBookings(): array + { + // This will be fully implemented when Consultation model exists (Story 3.4+) + // For now, return empty array - structure shown for developer guidance + return []; + + // Future implementation: + // return Consultation::where('status', 'pending') + // ->whereIn('day_of_week', $affectedDays) + // ->get() + // ->toArray(); } }; ``` @@ -201,6 +260,10 @@ new class extends Component { namespace App\Services; +use App\Models\Consultation; +use App\Models\WorkingHour; +use Carbon\Carbon; + class AvailabilityService { public function getAvailableSlots(Carbon $date): array @@ -232,6 +295,168 @@ class AvailabilityService } ``` +## Testing Requirements + +### Test File Location +`tests/Feature/Admin/WorkingHoursTest.php` + +### Factory Required +Create `database/factories/WorkingHourFactory.php`: +```php + $this->faker->numberBetween(0, 6), + 'start_time' => '09:00', + 'end_time' => '17:00', + 'is_active' => true, + ]; + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } +} +``` + +### Test Scenarios + +#### Unit Tests (WorkingHour Model) +```php +test('getDayName returns correct English day names', function () { + expect(WorkingHour::getDayName(0, 'en'))->toBe('Sunday'); + expect(WorkingHour::getDayName(6, 'en'))->toBe('Saturday'); +}); + +test('getDayName returns correct Arabic day names', function () { + expect(WorkingHour::getDayName(0, 'ar'))->toBe('الأحد'); + expect(WorkingHour::getDayName(4, 'ar'))->toBe('الخميس'); +}); + +test('getSlots returns correct 1-hour slots', function () { + $workingHour = WorkingHour::factory()->create([ + 'start_time' => '09:00', + 'end_time' => '12:00', + 'is_active' => true, + ]); + + $slots = $workingHour->getSlots(60); + + expect($slots)->toBe(['09:00', '10:00', '11:00']); +}); + +test('getSlots returns empty array when duration exceeds available time', function () { + $workingHour = WorkingHour::factory()->create([ + 'start_time' => '09:00', + 'end_time' => '09:30', + 'is_active' => true, + ]); + + expect($workingHour->getSlots(60))->toBe([]); +}); +``` + +#### Feature Tests (Volt Component) +```php +test('admin can view working hours configuration', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.working-hours') + ->actingAs($admin) + ->assertSuccessful() + ->assertSee(__('admin.working_hours')); +}); + +test('admin can save working hours configuration', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.working-hours') + ->actingAs($admin) + ->set('schedule.1.is_active', true) + ->set('schedule.1.start_time', '09:00') + ->set('schedule.1.end_time', '17:00') + ->call('save') + ->assertHasNoErrors(); + + expect(WorkingHour::where('day_of_week', 1)->first()) + ->is_active->toBeTrue() + ->start_time->toBe('09:00') + ->end_time->toBe('17:00'); +}); + +test('validation fails when end time is before start time', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.working-hours') + ->actingAs($admin) + ->set('schedule.1.is_active', true) + ->set('schedule.1.start_time', '17:00') + ->set('schedule.1.end_time', '09:00') + ->call('save') + ->assertHasErrors(['schedule.1.end_time']); +}); + +test('audit log is created when working hours are saved', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.working-hours') + ->actingAs($admin) + ->set('schedule.0.is_active', true) + ->call('save'); + + expect(AdminLog::where('target_type', 'working_hours')->count())->toBe(1); +}); + +test('inactive days do not require time validation', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.working-hours') + ->actingAs($admin) + ->set('schedule.1.is_active', false) + ->set('schedule.1.start_time', '17:00') + ->set('schedule.1.end_time', '09:00') + ->call('save') + ->assertHasNoErrors(); +}); +``` + +### Translation Keys Required +Add to `lang/en/validation.php`: +```php +'end_time_after_start' => 'End time must be after start time.', +``` + +Add to `lang/ar/validation.php`: +```php +'end_time_after_start' => 'يجب أن يكون وقت الانتهاء بعد وقت البدء.', +``` + +Add to `lang/en/messages.php`: +```php +'working_hours_saved' => 'Working hours saved successfully.', +'pending_bookings_warning' => 'Note: :count pending booking(s) may be affected.', +``` + +Add to `lang/ar/messages.php`: +```php +'working_hours_saved' => 'تم حفظ ساعات العمل بنجاح.', +'pending_bookings_warning' => 'ملاحظة: قد يتأثر :count حجز(حجوزات) معلقة.', +``` + ## Definition of Done - [ ] Can enable/disable each day of week diff --git a/docs/stories/story-3.2-time-slot-blocking.md b/docs/stories/story-3.2-time-slot-blocking.md index 6fe6632..129afee 100644 --- a/docs/stories/story-3.2-time-slot-blocking.md +++ b/docs/stories/story-3.2-time-slot-blocking.md @@ -73,6 +73,7 @@ Schema::create('blocked_times', function (Blueprint $table) { namespace App\Models; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; class BlockedTime extends Model @@ -128,6 +129,7 @@ class BlockedTime extends Model ```php block_date); + + return Consultation::where('scheduled_date', $date->toDateString()) + ->where('status', 'pending') + ->when(!$this->is_all_day, function ($query) { + $query->whereBetween('scheduled_time', [$this->start_time, $this->end_time]); + }) + ->with('user:id,full_name,company_name') + ->get() + ->toArray(); +} +``` + ### Integration with Availability Service ```php // In AvailabilityService @@ -296,6 +316,73 @@ public function isDateFullyBlocked(Carbon $date): bool ``` +## Assumptions + +- `AdminLog` model exists from Epic 1 with fields: `admin_id`, `action_type`, `target_type`, `target_id`, `new_values`, `ip_address` +- Route naming convention: `admin.blocked-times.{index|create|edit}` +- Admin middleware protecting all blocked-times routes +- Flux UI modal component available for delete confirmation + +## Required Translation Keys + +```php +// lang/en/*.php and lang/ar/*.php +'messages.blocked_time_saved' => 'Blocked time saved successfully', +'messages.blocked_time_deleted' => 'Blocked time deleted successfully', +'admin.blocked_times' => 'Blocked Times', +'admin.add_blocked_time' => 'Add Blocked Time', +'admin.all_day' => 'All Day', +'admin.closed' => 'Closed', +'admin.no_blocked_times' => 'No blocked times found', +'common.edit' => 'Edit', +'common.delete' => 'Delete', +'common.to' => 'to', +``` + +## Test Scenarios + +### Feature Tests (`tests/Feature/BlockedTimeTest.php`) + +**CRUD Operations:** +- [ ] Admin can create an all-day block +- [ ] Admin can create a time-range block (e.g., 09:00-12:00) +- [ ] Admin can add optional reason to blocked time +- [ ] Admin can edit an existing blocked time +- [ ] Admin can delete a blocked time +- [ ] Non-admin users cannot access blocked time routes + +**Validation:** +- [ ] Cannot create block with end_time before start_time +- [ ] Cannot create block for past dates (new blocks only) +- [ ] Can edit existing blocks for past dates (data integrity) +- [ ] Reason field respects 255 character max length + +**List View:** +- [ ] List displays all blocked times sorted by date (upcoming first) +- [ ] Filter by "upcoming" shows only future blocks +- [ ] Filter by "past" shows only past blocks +- [ ] Filter by "all" shows all blocks + +**Integration:** +- [ ] `blocksSlot()` returns true for times within blocked range +- [ ] `blocksSlot()` returns true for all times when all-day block +- [ ] `blocksSlot()` returns false for times outside blocked range +- [ ] `isDateFullyBlocked()` correctly identifies all-day blocks +- [ ] `getBlockedSlots()` returns correct slots for partial day blocks + +**Edge Cases:** +- [ ] Multiple blocks on same date handled correctly +- [ ] Block at end of working hours (edge of range) +- [ ] Warning displayed when blocking date with pending consultations + +### Unit Tests (`tests/Unit/BlockedTimeTest.php`) + +- [ ] `isAllDay()` returns true when start_time and end_time are null +- [ ] `isAllDay()` returns false when times are set +- [ ] `scopeUpcoming()` filters correctly +- [ ] `scopePast()` filters correctly +- [ ] `scopeForDate()` filters by exact date + ## Definition of Done - [ ] Can create all-day blocks @@ -313,8 +400,12 @@ public function isDateFullyBlocked(Carbon $date): bool ## Dependencies -- **Story 3.1:** Working hours configuration -- **Story 3.3:** Availability calendar (consumes blocked times) +- **Story 3.1:** Working hours configuration (`docs/stories/story-3.1-working-hours-configuration.md`) + - Provides: `WorkingHour` model, `AvailabilityService` base +- **Story 3.3:** Availability calendar (`docs/stories/story-3.3-availability-calendar-display.md`) + - Consumes: blocked times data for calendar display +- **Epic 1:** Core Foundation + - Provides: `AdminLog` model for audit logging, admin authentication ## Risk Assessment diff --git a/docs/stories/story-3.3-availability-calendar-display.md b/docs/stories/story-3.3-availability-calendar-display.md index 7f9452b..d78be40 100644 --- a/docs/stories/story-3.3-availability-calendar-display.md +++ b/docs/stories/story-3.3-availability-calendar-display.md @@ -42,6 +42,10 @@ So that **I can choose a convenient time for my consultation**. - [ ] Prevent double-booking (race condition handling) - [ ] Refresh availability when navigating months +### Navigation Constraints +- [ ] Prevent navigating to months before current month (all dates would be "past") +- [ ] Optionally limit future navigation (e.g., max 3 months ahead) - configurable + ### Responsive Design - [ ] Mobile-friendly calendar - [ ] Touch-friendly slot selection @@ -55,6 +59,18 @@ So that **I can choose a convenient time for my consultation**. ## Technical Notes +### File Structure + +Create the following files: +- `app/Services/AvailabilityService.php` - Core availability logic +- `resources/views/livewire/availability-calendar.blade.php` - Volt component +- `lang/en/calendar.php` - English calendar translations +- `lang/ar/calendar.php` - Arabic calendar translations +- `lang/en/booking.php` - English booking translations (if not exists) +- `lang/ar/booking.php` - Arabic booking translations (if not exists) +- `tests/Unit/Services/AvailabilityServiceTest.php` - Unit tests +- `tests/Feature/Livewire/AvailabilityCalendarTest.php` - Feature tests + ### Availability Service ```php year, $this->month, 1)->subMonth(); + + // Prevent navigating to past months + if ($date->lt(now()->startOfMonth())) { + return; + } + $this->year = $date->year; $this->month = $date->month; $this->selectedDate = null; @@ -375,6 +397,80 @@ new class extends Component { ``` +### RTL Support Implementation + +The calendar requires specific RTL handling for Arabic users: + +1. **Navigation arrows**: Already handled with locale-aware chevron direction (see Blade template) + +2. **Day headers order**: Use locale-aware day ordering: +```php +// In Volt component +private function getDayHeaders(): array +{ + $days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + // Arabic calendar traditionally starts on Saturday + if (app()->getLocale() === 'ar') { + // Reorder: Sat, Sun, Mon, Tue, Wed, Thu, Fri + array_unshift($days, array_pop($days)); + } + + return $days; +} +``` + +3. **Calendar grid direction**: Apply RTL class to the grid container: +```blade +
+``` + +4. **Required translation keys** (add to `lang/ar/calendar.php` and `lang/en/calendar.php`): +```php +// lang/en/calendar.php +return [ + 'Sun' => 'Sun', + 'Mon' => 'Mon', + 'Tue' => 'Tue', + 'Wed' => 'Wed', + 'Thu' => 'Thu', + 'Fri' => 'Fri', + 'Sat' => 'Sat', +]; + +// lang/ar/calendar.php +return [ + 'Sun' => 'أحد', + 'Mon' => 'إثن', + 'Tue' => 'ثلا', + 'Wed' => 'أرب', + 'Thu' => 'خمي', + 'Fri' => 'جمع', + 'Sat' => 'سبت', +]; +``` + +5. **Booking translation keys** (add to `lang/*/booking.php`): +```php +// lang/en/booking.php +return [ + 'available' => 'Available', + 'partial' => 'Partial', + 'unavailable' => 'Unavailable', + 'available_times' => 'Available Times', + 'no_slots_available' => 'No slots available for this date.', +]; + +// lang/ar/booking.php +return [ + 'available' => 'متاح', + 'partial' => 'متاح جزئياً', + 'unavailable' => 'غير متاح', + 'available_times' => 'الأوقات المتاحة', + 'no_slots_available' => 'لا توجد مواعيد متاحة لهذا التاريخ.', +]; +``` + ## Definition of Done - [ ] Calendar displays current month @@ -389,11 +485,325 @@ new class extends Component { - [ ] Tests for availability logic - [ ] Code formatted with Pint +## Testing Scenarios + +### AvailabilityService Unit Tests + +Create `tests/Unit/Services/AvailabilityServiceTest.php`: + +```php +create([ + 'day_of_week' => $day, + 'start_time' => '09:00', + 'end_time' => '17:00', + 'is_active' => true, + ]); + } +}); + +describe('getDateStatus', function () { + it('returns "past" for yesterday', function () { + $service = new AvailabilityService(); + $yesterday = Carbon::yesterday(); + + expect($service->getDateStatus($yesterday))->toBe('past'); + }); + + it('returns "closed" for non-working days (weekends)', function () { + $service = new AvailabilityService(); + $sunday = Carbon::now()->next(Carbon::SUNDAY); + + expect($service->getDateStatus($sunday))->toBe('closed'); + }); + + it('returns "blocked" for fully blocked date', function () { + $monday = Carbon::now()->next(Carbon::MONDAY); + BlockedTime::factory()->create([ + 'block_date' => $monday->toDateString(), + 'start_time' => null, // All day block + 'end_time' => null, + ]); + + $service = new AvailabilityService(); + + expect($service->getDateStatus($monday))->toBe('blocked'); + }); + + it('returns "full" when all slots are booked', function () { + $monday = Carbon::now()->next(Carbon::MONDAY); + $slots = ['09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00']; + + foreach ($slots as $slot) { + Consultation::factory()->create([ + 'scheduled_date' => $monday->toDateString(), + 'scheduled_time' => $slot, + 'status' => 'approved', + ]); + } + + $service = new AvailabilityService(); + + expect($service->getDateStatus($monday))->toBe('full'); + }); + + it('returns "partial" when some slots are booked', function () { + $monday = Carbon::now()->next(Carbon::MONDAY); + Consultation::factory()->create([ + 'scheduled_date' => $monday->toDateString(), + 'scheduled_time' => '10:00', + 'status' => 'approved', + ]); + + $service = new AvailabilityService(); + + expect($service->getDateStatus($monday))->toBe('partial'); + }); + + it('returns "available" for working day with no bookings', function () { + $monday = Carbon::now()->next(Carbon::MONDAY); + $service = new AvailabilityService(); + + expect($service->getDateStatus($monday))->toBe('available'); + }); +}); + +describe('getAvailableSlots', function () { + it('returns empty array for non-working days', function () { + $service = new AvailabilityService(); + $sunday = Carbon::now()->next(Carbon::SUNDAY); + + expect($service->getAvailableSlots($sunday))->toBe([]); + }); + + it('excludes booked consultation times', function () { + $monday = Carbon::now()->next(Carbon::MONDAY); + Consultation::factory()->create([ + 'scheduled_date' => $monday->toDateString(), + 'scheduled_time' => '10:00', + 'status' => 'pending', + ]); + + $service = new AvailabilityService(); + $slots = $service->getAvailableSlots($monday); + + expect($slots)->not->toContain('10:00'); + }); + + it('excludes blocked time ranges', function () { + $monday = Carbon::now()->next(Carbon::MONDAY); + BlockedTime::factory()->create([ + 'block_date' => $monday->toDateString(), + 'start_time' => '14:00', + 'end_time' => '16:00', + ]); + + $service = new AvailabilityService(); + $slots = $service->getAvailableSlots($monday); + + expect($slots)->not->toContain('14:00'); + expect($slots)->not->toContain('15:00'); + }); + + it('includes pending and approved consultations as booked', function () { + $monday = Carbon::now()->next(Carbon::MONDAY); + + Consultation::factory()->create([ + 'scheduled_date' => $monday->toDateString(), + 'scheduled_time' => '09:00', + 'status' => 'pending', + ]); + + Consultation::factory()->create([ + 'scheduled_date' => $monday->toDateString(), + 'scheduled_time' => '10:00', + 'status' => 'approved', + ]); + + // Cancelled should NOT block the slot + Consultation::factory()->create([ + 'scheduled_date' => $monday->toDateString(), + 'scheduled_time' => '11:00', + 'status' => 'cancelled', + ]); + + $service = new AvailabilityService(); + $slots = $service->getAvailableSlots($monday); + + expect($slots)->not->toContain('09:00'); + expect($slots)->not->toContain('10:00'); + expect($slots)->toContain('11:00'); // Cancelled slot is available + }); +}); + +describe('getMonthAvailability', function () { + it('returns status for every day in the month', function () { + $service = new AvailabilityService(); + $availability = $service->getMonthAvailability(2025, 1); + + expect($availability)->toHaveCount(31); // January has 31 days + }); + + it('handles year rollover correctly (December to January)', function () { + $service = new AvailabilityService(); + + $december = $service->getMonthAvailability(2024, 12); + $january = $service->getMonthAvailability(2025, 1); + + expect($december)->toHaveKey('2024-12-31'); + expect($january)->toHaveKey('2025-01-01'); + }); +}); +``` + +### Volt Component Feature Tests + +Create `tests/Feature/Livewire/AvailabilityCalendarTest.php`: + +```php +create([ + 'day_of_week' => $day, + 'start_time' => '09:00', + 'end_time' => '17:00', + 'is_active' => true, + ]); + } +}); + +it('displays current month by default', function () { + $currentMonth = now()->translatedFormat('F Y'); + + Volt::test('availability-calendar') + ->assertSee($currentMonth); +}); + +it('navigates to next month', function () { + $nextMonth = now()->addMonth()->translatedFormat('F Y'); + + Volt::test('availability-calendar') + ->call('nextMonth') + ->assertSee($nextMonth); +}); + +it('navigates to previous month', function () { + $prevMonth = now()->subMonth()->translatedFormat('F Y'); + + Volt::test('availability-calendar') + ->call('previousMonth') + ->assertSee($prevMonth); +}); + +it('handles year rollover when navigating months', function () { + // Start in December + Carbon::setTestNow(Carbon::create(2024, 12, 15)); + + Volt::test('availability-calendar') + ->assertSet('year', 2024) + ->assertSet('month', 12) + ->call('nextMonth') + ->assertSet('year', 2025) + ->assertSet('month', 1); + + Carbon::setTestNow(); +}); + +it('loads available slots when date is selected', function () { + $monday = Carbon::now()->next(Carbon::MONDAY)->format('Y-m-d'); + + Volt::test('availability-calendar') + ->call('selectDate', $monday) + ->assertSet('selectedDate', $monday) + ->assertNotEmpty('availableSlots'); +}); + +it('does not select unavailable dates', function () { + $sunday = Carbon::now()->next(Carbon::SUNDAY)->format('Y-m-d'); + + Volt::test('availability-calendar') + ->call('selectDate', $sunday) + ->assertSet('selectedDate', null) + ->assertSet('availableSlots', []); +}); + +it('clears selection when navigating months', function () { + $monday = Carbon::now()->next(Carbon::MONDAY)->format('Y-m-d'); + + Volt::test('availability-calendar') + ->call('selectDate', $monday) + ->assertSet('selectedDate', $monday) + ->call('nextMonth') + ->assertSet('selectedDate', null) + ->assertSet('availableSlots', []); +}); + +it('displays RTL layout for Arabic locale', function () { + app()->setLocale('ar'); + + Volt::test('availability-calendar') + ->assertSeeHtml('dir="rtl"'); +}); + +it('prevents navigating to past months', function () { + Volt::test('availability-calendar') + ->assertSet('year', now()->year) + ->assertSet('month', now()->month) + ->call('previousMonth') + ->assertSet('year', now()->year) // Should not change + ->assertSet('month', now()->month); // Should not change +}); +``` + +### Browser Tests (Optional - Story 3.4+ integration) + +```php +create([ + 'day_of_week' => Carbon::MONDAY, + 'is_active' => true, + ]); + + $user = User::factory()->create(); + + $page = visit('/book') + ->actingAs($user) + ->assertSee('Available') + ->click('[data-available-date]') + ->assertSee('Available Times') + ->click('[data-time-slot="09:00"]') + ->assertNoJavascriptErrors(); +}); +``` + ## Dependencies -- **Story 3.1:** Working hours (defines available time) -- **Story 3.2:** Blocked times (removes availability) -- **Story 3.4:** Booking submission (consumes selected slot) +- **Story 3.1:** Working hours (defines available time) - **MUST be completed first** +- **Story 3.2:** Blocked times (removes availability) - **MUST be completed first** +- **Story 3.4:** Booking submission (consumes selected slot) - developed after this story + +### Parent Component Dependency +This calendar component is designed to be embedded within a parent booking component (Story 3.4). The `$parent.selectSlot()` call expects the parent to have a `selectSlot(string $date, string $time)` method. For standalone testing, create a wrapper component or mock the parent interaction. ## Risk Assessment @@ -401,6 +811,38 @@ new class extends Component { - **Mitigation:** Database-level unique constraint, check on submission - **Rollback:** Refresh availability if booking fails +### Race Condition Prevention Strategy + +The calendar display component itself does not handle booking submission - that is Story 3.4's responsibility. However, this story must support race condition prevention by: + +1. **Fresh availability check on date selection**: Always reload slots when a date is clicked (already implemented in `loadAvailableSlots()`) + +2. **Database constraint** (to be added in Story 3.4 migration): +```php +// In consultations table migration +$table->unique(['scheduled_date', 'scheduled_time'], 'unique_consultation_slot'); +``` + +3. **Optimistic UI with graceful fallback**: If a slot is selected but booking fails due to race condition, the calendar should refresh availability: +```php +// In parent booking component (Story 3.4) +public function selectSlot(string $date, string $time): void +{ + // Re-verify slot is still available before proceeding + $service = app(AvailabilityService::class); + $availableSlots = $service->getAvailableSlots(Carbon::parse($date)); + + if (!in_array($time, $availableSlots)) { + $this->dispatch('slot-unavailable'); + $this->loadAvailableSlots(); // Refresh the calendar + return; + } + + $this->selectedDate = $date; + $this->selectedTime = $time; +} +``` + ## Estimation **Complexity:** High diff --git a/docs/stories/story-3.4-booking-request-submission.md b/docs/stories/story-3.4-booking-request-submission.md index 23831a5..01dfdb0 100644 --- a/docs/stories/story-3.4-booking-request-submission.md +++ b/docs/stories/story-3.4-booking-request-submission.md @@ -11,10 +11,11 @@ So that **I can schedule a meeting with the lawyer**. ## Story Context ### Existing System Integration -- **Integrates with:** consultations table, availability calendar, notifications -- **Technology:** Livewire Volt, form validation -- **Follows pattern:** Form submission with confirmation -- **Touch points:** Client dashboard, admin notifications +- **Integrates with:** `consultations` table, availability calendar (Story 3.3), notifications system +- **Technology:** Livewire Volt (class-based), Laravel form validation, DB transactions +- **Follows pattern:** Multi-step form submission with confirmation +- **Touch points:** Client dashboard, admin notifications, audit log +- **Component location:** `resources/views/livewire/booking/request.blade.php` ## Acceptance Criteria @@ -282,7 +283,7 @@ new class extends Component { ### 1-Per-Day Validation Rule ```php -// Custom validation rule +// app/Rules/OneBookingPerDay.php use Illuminate\Contracts\Validation\ValidationRule; class OneBookingPerDay implements ValidationRule @@ -301,6 +302,96 @@ class OneBookingPerDay implements ValidationRule } ``` +### Advanced Pattern: Race Condition Prevention + +The `submit()` method uses `DB::transaction()` with `lockForUpdate()` to prevent race conditions. This is an **advanced pattern** required because: +- Multiple clients could attempt to book the same slot simultaneously +- Without locking, both requests could pass validation and create duplicate bookings + +The `lockForUpdate()` acquires a row-level lock, ensuring only one transaction completes while others wait and then fail validation. + +## Files to Create + +| File | Purpose | +|------|---------| +| `resources/views/livewire/booking/request.blade.php` | Main Volt component for booking submission | +| `app/Rules/OneBookingPerDay.php` | Custom validation rule for 1-per-day limit | +| `app/Notifications/BookingSubmittedClient.php` | Email notification to client on submission | +| `app/Notifications/NewBookingAdmin.php` | Email notification to admin for new booking | + +### Notification Classes + +Create notifications using artisan: +```bash +php artisan make:notification BookingSubmittedClient +php artisan make:notification NewBookingAdmin +``` + +Both notifications should: +- Accept `Consultation $consultation` in constructor +- Implement `toMail()` for email delivery +- Use bilingual subjects based on user's `preferred_language` + +## Translation Keys Required + +Add to `lang/en/booking.php` and `lang/ar/booking.php`: + +```php +// lang/en/booking.php +'request_consultation' => 'Request Consultation', +'select_date_time' => 'Select a date and time for your consultation', +'selected_time' => 'Selected Time', +'problem_summary' => 'Problem Summary', +'problem_summary_placeholder' => 'Please describe your legal issue or question in detail...', +'problem_summary_help' => 'Minimum 20 characters. This helps the lawyer prepare for your consultation.', +'continue' => 'Continue', +'confirm_booking' => 'Confirm Your Booking', +'confirm_message' => 'Please review your booking details before submitting.', +'date' => 'Date', +'time' => 'Time', +'duration' => 'Duration', +'submit_request' => 'Submit Request', +'submitted_successfully' => 'Your booking request has been submitted. You will receive an email confirmation shortly.', +'already_booked_this_day' => 'You already have a booking on this day.', +'slot_no_longer_available' => 'This time slot is no longer available. Please select another.', +'slot_taken' => 'This slot was just booked. Please select another time.', +``` + +## Testing Requirements + +### Test File Location +`tests/Feature/Booking/BookingSubmissionTest.php` + +### Required Test Scenarios + +```php +// Happy path +test('authenticated client can submit booking request') +test('booking is created with pending status') +test('client receives confirmation notification') +test('admin receives new booking notification') + +// Validation +test('guest cannot access booking form') +test('problem summary is required') +test('problem summary must be at least 20 characters') +test('selected date must be today or future') + +// Business rules +test('client cannot book more than once per day') +test('client cannot book unavailable slot') +test('booking fails if slot taken during submission', function () { + // Test race condition prevention + // Create booking for same slot in parallel/before submission completes +}) + +// UI flow +test('confirmation step displays before final submission') +test('user can go back from confirmation to edit') +test('success message shown after submission') +test('redirects to consultations list after submission') +``` + ## Definition of Done - [ ] Can select date from calendar @@ -318,9 +409,13 @@ class OneBookingPerDay implements ValidationRule ## Dependencies -- **Story 3.3:** Availability calendar -- **Epic 2:** User authentication +- **Story 3.3:** Availability calendar (`docs/stories/story-3.3-availability-calendar-display.md`) + - Provides `AvailabilityService` for slot availability checking + - Provides `booking.availability-calendar` Livewire component +- **Epic 2:** User authentication (`docs/epics/epic-2-user-management.md`) + - Client must be logged in to submit bookings - **Epic 8:** Email notifications (partial) + - Notification infrastructure for sending emails ## Risk Assessment diff --git a/docs/stories/story-3.5-admin-booking-review-approval.md b/docs/stories/story-3.5-admin-booking-review-approval.md index 79e032e..9b66861 100644 --- a/docs/stories/story-3.5-admin-booking-review-approval.md +++ b/docs/stories/story-3.5-admin-booking-review-approval.md @@ -295,6 +295,356 @@ new class extends Component { }; ``` +### Notification Classes +Create these notification classes in `app/Notifications/`: + +```php +// app/Notifications/BookingApproved.php +namespace App\Notifications; + +use App\Models\Consultation; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Notifications\Notification; + +class BookingApproved extends Notification implements ShouldQueue +{ + use Queueable; + + public function __construct( + public Consultation $consultation, + public string $icsContent, + public ?string $paymentInstructions = null + ) {} + + 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.booking.approved', [ + 'consultation' => $this->consultation, + 'paymentInstructions' => $this->paymentInstructions, + 'locale' => $locale, + ]) + ->attachData( + $this->icsContent, + 'consultation.ics', + ['mime' => 'text/calendar'] + ); + } + + private function getSubject(string $locale): string + { + return $locale === 'ar' + ? 'تمت الموافقة على حجز استشارتك' + : 'Your Consultation Booking Approved'; + } +} +``` + +```php +// app/Notifications/BookingRejected.php +namespace App\Notifications; + +use App\Models\Consultation; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Notifications\Notification; + +class BookingRejected extends Notification implements ShouldQueue +{ + use Queueable; + + public function __construct( + public Consultation $consultation, + public ?string $rejectionReason = null + ) {} + + 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.booking.rejected', [ + 'consultation' => $this->consultation, + 'rejectionReason' => $this->rejectionReason, + 'locale' => $locale, + ]); + } + + private function getSubject(string $locale): string + { + return $locale === 'ar' + ? 'بخصوص طلب الاستشارة الخاص بك' + : 'Regarding Your Consultation Request'; + } +} +``` + +### Error Handling +```php +// In the approve() method, wrap calendar generation with error handling +public function approve(): void +{ + // ... validation ... + + try { + $calendarService = app(CalendarService::class); + $icsContent = $calendarService->generateIcs($this->consultation); + } catch (\Exception $e) { + // Log error but don't block approval + Log::error('Failed to generate calendar file', [ + 'consultation_id' => $this->consultation->id, + 'error' => $e->getMessage(), + ]); + $icsContent = null; + } + + // Update consultation status regardless + $this->consultation->update([ + 'status' => 'approved', + 'type' => $this->consultationType, + 'payment_amount' => $this->consultationType === 'paid' ? $this->paymentAmount : null, + 'payment_status' => $this->consultationType === 'paid' ? 'pending' : 'not_applicable', + ]); + + // Send notification (with or without .ics) + $this->consultation->user->notify( + new BookingApproved( + $this->consultation, + $icsContent ?? '', + $this->paymentInstructions + ) + ); + + // ... audit log and redirect ... +} +``` + +### Edge Cases +- **Already approved booking:** The approve/reject buttons should only appear for `status = 'pending'`. Add guard clause: + ```php + if ($this->consultation->status !== 'pending') { + session()->flash('error', __('messages.booking_already_processed')); + return; + } + ``` +- **Concurrent approval:** Use database transaction with locking to prevent race conditions +- **Missing user:** Check `$this->consultation->user` exists before sending notification + +### Testing Examples + +```php +use App\Models\Consultation; +use App\Models\User; +use App\Notifications\BookingApproved; +use App\Notifications\BookingRejected; +use App\Services\CalendarService; +use Illuminate\Support\Facades\Notification; +use Livewire\Volt\Volt; + +// Test: Admin can view pending bookings list +it('displays pending bookings list for admin', function () { + $admin = User::factory()->admin()->create(); + $consultations = Consultation::factory()->count(3)->pending()->create(); + + Volt::test('admin.bookings.pending-list') + ->actingAs($admin) + ->assertSee($consultations->first()->user->name) + ->assertStatus(200); +}); + +// Test: Admin can approve booking as free consultation +it('can approve booking as free consultation', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('consultationType', 'free') + ->call('approve') + ->assertHasNoErrors() + ->assertRedirect(route('admin.bookings.pending')); + + expect($consultation->fresh()) + ->status->toBe('approved') + ->type->toBe('free') + ->payment_status->toBe('not_applicable'); + + Notification::assertSentTo($consultation->user, BookingApproved::class); +}); + +// Test: Admin can approve booking as paid consultation with amount +it('can approve booking as paid consultation with amount', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('consultationType', 'paid') + ->set('paymentAmount', 150.00) + ->set('paymentInstructions', 'Bank transfer to account XYZ') + ->call('approve') + ->assertHasNoErrors(); + + expect($consultation->fresh()) + ->status->toBe('approved') + ->type->toBe('paid') + ->payment_amount->toBe(150.00) + ->payment_status->toBe('pending'); +}); + +// Test: Paid consultation requires payment amount +it('requires payment amount for paid consultation', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('consultationType', 'paid') + ->set('paymentAmount', null) + ->call('approve') + ->assertHasErrors(['paymentAmount']); +}); + +// Test: Admin can reject booking with reason +it('can reject booking with optional reason', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('rejectionReason', 'Schedule conflict') + ->call('reject') + ->assertHasNoErrors() + ->assertRedirect(route('admin.bookings.pending')); + + expect($consultation->fresh())->status->toBe('rejected'); + + Notification::assertSentTo($consultation->user, BookingRejected::class); +}); + +// Test: Quick approve from list +it('can quick approve booking from list', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.bookings.pending-list') + ->actingAs($admin) + ->call('quickApprove', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh()) + ->status->toBe('approved') + ->type->toBe('free'); +}); + +// Test: Quick reject from list +it('can quick reject booking from list', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.bookings.pending-list') + ->actingAs($admin) + ->call('quickReject', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh())->status->toBe('rejected'); +}); + +// Test: Audit log entry created on approval +it('creates audit log entry on approval', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('consultationType', 'free') + ->call('approve'); + + $this->assertDatabaseHas('admin_logs', [ + 'admin_id' => $admin->id, + 'action_type' => 'approve', + 'target_type' => 'consultation', + 'target_id' => $consultation->id, + ]); +}); + +// Test: Cannot approve already processed booking +it('cannot approve already approved booking', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->actingAs($admin) + ->call('approve') + ->assertHasErrors(); +}); + +// Test: Filter bookings by date range +it('can filter pending bookings by date range', function () { + $admin = User::factory()->admin()->create(); + + $oldBooking = Consultation::factory()->pending()->create([ + 'scheduled_date' => now()->subDays(10), + ]); + $newBooking = Consultation::factory()->pending()->create([ + 'scheduled_date' => now()->addDays(5), + ]); + + Volt::test('admin.bookings.pending-list') + ->actingAs($admin) + ->set('dateFrom', now()->toDateString()) + ->assertSee($newBooking->user->name) + ->assertDontSee($oldBooking->user->name); +}); + +// Test: Bilingual notification (Arabic) +it('sends approval notification in client preferred language', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $arabicUser = User::factory()->create(['preferred_language' => 'ar']); + $consultation = Consultation::factory()->pending()->create(['user_id' => $arabicUser->id]); + + Volt::test('admin.bookings.review', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('consultationType', 'free') + ->call('approve'); + + Notification::assertSentTo($arabicUser, BookingApproved::class, function ($notification) { + return $notification->consultation->user->preferred_language === 'ar'; + }); +}); +``` + ## Definition of Done - [ ] Pending bookings list displays correctly @@ -312,9 +662,10 @@ new class extends Component { ## Dependencies -- **Story 3.4:** Booking submission (creates pending bookings) -- **Story 3.6:** Calendar file generation (.ics) -- **Epic 8:** Email notifications +- **Story 3.4:** `docs/stories/story-3.4-booking-request-submission.md` - Creates pending bookings to review +- **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation +- **Epic 8:** `docs/epics/epic-8-email-notifications.md#story-84-booking-approved-email` - BookingApproved notification +- **Epic 8:** `docs/epics/epic-8-email-notifications.md#story-85-booking-rejected-email` - BookingRejected notification ## Risk Assessment diff --git a/docs/stories/story-3.6-calendar-file-generation.md b/docs/stories/story-3.6-calendar-file-generation.md index 9d094f1..db5a89d 100644 --- a/docs/stories/story-3.6-calendar-file-generation.md +++ b/docs/stories/story-3.6-calendar-file-generation.md @@ -16,6 +16,30 @@ So that **I can easily add the consultation to my calendar app**. - **Follows pattern:** Service class for generation - **Touch points:** Approval email, client dashboard download +### Required Consultation Model Fields +This story assumes the following fields exist on the `Consultation` model (from previous stories): +- `id` - Unique identifier (used as booking reference) +- `user_id` - Foreign key to User +- `scheduled_date` - Date of consultation +- `scheduled_time` - Time of consultation +- `duration` - Duration in minutes (default: 45) +- `status` - Consultation status (must be 'approved' for .ics generation) +- `type` - 'free' or 'paid' +- `payment_amount` - Amount for paid consultations (nullable) + +### Configuration Requirement +Create `config/libra.php` with office address: +```php + [ + 'ar' => 'مكتب ليبرا للمحاماة، فلسطين', + 'en' => 'Libra Law Firm, Palestine', + ], +]; +``` + ## Acceptance Criteria ### Calendar File Generation @@ -52,6 +76,15 @@ So that **I can easily add the consultation to my calendar app**. - [ ] Valid iCalendar format (passes validators) - [ ] Tests for file generation - [ ] Tests for calendar app compatibility +- [ ] Tests for bilingual content (Arabic/English) +- [ ] Tests for download route authorization +- [ ] Tests for email attachment + +### Edge Cases to Handle +- User with null `preferred_language` defaults to 'ar' +- Duration defaults to 45 minutes if not set on consultation +- Escape special characters (commas, semicolons, backslashes) in .ics content +- Ensure proper CRLF line endings per RFC 5545 ## Technical Notes @@ -242,6 +275,7 @@ Route::middleware(['auth'])->group(function () { ```php use App\Services\CalendarService; use App\Models\Consultation; +use App\Models\User; it('generates valid ics file', function () { $consultation = Consultation::factory()->approved()->create([ @@ -276,6 +310,99 @@ it('includes correct duration', function () { ->toContain('DTSTART;TZID=Asia/Jerusalem:20240315T100000') ->toContain('DTEND;TZID=Asia/Jerusalem:20240315T104500'); }); + +it('generates Arabic content for Arabic-preferring users', function () { + $user = User::factory()->create(['preferred_language' => 'ar']); + $consultation = Consultation::factory()->approved()->for($user)->create(); + + $service = new CalendarService(); + $ics = $service->generateIcs($consultation); + + expect($ics) + ->toContain('استشارة مع مكتب ليبرا للمحاماة') + ->toContain('رقم الحجز'); +}); + +it('generates English content for English-preferring users', function () { + $user = User::factory()->create(['preferred_language' => 'en']); + $consultation = Consultation::factory()->approved()->for($user)->create(); + + $service = new CalendarService(); + $ics = $service->generateIcs($consultation); + + expect($ics) + ->toContain('Consultation with Libra Law Firm') + ->toContain('Booking Reference'); +}); + +it('includes payment info for paid consultations', function () { + $consultation = Consultation::factory()->approved()->create([ + 'type' => 'paid', + 'payment_amount' => 150.00, + ]); + + $service = new CalendarService(); + $ics = $service->generateIcs($consultation); + + expect($ics)->toContain('150.00'); +}); + +it('includes 1-hour reminder alarm', function () { + $consultation = Consultation::factory()->approved()->create(); + + $service = new CalendarService(); + $ics = $service->generateIcs($consultation); + + expect($ics) + ->toContain('BEGIN:VALARM') + ->toContain('TRIGGER:-PT1H') + ->toContain('END:VALARM'); +}); + +it('returns download response with correct headers', function () { + $consultation = Consultation::factory()->approved()->create([ + 'scheduled_date' => '2024-03-15', + ]); + + $service = new CalendarService(); + $response = $service->generateDownloadResponse($consultation); + + expect($response->headers->get('Content-Type')) + ->toBe('text/calendar; charset=utf-8'); + expect($response->headers->get('Content-Disposition')) + ->toContain('consultation-2024-03-15.ics'); +}); + +it('prevents unauthorized users from downloading calendar file', function () { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $consultation = Consultation::factory()->approved()->for($owner)->create(); + + $this->actingAs($other) + ->get(route('client.consultations.calendar', $consultation)) + ->assertForbidden(); +}); + +it('prevents download for non-approved consultations', function () { + $user = User::factory()->create(); + $consultation = Consultation::factory()->for($user)->create([ + 'status' => 'pending', + ]); + + $this->actingAs($user) + ->get(route('client.consultations.calendar', $consultation)) + ->assertNotFound(); +}); + +it('allows owner to download calendar file', function () { + $user = User::factory()->create(); + $consultation = Consultation::factory()->approved()->for($user)->create(); + + $this->actingAs($user) + ->get(route('client.consultations.calendar', $consultation)) + ->assertOk() + ->assertHeader('Content-Type', 'text/calendar; charset=utf-8'); +}); ``` ## Definition of Done diff --git a/docs/stories/story-3.7-consultation-management.md b/docs/stories/story-3.7-consultation-management.md index b25f8ce..9a28c34 100644 --- a/docs/stories/story-3.7-consultation-management.md +++ b/docs/stories/story-3.7-consultation-management.md @@ -16,6 +16,14 @@ So that **I can track completed sessions, handle no-shows, and maintain accurate - **Follows pattern:** Admin management dashboard - **Touch points:** Consultation status, payment tracking, admin notes +### Service Dependencies +This story relies on services created in previous stories: + +- **AvailabilityService** (from Story 3.3): Used for `getAvailableSlots(Carbon $date): array` to validate reschedule slot availability +- **CalendarService** (from Story 3.6): Used for `generateIcs(Consultation $consultation): string` to generate new .ics file on reschedule + +These services must be implemented before the reschedule functionality can work. + ## Acceptance Criteria ### Consultations List View @@ -332,10 +340,18 @@ new class extends Component { ### Admin Notes ```php -// Add admin_notes column to consultations or separate table +// Design Decision: JSON array on consultation vs separate table +// Chosen: JSON array because: +// - Notes are always fetched with consultation (no N+1) +// - Simple CRUD without extra joins +// - Audit trail embedded in consultation record +// - No need for complex querying of notes independently +// +// Trade-off: Cannot easily query "all notes by admin X" - acceptable for this use case + // In Consultation model: protected $casts = [ - 'admin_notes' => 'array', // [{text, admin_id, created_at}] + 'admin_notes' => 'array', // [{text, admin_id, created_at, updated_at?}] ]; public function addNote(string $note): void @@ -348,6 +364,631 @@ public function addNote(string $note): void ]; $this->update(['admin_notes' => $notes]); } + +public function updateNote(int $index, string $newText): void +{ + $notes = $this->admin_notes ?? []; + if (isset($notes[$index])) { + $notes[$index]['text'] = $newText; + $notes[$index]['updated_at'] = now()->toISOString(); + $this->update(['admin_notes' => $notes]); + } +} + +public function deleteNote(int $index): void +{ + $notes = $this->admin_notes ?? []; + if (isset($notes[$index])) { + array_splice($notes, $index, 1); + $this->update(['admin_notes' => array_values($notes)]); + } +} +``` + +### Edge Cases + +Handle these scenarios with appropriate validation and error handling: + +**Status Transition Guards:** +```php +// In Consultation model - add these guards to status change methods +public function markAsCompleted(): void +{ + // Only approved consultations can be marked completed + if ($this->status !== ConsultationStatus::Approved) { + throw new \InvalidArgumentException( + __('messages.invalid_status_transition', ['from' => $this->status->value, 'to' => 'completed']) + ); + } + $this->update(['status' => ConsultationStatus::Completed]); +} + +public function markAsNoShow(): void +{ + // Only approved consultations can be marked as no-show + if ($this->status !== ConsultationStatus::Approved) { + throw new \InvalidArgumentException( + __('messages.invalid_status_transition', ['from' => $this->status->value, 'to' => 'no_show']) + ); + } + $this->update(['status' => ConsultationStatus::NoShow]); +} + +public function cancel(): void +{ + // Can cancel pending or approved, but not completed/no_show/already cancelled + if (!in_array($this->status, [ConsultationStatus::Pending, ConsultationStatus::Approved])) { + throw new \InvalidArgumentException( + __('messages.cannot_cancel_consultation') + ); + } + $this->update(['status' => ConsultationStatus::Cancelled]); +} + +public function markPaymentReceived(): void +{ + // Only paid consultations can have payment marked + if ($this->type !== 'paid') { + throw new \InvalidArgumentException(__('messages.not_paid_consultation')); + } + // Prevent double-marking + if ($this->payment_status === PaymentStatus::Received) { + throw new \InvalidArgumentException(__('messages.payment_already_received')); + } + $this->update([ + 'payment_status' => PaymentStatus::Received, + 'payment_received_at' => now(), + ]); +} +``` + +**Reschedule Edge Cases:** +- **Slot already booked:** Handled by `AvailabilityService::getAvailableSlots()` - returns only truly available slots +- **Reschedule to past date:** Validation rule `after_or_equal:today` prevents this +- **Same date/time selected:** Allow but skip notification if no change detected: +```php +// In reschedule() method +if ($oldDate->format('Y-m-d') === $this->newDate && $oldTime === $this->newTime) { + session()->flash('info', __('messages.no_changes_made')); + return; +} +``` + +**Concurrent Modification:** +```php +// Use database transaction with locking in Volt component +public function markCompleted(int $id): void +{ + DB::transaction(function () use ($id) { + $consultation = Consultation::lockForUpdate()->findOrFail($id); + // ... rest of logic + }); +} +``` + +**Notification Failures:** +```php +// In reschedule() - don't block on notification failure +try { + $this->consultation->user->notify( + new ConsultationRescheduled($this->consultation, $oldDate, $oldTime, $icsContent) + ); +} catch (\Exception $e) { + Log::error('Failed to send reschedule notification', [ + 'consultation_id' => $this->consultation->id, + 'error' => $e->getMessage(), + ]); + // Continue - consultation is rescheduled, notification failure is non-blocking +} +``` + +**Missing User (Deleted Account):** +```php +// Guard against orphaned consultations +if (!$this->consultation->user) { + session()->flash('error', __('messages.client_account_not_found')); + return; +} +``` + +### Testing Examples + +```php +use App\Models\Consultation; +use App\Models\User; +use App\Notifications\ConsultationCancelled; +use App\Notifications\ConsultationRescheduled; +use App\Services\AvailabilityService; +use App\Services\CalendarService; +use Illuminate\Support\Facades\Notification; +use Livewire\Volt\Volt; + +// ========================================== +// CONSULTATIONS LIST VIEW TESTS +// ========================================== + +it('displays all consultations for admin', function () { + $admin = User::factory()->admin()->create(); + $consultations = Consultation::factory()->count(3)->approved()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->assertSee($consultations->first()->user->name) + ->assertStatus(200); +}); + +it('filters consultations by status', function () { + $admin = User::factory()->admin()->create(); + $approved = Consultation::factory()->approved()->create(); + $completed = Consultation::factory()->completed()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->set('statusFilter', 'approved') + ->assertSee($approved->user->name) + ->assertDontSee($completed->user->name); +}); + +it('filters consultations by payment status', function () { + $admin = User::factory()->admin()->create(); + $pendingPayment = Consultation::factory()->approved()->create([ + 'type' => 'paid', + 'payment_status' => 'pending', + ]); + $received = Consultation::factory()->approved()->create([ + 'type' => 'paid', + 'payment_status' => 'received', + ]); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->set('paymentFilter', 'pending') + ->assertSee($pendingPayment->user->name) + ->assertDontSee($received->user->name); +}); + +it('searches consultations by client name or email', function () { + $admin = User::factory()->admin()->create(); + $targetUser = User::factory()->create(['name' => 'John Doe', 'email' => 'john@test.com']); + $otherUser = User::factory()->create(['name' => 'Jane Smith']); + $targetConsultation = Consultation::factory()->for($targetUser)->approved()->create(); + $otherConsultation = Consultation::factory()->for($otherUser)->approved()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->set('search', 'John') + ->assertSee('John Doe') + ->assertDontSee('Jane Smith'); +}); + +it('filters consultations by date range', function () { + $admin = User::factory()->admin()->create(); + $oldConsultation = Consultation::factory()->approved()->create([ + 'scheduled_date' => now()->subDays(10), + ]); + $newConsultation = Consultation::factory()->approved()->create([ + 'scheduled_date' => now()->addDays(5), + ]); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->set('dateFrom', now()->toDateString()) + ->assertSee($newConsultation->user->name) + ->assertDontSee($oldConsultation->user->name); +}); + +// ========================================== +// STATUS MANAGEMENT TESTS +// ========================================== + +it('can mark consultation as completed', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markCompleted', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh()->status->value)->toBe('completed'); +}); + +it('cannot mark pending consultation as completed', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markCompleted', $consultation->id) + ->assertHasErrors(); + + expect($consultation->fresh()->status->value)->toBe('pending'); +}); + +it('can mark consultation as no-show', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markNoShow', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh()->status->value)->toBe('no_show'); +}); + +it('cannot mark already completed consultation as no-show', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->completed()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markNoShow', $consultation->id) + ->assertHasErrors(); +}); + +it('can cancel approved consultation', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('cancel', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh()->status->value)->toBe('cancelled'); + Notification::assertSentTo($consultation->user, ConsultationCancelled::class); +}); + +it('can cancel pending consultation', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->pending()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('cancel', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh()->status->value)->toBe('cancelled'); +}); + +it('cannot cancel already cancelled consultation', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->cancelled()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('cancel', $consultation->id) + ->assertHasErrors(); +}); + +// ========================================== +// PAYMENT TRACKING TESTS +// ========================================== + +it('can mark payment as received for paid consultation', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'type' => 'paid', + 'payment_amount' => 150.00, + 'payment_status' => 'pending', + ]); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markPaymentReceived', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh()) + ->payment_status->value->toBe('received') + ->payment_received_at->not->toBeNull(); +}); + +it('cannot mark payment received for free consultation', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'type' => 'free', + 'payment_status' => 'not_applicable', + ]); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markPaymentReceived', $consultation->id) + ->assertHasErrors(); +}); + +it('cannot mark payment received twice', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'type' => 'paid', + 'payment_status' => 'received', + 'payment_received_at' => now()->subDay(), + ]); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markPaymentReceived', $consultation->id) + ->assertHasErrors(); +}); + +// ========================================== +// RESCHEDULE TESTS +// ========================================== + +it('can reschedule consultation to available slot', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'scheduled_date' => now()->addDays(3), + 'scheduled_time' => '10:00:00', + ]); + + $newDate = now()->addDays(5)->format('Y-m-d'); + $newTime = '14:00:00'; + + // Mock availability service + $this->mock(AvailabilityService::class) + ->shouldReceive('getAvailableSlots') + ->andReturn(['14:00:00', '15:00:00', '16:00:00']); + + Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('newDate', $newDate) + ->set('newTime', $newTime) + ->call('reschedule') + ->assertHasNoErrors() + ->assertRedirect(route('admin.consultations.index')); + + expect($consultation->fresh()) + ->scheduled_date->format('Y-m-d')->toBe($newDate) + ->scheduled_time->toBe($newTime); + + Notification::assertSentTo($consultation->user, ConsultationRescheduled::class); +}); + +it('cannot reschedule to unavailable slot', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + $newDate = now()->addDays(5)->format('Y-m-d'); + $newTime = '14:00:00'; + + // Mock availability service - slot not available + $this->mock(AvailabilityService::class) + ->shouldReceive('getAvailableSlots') + ->andReturn(['10:00:00', '11:00:00']); // 14:00 not in list + + Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('newDate', $newDate) + ->set('newTime', $newTime) + ->call('reschedule') + ->assertHasErrors(['newTime']); +}); + +it('cannot reschedule to past date', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('newDate', now()->subDay()->format('Y-m-d')) + ->set('newTime', '10:00:00') + ->call('reschedule') + ->assertHasErrors(['newDate']); +}); + +it('generates new ics file on reschedule', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + $newDate = now()->addDays(5)->format('Y-m-d'); + $newTime = '14:00:00'; + + $this->mock(AvailabilityService::class) + ->shouldReceive('getAvailableSlots') + ->andReturn(['14:00:00']); + + $calendarMock = $this->mock(CalendarService::class); + $calendarMock->shouldReceive('generateIcs') + ->once() + ->andReturn('BEGIN:VCALENDAR...'); + + Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('newDate', $newDate) + ->set('newTime', $newTime) + ->call('reschedule') + ->assertHasNoErrors(); +}); + +// ========================================== +// ADMIN NOTES TESTS +// ========================================== + +it('can add admin note to consultation', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + Volt::test('admin.consultations.detail', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('newNote', 'Client requested Arabic documents') + ->call('addNote') + ->assertHasNoErrors(); + + $notes = $consultation->fresh()->admin_notes; + expect($notes)->toHaveCount(1) + ->and($notes[0]['text'])->toBe('Client requested Arabic documents') + ->and($notes[0]['admin_id'])->toBe($admin->id); +}); + +it('can update admin note', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'admin_notes' => [ + ['text' => 'Original note', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()], + ], + ]); + + Volt::test('admin.consultations.detail', ['consultation' => $consultation]) + ->actingAs($admin) + ->call('updateNote', 0, 'Updated note') + ->assertHasNoErrors(); + + $notes = $consultation->fresh()->admin_notes; + expect($notes[0]['text'])->toBe('Updated note') + ->and($notes[0])->toHaveKey('updated_at'); +}); + +it('can delete admin note', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'admin_notes' => [ + ['text' => 'Note 1', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()], + ['text' => 'Note 2', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()], + ], + ]); + + Volt::test('admin.consultations.detail', ['consultation' => $consultation]) + ->actingAs($admin) + ->call('deleteNote', 0) + ->assertHasNoErrors(); + + $notes = $consultation->fresh()->admin_notes; + expect($notes)->toHaveCount(1) + ->and($notes[0]['text'])->toBe('Note 2'); +}); + +// ========================================== +// AUDIT LOG TESTS +// ========================================== + +it('creates audit log entry on status change', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markCompleted', $consultation->id); + + $this->assertDatabaseHas('admin_logs', [ + 'admin_id' => $admin->id, + 'action_type' => 'status_change', + 'target_type' => 'consultation', + 'target_id' => $consultation->id, + ]); +}); + +it('creates audit log entry on payment received', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'type' => 'paid', + 'payment_status' => 'pending', + ]); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('markPaymentReceived', $consultation->id); + + $this->assertDatabaseHas('admin_logs', [ + 'admin_id' => $admin->id, + 'action_type' => 'payment_received', + 'target_type' => 'consultation', + 'target_id' => $consultation->id, + ]); +}); + +it('creates audit log entry on reschedule', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create(); + + $this->mock(AvailabilityService::class) + ->shouldReceive('getAvailableSlots') + ->andReturn(['14:00:00']); + + Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) + ->actingAs($admin) + ->set('newDate', now()->addDays(5)->format('Y-m-d')) + ->set('newTime', '14:00:00') + ->call('reschedule'); + + $this->assertDatabaseHas('admin_logs', [ + 'admin_id' => $admin->id, + 'action_type' => 'reschedule', + 'target_type' => 'consultation', + 'target_id' => $consultation->id, + ]); +}); + +// ========================================== +// CLIENT HISTORY TESTS +// ========================================== + +it('displays consultation history for specific client', function () { + $admin = User::factory()->admin()->create(); + $client = User::factory()->create(); + + $consultations = Consultation::factory()->count(3)->for($client)->create(); + + Volt::test('admin.clients.consultation-history', ['user' => $client]) + ->actingAs($admin) + ->assertSee($consultations->first()->scheduled_date->format('Y-m-d')) + ->assertStatus(200); +}); + +it('shows summary statistics for client', function () { + $admin = User::factory()->admin()->create(); + $client = User::factory()->create(); + + Consultation::factory()->for($client)->completed()->count(2)->create(); + Consultation::factory()->for($client)->cancelled()->create(); + Consultation::factory()->for($client)->create(['status' => 'no_show']); + + Volt::test('admin.clients.consultation-history', ['user' => $client]) + ->actingAs($admin) + ->assertSee('2') // completed count + ->assertSee('1'); // no-show count +}); + +// ========================================== +// BILINGUAL TESTS +// ========================================== + +it('displays labels in Arabic when locale is ar', function () { + $admin = User::factory()->admin()->create(['preferred_language' => 'ar']); + $consultation = Consultation::factory()->approved()->create(); + + app()->setLocale('ar'); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->assertSee(__('admin.consultations')); +}); + +it('sends cancellation notification in client preferred language', function () { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $arabicUser = User::factory()->create(['preferred_language' => 'ar']); + $consultation = Consultation::factory()->approved()->for($arabicUser)->create(); + + Volt::test('admin.consultations.index') + ->actingAs($admin) + ->call('cancel', $consultation->id); + + Notification::assertSentTo($arabicUser, ConsultationCancelled::class, function ($notification) { + return $notification->consultation->user->preferred_language === 'ar'; + }); +}); ``` ## Definition of Done @@ -368,9 +1009,10 @@ public function addNote(string $note): void ## Dependencies -- **Story 3.5:** Booking approval -- **Story 3.6:** Calendar file generation -- **Epic 8:** Email notifications +- **Story 3.5:** `docs/stories/story-3.5-admin-booking-review-approval.md` - Creates approved consultations to manage; provides AdminLog pattern +- **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation on reschedule +- **Story 3.3:** `docs/stories/story-3.3-availability-calendar-display.md` - AvailabilityService for slot validation on reschedule +- **Epic 8:** `docs/epics/epic-8-email-notifications.md` - ConsultationCancelled and ConsultationRescheduled notifications ## Risk Assessment diff --git a/docs/stories/story-3.8-consultation-reminders.md b/docs/stories/story-3.8-consultation-reminders.md index 506241b..f3d4f08 100644 --- a/docs/stories/story-3.8-consultation-reminders.md +++ b/docs/stories/story-3.8-consultation-reminders.md @@ -52,6 +52,32 @@ So that **I don't forget my appointment and can prepare accordingly**. - [ ] 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 @@ -344,6 +370,96 @@ it('does not send reminder for cancelled consultation', function () { 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 @@ -363,8 +479,11 @@ it('does not send reminder for cancelled consultation', function () { ## Dependencies -- **Story 3.5:** Booking approval (creates approved consultations) -- **Epic 8:** Email infrastructure +- **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