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

26 KiB

Story 3.4: Booking Request Submission

Status

Draft

Epic Reference

Epic 3: Booking & Consultation System

Story

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

Acceptance Criteria

Booking Form (AC1-5)

  1. Client must be logged in
  2. Select date from availability calendar
  3. Select available time slot
  4. Problem summary field (required, textarea, min 20 characters)
  5. Confirmation before submission

Validation & Constraints (AC6-9)

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

Submission Flow (AC10-14)

  1. Booking enters "pending" status
  2. Client sees "Pending Review" confirmation
  3. Admin receives email notification
  4. Client receives submission confirmation email
  5. Redirect to consultations list after submission

UI/UX (AC15-18)

  1. Clear step-by-step flow
  2. Loading state during submission
  3. Success message with next steps
  4. Bilingual labels and messages

Quality Requirements (AC19-22)

  1. Prevent double-booking (race condition)
  2. Audit log entry for booking creation
  3. Tests for submission flow
  4. Tests for validation rules

Tasks / Subtasks

  • Task 1: Create Volt component file (AC: 1-5, 15-18)

    • Create resources/views/livewire/client/consultations/book.blade.php
    • Implement class-based Volt component with state properties
    • Add selectSlot(), clearSelection(), showConfirm(), submit() methods
    • Add validation rules for form fields
  • Task 2: Implement calendar integration (AC: 2, 3)

    • Embed availability-calendar component
    • Handle slot selection via $parent.selectSlot() pattern
    • Display selected date/time with change option
  • Task 3: Implement problem summary form (AC: 4, 8)

    • Add textarea with Flux UI components
    • Validate minimum 20 characters, maximum 2000 characters
    • Show validation errors with <flux:error>
  • Task 4: Implement 1-per-day validation (AC: 6, 9)

    • Create app/Rules/OneBookingPerDay.php validation rule
    • Check against booking_date with pending/approved status
    • Display clear error message when violated
  • Task 5: Implement slot availability check (AC: 7, 9)

    • Use AvailabilityService::getAvailableSlots() before confirmation
    • Display error if slot no longer available
    • Refresh calendar on error
  • Task 6: Implement confirmation step (AC: 5, 15)

    • Show booking summary before final submission
    • Display date, time, duration (45 min), problem summary
    • Add back button to edit
  • Task 7: Implement race condition prevention (AC: 19)

    • Use DB::transaction() with lockForUpdate() on slot check
    • Re-validate 1-per-day rule inside transaction
    • Throw exception if slot taken, catch and show error
  • Task 8: Create booking record (AC: 10)

    • Create Consultation with status ConsultationStatus::Pending
    • Set booking_date, booking_time, problem_summary, user_id
    • Leave consultation_type, payment_amount as null (admin sets later)
  • Task 9: Create email notifications (AC: 12, 13)

    • Create app/Mail/BookingSubmittedMail.php for client
    • Create app/Mail/NewBookingRequestMail.php for admin
    • Queue emails via SendBookingNotification job
    • Support bilingual content based on user's preferred_language
  • Task 10: Implement audit logging (AC: 20)

    • Create AdminLog entry on booking creation
    • Set admin_id to null (client action)
    • Set action to 'create', target_type to 'consultation'
  • Task 11: Implement success flow (AC: 11, 14, 17)

    • Flash success message to session
    • Redirect to route('client.consultations.index')
  • Task 12: Add route (AC: 1)

    • Add route in routes/web.php under client middleware group
    • Route: GET /client/consultations/book → Volt component
  • Task 13: Add translation keys (AC: 18)

    • Add keys to lang/en/booking.php
    • Add keys to lang/ar/booking.php
  • Task 14: Write tests (AC: 21, 22)

    • Create tests/Feature/Client/BookingSubmissionTest.php
    • Test happy path submission
    • Test validation rules
    • Test 1-per-day constraint
    • Test race condition handling
    • Test notifications sent
  • Task 15: Run Pint and verify

    • Run vendor/bin/pint --dirty
    • Verify all tests pass

Dev Notes

Relevant Source Tree

app/
├── Enums/
│   ├── ConsultationStatus.php    # Pending, Approved, Completed, Cancelled, NoShow
│   └── PaymentStatus.php         # Pending, Received, NotApplicable
├── Mail/
│   ├── BookingSubmittedMail.php  # TO CREATE
│   └── NewBookingRequestMail.php # TO CREATE
├── Models/
│   ├── Consultation.php          # EXISTS - booking_date, booking_time columns
│   ├── AdminLog.php              # EXISTS - action column (not action_type)
│   └── User.php
├── Rules/
│   └── OneBookingPerDay.php      # TO CREATE
├── Services/
│   └── AvailabilityService.php   # EXISTS - getAvailableSlots() method
└── Jobs/
    └── SendBookingNotification.php # TO CREATE (or use Mail directly)

resources/views/livewire/
├── availability-calendar.blade.php  # EXISTS - calendar component
└── client/
    └── consultations/
        └── book.blade.php           # TO CREATE - main booking form

lang/
├── en/booking.php                   # EXISTS - needs new keys added
└── ar/booking.php                   # EXISTS - needs new keys added

Consultation Model Fields (Actual)

// app/Models/Consultation.php - $fillable
'user_id',
'booking_date',      // NOT scheduled_date
'booking_time',      // NOT scheduled_time
'problem_summary',
'consultation_type', // null initially, admin sets later
'payment_amount',    // null initially
'payment_status',    // PaymentStatus::NotApplicable initially
'status',            // ConsultationStatus::Pending
'admin_notes',

Important: The model does NOT have a duration field. Duration (45 min) is a business constant, not stored per-consultation.

AdminLog Model Fields (Actual)

// app/Models/AdminLog.php - $fillable
'admin_id',      // null for client actions
'action',        // NOT action_type - values: 'create', 'update', 'delete'
'target_type',   // 'consultation', 'user', 'working_hours', etc.
'target_id',
'old_values',
'new_values',
'ip_address',
'created_at',

Database Record Creation

use App\Enums\ConsultationStatus;
use App\Enums\PaymentStatus;

$consultation = Consultation::create([
    'user_id' => auth()->id(),
    'booking_date' => $this->selectedDate,
    'booking_time' => $this->selectedTime,
    'problem_summary' => $this->problemSummary,
    'status' => ConsultationStatus::Pending,
    'payment_status' => PaymentStatus::NotApplicable,
    // consultation_type and payment_amount left null - admin sets later
]);

Volt Component Structure

<?php

use App\Enums\ConsultationStatus;
use App\Enums\PaymentStatus;
use App\Models\AdminLog;
use App\Models\Consultation;
use App\Models\User;
use App\Services\AvailabilityService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Component;

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::query()
            ->where('user_id', auth()->id())
            ->whereDate('booking_date', $this->selectedDate)
            ->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::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
    {
        try {
            DB::transaction(function () {
                // Check slot one more time with lock
                $slotTaken = Consultation::query()
                    ->whereDate('booking_date', $this->selectedDate)
                    ->where('booking_time', $this->selectedTime)
                    ->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
                    ->lockForUpdate()
                    ->exists();

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

                // Check 1-per-day again with lock
                $userHasBooking = Consultation::query()
                    ->where('user_id', auth()->id())
                    ->whereDate('booking_date', $this->selectedDate)
                    ->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
                    ->lockForUpdate()
                    ->exists();

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

                // Create booking
                $consultation = Consultation::create([
                    'user_id' => auth()->id(),
                    'booking_date' => $this->selectedDate,
                    'booking_time' => $this->selectedTime,
                    'problem_summary' => $this->problemSummary,
                    'status' => ConsultationStatus::Pending,
                    'payment_status' => PaymentStatus::NotApplicable,
                ]);

                // Send email to client
                Mail::to(auth()->user())->queue(
                    new \App\Mail\BookingSubmittedMail($consultation)
                );

                // Send email to admin
                $admin = User::query()->where('user_type', 'admin')->first();
                if ($admin) {
                    Mail::to($admin)->queue(
                        new \App\Mail\NewBookingRequestMail($consultation)
                    );
                }

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

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

        } catch (\Exception $e) {
            $this->addError('selectedTime', $e->getMessage());
            $this->showConfirmation = false;
        }
    }
}; ?>

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:availability-calendar />
        </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>

                @error('selectedTime')
                    <flux:callout variant="danger" class="mt-4">
                        {{ $message }}
                    </flux:callout>
                @enderror
            @endif
        </div>
    @endif
</div>

Calendar Integration Note

The availability-calendar component (from Story 3.3) uses the pattern $parent.selectSlot() to communicate slot selection back to the parent component. The parent booking component needs to have the selectSlot(string $date, string $time) method to receive this.

1-Per-Day Validation Rule

<?php
// app/Rules/OneBookingPerDay.php

namespace App\Rules;

use App\Enums\ConsultationStatus;
use App\Models\Consultation;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class OneBookingPerDay implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $exists = Consultation::query()
            ->where('user_id', auth()->id())
            ->whereDate('booking_date', $value)
            ->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
            ->exists();

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

Testing

Test File Location

tests/Feature/Client/BookingSubmissionTest.php

Required Test Scenarios

<?php

use App\Enums\ConsultationStatus;
use App\Models\Consultation;
use App\Models\User;
use App\Models\WorkingHour;
use Livewire\Volt\Volt;

beforeEach(function () {
    // Setup working hours for Monday
    WorkingHour::factory()->create([
        'day_of_week' => 1,
        'start_time' => '09:00',
        'end_time' => '17:00',
        'is_active' => true,
    ]);
});

// Happy path
test('authenticated client can submit booking request', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.')
        ->call('showConfirm')
        ->assertSet('showConfirmation', true)
        ->call('submit')
        ->assertRedirect(route('client.consultations.index'));

    expect(Consultation::where('user_id', $client->id)->exists())->toBeTrue();
});

test('booking is created with pending status', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', 'I need legal advice regarding a contract dispute.')
        ->call('showConfirm')
        ->call('submit');

    $consultation = Consultation::where('user_id', $client->id)->first();
    expect($consultation->status)->toBe(ConsultationStatus::Pending);
});

// Validation
test('guest cannot access booking form', function () {
    $this->get(route('client.consultations.book'))
        ->assertRedirect(route('login'));
});

test('problem summary is required', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', '')
        ->call('showConfirm')
        ->assertHasErrors(['problemSummary' => 'required']);
});

test('problem summary must be at least 20 characters', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', 'Too short')
        ->call('showConfirm')
        ->assertHasErrors(['problemSummary' => 'min']);
});

// Business rules
test('client cannot book more than once per day', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    // Create existing booking
    Consultation::factory()->pending()->create([
        'user_id' => $client->id,
        'booking_date' => $monday,
        'booking_time' => '09:00',
    ]);

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', 'I need legal advice regarding a contract dispute.')
        ->call('showConfirm')
        ->assertHasErrors(['selectedDate']);
});

test('client cannot book unavailable slot', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    // Create booking that takes the slot
    Consultation::factory()->approved()->create([
        'booking_date' => $monday,
        'booking_time' => '10:00',
    ]);

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', 'I need legal advice regarding a contract dispute.')
        ->call('showConfirm')
        ->assertHasErrors(['selectedTime']);
});

// UI flow
test('confirmation step displays before final submission', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', 'I need legal advice regarding a contract dispute.')
        ->assertSet('showConfirmation', false)
        ->call('showConfirm')
        ->assertSet('showConfirmation', true);
});

test('user can go back from confirmation to edit', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', 'I need legal advice regarding a contract dispute.')
        ->call('showConfirm')
        ->assertSet('showConfirmation', true)
        ->set('showConfirmation', false)
        ->assertSet('showConfirmation', false);
});

test('success message shown after submission', function () {
    $client = User::factory()->individual()->create();
    $monday = now()->next('Monday')->format('Y-m-d');

    Volt::test('client.consultations.book')
        ->actingAs($client)
        ->call('selectSlot', $monday, '10:00')
        ->set('problemSummary', 'I need legal advice regarding a contract dispute.')
        ->call('showConfirm')
        ->call('submit')
        ->assertSessionHas('success');
});

Files to Create

File Purpose
resources/views/livewire/client/consultations/book.blade.php Main Volt component for booking submission
app/Rules/OneBookingPerDay.php Custom validation rule for 1-per-day limit
app/Mail/BookingSubmittedMail.php Email to client on submission
app/Mail/NewBookingRequestMail.php Email to admin for new booking
tests/Feature/Client/BookingSubmissionTest.php Feature tests for booking flow

Mail Classes

Create mail classes using artisan:

php artisan make:mail BookingSubmittedMail
php artisan make:mail NewBookingRequestMail

Both mail classes should:

  • Accept Consultation $consultation in constructor
  • Use bilingual subjects based on recipient's preferred_language
  • Create corresponding email views in resources/views/emails/

Translation Keys Required

Add to 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.',

Add corresponding Arabic translations to lang/ar/booking.php.

Definition of Done

  • Volt component created at correct path
  • Can select date from calendar
  • Can select time slot
  • Problem summary required (min 20 chars)
  • 1-per-day limit enforced
  • Race condition prevented with DB locking
  • Confirmation step before submission
  • Booking created with "pending" status
  • Client email sent
  • Admin email sent
  • Audit log entry created
  • Bilingual support complete
  • All tests passing
  • Code formatted with Pint

Dependencies

  • Story 3.3: Availability calendar (COMPLETED)
    • Provides AvailabilityService for slot availability checking
    • Provides availability-calendar Livewire component
  • Epic 2: User authentication (COMPLETED)
    • Client must be logged in to submit bookings
  • Epic 8: Email notifications (partial - mail classes needed)

Risk Assessment

Risk Impact Mitigation
Double-booking race condition High DB transaction with lockForUpdate()
Email delivery failure Medium Use queue with retries
Timezone confusion Low Use whereDate() for date comparison

Estimation

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


Change Log

Date Version Description Author
2025-12-26 1.0 Initial draft -
2025-12-26 1.1 Fixed column names (booking_date/booking_time), removed hallucinated duration field, fixed AdminLog column, removed invalid cross-story task, added Tasks/Subtasks section, aligned with source tree architecture QA Validation