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): arrayto validate reschedule slot availability - CalendarService (from Story 3.6): Used for
generateIcs(Consultation $consultation): stringto 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:todayprevents 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:
- Model Layer: The
Consultationmodel properly implements domain logic with status transition guards usingInvalidArgumentException- this prevents invalid state changes at the model level - Database Safety: All status-changing operations use
DB::transaction()withlockForUpdate()to handle concurrent modifications - Notification Handling: Notifications are properly queued (
ShouldQueue) with graceful error handling that logs failures without blocking the main operation - 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
adminmiddleware - ✓ 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
Recommended Status
✓ 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.