1103 lines
36 KiB
Markdown
1103 lines
36 KiB
Markdown
# 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
|
|
- [x] 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
|
|
- [x] Sort by date, status, client name
|
|
- [x] Pagination (15/25/50 per page)
|
|
- [x] Quick status indicators
|
|
|
|
### Status Management
|
|
- [x] Mark consultation as completed
|
|
- [x] Mark consultation as no-show
|
|
- [x] Cancel booking on behalf of client
|
|
- [x] Status change confirmation
|
|
|
|
### Rescheduling
|
|
- [x] Reschedule appointment to new date/time
|
|
- [x] Validate new slot availability
|
|
- [x] Send notification to client
|
|
- [x] Generate new .ics file
|
|
|
|
### Payment Tracking
|
|
- [x] Mark payment as received (for paid consultations)
|
|
- [x] Payment date recorded
|
|
- [x] Payment status visible in list
|
|
|
|
### Admin Notes
|
|
- [x] Add internal admin notes
|
|
- [x] Notes not visible to client
|
|
- [x] View notes in consultation detail
|
|
- [x] Edit/delete notes
|
|
|
|
### Client History
|
|
- [x] View all consultations for a specific client
|
|
- [x] Linked from user profile
|
|
- [x] Summary statistics per client
|
|
|
|
### Quality Requirements
|
|
- [x] Audit log for all status changes
|
|
- [x] Bilingual labels
|
|
- [x] Tests for status transitions
|
|
|
|
## Technical Notes
|
|
|
|
### Status Enum
|
|
```php
|
|
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
|
|
```php
|
|
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
|
|
<?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
|
|
<?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
|
|
```php
|
|
// 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:**
|
|
```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
|
|
|
|
- [x] List view with all filters working
|
|
- [x] Can mark consultation as completed
|
|
- [x] Can mark consultation as no-show
|
|
- [x] Can cancel consultation
|
|
- [x] Can reschedule consultation
|
|
- [x] Can mark payment as received
|
|
- [x] Can add admin notes
|
|
- [x] Client notified on reschedule/cancel
|
|
- [x] New .ics sent on reschedule
|
|
- [x] Audit logging complete
|
|
- [x] Bilingual support
|
|
- [x] Tests for all status changes
|
|
- [x] 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]
|
|
|
|
- [x] Status transition guards implemented in model (prevents invalid completed/no_show/cancel operations)
|
|
- [x] Concurrent modification protection with database transactions and row locking
|
|
- [x] Notification failure handling with non-blocking logging
|
|
- [x] Missing user guard for reschedule operations
|
|
- [x] Same date/time detection for reschedule (avoids unnecessary notifications)
|
|
- [x] Pagination implemented (15/25/50 per page)
|
|
- [x] Sorting functionality on date and status columns
|
|
- [x] All filters working (status, type, payment, date range, search)
|
|
- [x] Admin notes CRUD with timestamps and admin tracking
|
|
- [x] 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
|
|
|
|
### 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.
|