11 KiB
11 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, notifications
- Technology: Livewire Volt, form validation
- Follows pattern: Form submission with confirmation
- Touch points: Client dashboard, admin notifications
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
// Custom validation rule
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'));
}
}
}
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
- Epic 2: User authentication
- Epic 8: Email notifications (partial)
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