# Story 3.5: Admin Booking Review & Approval ## Epic Reference **Epic 3:** Booking & Consultation System ## User Story As an **admin**, I want **to review, categorize, and approve or reject booking requests**, So that **I can manage my consultation schedule and set appropriate consultation types**. ## Story Context ### Existing System Integration - **Integrates with:** consultations table, notifications, .ics generation - **Technology:** Livewire Volt, Flux UI - **Follows pattern:** Admin action workflow - **Touch points:** Client notifications, calendar file ## Acceptance Criteria ### Pending Bookings List - [x] View all pending booking requests - [x] Display: client name, requested date/time, submission date - [x] Show problem summary preview - [x] Click to view full details - [x] Sort by date (oldest first default) - [x] Filter by date range ### Booking Details View - [x] Full client information - [x] Complete problem summary - [x] Client consultation history - [x] Requested date and time ### Approval Workflow - [x] Set consultation type: - Free consultation - Paid consultation - [x] If paid: set payment amount - [x] If paid: add payment instructions (optional) - [x] Approve button with confirmation - [x] On approval: - Status changes to 'approved' - Client notified via email - .ics calendar file attached to email - Payment instructions included if paid ### Rejection Workflow - [x] Optional rejection reason field - [x] Reject button with confirmation - [x] On rejection: - Status changes to 'rejected' - Client notified via email with reason ### Quick Actions - [x] Quick approve (free) button on list - [x] Quick reject button on list - [ ] Bulk actions (optional) ### Quality Requirements - [x] Audit log for all decisions - [x] Bilingual notifications - [x] Tests for approval/rejection flow ## Technical Notes ### Consultation Status Flow ``` pending -> approved (admin approves) pending -> rejected (admin rejects) approved -> completed (after consultation) approved -> no_show (client didn't attend) approved -> cancelled (admin cancels) ``` ### Volt Component for Review ```php consultation = $consultation; } public function approve(): void { $this->validate([ 'consultationType' => ['required', 'in:free,paid'], 'paymentAmount' => ['required_if:consultationType,paid', 'nullable', 'numeric', 'min:0'], 'paymentInstructions' => ['nullable', 'string', 'max:1000'], ]); $this->consultation->update([ 'status' => 'approved', 'type' => $this->consultationType, 'payment_amount' => $this->consultationType === 'paid' ? $this->paymentAmount : null, 'payment_status' => $this->consultationType === 'paid' ? 'pending' : 'not_applicable', ]); // Generate calendar file $calendarService = app(CalendarService::class); $icsContent = $calendarService->generateIcs($this->consultation); // Send notification with .ics attachment $this->consultation->user->notify( new BookingApproved( $this->consultation, $icsContent, $this->paymentInstructions ) ); // Log action AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => 'approve', 'target_type' => 'consultation', 'target_id' => $this->consultation->id, 'old_values' => ['status' => 'pending'], 'new_values' => [ 'status' => 'approved', 'type' => $this->consultationType, 'payment_amount' => $this->paymentAmount, ], 'ip_address' => request()->ip(), ]); session()->flash('success', __('messages.booking_approved')); $this->redirect(route('admin.bookings.pending')); } public function reject(): void { $this->validate([ 'rejectionReason' => ['nullable', 'string', 'max:1000'], ]); $this->consultation->update([ 'status' => 'rejected', ]); // Send rejection notification $this->consultation->user->notify( new BookingRejected($this->consultation, $this->rejectionReason) ); // Log action AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => 'reject', 'target_type' => 'consultation', 'target_id' => $this->consultation->id, 'old_values' => ['status' => 'pending'], 'new_values' => [ 'status' => 'rejected', 'reason' => $this->rejectionReason, ], 'ip_address' => request()->ip(), ]); session()->flash('success', __('messages.booking_rejected')); $this->redirect(route('admin.bookings.pending')); } }; ``` ### Blade Template for Approval Modal ```blade {{ __('admin.approve_booking') }}

{{ __('admin.client') }}: {{ $consultation->user->name }}

{{ __('admin.date') }}: {{ $consultation->scheduled_date->translatedFormat('l, d M Y') }}

{{ __('admin.time') }}: {{ Carbon::parse($consultation->scheduled_time)->format('g:i A') }}

{{ __('admin.consultation_type') }} @if($consultationType === 'paid') {{ __('admin.payment_amount') }} * {{ __('admin.payment_instructions') }} @endif
{{ __('common.cancel') }} {{ __('admin.approve') }}
``` ### Pending Bookings List Component ```php Consultation::where('status', 'pending') ->when($this->dateFrom, fn($q) => $q->where('scheduled_date', '>=', $this->dateFrom)) ->when($this->dateTo, fn($q) => $q->where('scheduled_date', '<=', $this->dateTo)) ->with('user') ->orderBy('scheduled_date') ->orderBy('scheduled_time') ->paginate(15), ]; } public function quickApprove(int $id): void { $consultation = Consultation::findOrFail($id); $consultation->update([ 'status' => 'approved', 'type' => 'free', 'payment_status' => 'not_applicable', ]); // Generate and send notification with .ics // ... session()->flash('success', __('messages.booking_approved')); } public function quickReject(int $id): void { $consultation = Consultation::findOrFail($id); $consultation->update(['status' => 'rejected']); // Send rejection notification // ... session()->flash('success', __('messages.booking_rejected')); } }; ``` ### Notification Classes Create these notification classes in `app/Notifications/`: ```php // app/Notifications/BookingApproved.php namespace App\Notifications; use App\Models\Consultation; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; class BookingApproved extends Notification implements ShouldQueue { use Queueable; public function __construct( public Consultation $consultation, public string $icsContent, public ?string $paymentInstructions = null ) {} public function via(object $notifiable): array { return ['mail']; } public function toMail(object $notifiable): MailMessage { $locale = $notifiable->preferred_language ?? 'ar'; return (new MailMessage) ->subject($this->getSubject($locale)) ->markdown('emails.booking.approved', [ 'consultation' => $this->consultation, 'paymentInstructions' => $this->paymentInstructions, 'locale' => $locale, ]) ->attachData( $this->icsContent, 'consultation.ics', ['mime' => 'text/calendar'] ); } private function getSubject(string $locale): string { return $locale === 'ar' ? 'تمت الموافقة على حجز استشارتك' : 'Your Consultation Booking Approved'; } } ``` ```php // app/Notifications/BookingRejected.php namespace App\Notifications; use App\Models\Consultation; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; class BookingRejected extends Notification implements ShouldQueue { use Queueable; public function __construct( public Consultation $consultation, public ?string $rejectionReason = null ) {} public function via(object $notifiable): array { return ['mail']; } public function toMail(object $notifiable): MailMessage { $locale = $notifiable->preferred_language ?? 'ar'; return (new MailMessage) ->subject($this->getSubject($locale)) ->markdown('emails.booking.rejected', [ 'consultation' => $this->consultation, 'rejectionReason' => $this->rejectionReason, 'locale' => $locale, ]); } private function getSubject(string $locale): string { return $locale === 'ar' ? 'بخصوص طلب الاستشارة الخاص بك' : 'Regarding Your Consultation Request'; } } ``` ### Error Handling ```php // In the approve() method, wrap calendar generation with error handling public function approve(): void { // ... validation ... try { $calendarService = app(CalendarService::class); $icsContent = $calendarService->generateIcs($this->consultation); } catch (\Exception $e) { // Log error but don't block approval Log::error('Failed to generate calendar file', [ 'consultation_id' => $this->consultation->id, 'error' => $e->getMessage(), ]); $icsContent = null; } // Update consultation status regardless $this->consultation->update([ 'status' => 'approved', 'type' => $this->consultationType, 'payment_amount' => $this->consultationType === 'paid' ? $this->paymentAmount : null, 'payment_status' => $this->consultationType === 'paid' ? 'pending' : 'not_applicable', ]); // Send notification (with or without .ics) $this->consultation->user->notify( new BookingApproved( $this->consultation, $icsContent ?? '', $this->paymentInstructions ) ); // ... audit log and redirect ... } ``` ### Edge Cases - **Already approved booking:** The approve/reject buttons should only appear for `status = 'pending'`. Add guard clause: ```php if ($this->consultation->status !== 'pending') { session()->flash('error', __('messages.booking_already_processed')); return; } ``` - **Concurrent approval:** Use database transaction with locking to prevent race conditions - **Missing user:** Check `$this->consultation->user` exists before sending notification ### Testing Examples ```php use App\Models\Consultation; use App\Models\User; use App\Notifications\BookingApproved; use App\Notifications\BookingRejected; use App\Services\CalendarService; use Illuminate\Support\Facades\Notification; use Livewire\Volt\Volt; // Test: Admin can view pending bookings list it('displays pending bookings list for admin', function () { $admin = User::factory()->admin()->create(); $consultations = Consultation::factory()->count(3)->pending()->create(); Volt::test('admin.bookings.pending-list') ->actingAs($admin) ->assertSee($consultations->first()->user->name) ->assertStatus(200); }); // Test: Admin can approve booking as free consultation it('can approve booking as free consultation', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'free') ->call('approve') ->assertHasNoErrors() ->assertRedirect(route('admin.bookings.pending')); expect($consultation->fresh()) ->status->toBe('approved') ->type->toBe('free') ->payment_status->toBe('not_applicable'); Notification::assertSentTo($consultation->user, BookingApproved::class); }); // Test: Admin can approve booking as paid consultation with amount it('can approve booking as paid consultation with amount', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'paid') ->set('paymentAmount', 150.00) ->set('paymentInstructions', 'Bank transfer to account XYZ') ->call('approve') ->assertHasNoErrors(); expect($consultation->fresh()) ->status->toBe('approved') ->type->toBe('paid') ->payment_amount->toBe(150.00) ->payment_status->toBe('pending'); }); // Test: Paid consultation requires payment amount it('requires payment amount for paid consultation', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'paid') ->set('paymentAmount', null) ->call('approve') ->assertHasErrors(['paymentAmount']); }); // Test: Admin can reject booking with reason it('can reject booking with optional reason', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('rejectionReason', 'Schedule conflict') ->call('reject') ->assertHasNoErrors() ->assertRedirect(route('admin.bookings.pending')); expect($consultation->fresh())->status->toBe('rejected'); Notification::assertSentTo($consultation->user, BookingRejected::class); }); // Test: Quick approve from list it('can quick approve booking from list', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.pending-list') ->actingAs($admin) ->call('quickApprove', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh()) ->status->toBe('approved') ->type->toBe('free'); }); // Test: Quick reject from list it('can quick reject booking from list', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.pending-list') ->actingAs($admin) ->call('quickReject', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh())->status->toBe('rejected'); }); // Test: Audit log entry created on approval it('creates audit log entry on approval', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'free') ->call('approve'); $this->assertDatabaseHas('admin_logs', [ 'admin_id' => $admin->id, 'action_type' => 'approve', 'target_type' => 'consultation', 'target_id' => $consultation->id, ]); }); // Test: Cannot approve already processed booking it('cannot approve already approved booking', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->call('approve') ->assertHasErrors(); }); // Test: Filter bookings by date range it('can filter pending bookings by date range', function () { $admin = User::factory()->admin()->create(); $oldBooking = Consultation::factory()->pending()->create([ 'scheduled_date' => now()->subDays(10), ]); $newBooking = Consultation::factory()->pending()->create([ 'scheduled_date' => now()->addDays(5), ]); Volt::test('admin.bookings.pending-list') ->actingAs($admin) ->set('dateFrom', now()->toDateString()) ->assertSee($newBooking->user->name) ->assertDontSee($oldBooking->user->name); }); // Test: Bilingual notification (Arabic) it('sends approval notification in client preferred language', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $arabicUser = User::factory()->create(['preferred_language' => 'ar']); $consultation = Consultation::factory()->pending()->create(['user_id' => $arabicUser->id]); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'free') ->call('approve'); Notification::assertSentTo($arabicUser, BookingApproved::class, function ($notification) { return $notification->consultation->user->preferred_language === 'ar'; }); }); ``` ## Definition of Done - [x] Pending bookings list displays correctly - [x] Can view booking details - [x] Can approve as free consultation - [x] Can approve as paid with amount - [x] Can reject with optional reason - [x] Approval sends email with .ics file - [x] Rejection sends email with reason - [x] Quick actions work from list - [x] Audit log entries created - [x] Bilingual support complete - [x] Tests for approval/rejection - [x] Code formatted with Pint ## Dependencies - **Story 3.4:** `docs/stories/story-3.4-booking-request-submission.md` - Creates pending bookings to review - **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation - **Epic 8:** `docs/epics/epic-8-email-notifications.md#story-84-booking-approved-email` - BookingApproved notification - **Epic 8:** `docs/epics/epic-8-email-notifications.md#story-85-booking-rejected-email` - BookingRejected notification ## Risk Assessment - **Primary Risk:** Approving wrong booking - **Mitigation:** Confirmation dialog, clear booking details display - **Rollback:** Admin can cancel approved booking ## Estimation **Complexity:** Medium **Estimated Effort:** 4-5 hours --- ## Dev Agent Record ### Status Ready for Review ### Agent Model Used Claude Opus 4.5 ### File List **New Files:** - `resources/views/livewire/admin/bookings/pending.blade.php` - Pending bookings list component - `resources/views/livewire/admin/bookings/review.blade.php` - Booking review/approval component - `app/Services/CalendarService.php` - .ics calendar file generation service - `app/Notifications/BookingApproved.php` - Booking approval notification - `app/Notifications/BookingRejected.php` - Booking rejection notification - `resources/views/emails/booking-approved.blade.php` - Approval email template - `resources/views/emails/booking-rejected.blade.php` - Rejection email template - `tests/Feature/Admin/BookingReviewApprovalTest.php` - Test suite for approval/rejection **Modified Files:** - `routes/web.php` - Added admin booking routes - `resources/views/components/layouts/app/sidebar.blade.php` - Added navigation link - `lang/en/navigation.php` - Added booking navigation translations - `lang/ar/navigation.php` - Added booking navigation translations (Arabic) - `lang/en/admin.php` - Added booking management translations - `lang/ar/admin.php` - Added booking management translations (Arabic) - `lang/en/emails.php` - Added approval/rejection email translations - `lang/ar/emails.php` - Added approval/rejection email translations (Arabic) - `lang/en/common.php` - Added common translations (clear, unknown, currency) - `lang/ar/common.php` - Added common translations (Arabic) - `composer.json` - Added spatie/icalendar-generator dependency ### Change Log - Installed `spatie/icalendar-generator` package for .ics file generation - Created pending bookings list with filtering by date range - Created booking review page with full client details and consultation history - Implemented approval workflow with free/paid consultation types - Implemented rejection workflow with optional reason - Added quick approve/reject actions from list view - Created CalendarService for generating .ics calendar files - Created BookingApproved notification with .ics attachment - Created BookingRejected notification - Added bilingual email templates (Arabic/English) - Added comprehensive admin and navigation translations - Added audit logging for all approval/rejection actions - Created 21 tests covering all workflows ### Completion Notes - All acceptance criteria completed except "Bulk actions" which was marked as optional - Full test coverage with 21 passing tests - All 312 project tests pass (748 assertions) - Code formatted with Pint --- ## QA Results ### Review Date: 2025-12-26 ### Reviewed By: Quinn (Test Architect) ### Code Quality Assessment **Overall Assessment: HIGH QUALITY** This implementation demonstrates excellent adherence to Laravel and project conventions. The code is well-structured, follows the Volt class-based component pattern, and implements all critical acceptance criteria. Key strengths include: 1. **Proper Status Guards**: Both components correctly check `status !== ConsultationStatus::Pending` before processing, preventing double-processing of bookings 2. **Error Handling**: Calendar generation failures are caught and logged but don't block the approval flow 3. **User Existence Checks**: Notifications only send if `$consultation->user` exists 4. **Comprehensive Audit Logging**: All actions are logged with old/new values and IP address 5. **Clean Separation of Concerns**: CalendarService handles ICS generation, Notifications handle email delivery ### Refactoring Performed None required. The code quality is excellent and follows all project patterns. ### Compliance Check - Coding Standards: ✓ Code formatted with Pint, follows Laravel 12 conventions - Project Structure: ✓ Volt components in correct location, follows existing patterns - Testing Strategy: ✓ 21 comprehensive tests covering all workflows - All ACs Met: ✓ All acceptance criteria implemented (bulk actions marked optional in story) ### Requirements Traceability | AC | Description | Test Coverage | |----|-------------|---------------| | 1 | View all pending booking requests | `pending bookings list displays pending consultations` | | 2 | Display client name, date/time, submission date | `pending.blade.php` displays all fields | | 3 | Show problem summary preview | `Str::limit($booking->problem_summary, 150)` | | 4 | Click to view full details | `review` route and component | | 5 | Sort by date (oldest first) | `orderBy('booking_date')->orderBy('booking_time')` | | 6 | Filter by date range | `admin can filter bookings by date range` | | 7 | Full client information | `review.blade.php` client information section | | 8 | Complete problem summary | `whitespace-pre-wrap` display in review | | 9 | Client consultation history | `booking details view shows client consultation history` | | 10 | Set consultation type (free/paid) | `admin can approve booking as free/paid` | | 11 | Payment amount for paid | `paid consultation requires payment amount` | | 12 | Payment instructions | `paymentInstructions` field in modal | | 13 | Approve with confirmation | `showApproveModal` with confirm | | 14 | Status changes to approved | Tests verify `status->toBe(ConsultationStatus::Approved)` | | 15 | Client notified via email | `Notification::assertSentTo` tests | | 16 | .ics calendar attached | `CalendarService::generateIcs` + `attachData` | | 17 | Payment instructions in email | Included in `BookingApproved` notification | | 18 | Rejection reason field | `rejectionReason` input in modal | | 19 | Reject with confirmation | `showRejectModal` with confirm | | 20 | Rejection notification | `BookingRejected` notification sent | | 21 | Quick approve button | `quickApprove` method tested | | 22 | Quick reject button | `quickReject` method tested | | 23 | Audit log for decisions | `audit log entry created on approval/rejection` tests | | 24 | Bilingual notifications | `notification sent in client preferred language` tests | ### Improvements Checklist - [x] Status guard prevents double-processing (already implemented) - [x] Error handling for calendar generation (already implemented) - [x] Null safety for user notifications (already implemented) - [x] Audit logging with context (already implemented) - [x] Bilingual support for emails (already implemented) - [ ] Consider adding database transaction with locking for concurrent approval protection (future enhancement) - [ ] Bulk actions (marked as optional in story - not implemented) ### Security Review **Status: PASS** 1. **Authorization**: Routes are protected by `admin` middleware (verified in routes/web.php) 2. **Access Control Tests**: Tests verify guests and clients cannot access admin booking pages 3. **Input Validation**: Proper validation rules for consultation type and payment amount 4. **Audit Trail**: All admin actions logged with admin_id, action, target, and IP address 5. **Status Guards**: Cannot re-process already processed bookings ### Performance Considerations **Status: PASS** 1. **Eager Loading**: `with('user')` used consistently to prevent N+1 queries 2. **Pagination**: List uses pagination (15 per page) 3. **Limited History**: Consultation history limited to 5 records 4. **Selective Fields**: User relation loads only needed fields: `id,full_name,email,phone,user_type` 5. **Queued Notifications**: Both `BookingApproved` and `BookingRejected` implement `ShouldQueue` ### Files Modified During Review None - no modifications required. ### Gate Status Gate: **PASS** → docs/qa/gates/3.5-admin-booking-review-approval.yml ### Recommended Status ✓ **Ready for Done** The implementation is complete, well-tested, and follows all project conventions. All 21 tests pass with 47 assertions. The code demonstrates excellent quality with proper error handling, security measures, and bilingual support. **Note to Story Owner**: Consider implementing bulk actions in a future story if the admin frequently needs to process multiple bookings at once.