libra/docs/stories/story-3.4-booking-request-s...

18 KiB

Story 3.4: Booking Request Submission

Epic Reference

Epic 3: Booking & Consultation System

User Story

As a client, I want to submit a consultation booking request, So that I can schedule a meeting with the lawyer.

Story Context

Existing System Integration

  • Integrates with: consultations table, availability calendar (Story 3.3), notifications system
  • Technology: Livewire Volt (class-based), Laravel form validation, DB transactions
  • Follows pattern: Multi-step form submission with confirmation
  • Touch points: Client dashboard, admin notifications, audit log
  • Component location: resources/views/livewire/booking/request.blade.php

Acceptance Criteria

Booking Form

  • Client must be logged in
  • Select date from availability calendar
  • Select available time slot
  • Problem summary field (required, textarea)
  • Confirmation before submission

Validation & Constraints

  • Validate: no more than 1 booking per day for this client
  • Validate: selected slot is still available
  • Validate: problem summary is not empty
  • Show clear error messages for violations

Submission Flow

  • Booking enters "pending" status
  • Client sees "Pending Review" confirmation
  • Admin receives email notification
  • Client receives submission confirmation email
  • Redirect to consultations list after submission

UI/UX

  • Clear step-by-step flow
  • Loading state during submission
  • Success message with next steps
  • Bilingual labels and messages

Quality Requirements

  • Prevent double-booking (race condition)
  • Audit log entry for booking creation
  • Tests for submission flow
  • Tests for validation rules

Technical Notes

Database Record

// consultations table fields on creation
$consultation = Consultation::create([
    'user_id' => auth()->id(),
    'scheduled_date' => $selectedDate,
    'scheduled_time' => $selectedTime,
    'duration' => 45, // default
    'status' => 'pending',
    'type' => null, // admin sets this later
    'payment_amount' => null,
    'payment_status' => 'not_applicable',
    'problem_summary' => $problemSummary,
]);

Volt Component

<?php

use App\Models\Consultation;
use App\Services\AvailabilityService;
use App\Notifications\BookingSubmittedClient;
use App\Notifications\NewBookingAdmin;
use Livewire\Volt\Component;
use Carbon\Carbon;

new class extends Component {
    public ?string $selectedDate = null;
    public ?string $selectedTime = null;
    public string $problemSummary = '';
    public bool $showConfirmation = false;

    public function selectSlot(string $date, string $time): void
    {
        $this->selectedDate = $date;
        $this->selectedTime = $time;
    }

    public function clearSelection(): void
    {
        $this->selectedDate = null;
        $this->selectedTime = null;
    }

    public function showConfirm(): void
    {
        $this->validate([
            'selectedDate' => ['required', 'date', 'after_or_equal:today'],
            'selectedTime' => ['required'],
            'problemSummary' => ['required', 'string', 'min:20', 'max:2000'],
        ]);

        // Check 1-per-day limit
        $existingBooking = Consultation::where('user_id', auth()->id())
            ->where('scheduled_date', $this->selectedDate)
            ->whereIn('status', ['pending', 'approved'])
            ->exists();

        if ($existingBooking) {
            $this->addError('selectedDate', __('booking.already_booked_this_day'));
            return;
        }

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

        if (!in_array($this->selectedTime, $availableSlots)) {
            $this->addError('selectedTime', __('booking.slot_no_longer_available'));
            return;
        }

        $this->showConfirmation = true;
    }

    public function submit(): void
    {
        // Double-check availability with lock
        DB::transaction(function () {
            // Check slot one more time with lock
            $exists = Consultation::where('scheduled_date', $this->selectedDate)
                ->where('scheduled_time', $this->selectedTime)
                ->whereIn('status', ['pending', 'approved'])
                ->lockForUpdate()
                ->exists();

            if ($exists) {
                throw new \Exception(__('booking.slot_taken'));
            }

            // Check 1-per-day again
            $userBooking = Consultation::where('user_id', auth()->id())
                ->where('scheduled_date', $this->selectedDate)
                ->whereIn('status', ['pending', 'approved'])
                ->lockForUpdate()
                ->exists();

            if ($userBooking) {
                throw new \Exception(__('booking.already_booked_this_day'));
            }

            // Create booking
            $consultation = Consultation::create([
                'user_id' => auth()->id(),
                'scheduled_date' => $this->selectedDate,
                'scheduled_time' => $this->selectedTime,
                'duration' => 45,
                'status' => 'pending',
                'problem_summary' => $this->problemSummary,
            ]);

            // Send notifications
            auth()->user()->notify(new BookingSubmittedClient($consultation));

            // Notify admin
            $admin = User::where('user_type', 'admin')->first();
            $admin?->notify(new NewBookingAdmin($consultation));

            // Log action
            AdminLog::create([
                'admin_id' => null, // Client action
                'action_type' => 'create',
                'target_type' => 'consultation',
                'target_id' => $consultation->id,
                'new_values' => $consultation->toArray(),
                'ip_address' => request()->ip(),
            ]);
        });

        session()->flash('success', __('booking.submitted_successfully'));
        $this->redirect(route('client.consultations.index'));
    }
};

Blade Template

<div class="max-w-4xl mx-auto">
    <flux:heading>{{ __('booking.request_consultation') }}</flux:heading>

    @if(!$selectedDate || !$selectedTime)
        <!-- Step 1: Calendar Selection -->
        <div class="mt-6">
            <p class="mb-4">{{ __('booking.select_date_time') }}</p>
            <livewire:booking.availability-calendar
                @slot-selected="selectSlot($event.detail.date, $event.detail.time)"
            />
        </div>
    @else
        <!-- Step 2: Problem Summary -->
        <div class="mt-6">
            <!-- Selected Time Display -->
            <div class="bg-gold/10 p-4 rounded-lg mb-6">
                <div class="flex justify-between items-center">
                    <div>
                        <p class="font-semibold">{{ __('booking.selected_time') }}</p>
                        <p>{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</p>
                        <p>{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</p>
                    </div>
                    <flux:button size="sm" wire:click="clearSelection">
                        {{ __('common.change') }}
                    </flux:button>
                </div>
            </div>

            @if(!$showConfirmation)
                <!-- Problem Summary Form -->
                <flux:field>
                    <flux:label>{{ __('booking.problem_summary') }} *</flux:label>
                    <flux:textarea
                        wire:model="problemSummary"
                        rows="6"
                        placeholder="{{ __('booking.problem_summary_placeholder') }}"
                    />
                    <flux:description>
                        {{ __('booking.problem_summary_help') }}
                    </flux:description>
                    <flux:error name="problemSummary" />
                </flux:field>

                <flux:button
                    wire:click="showConfirm"
                    class="mt-4"
                    wire:loading.attr="disabled"
                >
                    <span wire:loading.remove>{{ __('booking.continue') }}</span>
                    <span wire:loading>{{ __('common.loading') }}</span>
                </flux:button>
            @else
                <!-- Confirmation Step -->
                <flux:callout>
                    <flux:heading size="sm">{{ __('booking.confirm_booking') }}</flux:heading>
                    <p>{{ __('booking.confirm_message') }}</p>

                    <div class="mt-4 space-y-2">
                        <p><strong>{{ __('booking.date') }}:</strong>
                            {{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</p>
                        <p><strong>{{ __('booking.time') }}:</strong>
                            {{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</p>
                        <p><strong>{{ __('booking.duration') }}:</strong> 45 {{ __('common.minutes') }}</p>
                    </div>

                    <div class="mt-4">
                        <p><strong>{{ __('booking.problem_summary') }}:</strong></p>
                        <p class="mt-1 text-sm">{{ $problemSummary }}</p>
                    </div>
                </flux:callout>

                <div class="flex gap-3 mt-4">
                    <flux:button wire:click="$set('showConfirmation', false)">
                        {{ __('common.back') }}
                    </flux:button>
                    <flux:button
                        wire:click="submit"
                        variant="primary"
                        wire:loading.attr="disabled"
                    >
                        <span wire:loading.remove>{{ __('booking.submit_request') }}</span>
                        <span wire:loading>{{ __('common.submitting') }}</span>
                    </flux:button>
                </div>
            @endif
        </div>
    @endif
</div>

1-Per-Day Validation Rule

// app/Rules/OneBookingPerDay.php
use Illuminate\Contracts\Validation\ValidationRule;

class OneBookingPerDay implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $exists = Consultation::where('user_id', auth()->id())
            ->where('scheduled_date', $value)
            ->whereIn('status', ['pending', 'approved'])
            ->exists();

        if ($exists) {
            $fail(__('booking.already_booked_this_day'));
        }
    }
}

Advanced Pattern: Race Condition Prevention

The submit() method uses DB::transaction() with lockForUpdate() to prevent race conditions. This is an advanced pattern required because:

  • Multiple clients could attempt to book the same slot simultaneously
  • Without locking, both requests could pass validation and create duplicate bookings

The lockForUpdate() acquires a row-level lock, ensuring only one transaction completes while others wait and then fail validation.

Cross-Story Task: Update Working Hours Pending Bookings Warning

Context: Story 3.1 (Working Hours Configuration) implemented a stubbed checkPendingBookings() method that returns an empty array. Now that the Consultation model exists, this method should be implemented to warn admins when changing working hours that affect pending bookings.

File to update: resources/views/livewire/admin/settings/working-hours.blade.php

Implementation:

private function checkPendingBookings(): array
{
    $affectedBookings = [];

    foreach ($this->schedule as $day => $config) {
        $original = WorkingHour::where('day_of_week', $day)->first();

        // Check if day is being disabled or hours reduced
        $isBeingDisabled = $original?->is_active && !$config['is_active'];
        $hoursReduced = $original && (
            $config['start_time'] > Carbon::parse($original->start_time)->format('H:i') ||
            $config['end_time'] < Carbon::parse($original->end_time)->format('H:i')
        );

        if ($isBeingDisabled || $hoursReduced) {
            $query = Consultation::query()
                ->where('status', 'pending')
                ->whereRaw('DAYOFWEEK(scheduled_date) = ?', [$day + 1]); // MySQL DAYOFWEEK is 1-indexed (1=Sunday)

            if ($hoursReduced && !$isBeingDisabled) {
                $query->where(function ($q) use ($config) {
                    $q->where('scheduled_time', '<', $config['start_time'])
                      ->orWhere('scheduled_time', '>=', $config['end_time']);
                });
            }

            $bookings = $query->get();
            $affectedBookings = array_merge($affectedBookings, $bookings->toArray());
        }
    }

    return $affectedBookings;
}

Also update the save() method to use the warning message when bookings are affected:

$warnings = $this->checkPendingBookings();
// ... save logic ...
$message = __('messages.working_hours_saved');
if (!empty($warnings)) {
    $message .= ' ' . __('messages.pending_bookings_warning', ['count' => count($warnings)]);
}
session()->flash('success', $message);

Test to add: tests/Feature/Admin/WorkingHoursTest.php

test('warning shown when disabling day with pending bookings', function () {
    // Create pending consultation for Monday
    $consultation = Consultation::factory()->create([
        'scheduled_date' => now()->next('Monday'),
        'status' => 'pending',
    ]);

    WorkingHour::factory()->create([
        'day_of_week' => 1, // Monday
        'is_active' => true,
    ]);

    $this->actingAs($this->admin);

    Volt::test('admin.settings.working-hours')
        ->set('schedule.1.is_active', false)
        ->call('save')
        ->assertSee(__('messages.pending_bookings_warning', ['count' => 1]));
});

Files to Create

File Purpose
resources/views/livewire/booking/request.blade.php Main Volt component for booking submission
app/Rules/OneBookingPerDay.php Custom validation rule for 1-per-day limit
app/Notifications/BookingSubmittedClient.php Email notification to client on submission
app/Notifications/NewBookingAdmin.php Email notification to admin for new booking

Notification Classes

Create notifications using artisan:

php artisan make:notification BookingSubmittedClient
php artisan make:notification NewBookingAdmin

Both notifications should:

  • Accept Consultation $consultation in constructor
  • Implement toMail() for email delivery
  • Use bilingual subjects based on user's preferred_language

Translation Keys Required

Add to lang/en/booking.php and lang/ar/booking.php:

// lang/en/booking.php
'request_consultation' => 'Request Consultation',
'select_date_time' => 'Select a date and time for your consultation',
'selected_time' => 'Selected Time',
'problem_summary' => 'Problem Summary',
'problem_summary_placeholder' => 'Please describe your legal issue or question in detail...',
'problem_summary_help' => 'Minimum 20 characters. This helps the lawyer prepare for your consultation.',
'continue' => 'Continue',
'confirm_booking' => 'Confirm Your Booking',
'confirm_message' => 'Please review your booking details before submitting.',
'date' => 'Date',
'time' => 'Time',
'duration' => 'Duration',
'submit_request' => 'Submit Request',
'submitted_successfully' => 'Your booking request has been submitted. You will receive an email confirmation shortly.',
'already_booked_this_day' => 'You already have a booking on this day.',
'slot_no_longer_available' => 'This time slot is no longer available. Please select another.',
'slot_taken' => 'This slot was just booked. Please select another time.',

Testing Requirements

Test File Location

tests/Feature/Booking/BookingSubmissionTest.php

Required Test Scenarios

// Happy path
test('authenticated client can submit booking request')
test('booking is created with pending status')
test('client receives confirmation notification')
test('admin receives new booking notification')

// Validation
test('guest cannot access booking form')
test('problem summary is required')
test('problem summary must be at least 20 characters')
test('selected date must be today or future')

// Business rules
test('client cannot book more than once per day')
test('client cannot book unavailable slot')
test('booking fails if slot taken during submission', function () {
    // Test race condition prevention
    // Create booking for same slot in parallel/before submission completes
})

// UI flow
test('confirmation step displays before final submission')
test('user can go back from confirmation to edit')
test('success message shown after submission')
test('redirects to consultations list after submission')

Definition of Done

  • Can select date from calendar
  • Can select time slot
  • Problem summary required
  • 1-per-day limit enforced
  • Race condition prevented
  • Confirmation step before submission
  • Booking created with "pending" status
  • Client notification sent
  • Admin notification sent
  • Bilingual support complete
  • Tests for submission flow
  • Code formatted with Pint
  • Cross-story: Working Hours checkPendingBookings() implemented (see Technical Notes)

Dependencies

  • Story 3.3: Availability calendar (docs/stories/story-3.3-availability-calendar-display.md)
    • Provides AvailabilityService for slot availability checking
    • Provides booking.availability-calendar Livewire component
  • Epic 2: User authentication (docs/epics/epic-2-user-management.md)
    • Client must be logged in to submit bookings
  • Epic 8: Email notifications (partial)
    • Notification infrastructure for sending emails

Risk Assessment

  • Primary Risk: Double-booking from concurrent submissions
  • Mitigation: Database transaction with row locking
  • Rollback: Return to calendar with error message

Estimation

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