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