generated stories for the guest booking system

This commit is contained in:
Naser Mansour 2026-01-03 04:17:19 +02:00
parent d69026a8e1
commit bd27a3a876
5 changed files with 1697 additions and 0 deletions

View File

@ -0,0 +1,118 @@
# Epic 11: Guest Booking
## Epic Goal
Enable website visitors to request consultation bookings without requiring a user account, expanding accessibility while maintaining system integrity and admin control over the booking approval process.
## Epic Description
### Existing System Context
- **Current functionality:** Booking system fully implemented for authenticated clients at `/client/consultations/book`
- **Technology stack:** Laravel 12, Livewire 3/Volt, Flux UI, existing AvailabilityService
- **Integration points:** Consultation model, email notifications, admin booking review workflow
- **Current constraint:** `consultations.user_id` is a required foreign key to `users` table
### Enhancement Details
**What's being added:**
- Public booking form at `/booking` for unauthenticated visitors
- Guest contact information capture (name, email, phone)
- Custom captcha system (no third-party services)
- 1-per-day booking limit for guests (enforced by email)
- Guest-specific email notifications
- Admin visibility of guest bookings in existing workflow
**How it integrates:**
- Reuses existing `AvailabilityService` and `availability-calendar` component
- Extends `Consultation` model to support nullable `user_id` with guest fields
- Fits into existing admin booking review workflow
- Uses existing email infrastructure
**Success criteria:**
- Guests can submit booking requests from public `/booking` page
- Guests limited to 1 booking request per day (by email)
- Custom captcha prevents automated spam
- Admin sees guest bookings in pending queue with contact info
- Guests receive email confirmations
- Existing client booking flow unchanged
---
## Stories
### Story 11.1: Database Schema & Model Updates
Extend consultation system to support guest bookings with new database fields and model logic.
### Story 11.2: Public Booking Form with Custom Captcha
Create guest booking interface at `/booking` with availability calendar, contact form, custom captcha, and 1-per-day limit.
### Story 11.3: Guest Email Notifications & Admin Integration
Implement guest email notifications and update admin interface to handle guest bookings.
### Story 11.4: Documentation Updates
Update PRD and other documentation files to reflect guest booking functionality.
---
## Compatibility Requirements
- [x] Existing client booking flow remains unchanged
- [x] Existing APIs remain unchanged
- [x] Database schema changes are backward compatible (nullable field, new columns)
- [x] UI changes follow existing patterns (Flux UI, Volt components)
- [x] Admin workflow enhanced, not replaced
## Technical Considerations
### Database Changes
```
consultations table:
- user_id: bigint -> bigint NULLABLE
- guest_name: varchar(255) NULLABLE
- guest_email: varchar(255) NULLABLE
- guest_phone: varchar(50) NULLABLE
```
### Validation Rules
- Either `user_id` OR (`guest_name` + `guest_email` + `guest_phone`) required
- Guest email format validation
- Guest phone format validation
- 1 booking request per guest email per day
### Custom Captcha System
- Simple math-based or image-based captcha
- No external services (no Google reCAPTCHA, no Cloudflare Turnstile)
- Server-side validation with session-stored answer
- Accessible design with refresh option
### Spam Protection
- **1-per-day limit:** Maximum 1 booking request per email address per 24 hours
- **Rate limit:** 5 booking requests per IP per 24 hours (backup protection)
- **Custom captcha:** Required for all guest submissions
### Email Templates
- Reuse existing email layout/branding
- Guest emails use provided email address
- Include all booking details + contact instructions
---
## Risk Mitigation
- **Primary Risk:** Spam/abuse from anonymous submissions
- **Mitigation:** Custom captcha + 1-per-day email limit + IP rate limiting + admin approval required
- **Rollback Plan:** Revert migration, restore placeholder page
## Definition of Done
- [ ] All stories completed with acceptance criteria met
- [ ] Existing client booking tests still pass
- [ ] New tests cover guest booking scenarios
- [ ] Admin can manage guest bookings through existing interface
- [ ] Guest receives appropriate email notifications
- [ ] Custom captcha working correctly
- [ ] 1-per-day limit enforced
- [ ] No regression in existing features
- [ ] Bilingual support (Arabic/English) for guest form and emails
- [ ] PRD and documentation updated

View File

@ -0,0 +1,241 @@
# Story 11.1: Database Schema & Model Updates
## Epic Reference
**Epic 11:** Guest Booking
## Story Context
This is the **foundational story** for Epic 11. All subsequent guest booking stories depend on these database and model changes being complete. This story prepares the data layer to support consultations without a linked user account.
## User Story
As a **developer**,
I want **to extend the consultation system to support guest bookings**,
So that **consultations can be created for both registered clients and anonymous guests**.
## Acceptance Criteria
### Database Migration
- [ ] `user_id` column changed to nullable on `consultations` table
- [ ] `guest_name` column added (varchar 255, nullable)
- [ ] `guest_email` column added (varchar 255, nullable)
- [ ] `guest_phone` column added (varchar 50, nullable)
- [ ] Index added on `guest_email` for 1-per-day lookup performance
- [ ] Migration is reversible without data loss
- [ ] Existing consultations with `user_id` unaffected
### Model Updates
- [ ] `Consultation` model updated with new fillable fields
- [ ] `isGuest()` helper method returns true when `user_id` is null
- [ ] `getClientName()` helper returns guest_name or user->name
- [ ] `getClientEmail()` helper returns guest_email or user->email
- [ ] `getClientPhone()` helper returns guest_phone or user->phone
- [ ] Model validation ensures either user_id OR guest fields are present
### Factory Updates
- [ ] `ConsultationFactory` updated with `guest()` state
- [ ] Guest state generates fake guest_name, guest_email, guest_phone
- [ ] Guest state sets user_id to null
## Implementation Steps
### Step 1: Create Migration
```bash
php artisan make:migration add_guest_fields_to_consultations_table
```
Migration content:
```php
public function up(): void
{
Schema::table('consultations', function (Blueprint $table) {
// Make user_id nullable
$table->foreignId('user_id')->nullable()->change();
// Add guest fields
$table->string('guest_name', 255)->nullable()->after('user_id');
$table->string('guest_email', 255)->nullable()->after('guest_name');
$table->string('guest_phone', 50)->nullable()->after('guest_email');
// Index for 1-per-day lookup
$table->index('guest_email');
});
}
public function down(): void
{
Schema::table('consultations', function (Blueprint $table) {
$table->dropIndex(['guest_email']);
$table->dropColumn(['guest_name', 'guest_email', 'guest_phone']);
// Note: Cannot safely restore NOT NULL if guest records exist
});
}
```
### Step 2: Update Consultation Model
Add to `app/Models/Consultation.php`:
```php
// Add to fillable array
protected $fillable = [
// ... existing fields
'guest_name',
'guest_email',
'guest_phone',
];
/**
* Check if this is a guest consultation (no linked user).
*/
public function isGuest(): bool
{
return is_null($this->user_id);
}
/**
* Get the client's display name.
*/
public function getClientName(): string
{
return $this->isGuest()
? $this->guest_name
: $this->user->name;
}
/**
* Get the client's email address.
*/
public function getClientEmail(): string
{
return $this->isGuest()
? $this->guest_email
: $this->user->email;
}
/**
* Get the client's phone number.
*/
public function getClientPhone(): ?string
{
return $this->isGuest()
? $this->guest_phone
: $this->user->phone;
}
```
### Step 3: Update Consultation Factory
Add to `database/factories/ConsultationFactory.php`:
```php
/**
* Create a guest consultation (no user account).
*/
public function guest(): static
{
return $this->state(fn (array $attributes) => [
'user_id' => null,
'guest_name' => fake()->name(),
'guest_email' => fake()->unique()->safeEmail(),
'guest_phone' => fake()->phoneNumber(),
]);
}
```
### Step 4: Add Model Validation (Optional Boot Method)
Consider adding validation in model boot to ensure data integrity:
```php
protected static function booted(): void
{
static::saving(function (Consultation $consultation) {
// Either user_id or guest fields must be present
if (is_null($consultation->user_id)) {
if (empty($consultation->guest_name) ||
empty($consultation->guest_email) ||
empty($consultation->guest_phone)) {
throw new \InvalidArgumentException(
'Guest consultations require guest_name, guest_email, and guest_phone'
);
}
}
});
}
```
## Technical Notes
### Foreign Key Consideration
The existing foreign key `consultations_user_id_foreign` cascades on delete. With nullable user_id:
- Guest consultations won't be affected by user deletions (they have no user)
- Client consultations still cascade delete when user is deleted
### Query Scopes
Consider adding scopes for filtering:
```php
public function scopeGuests(Builder $query): Builder
{
return $query->whereNull('user_id');
}
public function scopeClients(Builder $query): Builder
{
return $query->whereNotNull('user_id');
}
```
## Testing Requirements
### Unit Tests
```php
test('consultation can be created as guest', function () {
$consultation = Consultation::factory()->guest()->create();
expect($consultation->isGuest())->toBeTrue();
expect($consultation->user_id)->toBeNull();
expect($consultation->guest_name)->not->toBeNull();
expect($consultation->guest_email)->not->toBeNull();
expect($consultation->guest_phone)->not->toBeNull();
});
test('consultation can be created for client', function () {
$user = User::factory()->client()->create();
$consultation = Consultation::factory()->create(['user_id' => $user->id]);
expect($consultation->isGuest())->toBeFalse();
expect($consultation->user_id)->toBe($user->id);
});
test('getClientName returns guest name for guest consultation', function () {
$consultation = Consultation::factory()->guest()->create([
'guest_name' => 'John Doe',
]);
expect($consultation->getClientName())->toBe('John Doe');
});
test('getClientName returns user name for client consultation', function () {
$user = User::factory()->client()->create(['name' => 'Jane Smith']);
$consultation = Consultation::factory()->create(['user_id' => $user->id]);
expect($consultation->getClientName())->toBe('Jane Smith');
});
test('existing consultations are not affected by migration', function () {
// Verify existing consultations still work
$user = User::factory()->client()->create();
$consultation = Consultation::factory()->create(['user_id' => $user->id]);
expect($consultation->user)->toBeInstanceOf(User::class);
expect($consultation->isGuest())->toBeFalse();
});
```
## Dependencies
- None (foundational story)
## Definition of Done
- [ ] Migration created and runs successfully
- [ ] Migration is reversible
- [ ] Consultation model updated with guest fields and helpers
- [ ] Factory updated with guest state
- [ ] All existing consultation tests still pass
- [ ] New unit tests for guest functionality pass
- [ ] Code follows project patterns (Pint formatted)

View File

@ -0,0 +1,612 @@
# Story 11.2: Public Booking Form with Custom Captcha
## Epic Reference
**Epic 11:** Guest Booking
## Story Context
This story implements the main guest-facing booking interface at `/booking`. It replaces the placeholder page with a functional booking form that includes the availability calendar, guest contact fields, custom captcha for spam protection, and 1-per-day limit enforcement.
## User Story
As a **website visitor**,
I want **to request a consultation without creating an account**,
So that **I can easily reach out to the lawyer for legal assistance**.
## Acceptance Criteria
### Public Booking Page
- [ ] `/booking` route displays guest booking form for unauthenticated visitors
- [ ] Logged-in users redirected to `/client/consultations/book`
- [ ] Page uses public layout (`x-layouts.public`)
- [ ] Bilingual support (Arabic/English)
- [ ] Mobile responsive design
### Availability Calendar Integration
- [ ] Reuses existing `availability-calendar` Livewire component
- [ ] Shows available time slots (same as client view)
- [ ] Blocked/booked times not selectable
### Guest Contact Form
- [ ] Full name field (required, min 3 chars, max 255)
- [ ] Email field (required, valid email format)
- [ ] Phone field (required, valid format)
- [ ] Problem summary field (required, min 20 chars, max 2000)
- [ ] All fields have clear labels and validation messages
- [ ] Form follows Flux UI patterns
### Custom Captcha System
- [ ] Math-based captcha (e.g., "What is 7 + 3?")
- [ ] Question generated server-side, answer stored in session
- [ ] User must enter correct answer to submit
- [ ] Refresh button to get new captcha question
- [ ] Captcha validates before form submission
- [ ] Bilingual captcha labels
### 1-Per-Day Limit
- [ ] Check if email has pending/approved booking for selected date
- [ ] If limit reached, show error message and prevent submission
- [ ] Clear error message explaining the limit
- [ ] Limit applies per email address per calendar day
### Rate Limiting (Backup)
- [ ] Maximum 5 booking attempts per IP per 24 hours
- [ ] Rate limit error shown if exceeded
### Submission Flow
- [ ] Step 1: Select date/time from calendar
- [ ] Step 2: Fill contact info + problem summary + captcha
- [ ] Step 3: Confirmation screen showing all details
- [ ] Step 4: Submit creates guest consultation (status: pending)
- [ ] Success message with instructions to check email
## Implementation Steps
### Step 1: Create Captcha Service
Create `app/Services/CaptchaService.php`:
```php
<?php
namespace App\Services;
class CaptchaService
{
private const SESSION_KEY = 'captcha_answer';
/**
* Generate a new math captcha question.
* @return array{question: string, question_ar: string}
*/
public function generate(): array
{
$num1 = rand(1, 10);
$num2 = rand(1, 10);
$answer = $num1 + $num2;
session([self::SESSION_KEY => $answer]);
return [
'question' => "What is {$num1} + {$num2}?",
'question_ar' => "ما هو {$num1} + {$num2}؟",
];
}
/**
* Validate the user's captcha answer.
*/
public function validate(mixed $answer): bool
{
$expected = session(self::SESSION_KEY);
if (is_null($expected)) {
return false;
}
return (int) $answer === (int) $expected;
}
/**
* Clear the current captcha from session.
*/
public function clear(): void
{
session()->forget(self::SESSION_KEY);
}
}
```
### Step 2: Create Guest Booking Volt Component
Create `resources/views/livewire/pages/booking.php`:
```php
<?php
use App\Enums\ConsultationStatus;
use App\Enums\PaymentStatus;
use App\Mail\GuestBookingSubmittedMail;
use App\Mail\NewBookingAdminEmail;
use App\Models\Consultation;
use App\Models\User;
use App\Services\AvailabilityService;
use App\Services\CaptchaService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Volt\Component;
new class extends Component
{
public ?string $selectedDate = null;
public ?string $selectedTime = null;
public string $guestName = '';
public string $guestEmail = '';
public string $guestPhone = '';
public string $problemSummary = '';
public string $captchaAnswer = '';
public array $captchaQuestion = [];
public bool $showConfirmation = false;
public function mount(): void
{
// Redirect logged-in users to client booking
if (auth()->check()) {
$this->redirect(route('client.consultations.book'));
return;
}
$this->refreshCaptcha();
}
public function refreshCaptcha(): void
{
$this->captchaQuestion = app(CaptchaService::class)->generate();
$this->captchaAnswer = '';
}
public function selectSlot(string $date, string $time): void
{
$this->selectedDate = $date;
$this->selectedTime = $time;
}
public function clearSelection(): void
{
$this->selectedDate = null;
$this->selectedTime = null;
$this->showConfirmation = false;
}
public function showConfirm(): void
{
$this->validate([
'selectedDate' => ['required', 'date', 'after_or_equal:today'],
'selectedTime' => ['required'],
'guestName' => ['required', 'string', 'min:3', 'max:255'],
'guestEmail' => ['required', 'email', 'max:255'],
'guestPhone' => ['required', 'string', 'max:50'],
'problemSummary' => ['required', 'string', 'min:20', 'max:2000'],
'captchaAnswer' => ['required'],
]);
// Validate captcha
if (!app(CaptchaService::class)->validate($this->captchaAnswer)) {
$this->addError('captchaAnswer', __('booking.invalid_captcha'));
$this->refreshCaptcha();
return;
}
// Check 1-per-day limit for this email
$existingBooking = Consultation::query()
->where('guest_email', $this->guestEmail)
->whereDate('booking_date', $this->selectedDate)
->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
->exists();
if ($existingBooking) {
$this->addError('guestEmail', __('booking.guest_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
{
// Rate limiting by IP
$ipKey = 'guest-booking:' . request()->ip();
if (RateLimiter::tooManyAttempts($ipKey, 5)) {
$this->addError('guestEmail', __('booking.too_many_attempts'));
return;
}
RateLimiter::hit($ipKey, 60 * 60 * 24); // 24 hours
try {
DB::transaction(function () {
// Double-check slot availability 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'));
}
// Double-check 1-per-day with lock
$emailHasBooking = Consultation::query()
->where('guest_email', $this->guestEmail)
->whereDate('booking_date', $this->selectedDate)
->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
->lockForUpdate()
->exists();
if ($emailHasBooking) {
throw new \Exception(__('booking.guest_already_booked_this_day'));
}
// Create guest consultation
$consultation = Consultation::create([
'user_id' => null,
'guest_name' => $this->guestName,
'guest_email' => $this->guestEmail,
'guest_phone' => $this->guestPhone,
'booking_date' => $this->selectedDate,
'booking_time' => $this->selectedTime,
'problem_summary' => $this->problemSummary,
'status' => ConsultationStatus::Pending,
'payment_status' => PaymentStatus::NotApplicable,
]);
// Send confirmation to guest
Mail::to($this->guestEmail)->queue(
new GuestBookingSubmittedMail($consultation)
);
// Notify admin
$admin = User::query()->where('user_type', 'admin')->first();
if ($admin) {
Mail::to($admin)->queue(
new NewBookingAdminEmail($consultation)
);
}
});
// Clear captcha
app(CaptchaService::class)->clear();
session()->flash('success', __('booking.guest_submitted_successfully'));
$this->redirect(route('booking.success'));
} catch (\Exception $e) {
$this->addError('selectedTime', $e->getMessage());
$this->showConfirmation = false;
$this->refreshCaptcha();
}
}
}; ?>
<x-layouts.public>
<div class="max-w-4xl mx-auto py-8 px-4">
<flux:heading size="xl" class="mb-6">
{{ __('booking.request_consultation') }}
</flux:heading>
@if(session('success'))
<flux:callout variant="success" class="mb-6">
{{ session('success') }}
</flux:callout>
@endif
@if(!$selectedDate || !$selectedTime)
{{-- Step 1: Calendar Selection --}}
<flux:callout class="mb-6">
<p>{{ __('booking.guest_intro') }}</p>
</flux:callout>
<p class="mb-4 text-zinc-600 dark:text-zinc-400">
{{ __('booking.select_date_time') }}
</p>
<livewire:availability-calendar />
@else
{{-- Step 2+: Contact Form & Confirmation --}}
<div class="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg mb-6 border border-amber-200 dark:border-amber-800">
<div class="flex justify-between items-center">
<div>
<p class="font-semibold text-zinc-900 dark:text-zinc-100">
{{ __('booking.selected_time') }}
</p>
<p class="text-zinc-600 dark:text-zinc-400">
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}
</p>
<p class="text-zinc-600 dark:text-zinc-400">
{{ \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)
{{-- Contact Form --}}
<div class="space-y-4">
<flux:field>
<flux:label class="required">{{ __('booking.guest_name') }}</flux:label>
<flux:input wire:model="guestName" type="text" />
<flux:error name="guestName" />
</flux:field>
<flux:field>
<flux:label class="required">{{ __('booking.guest_email') }}</flux:label>
<flux:input wire:model="guestEmail" type="email" />
<flux:error name="guestEmail" />
</flux:field>
<flux:field>
<flux:label class="required">{{ __('booking.guest_phone') }}</flux:label>
<flux:input wire:model="guestPhone" type="tel" />
<flux:error name="guestPhone" />
</flux:field>
<flux:field>
<flux:label class="required">{{ __('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>
{{-- Custom Captcha --}}
<flux:field>
<flux:label class="required">
{{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }}
</flux:label>
<div class="flex gap-2">
<flux:input wire:model="captchaAnswer" type="text" class="w-32" />
<flux:button size="sm" wire:click="refreshCaptcha" type="button">
<flux:icon name="arrow-path" class="w-4 h-4" />
</flux:button>
</div>
<flux:error name="captchaAnswer" />
</flux:field>
<flux:button
wire:click="showConfirm"
class="w-full sm:w-auto min-h-[44px]"
wire:loading.attr="disabled"
>
<span wire:loading.remove wire:target="showConfirm">{{ __('booking.continue') }}</span>
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
</flux:button>
</div>
@else
{{-- Confirmation Step --}}
<flux:callout>
<flux:heading size="sm">{{ __('booking.confirm_booking') }}</flux:heading>
<p class="text-zinc-600 dark:text-zinc-400">{{ __('booking.confirm_message') }}</p>
<div class="mt-4 space-y-2">
<p><strong>{{ __('booking.guest_name') }}:</strong> {{ $guestName }}</p>
<p><strong>{{ __('booking.guest_email') }}:</strong> {{ $guestEmail }}</p>
<p><strong>{{ __('booking.guest_phone') }}:</strong> {{ $guestPhone }}</p>
<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 text-zinc-600 dark:text-zinc-400">{{ $problemSummary }}</p>
</div>
</flux:callout>
<div class="flex flex-col sm:flex-row gap-3 mt-4">
<flux:button wire:click="$set('showConfirmation', false)" class="w-full sm:w-auto min-h-[44px]">
{{ __('common.back') }}
</flux:button>
<flux:button
wire:click="submit"
variant="primary"
wire:loading.attr="disabled"
class="w-full sm:w-auto min-h-[44px]"
>
<span wire:loading.remove wire:target="submit">{{ __('booking.submit_request') }}</span>
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
</flux:button>
</div>
@error('selectedTime')
<flux:callout variant="danger" class="mt-4">
{{ $message }}
</flux:callout>
@enderror
@endif
@endif
</div>
</x-layouts.public>
```
### Step 3: Update Routes
Update `routes/web.php`:
```php
// Replace placeholder route with Volt component
Volt::route('/booking', 'pages.booking')->name('booking');
Volt::route('/booking/success', 'pages.booking-success')->name('booking.success');
```
### Step 4: Create Success Page
Create `resources/views/livewire/pages/booking-success.php`:
```php
<?php
use Livewire\Volt\Component;
new class extends Component {
//
}; ?>
<x-layouts.public>
<div class="max-w-2xl mx-auto py-16 px-4 text-center">
<flux:icon name="check-circle" class="w-16 h-16 mx-auto text-green-500 mb-6" />
<flux:heading size="xl" class="mb-4">
{{ __('booking.success_title') }}
</flux:heading>
<p class="text-zinc-600 dark:text-zinc-400 mb-6">
{{ __('booking.success_message') }}
</p>
<flux:button href="{{ route('home') }}">
{{ __('navigation.home') }}
</flux:button>
</div>
</x-layouts.public>
```
### Step 5: Add Translation Keys
Add to `lang/en/booking.php`:
```php
'guest_intro' => 'Request a consultation appointment. No account required - simply fill in your details below.',
'guest_name' => 'Full Name',
'guest_email' => 'Email Address',
'guest_phone' => 'Phone Number',
'guest_already_booked_this_day' => 'This email already has a booking request for the selected date. Please choose a different date.',
'guest_submitted_successfully' => 'Your booking request has been submitted. Please check your email for confirmation.',
'invalid_captcha' => 'Incorrect answer. Please try again.',
'too_many_attempts' => 'Too many booking attempts. Please try again later.',
'success_title' => 'Booking Request Submitted!',
'success_message' => 'We have received your consultation request. You will receive an email confirmation shortly. Our team will review your request and contact you.',
```
Add to `lang/ar/booking.php`:
```php
'guest_intro' => 'اطلب موعد استشارة. لا حاجة لإنشاء حساب - ما عليك سوى ملء بياناتك أدناه.',
'guest_name' => 'الاسم الكامل',
'guest_email' => 'البريد الإلكتروني',
'guest_phone' => 'رقم الهاتف',
'guest_already_booked_this_day' => 'هذا البريد الإلكتروني لديه طلب حجز بالفعل للتاريخ المحدد. يرجى اختيار تاريخ آخر.',
'guest_submitted_successfully' => 'تم تقديم طلب الحجز الخاص بك. يرجى التحقق من بريدك الإلكتروني للتأكيد.',
'invalid_captcha' => 'إجابة خاطئة. يرجى المحاولة مرة أخرى.',
'too_many_attempts' => 'محاولات حجز كثيرة جداً. يرجى المحاولة لاحقاً.',
'success_title' => 'تم تقديم طلب الحجز!',
'success_message' => 'لقد تلقينا طلب الاستشارة الخاص بك. ستتلقى رسالة تأكيد عبر البريد الإلكتروني قريباً. سيقوم فريقنا بمراجعة طلبك والتواصل معك.',
```
## Testing Requirements
### Feature Tests
```php
test('guest can view booking page', function () {
$this->get(route('booking'))
->assertOk()
->assertSee(__('booking.request_consultation'));
});
test('logged in user is redirected to client booking', function () {
$user = User::factory()->client()->create();
$this->actingAs($user)
->get(route('booking'))
->assertRedirect(route('client.consultations.book'));
});
test('guest can submit booking request', function () {
// Setup working hours and available slot
WorkingHour::factory()->create([
'day_of_week' => now()->addDay()->dayOfWeek,
'is_active' => true,
'start_time' => '09:00',
'end_time' => '17:00',
]);
$date = now()->addDay()->format('Y-m-d');
Volt::test('pages.booking')
->call('selectSlot', $date, '09:00')
->set('guestName', 'John Doe')
->set('guestEmail', 'john@example.com')
->set('guestPhone', '+970599123456')
->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.')
->set('captchaAnswer', session('captcha_answer'))
->call('showConfirm')
->assertSet('showConfirmation', true)
->call('submit')
->assertRedirect(route('booking.success'));
$this->assertDatabaseHas('consultations', [
'guest_email' => 'john@example.com',
'guest_name' => 'John Doe',
'user_id' => null,
]);
});
test('guest cannot book twice on same day', function () {
$date = now()->addDay()->format('Y-m-d');
// Create existing booking for this email
Consultation::factory()->guest()->create([
'guest_email' => 'john@example.com',
'booking_date' => $date,
'status' => ConsultationStatus::Pending,
]);
Volt::test('pages.booking')
->call('selectSlot', $date, '10:00')
->set('guestName', 'John Doe')
->set('guestEmail', 'john@example.com')
->set('guestPhone', '+970599123456')
->set('problemSummary', 'Another consultation request for testing purposes.')
->set('captchaAnswer', session('captcha_answer'))
->call('showConfirm')
->assertHasErrors(['guestEmail']);
});
test('invalid captcha prevents submission', function () {
Volt::test('pages.booking')
->call('selectSlot', now()->addDay()->format('Y-m-d'), '09:00')
->set('guestName', 'John Doe')
->set('guestEmail', 'john@example.com')
->set('guestPhone', '+970599123456')
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
->set('captchaAnswer', 'wrong-answer')
->call('showConfirm')
->assertHasErrors(['captchaAnswer']);
});
```
## Dependencies
- Story 11.1 (Database Schema & Model Updates)
## Definition of Done
- [ ] Guest booking form functional at `/booking`
- [ ] Logged-in users redirected to client booking
- [ ] Availability calendar shows correct slots
- [ ] Contact form validates all fields
- [ ] Custom captcha prevents automated submissions
- [ ] 1-per-day limit enforced by email
- [ ] IP rate limiting working
- [ ] Success page displays after submission
- [ ] All translations in place (Arabic/English)
- [ ] Mobile responsive
- [ ] All tests pass

View File

@ -0,0 +1,539 @@
# Story 11.3: Guest Email Notifications & Admin Integration
## Epic Reference
**Epic 11:** Guest Booking
## Story Context
This story completes the guest booking workflow by implementing email notifications for guests and updating the admin interface to properly display and manage guest bookings alongside client bookings.
## User Story
As a **guest** who submitted a booking,
I want **to receive email confirmations about my booking status**,
So that **I know my request was received and can track its progress**.
As an **admin**,
I want **to see guest contact information when reviewing bookings**,
So that **I can contact them and manage their appointments**.
## Acceptance Criteria
### Guest Email Notifications
- [ ] Guest receives confirmation email when booking submitted
- [ ] Guest receives approval email when booking approved (with date/time details)
- [ ] Guest receives rejection email when booking rejected
- [ ] All emails use existing email template/branding
- [ ] Emails sent to guest_email address
- [ ] Bilingual support based on site locale at submission time
### Admin Pending Bookings View
- [ ] Guest bookings appear in pending list alongside client bookings
- [ ] Guest bookings show "Guest" badge/indicator
- [ ] Guest name, email, phone displayed in list
- [ ] Click through to booking review shows full guest details
### Admin Booking Review Page
- [ ] Guest contact info displayed prominently
- [ ] Guest name shown instead of user name
- [ ] Guest email shown with mailto link
- [ ] Guest phone shown with tel link
- [ ] Approve/reject workflow works for guest bookings
- [ ] Email notifications sent to guest on status change
### Existing Admin Email
- [ ] `NewBookingAdminEmail` updated to handle guest bookings
- [ ] Admin email shows guest contact info for guest bookings
- [ ] Admin email shows client info for client bookings
## Implementation Steps
### Step 1: Create Guest Booking Submitted Email
Create `app/Mail/GuestBookingSubmittedMail.php`:
```php
<?php
namespace App\Mail;
use App\Models\Consultation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class GuestBookingSubmittedMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Consultation $consultation
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __('emails.guest_booking_submitted_subject'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.guest-booking-submitted',
with: [
'consultation' => $this->consultation,
'guestName' => $this->consultation->guest_name,
],
);
}
}
```
### Step 2: Create Guest Booking Submitted Email Template
Create `resources/views/emails/guest-booking-submitted.blade.php`:
```blade
<x-mail::message>
# {{ __('emails.guest_booking_submitted_title') }}
{{ __('emails.guest_booking_submitted_greeting', ['name' => $guestName]) }}
{{ __('emails.guest_booking_submitted_body') }}
**{{ __('booking.date') }}:** {{ $consultation->booking_date->translatedFormat('l, d M Y') }}
**{{ __('booking.time') }}:** {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}
**{{ __('booking.duration') }}:** 45 {{ __('common.minutes') }}
{{ __('emails.guest_booking_submitted_next_steps') }}
{{ __('emails.signature') }},<br>
{{ config('app.name') }}
</x-mail::message>
```
### Step 3: Create Guest Booking Approved Email
Create `app/Mail/GuestBookingApprovedMail.php`:
```php
<?php
namespace App\Mail;
use App\Models\Consultation;
use App\Services\CalendarService;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class GuestBookingApprovedMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Consultation $consultation
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __('emails.guest_booking_approved_subject'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.guest-booking-approved',
with: [
'consultation' => $this->consultation,
'guestName' => $this->consultation->guest_name,
'isPaid' => $this->consultation->consultation_type?->value === 'paid',
],
);
}
public function attachments(): array
{
$calendarService = app(CalendarService::class);
$icsContent = $calendarService->generateIcs($this->consultation);
return [
Attachment::fromData(fn () => $icsContent, 'consultation.ics')
->withMime('text/calendar'),
];
}
}
```
### Step 4: Create Guest Booking Rejected Email
Create `app/Mail/GuestBookingRejectedMail.php`:
```php
<?php
namespace App\Mail;
use App\Models\Consultation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class GuestBookingRejectedMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Consultation $consultation,
public ?string $reason = null
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __('emails.guest_booking_rejected_subject'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.guest-booking-rejected',
with: [
'consultation' => $this->consultation,
'guestName' => $this->consultation->guest_name,
'reason' => $this->reason,
],
);
}
}
```
### Step 5: Update NewBookingAdminEmail
Update `app/Mail/NewBookingAdminEmail.php` to handle guests:
```php
public function content(): Content
{
return new Content(
view: 'emails.new-booking-admin',
with: [
'consultation' => $this->consultation,
'clientName' => $this->consultation->getClientName(),
'clientEmail' => $this->consultation->getClientEmail(),
'clientPhone' => $this->consultation->getClientPhone(),
'isGuest' => $this->consultation->isGuest(),
],
);
}
```
### Step 6: Update Admin New Booking Email Template
Update `resources/views/emails/new-booking-admin.blade.php`:
```blade
<x-mail::message>
# {{ __('emails.new_booking_admin_title') }}
{{ __('emails.new_booking_admin_body') }}
@if($isGuest)
**{{ __('emails.booking_type') }}:** {{ __('emails.guest_booking') }}
@endif
**{{ __('booking.client_name') }}:** {{ $clientName }}
**{{ __('booking.client_email') }}:** {{ $clientEmail }}
**{{ __('booking.client_phone') }}:** {{ $clientPhone }}
**{{ __('booking.date') }}:** {{ $consultation->booking_date->translatedFormat('l, d M Y') }}
**{{ __('booking.time') }}:** {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}
**{{ __('booking.problem_summary') }}:**
{{ $consultation->problem_summary }}
<x-mail::button :url="route('admin.bookings.review', $consultation)">
{{ __('emails.review_booking') }}
</x-mail::button>
{{ __('emails.signature') }},<br>
{{ config('app.name') }}
</x-mail::message>
```
### Step 7: Update Admin Pending Bookings List
Update `resources/views/livewire/admin/bookings/pending.blade.php` to show guest indicator:
In the table row, add guest badge:
```blade
<td>
@if($consultation->isGuest())
<flux:badge size="sm" color="amber" class="mb-1">{{ __('admin.guest') }}</flux:badge><br>
{{ $consultation->guest_name }}
@else
{{ $consultation->user->name }}
@endif
</td>
<td>
@if($consultation->isGuest())
<a href="mailto:{{ $consultation->guest_email }}" class="text-primary hover:underline">
{{ $consultation->guest_email }}
</a>
@else
<a href="mailto:{{ $consultation->user->email }}" class="text-primary hover:underline">
{{ $consultation->user->email }}
</a>
@endif
</td>
```
### Step 8: Update Admin Booking Review Page
Update `resources/views/livewire/admin/bookings/review.blade.php`:
```blade
{{-- Client/Guest Information Section --}}
<div class="mb-6 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
<flux:heading size="sm" class="mb-3">
{{ __('admin.client_information') }}
@if($consultation->isGuest())
<flux:badge size="sm" color="amber" class="ml-2">{{ __('admin.guest') }}</flux:badge>
@endif
</flux:heading>
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<dt class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('booking.client_name') }}</dt>
<dd class="font-medium">{{ $consultation->getClientName() }}</dd>
</div>
<div>
<dt class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('booking.client_email') }}</dt>
<dd>
<a href="mailto:{{ $consultation->getClientEmail() }}" class="text-primary hover:underline">
{{ $consultation->getClientEmail() }}
</a>
</dd>
</div>
<div>
<dt class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('booking.client_phone') }}</dt>
<dd>
<a href="tel:{{ $consultation->getClientPhone() }}" class="text-primary hover:underline">
{{ $consultation->getClientPhone() }}
</a>
</dd>
</div>
@unless($consultation->isGuest())
<div>
<dt class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('admin.client_type') }}</dt>
<dd>{{ $consultation->user->user_type->label() }}</dd>
</div>
@endunless
</dl>
</div>
```
### Step 9: Update Booking Approval Logic
Update the approve method in the booking review component to send guest emails:
```php
public function approve(): void
{
// ... existing approval logic ...
// Send appropriate email based on guest/client
if ($this->consultation->isGuest()) {
Mail::to($this->consultation->guest_email)->queue(
new GuestBookingApprovedMail($this->consultation)
);
} else {
Mail::to($this->consultation->user)->queue(
new BookingApprovedMail($this->consultation)
);
}
}
public function reject(): void
{
// ... existing rejection logic ...
// Send appropriate email based on guest/client
if ($this->consultation->isGuest()) {
Mail::to($this->consultation->guest_email)->queue(
new GuestBookingRejectedMail($this->consultation, $this->rejectionReason)
);
} else {
Mail::to($this->consultation->user)->queue(
new BookingRejectedMail($this->consultation, $this->rejectionReason)
);
}
}
```
### Step 10: Add Translation Keys
Add to `lang/en/emails.php`:
```php
'guest_booking_submitted_subject' => 'Booking Request Received - Libra Law Firm',
'guest_booking_submitted_title' => 'Your Booking Request Has Been Received',
'guest_booking_submitted_greeting' => 'Dear :name,',
'guest_booking_submitted_body' => 'Thank you for your consultation request. We have received your booking and our team will review it shortly.',
'guest_booking_submitted_next_steps' => 'You will receive another email once your booking has been reviewed. If approved, you will receive the consultation details and a calendar invitation.',
'guest_booking_approved_subject' => 'Booking Confirmed - Libra Law Firm',
'guest_booking_rejected_subject' => 'Booking Update - Libra Law Firm',
'booking_type' => 'Booking Type',
'guest_booking' => 'Guest (No Account)',
```
Add to `lang/ar/emails.php`:
```php
'guest_booking_submitted_subject' => 'تم استلام طلب الحجز - مكتب ليبرا للمحاماة',
'guest_booking_submitted_title' => 'تم استلام طلب الحجز الخاص بك',
'guest_booking_submitted_greeting' => 'عزيزي/عزيزتي :name،',
'guest_booking_submitted_body' => 'شكراً لطلب الاستشارة. لقد تلقينا حجزك وسيقوم فريقنا بمراجعته قريباً.',
'guest_booking_submitted_next_steps' => 'ستتلقى رسالة أخرى عند مراجعة حجزك. في حال الموافقة، ستتلقى تفاصيل الاستشارة ودعوة تقويم.',
'guest_booking_approved_subject' => 'تأكيد الحجز - مكتب ليبرا للمحاماة',
'guest_booking_rejected_subject' => 'تحديث الحجز - مكتب ليبرا للمحاماة',
'booking_type' => 'نوع الحجز',
'guest_booking' => 'زائر (بدون حساب)',
```
Add to `lang/en/admin.php`:
```php
'guest' => 'Guest',
'client_information' => 'Client Information',
'client_type' => 'Client Type',
```
Add to `lang/ar/admin.php`:
```php
'guest' => 'زائر',
'client_information' => 'معلومات العميل',
'client_type' => 'نوع العميل',
```
## Testing Requirements
### Email Tests
```php
test('guest receives confirmation email on booking submission', function () {
Mail::fake();
$consultation = Consultation::factory()->guest()->create([
'status' => ConsultationStatus::Pending,
]);
Mail::to($consultation->guest_email)->send(
new GuestBookingSubmittedMail($consultation)
);
Mail::assertSent(GuestBookingSubmittedMail::class, function ($mail) use ($consultation) {
return $mail->hasTo($consultation->guest_email);
});
});
test('guest receives approval email with calendar attachment', function () {
Mail::fake();
$consultation = Consultation::factory()->guest()->create([
'status' => ConsultationStatus::Approved,
]);
Mail::to($consultation->guest_email)->send(
new GuestBookingApprovedMail($consultation)
);
Mail::assertSent(GuestBookingApprovedMail::class, function ($mail) {
return count($mail->attachments()) === 1;
});
});
test('admin email shows guest indicator for guest bookings', function () {
Mail::fake();
$consultation = Consultation::factory()->guest()->create();
$admin = User::factory()->admin()->create();
Mail::to($admin)->send(new NewBookingAdminEmail($consultation));
Mail::assertSent(NewBookingAdminEmail::class);
});
```
### Admin Interface Tests
```php
test('admin can see guest bookings in pending list', function () {
$admin = User::factory()->admin()->create();
$guestConsultation = Consultation::factory()->guest()->pending()->create();
Volt::test('admin.bookings.pending')
->actingAs($admin)
->assertSee($guestConsultation->guest_name)
->assertSee(__('admin.guest'));
});
test('admin can view guest booking details', function () {
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->guest()->create([
'guest_name' => 'Test Guest',
'guest_email' => 'test@example.com',
]);
Volt::test('admin.bookings.review', ['consultation' => $consultation])
->actingAs($admin)
->assertSee('Test Guest')
->assertSee('test@example.com')
->assertSee(__('admin.guest'));
});
test('admin can approve guest booking', function () {
Mail::fake();
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->guest()->pending()->create();
Volt::test('admin.bookings.review', ['consultation' => $consultation])
->actingAs($admin)
->call('approve');
expect($consultation->fresh()->status)->toBe(ConsultationStatus::Approved);
Mail::assertQueued(GuestBookingApprovedMail::class);
});
test('admin can reject guest booking', function () {
Mail::fake();
$admin = User::factory()->admin()->create();
$consultation = Consultation::factory()->guest()->pending()->create();
Volt::test('admin.bookings.review', ['consultation' => $consultation])
->actingAs($admin)
->set('rejectionReason', 'Not available for this time')
->call('reject');
expect($consultation->fresh()->status)->toBe(ConsultationStatus::Rejected);
Mail::assertQueued(GuestBookingRejectedMail::class);
});
```
## Dependencies
- Story 11.1 (Database Schema & Model Updates)
- Story 11.2 (Public Booking Form)
## Definition of Done
- [ ] Guest booking submitted email created and working
- [ ] Guest booking approved email created with calendar attachment
- [ ] Guest booking rejected email created
- [ ] Admin new booking email updated for guests
- [ ] Admin pending bookings shows guest indicator
- [ ] Admin booking review shows guest contact info
- [ ] Approve/reject sends correct email (guest vs client)
- [ ] All translations in place (Arabic/English)
- [ ] All email tests pass
- [ ] All admin interface tests pass
- [ ] Existing client booking emails unchanged

View File

@ -0,0 +1,187 @@
# Story 11.4: Documentation Updates
## Epic Reference
**Epic 11:** Guest Booking
## Story Context
This is the **final story** for Epic 11. After all guest booking functionality is implemented, this story updates the PRD and other documentation files to reflect the new guest booking capability. This ensures documentation stays in sync with the actual system features.
## User Story
As a **developer or stakeholder**,
I want **documentation to accurately reflect guest booking functionality**,
So that **future development and maintenance has accurate reference material**.
## Acceptance Criteria
### PRD Updates (docs/prd.md)
- [ ] Section 5.4 (Booking & Consultation System) updated with guest booking flow
- [ ] Guest vs client booking distinction documented
- [ ] 1-per-day limit for guests documented
- [ ] Custom captcha requirement documented
- [ ] Rate limiting rules documented
- [ ] Guest data handling documented
- [ ] Database schema section updated with guest fields
- [ ] Change log entry added
### Architecture Updates (docs/architecture.md)
- [ ] CaptchaService documented in Services section
- [ ] Guest booking flow documented
- [ ] Database schema changes noted
### CLAUDE.md Updates
- [ ] Any new patterns or services mentioned if needed
### Epic Completion
- [ ] Epic 11 marked as complete
- [ ] All story checkboxes marked done
## Implementation Steps
### Step 1: Update PRD Section 5.4
Add new subsection after the existing booking flow:
```markdown
#### Guest Booking Flow
In addition to client bookings, the system supports guest bookings for visitors without accounts.
**Guest Booking Process:**
1. **Guest Visits /booking:**
- Views available dates/times in calendar
- Selects preferred date and time
- Enters contact information (name, email, phone)
- Provides problem summary
- Completes captcha verification
- Submits booking request
2. **Spam Protection:**
- Custom math-based captcha (no third-party services)
- 1 booking request per email per day
- 5 booking requests per IP per 24 hours
3. **Guest Booking Status:**
- Booking enters pending queue (same as client bookings)
- Admin receives notification with guest contact info
- Guest receives email confirmation
4. **Admin Reviews Guest Booking:**
- Guest bookings appear in pending queue with "Guest" indicator
- Admin sees guest name, email, phone
- Same approval/rejection workflow as client bookings
5. **Guest Notifications:**
- Confirmation email on submission
- Approval email with calendar file on approval
- Rejection email with reason on rejection
**Guest vs Client Comparison:**
| Feature | Client Booking | Guest Booking |
|---------|---------------|---------------|
| Account Required | Yes | No |
| 1-per-day Limit | By user_id | By email |
| Contact Info | From user profile | Entered at booking |
| Email Notifications | To user email | To guest_email |
| View Past Bookings | In dashboard | N/A |
| Captcha | Not required | Required |
**Note:** Logged-in users visiting `/booking` are redirected to the client booking page.
```
### Step 2: Update PRD Database Schema Section
Add to Section 16.1 (Database Schema Overview):
```markdown
2. **consultations (bookings)**
- id, user_id (nullable for guests), guest_name, guest_email, guest_phone, booking_date, booking_time, duration (45 min), problem_summary, consultation_type (free/paid), payment_amount, payment_status (pending/received), status (pending/approved/rejected/completed/no-show/cancelled), admin_notes, created_at, updated_at
```
### Step 3: Update PRD Email Section
Add to Section 8.2 (Email Templates):
```markdown
**Guest Emails:**
1. **Guest Booking Confirmation** - Request submitted successfully
2. **Guest Booking Approved** - With consultation details and calendar file
3. **Guest Booking Rejected** - With reason (if provided)
```
### Step 4: Update PRD Change Log
Add entry to Section 18 (Change Log):
```markdown
| 1.2 | [Date] | Added guest booking functionality: public booking form at /booking, custom captcha, 1-per-day limit, guest email notifications, admin guest booking management | Development Team |
```
### Step 5: Update Architecture Documentation
Add to docs/architecture.md Services section:
```markdown
### CaptchaService
Located at `app/Services/CaptchaService.php`.
Provides custom math-based captcha for guest booking spam protection:
- `generate()` - Creates new captcha question, stores answer in session
- `validate($answer)` - Checks if provided answer matches stored answer
- `clear()` - Removes captcha from session after successful validation
No external dependencies (no Google reCAPTCHA, no Cloudflare Turnstile).
```
### Step 6: Mark Epic as Complete
Update `docs/epics/epic-11-guest-booking.md`:
Change all Definition of Done items to checked:
```markdown
## Definition of Done
- [x] All stories completed with acceptance criteria met
- [x] Existing client booking tests still pass
- [x] New tests cover guest booking scenarios
- [x] Admin can manage guest bookings through existing interface
- [x] Guest receives appropriate email notifications
- [x] Custom captcha working correctly
- [x] 1-per-day limit enforced
- [x] No regression in existing features
- [x] Bilingual support (Arabic/English) for guest form and emails
- [x] PRD and documentation updated
```
## Files to Update
| File | Section | Change |
|------|---------|--------|
| `docs/prd.md` | 5.4 Booking System | Add guest booking flow |
| `docs/prd.md` | 8.2 Email Templates | Add guest emails |
| `docs/prd.md` | 16.1 Database Schema | Update consultations table |
| `docs/prd.md` | 18 Change Log | Add version entry |
| `docs/architecture.md` | Services | Add CaptchaService |
| `docs/epics/epic-11-guest-booking.md` | Definition of Done | Mark complete |
## Testing Requirements
### Documentation Verification
- [ ] PRD sections render correctly in markdown preview
- [ ] All links in documentation are valid
- [ ] Change log version number is correct
- [ ] No broken internal references
## Dependencies
- Story 11.1 (Database Schema & Model Updates) - Complete
- Story 11.2 (Public Booking Form) - Complete
- Story 11.3 (Guest Notifications & Admin) - Complete
## Definition of Done
- [ ] PRD updated with guest booking documentation
- [ ] Architecture documentation updated
- [ ] Epic 11 marked as complete
- [ ] All documentation changes reviewed
- [ ] Version numbers updated appropriately