reviewed epic 3

This commit is contained in:
Naser Mansour
2025-12-20 21:25:58 +02:00
parent b93b9363a6
commit 8b8d9735b9
8 changed files with 2116 additions and 24 deletions
@@ -16,6 +16,14 @@ So that **I can track completed sessions, handle no-shows, and maintain accurate
- **Follows pattern:** Admin management dashboard
- **Touch points:** Consultation status, payment tracking, admin notes
### Service Dependencies
This story relies on services created in previous stories:
- **AvailabilityService** (from Story 3.3): Used for `getAvailableSlots(Carbon $date): array` to validate reschedule slot availability
- **CalendarService** (from Story 3.6): Used for `generateIcs(Consultation $consultation): string` to generate new .ics file on reschedule
These services must be implemented before the reschedule functionality can work.
## Acceptance Criteria
### Consultations List View
@@ -332,10 +340,18 @@ new class extends Component {
### Admin Notes
```php
// Add admin_notes column to consultations or separate table
// Design Decision: JSON array on consultation vs separate table
// Chosen: JSON array because:
// - Notes are always fetched with consultation (no N+1)
// - Simple CRUD without extra joins
// - Audit trail embedded in consultation record
// - No need for complex querying of notes independently
//
// Trade-off: Cannot easily query "all notes by admin X" - acceptable for this use case
// In Consultation model:
protected $casts = [
'admin_notes' => 'array', // [{text, admin_id, created_at}]
'admin_notes' => 'array', // [{text, admin_id, created_at, updated_at?}]
];
public function addNote(string $note): void
@@ -348,6 +364,631 @@ public function addNote(string $note): void
];
$this->update(['admin_notes' => $notes]);
}
public function updateNote(int $index, string $newText): void
{
$notes = $this->admin_notes ?? [];
if (isset($notes[$index])) {
$notes[$index]['text'] = $newText;
$notes[$index]['updated_at'] = now()->toISOString();
$this->update(['admin_notes' => $notes]);
}
}
public function deleteNote(int $index): void
{
$notes = $this->admin_notes ?? [];
if (isset($notes[$index])) {
array_splice($notes, $index, 1);
$this->update(['admin_notes' => array_values($notes)]);
}
}
```
### Edge Cases
Handle these scenarios with appropriate validation and error handling:
**Status Transition Guards:**
```php
// In Consultation model - add these guards to status change methods
public function markAsCompleted(): void
{
// Only approved consultations can be marked completed
if ($this->status !== ConsultationStatus::Approved) {
throw new \InvalidArgumentException(
__('messages.invalid_status_transition', ['from' => $this->status->value, 'to' => 'completed'])
);
}
$this->update(['status' => ConsultationStatus::Completed]);
}
public function markAsNoShow(): void
{
// Only approved consultations can be marked as no-show
if ($this->status !== ConsultationStatus::Approved) {
throw new \InvalidArgumentException(
__('messages.invalid_status_transition', ['from' => $this->status->value, 'to' => 'no_show'])
);
}
$this->update(['status' => ConsultationStatus::NoShow]);
}
public function cancel(): void
{
// Can cancel pending or approved, but not completed/no_show/already cancelled
if (!in_array($this->status, [ConsultationStatus::Pending, ConsultationStatus::Approved])) {
throw new \InvalidArgumentException(
__('messages.cannot_cancel_consultation')
);
}
$this->update(['status' => ConsultationStatus::Cancelled]);
}
public function markPaymentReceived(): void
{
// Only paid consultations can have payment marked
if ($this->type !== 'paid') {
throw new \InvalidArgumentException(__('messages.not_paid_consultation'));
}
// Prevent double-marking
if ($this->payment_status === PaymentStatus::Received) {
throw new \InvalidArgumentException(__('messages.payment_already_received'));
}
$this->update([
'payment_status' => PaymentStatus::Received,
'payment_received_at' => now(),
]);
}
```
**Reschedule Edge Cases:**
- **Slot already booked:** Handled by `AvailabilityService::getAvailableSlots()` - returns only truly available slots
- **Reschedule to past date:** Validation rule `after_or_equal:today` prevents this
- **Same date/time selected:** Allow but skip notification if no change detected:
```php
// In reschedule() method
if ($oldDate->format('Y-m-d') === $this->newDate && $oldTime === $this->newTime) {
session()->flash('info', __('messages.no_changes_made'));
return;
}
```
**Concurrent Modification:**
```php
// Use database transaction with locking in Volt component
public function markCompleted(int $id): void
{
DB::transaction(function () use ($id) {
$consultation = Consultation::lockForUpdate()->findOrFail($id);
// ... rest of logic
});
}
```
**Notification Failures:**
```php
// In reschedule() - don't block on notification failure
try {
$this->consultation->user->notify(
new ConsultationRescheduled($this->consultation, $oldDate, $oldTime, $icsContent)
);
} catch (\Exception $e) {
Log::error('Failed to send reschedule notification', [
'consultation_id' => $this->consultation->id,
'error' => $e->getMessage(),
]);
// Continue - consultation is rescheduled, notification failure is non-blocking
}
```
**Missing User (Deleted Account):**
```php
// Guard against orphaned consultations
if (!$this->consultation->user) {
session()->flash('error', __('messages.client_account_not_found'));
return;
}
```
### Testing Examples
```php
use App\Models\Consultation;
use App\Models\User;
use App\Notifications\ConsultationCancelled;
use App\Notifications\ConsultationRescheduled;
use App\Services\AvailabilityService;
use App\Services\CalendarService;
use Illuminate\Support\Facades\Notification;
use Livewire\Volt\Volt;
// ==========================================
// CONSULTATIONS LIST VIEW TESTS
// ==========================================
it('displays all consultations for admin', function () {
$admin = User::factory()->admin()->create();
$consultations = Consultation::factory()->count(3)->approved()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->assertSee($consultations->first()->user->name)
->assertStatus(200);
});
it('filters consultations by status', function () {
$admin = User::factory()->admin()->create();
$approved = Consultation::factory()->approved()->create();
$completed = Consultation::factory()->completed()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->set('statusFilter', 'approved')
->assertSee($approved->user->name)
->assertDontSee($completed->user->name);
});
it('filters consultations by payment status', function () {
$admin = User::factory()->admin()->create();
$pendingPayment = Consultation::factory()->approved()->create([
'type' => 'paid',
'payment_status' => 'pending',
]);
$received = Consultation::factory()->approved()->create([
'type' => 'paid',
'payment_status' => 'received',
]);
Volt::test('admin.consultations.index')
->actingAs($admin)
->set('paymentFilter', 'pending')
->assertSee($pendingPayment->user->name)
->assertDontSee($received->user->name);
});
it('searches consultations by client name or email', function () {
$admin = User::factory()->admin()->create();
$targetUser = User::factory()->create(['name' => 'John Doe', 'email' => 'john@test.com']);
$otherUser = User::factory()->create(['name' => 'Jane Smith']);
$targetConsultation = Consultation::factory()->for($targetUser)->approved()->create();
$otherConsultation = Consultation::factory()->for($otherUser)->approved()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->set('search', 'John')
->assertSee('John Doe')
->assertDontSee('Jane Smith');
});
it('filters consultations by date range', function () {
$admin = User::factory()->admin()->create();
$oldConsultation = Consultation::factory()->approved()->create([
'scheduled_date' => now()->subDays(10),
]);
$newConsultation = Consultation::factory()->approved()->create([
'scheduled_date' => now()->addDays(5),
]);
Volt::test('admin.consultations.index')
->actingAs($admin)
->set('dateFrom', now()->toDateString())
->assertSee($newConsultation->user->name)
->assertDontSee($oldConsultation->user->name);
});
// ==========================================
// STATUS MANAGEMENT TESTS
// ==========================================
it('can mark consultation as completed', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markCompleted', $consultation->id)
->assertHasNoErrors();
expect($consultation->fresh()->status->value)->toBe('completed');
});
it('cannot mark pending consultation as completed', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->pending()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markCompleted', $consultation->id)
->assertHasErrors();
expect($consultation->fresh()->status->value)->toBe('pending');
});
it('can mark consultation as no-show', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markNoShow', $consultation->id)
->assertHasNoErrors();
expect($consultation->fresh()->status->value)->toBe('no_show');
});
it('cannot mark already completed consultation as no-show', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->completed()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markNoShow', $consultation->id)
->assertHasErrors();
});
it('can cancel approved consultation', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('cancel', $consultation->id)
->assertHasNoErrors();
expect($consultation->fresh()->status->value)->toBe('cancelled');
Notification::assertSentTo($consultation->user, ConsultationCancelled::class);
});
it('can cancel pending consultation', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->pending()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('cancel', $consultation->id)
->assertHasNoErrors();
expect($consultation->fresh()->status->value)->toBe('cancelled');
});
it('cannot cancel already cancelled consultation', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->cancelled()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('cancel', $consultation->id)
->assertHasErrors();
});
// ==========================================
// PAYMENT TRACKING TESTS
// ==========================================
it('can mark payment as received for paid consultation', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create([
'type' => 'paid',
'payment_amount' => 150.00,
'payment_status' => 'pending',
]);
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markPaymentReceived', $consultation->id)
->assertHasNoErrors();
expect($consultation->fresh())
->payment_status->value->toBe('received')
->payment_received_at->not->toBeNull();
});
it('cannot mark payment received for free consultation', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create([
'type' => 'free',
'payment_status' => 'not_applicable',
]);
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markPaymentReceived', $consultation->id)
->assertHasErrors();
});
it('cannot mark payment received twice', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create([
'type' => 'paid',
'payment_status' => 'received',
'payment_received_at' => now()->subDay(),
]);
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markPaymentReceived', $consultation->id)
->assertHasErrors();
});
// ==========================================
// RESCHEDULE TESTS
// ==========================================
it('can reschedule consultation to available slot', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create([
'scheduled_date' => now()->addDays(3),
'scheduled_time' => '10:00:00',
]);
$newDate = now()->addDays(5)->format('Y-m-d');
$newTime = '14:00:00';
// Mock availability service
$this->mock(AvailabilityService::class)
->shouldReceive('getAvailableSlots')
->andReturn(['14:00:00', '15:00:00', '16:00:00']);
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
->actingAs($admin)
->set('newDate', $newDate)
->set('newTime', $newTime)
->call('reschedule')
->assertHasNoErrors()
->assertRedirect(route('admin.consultations.index'));
expect($consultation->fresh())
->scheduled_date->format('Y-m-d')->toBe($newDate)
->scheduled_time->toBe($newTime);
Notification::assertSentTo($consultation->user, ConsultationRescheduled::class);
});
it('cannot reschedule to unavailable slot', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
$newDate = now()->addDays(5)->format('Y-m-d');
$newTime = '14:00:00';
// Mock availability service - slot not available
$this->mock(AvailabilityService::class)
->shouldReceive('getAvailableSlots')
->andReturn(['10:00:00', '11:00:00']); // 14:00 not in list
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
->actingAs($admin)
->set('newDate', $newDate)
->set('newTime', $newTime)
->call('reschedule')
->assertHasErrors(['newTime']);
});
it('cannot reschedule to past date', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
->actingAs($admin)
->set('newDate', now()->subDay()->format('Y-m-d'))
->set('newTime', '10:00:00')
->call('reschedule')
->assertHasErrors(['newDate']);
});
it('generates new ics file on reschedule', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
$newDate = now()->addDays(5)->format('Y-m-d');
$newTime = '14:00:00';
$this->mock(AvailabilityService::class)
->shouldReceive('getAvailableSlots')
->andReturn(['14:00:00']);
$calendarMock = $this->mock(CalendarService::class);
$calendarMock->shouldReceive('generateIcs')
->once()
->andReturn('BEGIN:VCALENDAR...');
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
->actingAs($admin)
->set('newDate', $newDate)
->set('newTime', $newTime)
->call('reschedule')
->assertHasNoErrors();
});
// ==========================================
// ADMIN NOTES TESTS
// ==========================================
it('can add admin note to consultation', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
Volt::test('admin.consultations.detail', ['consultation' => $consultation])
->actingAs($admin)
->set('newNote', 'Client requested Arabic documents')
->call('addNote')
->assertHasNoErrors();
$notes = $consultation->fresh()->admin_notes;
expect($notes)->toHaveCount(1)
->and($notes[0]['text'])->toBe('Client requested Arabic documents')
->and($notes[0]['admin_id'])->toBe($admin->id);
});
it('can update admin note', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create([
'admin_notes' => [
['text' => 'Original note', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()],
],
]);
Volt::test('admin.consultations.detail', ['consultation' => $consultation])
->actingAs($admin)
->call('updateNote', 0, 'Updated note')
->assertHasNoErrors();
$notes = $consultation->fresh()->admin_notes;
expect($notes[0]['text'])->toBe('Updated note')
->and($notes[0])->toHaveKey('updated_at');
});
it('can delete admin note', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create([
'admin_notes' => [
['text' => 'Note 1', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()],
['text' => 'Note 2', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()],
],
]);
Volt::test('admin.consultations.detail', ['consultation' => $consultation])
->actingAs($admin)
->call('deleteNote', 0)
->assertHasNoErrors();
$notes = $consultation->fresh()->admin_notes;
expect($notes)->toHaveCount(1)
->and($notes[0]['text'])->toBe('Note 2');
});
// ==========================================
// AUDIT LOG TESTS
// ==========================================
it('creates audit log entry on status change', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markCompleted', $consultation->id);
$this->assertDatabaseHas('admin_logs', [
'admin_id' => $admin->id,
'action_type' => 'status_change',
'target_type' => 'consultation',
'target_id' => $consultation->id,
]);
});
it('creates audit log entry on payment received', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create([
'type' => 'paid',
'payment_status' => 'pending',
]);
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('markPaymentReceived', $consultation->id);
$this->assertDatabaseHas('admin_logs', [
'admin_id' => $admin->id,
'action_type' => 'payment_received',
'target_type' => 'consultation',
'target_id' => $consultation->id,
]);
});
it('creates audit log entry on reschedule', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->approved()->create();
$this->mock(AvailabilityService::class)
->shouldReceive('getAvailableSlots')
->andReturn(['14:00:00']);
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
->actingAs($admin)
->set('newDate', now()->addDays(5)->format('Y-m-d'))
->set('newTime', '14:00:00')
->call('reschedule');
$this->assertDatabaseHas('admin_logs', [
'admin_id' => $admin->id,
'action_type' => 'reschedule',
'target_type' => 'consultation',
'target_id' => $consultation->id,
]);
});
// ==========================================
// CLIENT HISTORY TESTS
// ==========================================
it('displays consultation history for specific client', function () {
$admin = User::factory()->admin()->create();
$client = User::factory()->create();
$consultations = Consultation::factory()->count(3)->for($client)->create();
Volt::test('admin.clients.consultation-history', ['user' => $client])
->actingAs($admin)
->assertSee($consultations->first()->scheduled_date->format('Y-m-d'))
->assertStatus(200);
});
it('shows summary statistics for client', function () {
$admin = User::factory()->admin()->create();
$client = User::factory()->create();
Consultation::factory()->for($client)->completed()->count(2)->create();
Consultation::factory()->for($client)->cancelled()->create();
Consultation::factory()->for($client)->create(['status' => 'no_show']);
Volt::test('admin.clients.consultation-history', ['user' => $client])
->actingAs($admin)
->assertSee('2') // completed count
->assertSee('1'); // no-show count
});
// ==========================================
// BILINGUAL TESTS
// ==========================================
it('displays labels in Arabic when locale is ar', function () {
$admin = User::factory()->admin()->create(['preferred_language' => 'ar']);
$consultation = Consultation::factory()->approved()->create();
app()->setLocale('ar');
Volt::test('admin.consultations.index')
->actingAs($admin)
->assertSee(__('admin.consultations'));
});
it('sends cancellation notification in client preferred language', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$arabicUser = User::factory()->create(['preferred_language' => 'ar']);
$consultation = Consultation::factory()->approved()->for($arabicUser)->create();
Volt::test('admin.consultations.index')
->actingAs($admin)
->call('cancel', $consultation->id);
Notification::assertSentTo($arabicUser, ConsultationCancelled::class, function ($notification) {
return $notification->consultation->user->preferred_language === 'ar';
});
});
```
## Definition of Done
@@ -368,9 +1009,10 @@ public function addNote(string $note): void
## Dependencies
- **Story 3.5:** Booking approval
- **Story 3.6:** Calendar file generation
- **Epic 8:** Email notifications
- **Story 3.5:** `docs/stories/story-3.5-admin-booking-review-approval.md` - Creates approved consultations to manage; provides AdminLog pattern
- **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation on reschedule
- **Story 3.3:** `docs/stories/story-3.3-availability-calendar-display.md` - AvailabilityService for slot validation on reschedule
- **Epic 8:** `docs/epics/epic-8-email-notifications.md` - ConsultationCancelled and ConsultationRescheduled notifications
## Risk Assessment