reviewed epic 3
This commit is contained in:
parent
b93b9363a6
commit
8b8d9735b9
|
|
@ -66,10 +66,14 @@ Schema::create('working_hours', function (Blueprint $table) {
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class WorkingHour extends Model
|
class WorkingHour extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'day_of_week',
|
'day_of_week',
|
||||||
'start_time',
|
'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
|
### Volt Component
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminLog;
|
||||||
use App\Models\WorkingHour;
|
use App\Models\WorkingHour;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
|
@ -133,6 +155,20 @@ new class extends Component {
|
||||||
|
|
||||||
public function save(): void
|
public function save(): void
|
||||||
{
|
{
|
||||||
|
// Validate end time is after start time for active days
|
||||||
|
foreach ($this->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) {
|
foreach ($this->schedule as $day => $config) {
|
||||||
WorkingHour::updateOrCreate(
|
WorkingHour::updateOrCreate(
|
||||||
['day_of_week' => $day],
|
['day_of_week' => $day],
|
||||||
|
|
@ -144,16 +180,39 @@ new class extends Component {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log action
|
// Log action with old and new values
|
||||||
AdminLog::create([
|
AdminLog::create([
|
||||||
'admin_id' => auth()->id(),
|
'admin_id' => auth()->id(),
|
||||||
'action_type' => 'update',
|
'action_type' => 'update',
|
||||||
'target_type' => 'working_hours',
|
'target_type' => 'working_hours',
|
||||||
|
'old_values' => $oldValues,
|
||||||
'new_values' => $this->schedule,
|
'new_values' => $this->schedule,
|
||||||
'ip_address' => request()->ip(),
|
'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;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\WorkingHour;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
class AvailabilityService
|
class AvailabilityService
|
||||||
{
|
{
|
||||||
public function getAvailableSlots(Carbon $date): array
|
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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\WorkingHour;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class WorkingHourFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = WorkingHour::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'day_of_week' => $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
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] Can enable/disable each day of week
|
- [ ] Can enable/disable each day of week
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ Schema::create('blocked_times', function (Blueprint $table) {
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class BlockedTime extends Model
|
class BlockedTime extends Model
|
||||||
|
|
@ -128,6 +129,7 @@ class BlockedTime extends Model
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminLog;
|
||||||
use App\Models\BlockedTime;
|
use App\Models\BlockedTime;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
|
@ -210,6 +212,24 @@ new class extends Component {
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Pending Booking Warning Check
|
||||||
|
```php
|
||||||
|
// Add to Volt component - check for pending bookings before save
|
||||||
|
public function checkPendingBookings(): array
|
||||||
|
{
|
||||||
|
$date = Carbon::parse($this->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
|
### Integration with Availability Service
|
||||||
```php
|
```php
|
||||||
// In AvailabilityService
|
// In AvailabilityService
|
||||||
|
|
@ -296,6 +316,73 @@ public function isDateFullyBlocked(Carbon $date): bool
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] Can create all-day blocks
|
- [ ] Can create all-day blocks
|
||||||
|
|
@ -313,8 +400,12 @@ public function isDateFullyBlocked(Carbon $date): bool
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Story 3.1:** Working hours configuration
|
- **Story 3.1:** Working hours configuration (`docs/stories/story-3.1-working-hours-configuration.md`)
|
||||||
- **Story 3.3:** Availability calendar (consumes blocked times)
|
- 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
|
## Risk Assessment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ So that **I can choose a convenient time for my consultation**.
|
||||||
- [ ] Prevent double-booking (race condition handling)
|
- [ ] Prevent double-booking (race condition handling)
|
||||||
- [ ] Refresh availability when navigating months
|
- [ ] 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
|
### Responsive Design
|
||||||
- [ ] Mobile-friendly calendar
|
- [ ] Mobile-friendly calendar
|
||||||
- [ ] Touch-friendly slot selection
|
- [ ] Touch-friendly slot selection
|
||||||
|
|
@ -55,6 +59,18 @@ So that **I can choose a convenient time for my consultation**.
|
||||||
|
|
||||||
## Technical Notes
|
## 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
|
### Availability Service
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -210,6 +226,12 @@ new class extends Component {
|
||||||
public function previousMonth(): void
|
public function previousMonth(): void
|
||||||
{
|
{
|
||||||
$date = Carbon::create($this->year, $this->month, 1)->subMonth();
|
$date = Carbon::create($this->year, $this->month, 1)->subMonth();
|
||||||
|
|
||||||
|
// Prevent navigating to past months
|
||||||
|
if ($date->lt(now()->startOfMonth())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->year = $date->year;
|
$this->year = $date->year;
|
||||||
$this->month = $date->month;
|
$this->month = $date->month;
|
||||||
$this->selectedDate = null;
|
$this->selectedDate = null;
|
||||||
|
|
@ -375,6 +397,80 @@ new class extends Component {
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<div class="grid grid-cols-7 gap-1" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] Calendar displays current month
|
- [ ] Calendar displays current month
|
||||||
|
|
@ -389,11 +485,325 @@ new class extends Component {
|
||||||
- [ ] Tests for availability logic
|
- [ ] Tests for availability logic
|
||||||
- [ ] Code formatted with Pint
|
- [ ] Code formatted with Pint
|
||||||
|
|
||||||
|
## Testing Scenarios
|
||||||
|
|
||||||
|
### AvailabilityService Unit Tests
|
||||||
|
|
||||||
|
Create `tests/Unit/Services/AvailabilityServiceTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\{WorkingHour, BlockedTime, Consultation};
|
||||||
|
use App\Services\AvailabilityService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Setup default working hours (Monday-Friday, 9am-5pm)
|
||||||
|
foreach ([1, 2, 3, 4, 5] as $day) {
|
||||||
|
WorkingHour::factory()->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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\{WorkingHour, BlockedTime, Consultation, User};
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Setup working hours for weekdays
|
||||||
|
foreach ([1, 2, 3, 4, 5] as $day) {
|
||||||
|
WorkingHour::factory()->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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\{WorkingHour, User};
|
||||||
|
|
||||||
|
it('allows selecting a time slot with mouse click', function () {
|
||||||
|
// Setup working hours
|
||||||
|
WorkingHour::factory()->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
|
## Dependencies
|
||||||
|
|
||||||
- **Story 3.1:** Working hours (defines available time)
|
- **Story 3.1:** Working hours (defines available time) - **MUST be completed first**
|
||||||
- **Story 3.2:** Blocked times (removes availability)
|
- **Story 3.2:** Blocked times (removes availability) - **MUST be completed first**
|
||||||
- **Story 3.4:** Booking submission (consumes selected slot)
|
- **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
|
## Risk Assessment
|
||||||
|
|
||||||
|
|
@ -401,6 +811,38 @@ new class extends Component {
|
||||||
- **Mitigation:** Database-level unique constraint, check on submission
|
- **Mitigation:** Database-level unique constraint, check on submission
|
||||||
- **Rollback:** Refresh availability if booking fails
|
- **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
|
## Estimation
|
||||||
|
|
||||||
**Complexity:** High
|
**Complexity:** High
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ So that **I can schedule a meeting with the lawyer**.
|
||||||
## Story Context
|
## Story Context
|
||||||
|
|
||||||
### Existing System Integration
|
### Existing System Integration
|
||||||
- **Integrates with:** consultations table, availability calendar, notifications
|
- **Integrates with:** `consultations` table, availability calendar (Story 3.3), notifications system
|
||||||
- **Technology:** Livewire Volt, form validation
|
- **Technology:** Livewire Volt (class-based), Laravel form validation, DB transactions
|
||||||
- **Follows pattern:** Form submission with confirmation
|
- **Follows pattern:** Multi-step form submission with confirmation
|
||||||
- **Touch points:** Client dashboard, admin notifications
|
- **Touch points:** Client dashboard, admin notifications, audit log
|
||||||
|
- **Component location:** `resources/views/livewire/booking/request.blade.php`
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
|
@ -282,7 +283,7 @@ new class extends Component {
|
||||||
|
|
||||||
### 1-Per-Day Validation Rule
|
### 1-Per-Day Validation Rule
|
||||||
```php
|
```php
|
||||||
// Custom validation rule
|
// app/Rules/OneBookingPerDay.php
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
class OneBookingPerDay implements 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
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] Can select date from calendar
|
- [ ] Can select date from calendar
|
||||||
|
|
@ -318,9 +409,13 @@ class OneBookingPerDay implements ValidationRule
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Story 3.3:** Availability calendar
|
- **Story 3.3:** Availability calendar (`docs/stories/story-3.3-availability-calendar-display.md`)
|
||||||
- **Epic 2:** User authentication
|
- 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)
|
- **Epic 8:** Email notifications (partial)
|
||||||
|
- Notification infrastructure for sending emails
|
||||||
|
|
||||||
## Risk Assessment
|
## Risk Assessment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] Pending bookings list displays correctly
|
- [ ] Pending bookings list displays correctly
|
||||||
|
|
@ -312,9 +662,10 @@ new class extends Component {
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Story 3.4:** Booking submission (creates pending bookings)
|
- **Story 3.4:** `docs/stories/story-3.4-booking-request-submission.md` - Creates pending bookings to review
|
||||||
- **Story 3.6:** Calendar file generation (.ics)
|
- **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation
|
||||||
- **Epic 8:** Email notifications
|
- **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
|
## Risk Assessment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,30 @@ So that **I can easily add the consultation to my calendar app**.
|
||||||
- **Follows pattern:** Service class for generation
|
- **Follows pattern:** Service class for generation
|
||||||
- **Touch points:** Approval email, client dashboard download
|
- **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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'office_address' => [
|
||||||
|
'ar' => 'مكتب ليبرا للمحاماة، فلسطين',
|
||||||
|
'en' => 'Libra Law Firm, Palestine',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Calendar File Generation
|
### Calendar File Generation
|
||||||
|
|
@ -52,6 +76,15 @@ So that **I can easily add the consultation to my calendar app**.
|
||||||
- [ ] Valid iCalendar format (passes validators)
|
- [ ] Valid iCalendar format (passes validators)
|
||||||
- [ ] Tests for file generation
|
- [ ] Tests for file generation
|
||||||
- [ ] Tests for calendar app compatibility
|
- [ ] 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
|
## Technical Notes
|
||||||
|
|
||||||
|
|
@ -242,6 +275,7 @@ Route::middleware(['auth'])->group(function () {
|
||||||
```php
|
```php
|
||||||
use App\Services\CalendarService;
|
use App\Services\CalendarService;
|
||||||
use App\Models\Consultation;
|
use App\Models\Consultation;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
it('generates valid ics file', function () {
|
it('generates valid ics file', function () {
|
||||||
$consultation = Consultation::factory()->approved()->create([
|
$consultation = Consultation::factory()->approved()->create([
|
||||||
|
|
@ -276,6 +310,99 @@ it('includes correct duration', function () {
|
||||||
->toContain('DTSTART;TZID=Asia/Jerusalem:20240315T100000')
|
->toContain('DTSTART;TZID=Asia/Jerusalem:20240315T100000')
|
||||||
->toContain('DTEND;TZID=Asia/Jerusalem:20240315T104500');
|
->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
|
## Definition of Done
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ So that **I can track completed sessions, handle no-shows, and maintain accurate
|
||||||
- **Follows pattern:** Admin management dashboard
|
- **Follows pattern:** Admin management dashboard
|
||||||
- **Touch points:** Consultation status, payment tracking, admin notes
|
- **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
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Consultations List View
|
### Consultations List View
|
||||||
|
|
@ -332,10 +340,18 @@ new class extends Component {
|
||||||
|
|
||||||
### Admin Notes
|
### Admin Notes
|
||||||
```php
|
```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:
|
// In Consultation model:
|
||||||
protected $casts = [
|
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
|
public function addNote(string $note): void
|
||||||
|
|
@ -348,6 +364,631 @@ public function addNote(string $note): void
|
||||||
];
|
];
|
||||||
$this->update(['admin_notes' => $notes]);
|
$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
|
## Definition of Done
|
||||||
|
|
@ -368,9 +1009,10 @@ public function addNote(string $note): void
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Story 3.5:** Booking approval
|
- **Story 3.5:** `docs/stories/story-3.5-admin-booking-review-approval.md` - Creates approved consultations to manage; provides AdminLog pattern
|
||||||
- **Story 3.6:** Calendar file generation
|
- **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation on reschedule
|
||||||
- **Epic 8:** Email notifications
|
- **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
|
## Risk Assessment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,32 @@ So that **I don't forget my appointment and can prepare accordingly**.
|
||||||
- [ ] Logging for debugging
|
- [ ] Logging for debugging
|
||||||
- [ ] Tests for reminder logic
|
- [ ] 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
|
## Technical Notes
|
||||||
|
|
||||||
### Reminder Commands
|
### Reminder Commands
|
||||||
|
|
@ -344,6 +370,96 @@ it('does not send reminder for cancelled consultation', function () {
|
||||||
|
|
||||||
Notification::assertNotSentTo($consultation->user, ConsultationReminder24h::class);
|
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
|
## Definition of Done
|
||||||
|
|
@ -363,8 +479,11 @@ it('does not send reminder for cancelled consultation', function () {
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Story 3.5:** Booking approval (creates approved consultations)
|
- **Story 3.4:** Consultation model and booking submission (`app/Models/Consultation.php`, `database/factories/ConsultationFactory.php`)
|
||||||
- **Epic 8:** Email infrastructure
|
- **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
|
## Risk Assessment
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue