libra/docs/stories/story-3.7-consultation-mana...

36 KiB

Story 3.7: Consultation Management

Epic Reference

Epic 3: Booking & Consultation System

User Story

As an admin, I want to manage consultations throughout their lifecycle, So that I can track completed sessions, handle no-shows, and maintain accurate records.

Story Context

Existing System Integration

  • Integrates with: consultations table, notifications
  • Technology: Livewire Volt, Flux UI
  • 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

  • View all consultations with filters:
    • Status (pending/approved/completed/cancelled/no_show)
    • Type (free/paid)
    • Payment status (pending/received/not_applicable)
    • Date range
    • Client name/email search
  • Sort by date, status, client name
  • Pagination (15/25/50 per page)
  • Quick status indicators

Status Management

  • Mark consultation as completed
  • Mark consultation as no-show
  • Cancel booking on behalf of client
  • Status change confirmation

Rescheduling

  • Reschedule appointment to new date/time
  • Validate new slot availability
  • Send notification to client
  • Generate new .ics file

Payment Tracking

  • Mark payment as received (for paid consultations)
  • Payment date recorded
  • Payment status visible in list

Admin Notes

  • Add internal admin notes
  • Notes not visible to client
  • View notes in consultation detail
  • Edit/delete notes

Client History

  • View all consultations for a specific client
  • Linked from user profile
  • Summary statistics per client

Quality Requirements

  • Audit log for all status changes
  • Bilingual labels
  • Tests for status transitions

Technical Notes

Status Enum

enum ConsultationStatus: string
{
    case Pending = 'pending';
    case Approved = 'approved';
    case Completed = 'completed';
    case Cancelled = 'cancelled';
    case NoShow = 'no_show';
}

enum PaymentStatus: string
{
    case Pending = 'pending';
    case Received = 'received';
    case NotApplicable = 'not_applicable';
}

Consultation Model Methods

class Consultation extends Model
{
    public function markAsCompleted(): void
    {
        $this->update(['status' => ConsultationStatus::Completed]);
    }

    public function markAsNoShow(): void
    {
        $this->update(['status' => ConsultationStatus::NoShow]);
    }

    public function cancel(): void
    {
        $this->update(['status' => ConsultationStatus::Cancelled]);
    }

    public function markPaymentReceived(): void
    {
        $this->update([
            'payment_status' => PaymentStatus::Received,
            'payment_received_at' => now(),
        ]);
    }

    public function reschedule(string $newDate, string $newTime): void
    {
        $this->update([
            'scheduled_date' => $newDate,
            'scheduled_time' => $newTime,
        ]);
    }

    // Scopes
    public function scopeUpcoming($query)
    {
        return $query->where('scheduled_date', '>=', today())
                     ->where('status', ConsultationStatus::Approved);
    }

    public function scopePast($query)
    {
        return $query->where('scheduled_date', '<', today())
                     ->orWhereIn('status', [
                         ConsultationStatus::Completed,
                         ConsultationStatus::Cancelled,
                         ConsultationStatus::NoShow,
                     ]);
    }
}

Volt Component for Management

<?php

use App\Models\Consultation;
use Livewire\Volt\Component;
use Livewire\WithPagination;

new class extends Component {
    use WithPagination;

    public string $search = '';
    public string $statusFilter = '';
    public string $typeFilter = '';
    public string $paymentFilter = '';
    public string $dateFrom = '';
    public string $dateTo = '';
    public string $sortBy = 'scheduled_date';
    public string $sortDir = 'desc';

    public function updatedSearch()
    {
        $this->resetPage();
    }

    public function markCompleted(int $id): void
    {
        $consultation = Consultation::findOrFail($id);
        $oldStatus = $consultation->status;

        $consultation->markAsCompleted();

        $this->logStatusChange($consultation, $oldStatus, 'completed');

        session()->flash('success', __('messages.marked_completed'));
    }

    public function markNoShow(int $id): void
    {
        $consultation = Consultation::findOrFail($id);
        $oldStatus = $consultation->status;

        $consultation->markAsNoShow();

        $this->logStatusChange($consultation, $oldStatus, 'no_show');

        session()->flash('success', __('messages.marked_no_show'));
    }

    public function cancel(int $id): void
    {
        $consultation = Consultation::findOrFail($id);
        $oldStatus = $consultation->status;

        $consultation->cancel();

        // Notify client
        $consultation->user->notify(new ConsultationCancelled($consultation));

        $this->logStatusChange($consultation, $oldStatus, 'cancelled');

        session()->flash('success', __('messages.consultation_cancelled'));
    }

    public function markPaymentReceived(int $id): void
    {
        $consultation = Consultation::findOrFail($id);
        $consultation->markPaymentReceived();

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => 'payment_received',
            'target_type' => 'consultation',
            'target_id' => $consultation->id,
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.payment_marked_received'));
    }

    private function logStatusChange(Consultation $consultation, string $oldStatus, string $newStatus): void
    {
        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => 'status_change',
            'target_type' => 'consultation',
            'target_id' => $consultation->id,
            'old_values' => ['status' => $oldStatus],
            'new_values' => ['status' => $newStatus],
            'ip_address' => request()->ip(),
        ]);
    }

    public function with(): array
    {
        return [
            'consultations' => Consultation::query()
                ->with('user')
                ->when($this->search, fn($q) => $q->whereHas('user', fn($uq) =>
                    $uq->where('name', 'like', "%{$this->search}%")
                       ->orWhere('email', 'like', "%{$this->search}%")
                ))
                ->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
                ->when($this->typeFilter, fn($q) => $q->where('type', $this->typeFilter))
                ->when($this->paymentFilter, fn($q) => $q->where('payment_status', $this->paymentFilter))
                ->when($this->dateFrom, fn($q) => $q->where('scheduled_date', '>=', $this->dateFrom))
                ->when($this->dateTo, fn($q) => $q->where('scheduled_date', '<=', $this->dateTo))
                ->orderBy($this->sortBy, $this->sortDir)
                ->paginate(15),
        ];
    }
};

Reschedule Component

<?php

use App\Models\Consultation;
use App\Services\{AvailabilityService, CalendarService};
use App\Notifications\ConsultationRescheduled;
use Livewire\Volt\Component;

new class extends Component {
    public Consultation $consultation;
    public string $newDate = '';
    public string $newTime = '';
    public array $availableSlots = [];

    public function mount(Consultation $consultation): void
    {
        $this->consultation = $consultation;
        $this->newDate = $consultation->scheduled_date->format('Y-m-d');
    }

    public function updatedNewDate(): void
    {
        if ($this->newDate) {
            $service = app(AvailabilityService::class);
            $this->availableSlots = $service->getAvailableSlots(
                Carbon::parse($this->newDate)
            );
            $this->newTime = '';
        }
    }

    public function reschedule(): void
    {
        $this->validate([
            'newDate' => ['required', 'date', 'after_or_equal:today'],
            'newTime' => ['required'],
        ]);

        // Verify slot available
        $service = app(AvailabilityService::class);
        $slots = $service->getAvailableSlots(Carbon::parse($this->newDate));

        if (!in_array($this->newTime, $slots)) {
            $this->addError('newTime', __('booking.slot_not_available'));
            return;
        }

        $oldDate = $this->consultation->scheduled_date;
        $oldTime = $this->consultation->scheduled_time;

        $this->consultation->reschedule($this->newDate, $this->newTime);

        // Generate new .ics
        $calendarService = app(CalendarService::class);
        $icsContent = $calendarService->generateIcs($this->consultation->fresh());

        // Notify client
        $this->consultation->user->notify(
            new ConsultationRescheduled($this->consultation, $oldDate, $oldTime, $icsContent)
        );

        // Log
        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => 'reschedule',
            'target_type' => 'consultation',
            'target_id' => $this->consultation->id,
            'old_values' => ['date' => $oldDate, 'time' => $oldTime],
            'new_values' => ['date' => $this->newDate, 'time' => $this->newTime],
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.consultation_rescheduled'));
        $this->redirect(route('admin.consultations.index'));
    }
};

Admin Notes

// 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, updated_at?}]
];

public function addNote(string $note): void
{
    $notes = $this->admin_notes ?? [];
    $notes[] = [
        'text' => $note,
        'admin_id' => auth()->id(),
        'created_at' => now()->toISOString(),
    ];
    $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:

// 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:
// In reschedule() method
if ($oldDate->format('Y-m-d') === $this->newDate && $oldTime === $this->newTime) {
    session()->flash('info', __('messages.no_changes_made'));
    return;
}

Concurrent Modification:

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

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

// Guard against orphaned consultations
if (!$this->consultation->user) {
    session()->flash('error', __('messages.client_account_not_found'));
    return;
}

Testing Examples

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

  • List view with all filters working
  • Can mark consultation as completed
  • Can mark consultation as no-show
  • Can cancel consultation
  • Can reschedule consultation
  • Can mark payment as received
  • Can add admin notes
  • Client notified on reschedule/cancel
  • New .ics sent on reschedule
  • Audit logging complete
  • Bilingual support
  • Tests for all status changes
  • Code formatted with Pint

Dependencies

  • 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

  • Primary Risk: Status change on wrong consultation
  • Mitigation: Confirmation dialogs, clear identification
  • Rollback: Manual status correction, audit log

Estimation

Complexity: Medium-High Estimated Effort: 5-6 hours

QA Results

Review Date: 2025-12-26

Reviewed By: Quinn (Test Architect)

Code Quality Assessment

The implementation of Story 3.7 demonstrates excellent quality overall. The codebase follows Laravel/Livewire best practices with proper separation of concerns, thorough error handling, and comprehensive test coverage. Key highlights:

  1. Model Layer: The Consultation model properly implements domain logic with status transition guards using InvalidArgumentException - this prevents invalid state changes at the model level
  2. Database Safety: All status-changing operations use DB::transaction() with lockForUpdate() to handle concurrent modifications
  3. Notification Handling: Notifications are properly queued (ShouldQueue) with graceful error handling that logs failures without blocking the main operation
  4. Bilingual Support: Full AR/EN support with proper RTL handling in email templates

Refactoring Performed

None required - the code quality is production-ready.

Compliance Check

  • Coding Standards: ✓ All code passes Laravel Pint
  • Project Structure: ✓ Follows Volt class-based component pattern consistently
  • Testing Strategy: ✓ Comprehensive test coverage with 33 passing tests
  • All ACs Met: ✓ All 24 acceptance criteria fully implemented

Improvements Checklist

[All items handled satisfactorily]

  • Status transition guards implemented in model (prevents invalid completed/no_show/cancel operations)
  • Concurrent modification protection with database transactions and row locking
  • Notification failure handling with non-blocking logging
  • Missing user guard for reschedule operations
  • Same date/time detection for reschedule (avoids unnecessary notifications)
  • Pagination implemented (15/25/50 per page)
  • Sorting functionality on date and status columns
  • All filters working (status, type, payment, date range, search)
  • Admin notes CRUD with timestamps and admin tracking
  • Audit logging for all status changes, payment received, and reschedule operations

Security Review

Status: PASS

  • ✓ Route protection via admin middleware
  • ✓ Access control tests verify guests and clients cannot access admin routes
  • ✓ No SQL injection risks - uses Eloquent properly
  • ✓ No XSS vulnerabilities - Blade escaping used throughout
  • ✓ Proper authorization checks before status changes

Performance Considerations

Status: PASS

  • ✓ Eager loading used for user relationship in consultations list (with('user:id,full_name,email,phone,user_type'))
  • ✓ Selective column loading on user relationship
  • ✓ Pagination implemented to prevent large result sets
  • ✓ Client history uses N+1 safe queries via model counts

Minor Observation: The consultation-history component makes 4 separate count queries for statistics. These could be combined into a single query with conditional counts, but the impact is minimal for the expected data volume.

Files Modified During Review

None - no modifications were necessary.

Gate Status

Gate: PASS → docs/qa/gates/3.7-consultation-management.yml

✓ Ready for Done

All acceptance criteria are fully implemented with comprehensive test coverage. The implementation demonstrates excellent adherence to Laravel best practices, proper error handling, and bilingual support. The code is production-ready.