23 KiB
Story 3.9: Guest Booking Request
Status
Draft
Epic Reference
Epic 3: Booking & Consultation System
Story
As a guest visitor, I want to submit a consultation booking request with my contact details, So that I can request legal services without needing an account first.
Story Context
Existing System Integration
- Integrates with: Availability calendar (Story 3.3), Admin booking review (Story 3.5), User creation (Epic 2)
- Technology: Livewire Volt, Flux UI, existing availability calendar component
- Follows pattern: Client booking submission (Story 3.4), Admin approval workflow (Story 3.5)
- Touch points: Public
/bookingroute, admin pending bookings queue, user creation, email notifications
Business Context
The landing page mentions a "simplified booking form" for first-time visitors. Currently, only authenticated clients can book consultations. This story enables guests to submit booking requests with their full details, allowing the admin to review and optionally create an account upon approval.
Acceptance Criteria
Public Booking Page (AC1-6)
- AC1: Public
/bookingpage accessible without login - AC2: Display availability calendar (reuse existing component)
- AC3: Guest can select date and available time slot
- AC4: Account type toggle: Individual / Company
- AC5: Form fields for Individual:
- Full Name (required)
- National ID (required)
- Email (required, valid email)
- Phone (required)
- Preferred Language (Arabic/English)
- AC6: Additional fields for Company:
- Company Name (required)
- Company Registration Number (required)
- Contact Person Name (required)
- Contact Person ID (required)
- Email (required)
- Phone (required)
- Preferred Language (Arabic/English)
Problem Summary (AC7-8)
- AC7: Problem summary field (required, textarea, min 20 characters, max 2000)
- AC8: Confirmation step before submission showing all entered data
Validation & Constraints (AC9-12)
- AC9: Duplicate check: If email OR national_id exists in users table, show "Account already exists. Please login to book." with login link
- AC10: 1-per-day limit by email: Check guest_booking_requests table for same email on same booking_date
- AC11: Validate selected slot is still available
- AC12: Clear error messages for all validation failures (bilingual)
Submission Flow (AC13-16)
- AC13: On submit, create record in
guest_booking_requeststable with status 'pending' - AC14: Guest sees success page: "Request received. We will contact you soon."
- AC15: Admin receives email notification about new guest booking request
- AC16: Guest receives submission confirmation email
Admin Review Workflow (AC17-22)
- AC17: Guest booking requests appear in admin pending bookings queue with "Guest" badge
- AC18: Admin can view full guest details and problem summary
- AC19: Admin action: Approve + Create Account
- Creates user account with provided data
- Generates random password
- Creates consultation from guest request
- Sends welcome email with credentials
- Sends booking approved email with .ics
- Deletes guest_booking_request record
- AC20: Admin action: Reject
- Sends rejection email to guest
- Deletes guest_booking_request record (data discarded)
- AC21: Admin action: Create Account Only
- Creates user account without booking
- Sends welcome email with credentials
- Deletes guest_booking_request record
- AC22: All admin actions logged in audit trail
Quality Requirements (AC23-26)
- AC23: Bilingual support (Arabic/English) for all UI and emails
- AC24: Mobile-responsive design
- AC25: Race condition prevention for slot availability
- AC26: Comprehensive test coverage
Technical Notes
New Database Table: guest_booking_requests
Schema::create('guest_booking_requests', function (Blueprint $table) {
$table->id();
$table->enum('account_type', ['individual', 'company']);
// Individual fields
$table->string('full_name');
$table->string('national_id');
// Company fields (nullable for individuals)
$table->string('company_name')->nullable();
$table->string('company_cert_number')->nullable();
$table->string('contact_person_name')->nullable();
$table->string('contact_person_id')->nullable();
// Common fields
$table->string('email');
$table->string('phone');
$table->string('preferred_language')->default('ar');
// Booking fields
$table->date('booking_date');
$table->time('booking_time');
$table->text('problem_summary');
$table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
$table->timestamps();
// Indexes for duplicate checking
$table->index('email');
$table->index('national_id');
$table->index(['email', 'booking_date']); // For 1-per-day check
});
Model: GuestBookingRequest
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GuestBookingRequest extends Model
{
use HasFactory;
protected $fillable = [
'account_type',
'full_name',
'national_id',
'company_name',
'company_cert_number',
'contact_person_name',
'contact_person_id',
'email',
'phone',
'preferred_language',
'booking_date',
'booking_time',
'problem_summary',
'status',
];
protected function casts(): array
{
return [
'booking_date' => 'date',
];
}
public function isIndividual(): bool
{
return $this->account_type === 'individual';
}
public function isCompany(): bool
{
return $this->account_type === 'company';
}
public function scopePending($query)
{
return $query->where('status', 'pending');
}
}
Volt Component Structure (Public Booking)
<?php
use App\Models\GuestBookingRequest;
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 {
// Step tracking
public int $step = 1; // 1: calendar, 2: details, 3: confirm
// Booking selection
public ?string $selectedDate = null;
public ?string $selectedTime = null;
// Account type
public string $accountType = 'individual';
// Individual fields
public string $fullName = '';
public string $nationalId = '';
// Company fields
public string $companyName = '';
public string $companyCertNumber = '';
public string $contactPersonName = '';
public string $contactPersonId = '';
// Common fields
public string $email = '';
public string $phone = '';
public string $preferredLanguage = 'ar';
public string $problemSummary = '';
public function selectSlot(string $date, string $time): void
{
$this->selectedDate = $date;
$this->selectedTime = $time;
$this->step = 2;
}
public function goToConfirm(): void
{
$this->validateDetails();
$this->step = 3;
}
public function submit(): void
{
// Full validation and submission logic
// See detailed implementation below
}
protected function validateDetails(): void
{
$rules = [
'selectedDate' => ['required', 'date', 'after_or_equal:today'],
'selectedTime' => ['required'],
'accountType' => ['required', 'in:individual,company'],
'fullName' => ['required', 'string', 'max:255'],
'nationalId' => ['required', 'string', 'max:50'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['required', 'string', 'max:20'],
'preferredLanguage' => ['required', 'in:ar,en'],
'problemSummary' => ['required', 'string', 'min:20', 'max:2000'],
];
if ($this->accountType === 'company') {
$rules['companyName'] = ['required', 'string', 'max:255'];
$rules['companyCertNumber'] = ['required', 'string', 'max:100'];
$rules['contactPersonName'] = ['required', 'string', 'max:255'];
$rules['contactPersonId'] = ['required', 'string', 'max:50'];
}
$this->validate($rules);
// Check for existing account
$existingUser = User::where('email', $this->email)
->orWhere('national_id', $this->nationalId)
->first();
if ($existingUser) {
$this->addError('email', __('booking.account_exists_please_login'));
return;
}
// Check 1-per-day limit by email
$existingRequest = GuestBookingRequest::where('email', $this->email)
->whereDate('booking_date', $this->selectedDate)
->where('status', 'pending')
->exists();
if ($existingRequest) {
$this->addError('email', __('booking.guest_already_requested_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;
}
}
};
Admin Review Extension
Extend the existing admin.bookings.pending component to include guest booking requests:
public function with(): array
{
return [
'clientBookings' => Consultation::where('status', ConsultationStatus::Pending)
->with('user:id,full_name,email,phone,user_type')
->orderBy('booking_date')
->orderBy('booking_time')
->get(),
'guestBookings' => GuestBookingRequest::pending()
->orderBy('booking_date')
->orderBy('booking_time')
->get(),
];
}
Admin Actions for Guest Bookings
public function approveGuestAndCreateAccount(int $guestRequestId): void
{
$guestRequest = GuestBookingRequest::findOrFail($guestRequestId);
DB::transaction(function () use ($guestRequest) {
// Generate random password
$password = Str::random(12);
// Create user account
$user = User::create([
'user_type' => $guestRequest->isIndividual() ? UserType::Individual : UserType::Company,
'full_name' => $guestRequest->full_name,
'national_id' => $guestRequest->national_id,
'company_name' => $guestRequest->company_name,
'company_cert_number' => $guestRequest->company_cert_number,
'contact_person_name' => $guestRequest->contact_person_name,
'contact_person_id' => $guestRequest->contact_person_id,
'email' => $guestRequest->email,
'phone' => $guestRequest->phone,
'password' => Hash::make($password),
'status' => UserStatus::Active,
'preferred_language' => $guestRequest->preferred_language,
]);
// Create consultation
$consultation = Consultation::create([
'user_id' => $user->id,
'booking_date' => $guestRequest->booking_date,
'booking_time' => $guestRequest->booking_time,
'problem_summary' => $guestRequest->problem_summary,
'status' => ConsultationStatus::Pending,
'payment_status' => PaymentStatus::NotApplicable,
]);
// Send welcome email with credentials
$user->notify(new WelcomeAccountNotification($password));
// Delete guest request
$guestRequest->delete();
// Log action
AdminLog::create([
'admin_id' => auth()->id(),
'action' => 'create',
'target_type' => 'user',
'target_id' => $user->id,
'new_values' => ['source' => 'guest_booking', 'guest_request_id' => $guestRequestId],
'ip_address' => request()->ip(),
]);
});
session()->flash('success', __('admin.guest_approved_account_created'));
}
public function rejectGuestBooking(int $guestRequestId, ?string $reason = null): void
{
$guestRequest = GuestBookingRequest::findOrFail($guestRequestId);
// Send rejection email
Mail::to($guestRequest->email)->queue(
new GuestBookingRejectedMail($guestRequest, $reason)
);
// Log action before deletion
AdminLog::create([
'admin_id' => auth()->id(),
'action' => 'reject',
'target_type' => 'guest_booking_request',
'target_id' => $guestRequestId,
'old_values' => $guestRequest->toArray(),
'ip_address' => request()->ip(),
]);
// Delete guest request (discard data)
$guestRequest->delete();
session()->flash('success', __('admin.guest_booking_rejected'));
}
public function createAccountOnly(int $guestRequestId): void
{
$guestRequest = GuestBookingRequest::findOrFail($guestRequestId);
DB::transaction(function () use ($guestRequest) {
$password = Str::random(12);
$user = User::create([
'user_type' => $guestRequest->isIndividual() ? UserType::Individual : UserType::Company,
'full_name' => $guestRequest->full_name,
'national_id' => $guestRequest->national_id,
'company_name' => $guestRequest->company_name,
'company_cert_number' => $guestRequest->company_cert_number,
'contact_person_name' => $guestRequest->contact_person_name,
'contact_person_id' => $guestRequest->contact_person_id,
'email' => $guestRequest->email,
'phone' => $guestRequest->phone,
'password' => Hash::make($password),
'status' => UserStatus::Active,
'preferred_language' => $guestRequest->preferred_language,
]);
$user->notify(new WelcomeAccountNotification($password));
$guestRequest->delete();
AdminLog::create([
'admin_id' => auth()->id(),
'action' => 'create',
'target_type' => 'user',
'target_id' => $user->id,
'new_values' => ['source' => 'guest_booking_account_only', 'guest_request_id' => $guestRequest->id],
'ip_address' => request()->ip(),
]);
});
session()->flash('success', __('admin.account_created_no_booking'));
}
Email Notifications
New Mail Classes:
App\Mail\GuestBookingSubmittedMail- Confirmation to guestApp\Mail\NewGuestBookingRequestMail- Notification to adminApp\Mail\GuestBookingRejectedMail- Rejection notification to guest
Route Configuration
// routes/web.php
// Public guest booking route
Route::get('/booking', function () {
return view('livewire.booking.guest');
})->name('booking');
Tasks / Subtasks
-
Task 1: Create migration for guest_booking_requests table (AC13)
- Create migration file
- Run migration
-
Task 2: Create GuestBookingRequest model (AC13)
- Create model with fillable, casts, scopes
- Create factory for testing
-
Task 3: Create public booking Volt component (AC1-8, AC12)
- Create
resources/views/livewire/booking/guest.blade.php - Implement 3-step flow: calendar selection, details form, confirmation
- Integrate availability calendar component
- Implement account type toggle with conditional fields
- Add confirmation step with data review
- Create
-
Task 4: Implement validation logic (AC9-11)
- Duplicate user check (email OR national_id)
- 1-per-day limit by email
- Slot availability verification
-
Task 5: Implement submission flow (AC13-16)
- Create guest_booking_request record
- Send confirmation email to guest
- Send notification email to admin
- Display success page
-
Task 6: Create email classes (AC15-16, AC20)
- Create GuestBookingSubmittedMail
- Create NewGuestBookingRequestMail
- Create GuestBookingRejectedMail
- Create bilingual email templates
-
Task 7: Extend admin pending bookings (AC17-18)
- Modify admin.bookings.pending to include guest requests
- Add "Guest" badge to distinguish from client bookings
- Create guest booking detail view
-
Task 8: Implement admin actions (AC19-22)
- Implement approveGuestAndCreateAccount method
- Implement rejectGuestBooking method
- Implement createAccountOnly method
- Add confirmation modals for each action
- Implement audit logging
-
Task 9: Add translation keys (AC23)
- Add keys to lang/en/booking.php
- Add keys to lang/ar/booking.php
- Add keys to lang/en/admin.php
- Add keys to lang/ar/admin.php
-
Task 10: Write tests (AC26)
- Test public booking page access
- Test form validation
- Test duplicate user detection
- Test 1-per-day limit
- Test successful submission
- Test admin approve + create account
- Test admin reject
- Test admin create account only
- Test email notifications
-
Task 11: Run Pint and verify
- Run
vendor/bin/pint --dirty - Verify all tests pass
- Run
Files to Create
| File | Purpose |
|---|---|
database/migrations/xxxx_create_guest_booking_requests_table.php |
Database table for guest requests |
app/Models/GuestBookingRequest.php |
Eloquent model |
database/factories/GuestBookingRequestFactory.php |
Test factory |
resources/views/livewire/booking/guest.blade.php |
Public booking Volt component |
app/Mail/GuestBookingSubmittedMail.php |
Guest confirmation email |
app/Mail/NewGuestBookingRequestMail.php |
Admin notification email |
app/Mail/GuestBookingRejectedMail.php |
Guest rejection email |
resources/views/emails/guest-booking-submitted.blade.php |
Email template |
resources/views/emails/new-guest-booking-request.blade.php |
Email template |
resources/views/emails/guest-booking-rejected.blade.php |
Email template |
resources/views/livewire/admin/bookings/guest-review.blade.php |
Admin guest review component |
tests/Feature/Booking/GuestBookingTest.php |
Feature tests |
tests/Feature/Admin/GuestBookingReviewTest.php |
Admin action tests |
Files to Modify
| File | Changes |
|---|---|
routes/web.php |
Add public /booking route |
resources/views/livewire/admin/bookings/pending.blade.php |
Include guest bookings with badge |
lang/en/booking.php |
Add guest booking translation keys |
lang/ar/booking.php |
Add Arabic translations |
lang/en/admin.php |
Add admin action translations |
lang/ar/admin.php |
Add Arabic translations |
lang/en/emails.php |
Add email translation keys |
lang/ar/emails.php |
Add Arabic translations |
Translation Keys Required
lang/en/booking.php:
'guest_booking_title' => 'Book a Consultation',
'guest_booking_subtitle' => 'Fill in your details to request a consultation',
'account_type' => 'Account Type',
'individual' => 'Individual',
'company' => 'Company',
'full_name' => 'Full Name',
'national_id' => 'National ID',
'company_name' => 'Company Name',
'company_cert_number' => 'Company Registration Number',
'contact_person_name' => 'Contact Person Name',
'contact_person_id' => 'Contact Person ID',
'email' => 'Email Address',
'phone' => 'Phone Number',
'preferred_language' => 'Preferred Language',
'account_exists_please_login' => 'An account with this email or ID already exists. Please login to book.',
'guest_already_requested_this_day' => 'You have already submitted a request for this day. Please wait for a response.',
'guest_booking_submitted' => 'Your booking request has been submitted. We will contact you soon.',
'step_select_time' => 'Select Date & Time',
'step_your_details' => 'Your Details',
'step_confirm' => 'Confirm',
lang/en/admin.php:
'guest_booking_request' => 'Guest Booking Request',
'guest_badge' => 'Guest',
'approve_create_account' => 'Approve & Create Account',
'reject_guest' => 'Reject',
'create_account_only' => 'Create Account Only',
'guest_approved_account_created' => 'Guest approved. Account created and booking confirmed.',
'guest_booking_rejected' => 'Guest booking request rejected.',
'account_created_no_booking' => 'Account created. No booking was created.',
'confirm_approve_guest' => 'This will create a new user account and approve the booking. Continue?',
'confirm_reject_guest' => 'This will reject the request and discard all guest data. Continue?',
'confirm_create_account_only' => 'This will create a user account but NOT approve the booking. Continue?',
Definition of Done
- Migration created and run successfully
- GuestBookingRequest model with factory
- Public /booking page accessible without login
- Guest can select date/time from calendar
- Guest can fill individual or company details
- Duplicate user detection works (email OR national_id)
- 1-per-day limit by email enforced
- Confirmation step shows all data before submit
- Guest request saved to database
- Guest receives confirmation email
- Admin receives notification email
- Guest requests appear in admin queue with badge
- Admin can approve + create account
- Admin can reject (data discarded)
- Admin can create account only
- All actions logged in audit trail
- Rejection email sent to guest
- Welcome email with credentials sent on account creation
- Bilingual support complete
- Mobile responsive
- All tests passing
- Code formatted with Pint
Dependencies
- Story 3.3: Availability calendar component (COMPLETED)
- Story 3.4: Booking submission patterns (COMPLETED)
- Story 3.5: Admin approval workflow (COMPLETED)
- Epic 2: User creation logic and WelcomeAccountNotification (COMPLETED)
Risk Assessment
| Risk | Impact | Mitigation |
|---|---|---|
| Data exposure if guest request not properly deleted | High | Ensure deletion in all paths, add cleanup job |
| Race condition on slot booking | High | DB transaction with locking |
| Spam submissions | Medium | Rate limiting, honeypot field |
| Email delivery failure | Medium | Queue with retries |
Compatibility Verification
- No breaking changes to existing booking flow
- Existing client booking (
/client/consultations/book) unchanged - Admin pending list shows both types clearly
- All existing tests continue to pass
Estimation
Complexity: High Estimated Effort: 8-10 hours
This is a significant feature that introduces a new model, extends admin workflows, and adds a public-facing booking page. It requires careful attention to data handling (guest data must be properly discarded on rejection) and security (prevent duplicate accounts).
Change Log
| Date | Version | Description | Author |
|---|---|---|---|
| 2025-12-29 | 1.0 | Initial draft | PM Agent |