generated stories for the guest booking system
This commit is contained in:
parent
d69026a8e1
commit
bd27a3a876
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue