510 lines
18 KiB
Markdown
510 lines
18 KiB
Markdown
# 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
|
|
```php
|
|
// 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
|
|
<?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
|
|
```blade
|
|
<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
|
|
```php
|
|
// 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:**
|
|
```php
|
|
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:
|
|
```php
|
|
$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`
|
|
```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:
|
|
```bash
|
|
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`:
|
|
|
|
```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
|
|
|
|
```php
|
|
// 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
|