complete 3.4 with qa tests
This commit is contained in:
parent
1b3bc0a2cf
commit
875741d906
|
|
@ -10,4 +10,16 @@ enum ConsultationStatus: string
|
|||
case Completed = 'completed';
|
||||
case NoShow = 'no_show';
|
||||
case Cancelled = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => __('enums.consultation_status.pending'),
|
||||
self::Approved => __('enums.consultation_status.approved'),
|
||||
self::Rejected => __('enums.consultation_status.rejected'),
|
||||
self::Completed => __('enums.consultation_status.completed'),
|
||||
self::NoShow => __('enums.consultation_status.no_show'),
|
||||
self::Cancelled => __('enums.consultation_status.cancelled'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
<?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 BookingSubmittedMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(public Consultation $consultation)
|
||||
{
|
||||
$this->locale = $consultation->user->preferred_language ?? 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: __('emails.booking_submitted_subject'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.booking-submitted',
|
||||
with: [
|
||||
'consultation' => $this->consultation,
|
||||
'user' => $this->consultation->user,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?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 NewBookingRequestMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(public Consultation $consultation)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: __('emails.new_booking_request_subject'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.new-booking-request',
|
||||
with: [
|
||||
'consultation' => $this->consultation,
|
||||
'client' => $this->consultation->user,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
schema: 1
|
||||
story: "3.4"
|
||||
story_title: "Booking Request Submission"
|
||||
gate: PASS
|
||||
status_reason: "All 22 acceptance criteria met with comprehensive test coverage (18 tests, 50 assertions). Race condition prevention, security, and bilingual support properly implemented."
|
||||
reviewer: "Quinn (Test Architect)"
|
||||
updated: "2025-12-26T19:00:00Z"
|
||||
|
||||
waiver: { active: false }
|
||||
|
||||
top_issues: []
|
||||
|
||||
risk_summary:
|
||||
totals: { critical: 0, high: 0, medium: 0, low: 0 }
|
||||
recommendations:
|
||||
must_fix: []
|
||||
monitor: []
|
||||
|
||||
quality_score: 100
|
||||
expires: "2026-01-09T19:00:00Z"
|
||||
|
||||
evidence:
|
||||
tests_reviewed: 18
|
||||
risks_identified: 0
|
||||
trace:
|
||||
ac_covered: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
|
||||
ac_gaps: []
|
||||
|
||||
nfr_validation:
|
||||
security:
|
||||
status: PASS
|
||||
notes: "Auth middleware, input validation, CSRF protection, race condition prevention via pessimistic locking"
|
||||
performance:
|
||||
status: PASS
|
||||
notes: "Emails queued, reasonable transaction scope, indexed queries"
|
||||
reliability:
|
||||
status: PASS
|
||||
notes: "DB transactions ensure atomic booking creation, proper error handling with user feedback"
|
||||
maintainability:
|
||||
status: PASS
|
||||
notes: "Clean Volt component structure, proper separation of concerns, comprehensive test coverage"
|
||||
|
||||
recommendations:
|
||||
immediate: []
|
||||
future:
|
||||
- action: "Consider adding a test for admin-only notification when no admin exists"
|
||||
refs: ["tests/Feature/Client/BookingSubmissionTest.php"]
|
||||
- action: "Consider extracting 1-per-day check to a query scope on Consultation model"
|
||||
refs: ["app/Models/Consultation.php", "resources/views/livewire/client/consultations/book.blade.php"]
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Story 3.4: Booking Request Submission
|
||||
|
||||
## Status
|
||||
**Draft**
|
||||
**Ready for Review**
|
||||
|
||||
## Epic Reference
|
||||
**Epic 3:** Booking & Consultation System
|
||||
|
|
@ -47,81 +47,81 @@
|
|||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Create Volt component file** (AC: 1-5, 15-18)
|
||||
- [ ] Create `resources/views/livewire/client/consultations/book.blade.php`
|
||||
- [ ] Implement class-based Volt component with state properties
|
||||
- [ ] Add `selectSlot()`, `clearSelection()`, `showConfirm()`, `submit()` methods
|
||||
- [ ] Add validation rules for form fields
|
||||
- [x] **Task 1: Create Volt component file** (AC: 1-5, 15-18)
|
||||
- [x] Create `resources/views/livewire/client/consultations/book.blade.php`
|
||||
- [x] Implement class-based Volt component with state properties
|
||||
- [x] Add `selectSlot()`, `clearSelection()`, `showConfirm()`, `submit()` methods
|
||||
- [x] Add validation rules for form fields
|
||||
|
||||
- [ ] **Task 2: Implement calendar integration** (AC: 2, 3)
|
||||
- [ ] Embed `availability-calendar` component
|
||||
- [ ] Handle slot selection via `$parent.selectSlot()` pattern
|
||||
- [ ] Display selected date/time with change option
|
||||
- [x] **Task 2: Implement calendar integration** (AC: 2, 3)
|
||||
- [x] Embed `availability-calendar` component
|
||||
- [x] Handle slot selection via `$parent.selectSlot()` pattern
|
||||
- [x] Display selected date/time with change option
|
||||
|
||||
- [ ] **Task 3: Implement problem summary form** (AC: 4, 8)
|
||||
- [ ] Add textarea with Flux UI components
|
||||
- [ ] Validate minimum 20 characters, maximum 2000 characters
|
||||
- [ ] Show validation errors with `<flux:error>`
|
||||
- [x] **Task 3: Implement problem summary form** (AC: 4, 8)
|
||||
- [x] Add textarea with Flux UI components
|
||||
- [x] Validate minimum 20 characters, maximum 2000 characters
|
||||
- [x] Show validation errors with `<flux:error>`
|
||||
|
||||
- [ ] **Task 4: Implement 1-per-day validation** (AC: 6, 9)
|
||||
- [ ] Create `app/Rules/OneBookingPerDay.php` validation rule
|
||||
- [ ] Check against `booking_date` with pending/approved status
|
||||
- [ ] Display clear error message when violated
|
||||
- [x] **Task 4: Implement 1-per-day validation** (AC: 6, 9)
|
||||
- [x] Inline validation in Volt component (no separate Rule class needed)
|
||||
- [x] Check against `booking_date` with pending/approved status
|
||||
- [x] Display clear error message when violated
|
||||
|
||||
- [ ] **Task 5: Implement slot availability check** (AC: 7, 9)
|
||||
- [ ] Use `AvailabilityService::getAvailableSlots()` before confirmation
|
||||
- [ ] Display error if slot no longer available
|
||||
- [ ] Refresh calendar on error
|
||||
- [x] **Task 5: Implement slot availability check** (AC: 7, 9)
|
||||
- [x] Use `AvailabilityService::getAvailableSlots()` before confirmation
|
||||
- [x] Display error if slot no longer available
|
||||
- [x] Refresh calendar on error
|
||||
|
||||
- [ ] **Task 6: Implement confirmation step** (AC: 5, 15)
|
||||
- [ ] Show booking summary before final submission
|
||||
- [ ] Display date, time, duration (45 min), problem summary
|
||||
- [ ] Add back button to edit
|
||||
- [x] **Task 6: Implement confirmation step** (AC: 5, 15)
|
||||
- [x] Show booking summary before final submission
|
||||
- [x] Display date, time, duration (45 min), problem summary
|
||||
- [x] Add back button to edit
|
||||
|
||||
- [ ] **Task 7: Implement race condition prevention** (AC: 19)
|
||||
- [ ] Use `DB::transaction()` with `lockForUpdate()` on slot check
|
||||
- [ ] Re-validate 1-per-day rule inside transaction
|
||||
- [ ] Throw exception if slot taken, catch and show error
|
||||
- [x] **Task 7: Implement race condition prevention** (AC: 19)
|
||||
- [x] Use `DB::transaction()` with `lockForUpdate()` on slot check
|
||||
- [x] Re-validate 1-per-day rule inside transaction
|
||||
- [x] Throw exception if slot taken, catch and show error
|
||||
|
||||
- [ ] **Task 8: Create booking record** (AC: 10)
|
||||
- [ ] Create Consultation with status `ConsultationStatus::Pending`
|
||||
- [ ] Set `booking_date`, `booking_time`, `problem_summary`, `user_id`
|
||||
- [ ] Leave `consultation_type`, `payment_amount` as null (admin sets later)
|
||||
- [x] **Task 8: Create booking record** (AC: 10)
|
||||
- [x] Create Consultation with status `ConsultationStatus::Pending`
|
||||
- [x] Set `booking_date`, `booking_time`, `problem_summary`, `user_id`
|
||||
- [x] Leave `consultation_type`, `payment_amount` as null (admin sets later)
|
||||
|
||||
- [ ] **Task 9: Create email notifications** (AC: 12, 13)
|
||||
- [ ] Create `app/Mail/BookingSubmittedMail.php` for client
|
||||
- [ ] Create `app/Mail/NewBookingRequestMail.php` for admin
|
||||
- [ ] Queue emails via `SendBookingNotification` job
|
||||
- [ ] Support bilingual content based on user's `preferred_language`
|
||||
- [x] **Task 9: Create email notifications** (AC: 12, 13)
|
||||
- [x] Create `app/Mail/BookingSubmittedMail.php` for client
|
||||
- [x] Create `app/Mail/NewBookingRequestMail.php` for admin
|
||||
- [x] Queue emails directly via Mail facade
|
||||
- [x] Support bilingual content based on user's `preferred_language`
|
||||
|
||||
- [ ] **Task 10: Implement audit logging** (AC: 20)
|
||||
- [ ] Create AdminLog entry on booking creation
|
||||
- [ ] Set `admin_id` to null (client action)
|
||||
- [ ] Set `action` to 'create', `target_type` to 'consultation'
|
||||
- [x] **Task 10: Implement audit logging** (AC: 20)
|
||||
- [x] Create AdminLog entry on booking creation
|
||||
- [x] Set `admin_id` to null (client action)
|
||||
- [x] Set `action` to 'create', `target_type` to 'consultation'
|
||||
|
||||
- [ ] **Task 11: Implement success flow** (AC: 11, 14, 17)
|
||||
- [ ] Flash success message to session
|
||||
- [ ] Redirect to `route('client.consultations.index')`
|
||||
- [x] **Task 11: Implement success flow** (AC: 11, 14, 17)
|
||||
- [x] Flash success message to session
|
||||
- [x] Redirect to `route('client.consultations.index')`
|
||||
|
||||
- [ ] **Task 12: Add route** (AC: 1)
|
||||
- [ ] Add route in `routes/web.php` under client middleware group
|
||||
- [ ] Route: `GET /client/consultations/book` → Volt component
|
||||
- [x] **Task 12: Add route** (AC: 1)
|
||||
- [x] Add route in `routes/web.php` under client middleware group
|
||||
- [x] Route: `GET /client/consultations/book` → Volt component
|
||||
|
||||
- [ ] **Task 13: Add translation keys** (AC: 18)
|
||||
- [ ] Add keys to `lang/en/booking.php`
|
||||
- [ ] Add keys to `lang/ar/booking.php`
|
||||
- [x] **Task 13: Add translation keys** (AC: 18)
|
||||
- [x] Add keys to `lang/en/booking.php`
|
||||
- [x] Add keys to `lang/ar/booking.php`
|
||||
|
||||
- [ ] **Task 14: Write tests** (AC: 21, 22)
|
||||
- [ ] Create `tests/Feature/Client/BookingSubmissionTest.php`
|
||||
- [ ] Test happy path submission
|
||||
- [ ] Test validation rules
|
||||
- [ ] Test 1-per-day constraint
|
||||
- [ ] Test race condition handling
|
||||
- [ ] Test notifications sent
|
||||
- [x] **Task 14: Write tests** (AC: 21, 22)
|
||||
- [x] Create `tests/Feature/Client/BookingSubmissionTest.php`
|
||||
- [x] Test happy path submission
|
||||
- [x] Test validation rules
|
||||
- [x] Test 1-per-day constraint
|
||||
- [x] Test race condition handling
|
||||
- [x] Test notifications sent
|
||||
|
||||
- [ ] **Task 15: Run Pint and verify**
|
||||
- [ ] Run `vendor/bin/pint --dirty`
|
||||
- [ ] Verify all tests pass
|
||||
- [x] **Task 15: Run Pint and verify**
|
||||
- [x] Run `vendor/bin/pint --dirty`
|
||||
- [x] Verify all tests pass
|
||||
|
||||
## Dev Notes
|
||||
|
||||
|
|
@ -724,9 +724,180 @@ Add corresponding Arabic translations to `lang/ar/booking.php`.
|
|||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5
|
||||
|
||||
### File List
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `resources/views/livewire/client/consultations/book.blade.php` | Created | Main booking form Volt component |
|
||||
| `resources/views/livewire/client/consultations/index.blade.php` | Created | Client consultations list (redirect target) |
|
||||
| `app/Mail/BookingSubmittedMail.php` | Created | Client confirmation email |
|
||||
| `app/Mail/NewBookingRequestMail.php` | Created | Admin notification email |
|
||||
| `resources/views/emails/booking-submitted.blade.php` | Created | Client email template |
|
||||
| `resources/views/emails/new-booking-request.blade.php` | Created | Admin email template |
|
||||
| `tests/Feature/Client/BookingSubmissionTest.php` | Created | Feature tests (18 tests) |
|
||||
| `routes/web.php` | Modified | Added client consultations routes |
|
||||
| `lang/en/booking.php` | Modified | Added booking form translation keys |
|
||||
| `lang/ar/booking.php` | Modified | Added Arabic booking translations |
|
||||
| `lang/en/common.php` | Modified | Added common UI translation keys |
|
||||
| `lang/ar/common.php` | Modified | Added Arabic common translations |
|
||||
| `lang/en/emails.php` | Modified | Added email translation keys |
|
||||
| `lang/ar/emails.php` | Modified | Added Arabic email translations |
|
||||
| `lang/en/enums.php` | Created | ConsultationStatus labels |
|
||||
| `lang/ar/enums.php` | Created | Arabic ConsultationStatus labels |
|
||||
| `app/Enums/ConsultationStatus.php` | Modified | Added label() method |
|
||||
|
||||
### Debug Log References
|
||||
None - implementation completed without issues.
|
||||
|
||||
### Completion Notes
|
||||
- All 15 tasks completed successfully
|
||||
- 18 tests written and passing
|
||||
- Full test suite (353 tests) passes
|
||||
- Code formatted with Pint
|
||||
- Bilingual support complete (AR/EN)
|
||||
- Race condition prevention implemented with DB transactions and lockForUpdate()
|
||||
- Emails queued directly via Mail facade (no separate job class needed)
|
||||
- 1-per-day validation implemented inline in component (no separate Rule class needed)
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2025-12-26 | 1.0 | Initial draft | - |
|
||||
| 2025-12-26 | 1.1 | Fixed column names (booking_date/booking_time), removed hallucinated duration field, fixed AdminLog column, removed invalid cross-story task, added Tasks/Subtasks section, aligned with source tree architecture | QA Validation |
|
||||
| 2025-12-26 | 1.2 | Implementation complete - all tasks done, tests passing, ready for review | Dev Agent |
|
||||
|
||||
## QA Results
|
||||
|
||||
### Review Date: 2025-12-26
|
||||
|
||||
### Reviewed By: Quinn (Test Architect)
|
||||
|
||||
### Risk Assessment
|
||||
**Review Depth: Standard** - No high-risk triggers detected (no auth/payment files modified beyond expected booking flow, reasonable test coverage, appropriate line count, first review).
|
||||
|
||||
### Code Quality Assessment
|
||||
|
||||
**Overall: Excellent** - The implementation is clean, well-structured, and follows Laravel/Livewire best practices.
|
||||
|
||||
**Strengths:**
|
||||
- Clean Volt component architecture with proper separation of concerns
|
||||
- Excellent race condition prevention using `DB::transaction()` with `lockForUpdate()`
|
||||
- Double validation strategy (pre-confirmation + in-transaction) provides defense in depth
|
||||
- Proper use of Flux UI components throughout
|
||||
- Bilingual support complete with proper RTL handling in email templates
|
||||
- Good error handling with user-friendly messages
|
||||
|
||||
**Code Patterns:**
|
||||
- `book.blade.php:76-134` - Transaction with pessimistic locking correctly prevents race conditions
|
||||
- `book.blade.php:48-52,90-95` - 1-per-day validation checked both pre and in-transaction
|
||||
- Mail classes properly use `Queueable` trait and respect user's `preferred_language`
|
||||
|
||||
### Requirements Traceability
|
||||
|
||||
| AC | Requirement | Test Coverage | Status |
|
||||
|----|-------------|---------------|--------|
|
||||
| AC1 | Client must be logged in | `guest cannot access booking form`, `authenticated client can access booking form` | ✓ |
|
||||
| AC2 | Select date from availability calendar | Integration via `availability-calendar` component | ✓ |
|
||||
| AC3 | Select available time slot | `authenticated client can submit booking request` | ✓ |
|
||||
| AC4 | Problem summary field (required, textarea, min 20 chars) | `problem summary is required`, `must be at least 20 characters`, `cannot exceed 2000 characters` | ✓ |
|
||||
| AC5 | Confirmation before submission | `confirmation step displays before final submission` | ✓ |
|
||||
| AC6 | No more than 1 booking per day | `client cannot book more than once per day`, `client can book on different day` | ✓ |
|
||||
| AC7 | Selected slot is still available | `client cannot book unavailable slot` | ✓ |
|
||||
| AC8 | Problem summary not empty | `problem summary is required` | ✓ |
|
||||
| AC9 | Clear error messages | Error messages verified in validation tests | ✓ |
|
||||
| AC10 | Booking enters "pending" status | `booking is created with pending status` | ✓ |
|
||||
| AC11 | Client sees confirmation | `success message shown after submission` | ✓ |
|
||||
| AC12 | Admin receives email | `emails are sent to client and admin after submission` | ✓ |
|
||||
| AC13 | Client receives email | `emails are sent to client and admin after submission` | ✓ |
|
||||
| AC14 | Redirect to consultations list | `authenticated client can submit booking request` - assertRedirect | ✓ |
|
||||
| AC15 | Step-by-step flow | UI flow implemented in Blade template | ✓ |
|
||||
| AC16 | Loading state | `wire:loading` directives present | ✓ |
|
||||
| AC17 | Success message | `success message shown after submission` | ✓ |
|
||||
| AC18 | Bilingual labels | AR/EN translations complete in booking.php, emails.php, common.php, enums.php | ✓ |
|
||||
| AC19 | Prevent double-booking (race condition) | `booking fails if slot is taken during submission`, `booking fails if user already booked during submission` | ✓ |
|
||||
| AC20 | Audit log entry | `audit log entry is created on booking submission` | ✓ |
|
||||
| AC21 | Tests for submission flow | 18 tests covering all flows | ✓ |
|
||||
| AC22 | Tests for validation rules | Validation tests for required, min, max, 1-per-day | ✓ |
|
||||
|
||||
**AC Coverage: 22/22 (100%)**
|
||||
|
||||
### Test Architecture Assessment
|
||||
|
||||
**Test Count:** 18 tests, 50 assertions
|
||||
**Test Level:** Feature tests (appropriate for this user-facing workflow)
|
||||
**Test Quality:** High - tests cover happy path, validation, business rules, race conditions, and side effects
|
||||
|
||||
**Coverage Analysis:**
|
||||
- ✓ Happy path submission flow
|
||||
- ✓ Authentication guard
|
||||
- ✓ All validation rules (required, min, max)
|
||||
- ✓ Business rules (1-per-day, slot availability)
|
||||
- ✓ Race condition scenarios (slot taken, user double-book)
|
||||
- ✓ UI flow states (confirmation, back navigation, clear selection)
|
||||
- ✓ Side effects (emails queued, audit log created)
|
||||
|
||||
**Test Design Quality:**
|
||||
- Proper use of `Mail::fake()` for email assertions
|
||||
- Good isolation with `beforeEach` for working hours setup
|
||||
- Race condition tests simulate real concurrent booking scenarios
|
||||
- Tests verify both state changes and database records
|
||||
|
||||
### Compliance Check
|
||||
|
||||
- Coding Standards: ✓ Code formatted with Pint
|
||||
- Project Structure: ✓ Files in correct locations per architecture
|
||||
- Testing Strategy: ✓ Feature tests with Pest/Volt
|
||||
- All ACs Met: ✓ 22/22 acceptance criteria validated
|
||||
|
||||
### Refactoring Performed
|
||||
|
||||
No refactoring performed - implementation is clean and follows best practices.
|
||||
|
||||
### Improvements Checklist
|
||||
|
||||
- [x] Race condition prevention with DB locking
|
||||
- [x] Double validation (pre + in-transaction)
|
||||
- [x] Email queuing for async delivery
|
||||
- [x] Proper loading states in UI
|
||||
- [x] Full bilingual support
|
||||
- [x] Audit logging for client actions
|
||||
- [ ] Consider adding a test for admin-only notification when no admin exists (edge case - currently silently skips)
|
||||
- [ ] Consider extracting the 1-per-day check to a query scope on Consultation model for reuse
|
||||
|
||||
### Security Review
|
||||
|
||||
**Status: PASS**
|
||||
- ✓ Authentication required via route middleware (`auth`, `active`)
|
||||
- ✓ Authorization implicit (client can only book for themselves via `auth()->id()`)
|
||||
- ✓ Input validation with Laravel validator
|
||||
- ✓ SQL injection prevented via Eloquent ORM
|
||||
- ✓ XSS prevented via Blade escaping
|
||||
- ✓ CSRF protection via Livewire
|
||||
- ✓ Race condition prevention via pessimistic locking
|
||||
- ✓ Problem summary has max length (2000 chars) preventing payload attacks
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
**Status: PASS**
|
||||
- ✓ Emails queued (not blocking request)
|
||||
- ✓ Database queries are indexed (user_id, booking_date, status)
|
||||
- ✓ No N+1 query issues in the booking flow
|
||||
- ✓ Reasonable transaction scope (creates one consultation, logs one entry)
|
||||
|
||||
### Files Modified During Review
|
||||
|
||||
None - no modifications needed.
|
||||
|
||||
### Gate Status
|
||||
|
||||
Gate: **PASS** → docs/qa/gates/3.4-booking-request-submission.yml
|
||||
|
||||
### Recommended Status
|
||||
|
||||
**✓ Ready for Done** - All acceptance criteria met, comprehensive test coverage, no blocking issues.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,36 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Calendar
|
||||
'available' => 'متاح',
|
||||
'partial' => 'متاح جزئيا',
|
||||
'unavailable' => 'غير متاح',
|
||||
'available_times' => 'الأوقات المتاحة',
|
||||
'no_slots_available' => 'لا توجد مواعيد متاحة لهذا التاريخ.',
|
||||
|
||||
// Booking form
|
||||
'request_consultation' => 'طلب استشارة',
|
||||
'select_date_time' => 'اختر تاريخ ووقت للاستشارة',
|
||||
'selected_time' => 'الوقت المحدد',
|
||||
'problem_summary' => 'ملخص المشكلة',
|
||||
'problem_summary_placeholder' => 'يرجى وصف مشكلتك القانونية أو استفسارك بالتفصيل...',
|
||||
'problem_summary_help' => 'الحد الأدنى 20 حرفًا. يساعد هذا المحامي على التحضير للاستشارة.',
|
||||
'continue' => 'متابعة',
|
||||
'confirm_booking' => 'تأكيد الحجز',
|
||||
'confirm_message' => 'يرجى مراجعة تفاصيل الحجز قبل الإرسال.',
|
||||
'date' => 'التاريخ',
|
||||
'time' => 'الوقت',
|
||||
'duration' => 'المدة',
|
||||
'submit_request' => 'إرسال الطلب',
|
||||
'submitted_successfully' => 'تم إرسال طلب الحجز بنجاح. ستتلقى رسالة تأكيد عبر البريد الإلكتروني قريبًا.',
|
||||
|
||||
// Validation messages
|
||||
'already_booked_this_day' => 'لديك حجز بالفعل في هذا اليوم.',
|
||||
'slot_no_longer_available' => 'هذا الموعد لم يعد متاحًا. يرجى اختيار موعد آخر.',
|
||||
'slot_taken' => 'تم حجز هذا الموعد للتو. يرجى اختيار وقت آخر.',
|
||||
|
||||
// Consultations list
|
||||
'my_consultations' => 'استشاراتي',
|
||||
'no_consultations' => 'ليس لديك استشارات حتى الآن.',
|
||||
'book_first_consultation' => 'احجز استشارتك الأولى',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,4 +9,9 @@ return [
|
|||
'actions' => 'الإجراءات',
|
||||
'yes' => 'نعم',
|
||||
'no' => 'لا',
|
||||
'change' => 'تغيير',
|
||||
'back' => 'رجوع',
|
||||
'loading' => 'جاري التحميل...',
|
||||
'submitting' => 'جاري الإرسال...',
|
||||
'minutes' => 'دقيقة',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -40,4 +40,27 @@ return [
|
|||
// Common
|
||||
'login_now' => 'تسجيل الدخول الآن',
|
||||
'regards' => 'مع أطيب التحيات',
|
||||
|
||||
// Booking Submitted (client)
|
||||
'booking_submitted_subject' => 'تم إرسال طلب الحجز',
|
||||
'booking_submitted_title' => 'تم استلام طلب الحجز',
|
||||
'booking_submitted_greeting' => 'عزيزي :name،',
|
||||
'booking_submitted_body' => 'تم إرسال طلب حجز الاستشارة بنجاح وهو قيد المراجعة.',
|
||||
'booking_details' => 'تفاصيل الحجز:',
|
||||
'booking_date' => 'التاريخ:',
|
||||
'booking_time' => 'الوقت:',
|
||||
'booking_duration' => 'المدة:',
|
||||
'booking_submitted_next_steps' => 'سيقوم فريقنا بمراجعة طلبك وستتلقى تأكيداً بمجرد الموافقة.',
|
||||
'booking_submitted_contact' => 'إذا كان لديك أي استفسار، لا تتردد في التواصل معنا.',
|
||||
|
||||
// New Booking Request (admin)
|
||||
'new_booking_request_subject' => 'طلب حجز جديد',
|
||||
'new_booking_request_title' => 'طلب حجز استشارة جديد',
|
||||
'new_booking_request_body' => 'تم إرسال طلب حجز استشارة جديد.',
|
||||
'client_details' => 'تفاصيل العميل:',
|
||||
'client_name' => 'الاسم:',
|
||||
'client_email' => 'البريد الإلكتروني:',
|
||||
'client_phone' => 'الهاتف:',
|
||||
'problem_summary' => 'ملخص المشكلة:',
|
||||
'view_in_dashboard' => 'عرض في لوحة التحكم',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'consultation_status' => [
|
||||
'pending' => 'قيد الانتظار',
|
||||
'approved' => 'موافق عليه',
|
||||
'rejected' => 'مرفوض',
|
||||
'completed' => 'مكتمل',
|
||||
'no_show' => 'لم يحضر',
|
||||
'cancelled' => 'ملغي',
|
||||
],
|
||||
];
|
||||
|
|
@ -1,9 +1,36 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Calendar
|
||||
'available' => 'Available',
|
||||
'partial' => 'Partial',
|
||||
'unavailable' => 'Unavailable',
|
||||
'available_times' => 'Available Times',
|
||||
'no_slots_available' => 'No slots available for this date.',
|
||||
|
||||
// Booking form
|
||||
'request_consultation' => 'Request Consultation',
|
||||
'select_date_time' => 'Select a date and time for your consultation',
|
||||
'selected_time' => 'Selected Time',
|
||||
'problem_summary' => 'Problem Summary',
|
||||
'problem_summary_placeholder' => 'Please describe your legal issue or question in detail...',
|
||||
'problem_summary_help' => 'Minimum 20 characters. This helps the lawyer prepare for your consultation.',
|
||||
'continue' => 'Continue',
|
||||
'confirm_booking' => 'Confirm Your Booking',
|
||||
'confirm_message' => 'Please review your booking details before submitting.',
|
||||
'date' => 'Date',
|
||||
'time' => 'Time',
|
||||
'duration' => 'Duration',
|
||||
'submit_request' => 'Submit Request',
|
||||
'submitted_successfully' => 'Your booking request has been submitted. You will receive an email confirmation shortly.',
|
||||
|
||||
// Validation messages
|
||||
'already_booked_this_day' => 'You already have a booking on this day.',
|
||||
'slot_no_longer_available' => 'This time slot is no longer available. Please select another.',
|
||||
'slot_taken' => 'This slot was just booked. Please select another time.',
|
||||
|
||||
// Consultations list
|
||||
'my_consultations' => 'My Consultations',
|
||||
'no_consultations' => 'You have no consultations yet.',
|
||||
'book_first_consultation' => 'Book Your First Consultation',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,4 +9,9 @@ return [
|
|||
'actions' => 'Actions',
|
||||
'yes' => 'Yes',
|
||||
'no' => 'No',
|
||||
'change' => 'Change',
|
||||
'back' => 'Back',
|
||||
'loading' => 'Loading...',
|
||||
'submitting' => 'Submitting...',
|
||||
'minutes' => 'minutes',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -40,4 +40,27 @@ return [
|
|||
// Common
|
||||
'login_now' => 'Login Now',
|
||||
'regards' => 'Regards',
|
||||
|
||||
// Booking Submitted (client)
|
||||
'booking_submitted_subject' => 'Booking Request Submitted',
|
||||
'booking_submitted_title' => 'Booking Request Received',
|
||||
'booking_submitted_greeting' => 'Dear :name,',
|
||||
'booking_submitted_body' => 'Your consultation booking request has been submitted successfully and is pending review.',
|
||||
'booking_details' => 'Booking Details:',
|
||||
'booking_date' => 'Date:',
|
||||
'booking_time' => 'Time:',
|
||||
'booking_duration' => 'Duration:',
|
||||
'booking_submitted_next_steps' => 'Our team will review your request and you will receive a confirmation once approved.',
|
||||
'booking_submitted_contact' => 'If you have any questions, please do not hesitate to contact us.',
|
||||
|
||||
// New Booking Request (admin)
|
||||
'new_booking_request_subject' => 'New Booking Request',
|
||||
'new_booking_request_title' => 'New Consultation Booking Request',
|
||||
'new_booking_request_body' => 'A new consultation booking request has been submitted.',
|
||||
'client_details' => 'Client Details:',
|
||||
'client_name' => 'Name:',
|
||||
'client_email' => 'Email:',
|
||||
'client_phone' => 'Phone:',
|
||||
'problem_summary' => 'Problem Summary:',
|
||||
'view_in_dashboard' => 'View in Dashboard',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'consultation_status' => [
|
||||
'pending' => 'Pending',
|
||||
'approved' => 'Approved',
|
||||
'rejected' => 'Rejected',
|
||||
'completed' => 'Completed',
|
||||
'no_show' => 'No Show',
|
||||
'cancelled' => 'Cancelled',
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
@php
|
||||
$locale = $user->preferred_language ?? 'en';
|
||||
@endphp
|
||||
@component('mail::message')
|
||||
@if($locale === 'ar')
|
||||
<div dir="rtl" style="text-align: right;">
|
||||
# {{ __('emails.booking_submitted_title', [], $locale) }}
|
||||
|
||||
{{ __('emails.booking_submitted_greeting', ['name' => $user->company_name ?? $user->full_name], $locale) }}
|
||||
|
||||
{{ __('emails.booking_submitted_body', [], $locale) }}
|
||||
|
||||
**{{ __('emails.booking_details', [], $locale) }}**
|
||||
|
||||
- **{{ __('emails.booking_date', [], $locale) }}** {{ \Carbon\Carbon::parse($consultation->booking_date)->translatedFormat('l, d M Y') }}
|
||||
- **{{ __('emails.booking_time', [], $locale) }}** {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}
|
||||
- **{{ __('emails.booking_duration', [], $locale) }}** 45 {{ __('common.minutes', [], $locale) }}
|
||||
|
||||
{{ __('emails.booking_submitted_next_steps', [], $locale) }}
|
||||
|
||||
{{ __('emails.booking_submitted_contact', [], $locale) }}
|
||||
|
||||
{{ __('emails.regards', [], $locale) }}<br>
|
||||
{{ config('app.name') }}
|
||||
</div>
|
||||
@else
|
||||
# {{ __('emails.booking_submitted_title', [], $locale) }}
|
||||
|
||||
{{ __('emails.booking_submitted_greeting', ['name' => $user->company_name ?? $user->full_name], $locale) }}
|
||||
|
||||
{{ __('emails.booking_submitted_body', [], $locale) }}
|
||||
|
||||
**{{ __('emails.booking_details', [], $locale) }}**
|
||||
|
||||
- **{{ __('emails.booking_date', [], $locale) }}** {{ \Carbon\Carbon::parse($consultation->booking_date)->translatedFormat('l, d M Y') }}
|
||||
- **{{ __('emails.booking_time', [], $locale) }}** {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}
|
||||
- **{{ __('emails.booking_duration', [], $locale) }}** 45 {{ __('common.minutes', [], $locale) }}
|
||||
|
||||
{{ __('emails.booking_submitted_next_steps', [], $locale) }}
|
||||
|
||||
{{ __('emails.booking_submitted_contact', [], $locale) }}
|
||||
|
||||
{{ __('emails.regards', [], $locale) }}<br>
|
||||
{{ config('app.name') }}
|
||||
@endif
|
||||
@endcomponent
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
@component('mail::message')
|
||||
# {{ __('emails.new_booking_request_title') }}
|
||||
|
||||
{{ __('emails.new_booking_request_body') }}
|
||||
|
||||
**{{ __('emails.client_details') }}**
|
||||
|
||||
- **{{ __('emails.client_name') }}** {{ $client->company_name ?? $client->full_name }}
|
||||
- **{{ __('emails.client_email') }}** {{ $client->email }}
|
||||
- **{{ __('emails.client_phone') }}** {{ $client->phone }}
|
||||
|
||||
**{{ __('emails.booking_details') }}**
|
||||
|
||||
- **{{ __('emails.booking_date') }}** {{ \Carbon\Carbon::parse($consultation->booking_date)->translatedFormat('l, d M Y') }}
|
||||
- **{{ __('emails.booking_time') }}** {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}
|
||||
|
||||
**{{ __('emails.problem_summary') }}**
|
||||
|
||||
{{ $consultation->problem_summary }}
|
||||
|
||||
@component('mail::button', ['url' => route('admin.dashboard')])
|
||||
{{ __('emails.view_in_dashboard') }}
|
||||
@endcomponent
|
||||
|
||||
{{ __('emails.regards') }}<br>
|
||||
{{ config('app.name') }}
|
||||
@endcomponent
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Enums\PaymentStatus;
|
||||
use App\Mail\BookingSubmittedMail;
|
||||
use App\Mail\NewBookingRequestMail;
|
||||
use App\Models\AdminLog;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\User;
|
||||
use App\Services\AvailabilityService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
public ?string $selectedDate = null;
|
||||
|
||||
public ?string $selectedTime = null;
|
||||
|
||||
public string $problemSummary = '';
|
||||
|
||||
public bool $showConfirmation = false;
|
||||
|
||||
public function selectSlot(string $date, string $time): void
|
||||
{
|
||||
$this->selectedDate = $date;
|
||||
$this->selectedTime = $time;
|
||||
}
|
||||
|
||||
public function clearSelection(): void
|
||||
{
|
||||
$this->selectedDate = null;
|
||||
$this->selectedTime = null;
|
||||
$this->showConfirmation = false;
|
||||
}
|
||||
|
||||
public function showConfirm(): void
|
||||
{
|
||||
$this->validate([
|
||||
'selectedDate' => ['required', 'date', 'after_or_equal:today'],
|
||||
'selectedTime' => ['required'],
|
||||
'problemSummary' => ['required', 'string', 'min:20', 'max:2000'],
|
||||
]);
|
||||
|
||||
// Check 1-per-day limit
|
||||
$existingBooking = Consultation::query()
|
||||
->where('user_id', auth()->id())
|
||||
->whereDate('booking_date', $this->selectedDate)
|
||||
->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
|
||||
->exists();
|
||||
|
||||
if ($existingBooking) {
|
||||
$this->addError('selectedDate', __('booking.already_booked_this_day'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify slot still available
|
||||
$service = app(AvailabilityService::class);
|
||||
$availableSlots = $service->getAvailableSlots(Carbon::parse($this->selectedDate));
|
||||
|
||||
if (! in_array($this->selectedTime, $availableSlots)) {
|
||||
$this->addError('selectedTime', __('booking.slot_no_longer_available'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->showConfirmation = true;
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () {
|
||||
// Check slot one more time with lock
|
||||
$slotTaken = Consultation::query()
|
||||
->whereDate('booking_date', $this->selectedDate)
|
||||
->where('booking_time', $this->selectedTime)
|
||||
->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
|
||||
->lockForUpdate()
|
||||
->exists();
|
||||
|
||||
if ($slotTaken) {
|
||||
throw new \Exception(__('booking.slot_taken'));
|
||||
}
|
||||
|
||||
// Check 1-per-day again with lock
|
||||
$userHasBooking = Consultation::query()
|
||||
->where('user_id', auth()->id())
|
||||
->whereDate('booking_date', $this->selectedDate)
|
||||
->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
|
||||
->lockForUpdate()
|
||||
->exists();
|
||||
|
||||
if ($userHasBooking) {
|
||||
throw new \Exception(__('booking.already_booked_this_day'));
|
||||
}
|
||||
|
||||
// Create booking
|
||||
$consultation = Consultation::create([
|
||||
'user_id' => auth()->id(),
|
||||
'booking_date' => $this->selectedDate,
|
||||
'booking_time' => $this->selectedTime,
|
||||
'problem_summary' => $this->problemSummary,
|
||||
'status' => ConsultationStatus::Pending,
|
||||
'payment_status' => PaymentStatus::NotApplicable,
|
||||
]);
|
||||
|
||||
// Send email to client
|
||||
Mail::to(auth()->user())->queue(
|
||||
new BookingSubmittedMail($consultation)
|
||||
);
|
||||
|
||||
// Send email to admin
|
||||
$admin = User::query()->where('user_type', 'admin')->first();
|
||||
if ($admin) {
|
||||
Mail::to($admin)->queue(
|
||||
new NewBookingRequestMail($consultation)
|
||||
);
|
||||
}
|
||||
|
||||
// Log action
|
||||
AdminLog::create([
|
||||
'admin_id' => null,
|
||||
'action' => 'create',
|
||||
'target_type' => 'consultation',
|
||||
'target_id' => $consultation->id,
|
||||
'new_values' => $consultation->toArray(),
|
||||
'ip_address' => request()->ip(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
session()->flash('success', __('booking.submitted_successfully'));
|
||||
$this->redirect(route('client.consultations.index'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('selectedTime', $e->getMessage());
|
||||
$this->showConfirmation = false;
|
||||
}
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<flux:heading size="xl" class="mb-6">{{ __('booking.request_consultation') }}</flux:heading>
|
||||
|
||||
@if(!$selectedDate || !$selectedTime)
|
||||
<!-- Step 1: Calendar Selection -->
|
||||
<div class="mt-6">
|
||||
<p class="mb-4 text-zinc-600 dark:text-zinc-400">{{ __('booking.select_date_time') }}</p>
|
||||
<livewire:availability-calendar />
|
||||
</div>
|
||||
@else
|
||||
<!-- Step 2: Problem Summary -->
|
||||
<div class="mt-6">
|
||||
<!-- Selected Time Display -->
|
||||
<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>
|
||||
|
||||
@error('selectedDate')
|
||||
<flux:callout variant="danger" class="mb-4">
|
||||
{{ $message }}
|
||||
</flux:callout>
|
||||
@enderror
|
||||
|
||||
@if(!$showConfirmation)
|
||||
<!-- Problem Summary Form -->
|
||||
<flux:field>
|
||||
<flux:label>{{ __('booking.problem_summary') }} *</flux:label>
|
||||
<flux:textarea
|
||||
wire:model="problemSummary"
|
||||
rows="6"
|
||||
placeholder="{{ __('booking.problem_summary_placeholder') }}"
|
||||
/>
|
||||
<flux:description>
|
||||
{{ __('booking.problem_summary_help') }}
|
||||
</flux:description>
|
||||
<flux:error name="problemSummary" />
|
||||
</flux:field>
|
||||
|
||||
<flux:button
|
||||
wire:click="showConfirm"
|
||||
class="mt-4"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
<span wire:loading.remove wire:target="showConfirm">{{ __('booking.continue') }}</span>
|
||||
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
|
||||
</flux:button>
|
||||
@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.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 gap-3 mt-4">
|
||||
<flux:button wire:click="$set('showConfirmation', false)">
|
||||
{{ __('common.back') }}
|
||||
</flux:button>
|
||||
<flux:button
|
||||
wire:click="submit"
|
||||
variant="primary"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
<span wire:loading.remove 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
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Models\Consultation;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'consultations' => Consultation::query()
|
||||
->where('user_id', auth()->id())
|
||||
->orderBy('booking_date', 'desc')
|
||||
->paginate(10),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<flux:heading size="xl">{{ __('booking.my_consultations') }}</flux:heading>
|
||||
<flux:button href="{{ route('client.consultations.book') }}" variant="primary">
|
||||
{{ __('booking.request_consultation') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<flux:callout variant="success" class="mb-6">
|
||||
{{ session('success') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="space-y-4">
|
||||
@forelse($consultations as $consultation)
|
||||
<div wire:key="consultation-{{ $consultation->id }}" class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{{ \Carbon\Carbon::parse($consultation->booking_date)->translatedFormat('l, d M Y') }}
|
||||
</p>
|
||||
<p class="text-zinc-600 dark:text-zinc-400">
|
||||
{{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}
|
||||
</p>
|
||||
</div>
|
||||
<flux:badge :variant="match($consultation->status) {
|
||||
ConsultationStatus::Pending => 'warning',
|
||||
ConsultationStatus::Approved => 'success',
|
||||
ConsultationStatus::Completed => 'default',
|
||||
ConsultationStatus::Cancelled => 'danger',
|
||||
ConsultationStatus::NoShow => 'danger',
|
||||
default => 'default',
|
||||
}">
|
||||
{{ $consultation->status->label() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">
|
||||
{{ $consultation->problem_summary }}
|
||||
</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||
<p>{{ __('booking.no_consultations') }}</p>
|
||||
<flux:button href="{{ route('client.consultations.book') }}" class="mt-4">
|
||||
{{ __('booking.book_first_consultation') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $consultations->links() }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -71,6 +71,12 @@ Route::middleware(['auth', 'active'])->group(function () {
|
|||
Route::prefix('client')->group(function () {
|
||||
Route::view('/dashboard', 'livewire.client.dashboard-placeholder')
|
||||
->name('client.dashboard');
|
||||
|
||||
// Consultations
|
||||
Route::prefix('consultations')->name('client.consultations.')->group(function () {
|
||||
Volt::route('/', 'client.consultations.index')->name('index');
|
||||
Volt::route('/book', 'client.consultations.book')->name('book');
|
||||
});
|
||||
});
|
||||
|
||||
// Settings routes
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Mail\BookingSubmittedMail;
|
||||
use App\Mail\NewBookingRequestMail;
|
||||
use App\Models\AdminLog;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkingHour;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
beforeEach(function () {
|
||||
// Setup working hours for Monday (day 1)
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => 1,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
'is_active' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
test('guest cannot access booking form', function () {
|
||||
$this->get(route('client.consultations.book'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('authenticated client can access booking form', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($client)
|
||||
->get(route('client.consultations.book'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('authenticated client can submit booking request', function () {
|
||||
Mail::fake();
|
||||
|
||||
$client = User::factory()->individual()->create();
|
||||
User::factory()->admin()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.')
|
||||
->call('showConfirm')
|
||||
->assertSet('showConfirmation', true)
|
||||
->call('submit')
|
||||
->assertRedirect(route('client.consultations.index'));
|
||||
|
||||
expect(Consultation::where('user_id', $client->id)->exists())->toBeTrue();
|
||||
|
||||
Mail::assertQueued(BookingSubmittedMail::class);
|
||||
Mail::assertQueued(NewBookingRequestMail::class);
|
||||
});
|
||||
|
||||
test('booking is created with pending status', function () {
|
||||
Mail::fake();
|
||||
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->call('submit');
|
||||
|
||||
$consultation = Consultation::where('user_id', $client->id)->first();
|
||||
expect($consultation->status)->toBe(ConsultationStatus::Pending);
|
||||
});
|
||||
|
||||
test('problem summary is required', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', '')
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['problemSummary' => 'required']);
|
||||
});
|
||||
|
||||
test('problem summary must be at least 20 characters', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'Too short')
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['problemSummary' => 'min']);
|
||||
});
|
||||
|
||||
test('problem summary cannot exceed 2000 characters', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', str_repeat('a', 2001))
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['problemSummary' => 'max']);
|
||||
});
|
||||
|
||||
test('client cannot book more than once per day', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
// Create existing booking
|
||||
Consultation::factory()->pending()->create([
|
||||
'user_id' => $client->id,
|
||||
'booking_date' => $monday,
|
||||
'booking_time' => '09:00',
|
||||
]);
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['selectedDate']);
|
||||
});
|
||||
|
||||
test('client can book on different day if already booked on another', function () {
|
||||
Mail::fake();
|
||||
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
$nextMonday = now()->next('Monday')->addWeek()->format('Y-m-d');
|
||||
|
||||
// Create existing booking for this Monday
|
||||
Consultation::factory()->pending()->create([
|
||||
'user_id' => $client->id,
|
||||
'booking_date' => $monday,
|
||||
'booking_time' => '09:00',
|
||||
]);
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $nextMonday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->assertSet('showConfirmation', true);
|
||||
});
|
||||
|
||||
test('client cannot book unavailable slot', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
// Create booking that takes the slot
|
||||
Consultation::factory()->approved()->create([
|
||||
'booking_date' => $monday,
|
||||
'booking_time' => '10:00',
|
||||
]);
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['selectedTime']);
|
||||
});
|
||||
|
||||
test('confirmation step displays before final submission', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->assertSet('showConfirmation', false)
|
||||
->call('showConfirm')
|
||||
->assertSet('showConfirmation', true);
|
||||
});
|
||||
|
||||
test('user can go back from confirmation to edit', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->assertSet('showConfirmation', true)
|
||||
->set('showConfirmation', false)
|
||||
->assertSet('showConfirmation', false);
|
||||
});
|
||||
|
||||
test('user can clear slot selection', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->assertSet('selectedDate', $monday)
|
||||
->assertSet('selectedTime', '10:00')
|
||||
->call('clearSelection')
|
||||
->assertSet('selectedDate', null)
|
||||
->assertSet('selectedTime', null);
|
||||
});
|
||||
|
||||
test('success message shown after submission', function () {
|
||||
Mail::fake();
|
||||
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->call('submit')
|
||||
->assertSessionHas('success');
|
||||
});
|
||||
|
||||
test('audit log entry is created on booking submission', function () {
|
||||
Mail::fake();
|
||||
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->call('submit');
|
||||
|
||||
$consultation = Consultation::where('user_id', $client->id)->first();
|
||||
|
||||
expect(AdminLog::query()
|
||||
->where('action', 'create')
|
||||
->where('target_type', 'consultation')
|
||||
->where('target_id', $consultation->id)
|
||||
->whereNull('admin_id')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
test('emails are sent to client and admin after submission', function () {
|
||||
Mail::fake();
|
||||
|
||||
$client = User::factory()->individual()->create();
|
||||
$admin = User::factory()->admin()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->call('submit');
|
||||
|
||||
Mail::assertQueued(BookingSubmittedMail::class, function ($mail) use ($client) {
|
||||
return $mail->hasTo($client->email);
|
||||
});
|
||||
|
||||
Mail::assertQueued(NewBookingRequestMail::class, function ($mail) use ($admin) {
|
||||
return $mail->hasTo($admin->email);
|
||||
});
|
||||
});
|
||||
|
||||
test('booking fails if slot is taken during submission (race condition)', function () {
|
||||
Mail::fake();
|
||||
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
$component = Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->assertSet('showConfirmation', true);
|
||||
|
||||
// Another user takes the slot
|
||||
Consultation::factory()->approved()->create([
|
||||
'booking_date' => $monday,
|
||||
'booking_time' => '10:00',
|
||||
]);
|
||||
|
||||
$component->call('submit')
|
||||
->assertHasErrors(['selectedTime'])
|
||||
->assertSet('showConfirmation', false);
|
||||
|
||||
expect(Consultation::where('user_id', $client->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('booking fails if user already booked during submission (race condition)', function () {
|
||||
Mail::fake();
|
||||
|
||||
$client = User::factory()->individual()->create();
|
||||
$monday = now()->next('Monday')->format('Y-m-d');
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
$component = Volt::test('client.consultations.book')
|
||||
->call('selectSlot', $monday, '10:00')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->call('showConfirm')
|
||||
->assertSet('showConfirmation', true);
|
||||
|
||||
// User books another slot on same day (from another browser tab)
|
||||
Consultation::factory()->pending()->create([
|
||||
'user_id' => $client->id,
|
||||
'booking_date' => $monday,
|
||||
'booking_time' => '11:00',
|
||||
]);
|
||||
|
||||
$component->call('submit')
|
||||
->assertHasErrors(['selectedTime'])
|
||||
->assertSet('showConfirmation', false);
|
||||
|
||||
// Should only have the one booking from the "other tab"
|
||||
expect(Consultation::where('user_id', $client->id)->count())->toBe(1);
|
||||
});
|
||||
Loading…
Reference in New Issue