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

15 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.

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

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