733 lines
26 KiB
Markdown
733 lines
26 KiB
Markdown
# 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)
|
|
6. Validate: no more than 1 booking per day for this client
|
|
7. Validate: selected slot is still available
|
|
8. Validate: problem summary is not empty
|
|
9. Show clear error messages for violations
|
|
|
|
### Submission Flow (AC10-14)
|
|
10. Booking enters "pending" status
|
|
11. Client sees "Pending Review" confirmation
|
|
12. Admin receives email notification
|
|
13. Client receives submission confirmation email
|
|
14. Redirect to consultations list after submission
|
|
|
|
### UI/UX (AC15-18)
|
|
15. Clear step-by-step flow
|
|
16. Loading state during submission
|
|
17. Success message with next steps
|
|
18. Bilingual labels and messages
|
|
|
|
### Quality Requirements (AC19-22)
|
|
19. Prevent double-booking (race condition)
|
|
20. Audit log entry for booking creation
|
|
21. Tests for submission flow
|
|
22. 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)
|
|
```php
|
|
// 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)
|
|
```php
|
|
// 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
|
|
```php
|
|
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
|
|
<?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
|
|
```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: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
|
|
<?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
|
|
<?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:
|
|
```bash
|
|
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`:
|
|
```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 |
|