reviewed epic 3
This commit is contained in:
@@ -295,6 +295,356 @@ new class extends Component {
|
||||
};
|
||||
```
|
||||
|
||||
### Notification Classes
|
||||
Create these notification classes in `app/Notifications/`:
|
||||
|
||||
```php
|
||||
// app/Notifications/BookingApproved.php
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Consultation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingApproved extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public Consultation $consultation,
|
||||
public string $icsContent,
|
||||
public ?string $paymentInstructions = null
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->preferred_language ?? 'ar';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($this->getSubject($locale))
|
||||
->markdown('emails.booking.approved', [
|
||||
'consultation' => $this->consultation,
|
||||
'paymentInstructions' => $this->paymentInstructions,
|
||||
'locale' => $locale,
|
||||
])
|
||||
->attachData(
|
||||
$this->icsContent,
|
||||
'consultation.ics',
|
||||
['mime' => 'text/calendar']
|
||||
);
|
||||
}
|
||||
|
||||
private function getSubject(string $locale): string
|
||||
{
|
||||
return $locale === 'ar'
|
||||
? 'تمت الموافقة على حجز استشارتك'
|
||||
: 'Your Consultation Booking Approved';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// app/Notifications/BookingRejected.php
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Consultation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingRejected extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public Consultation $consultation,
|
||||
public ?string $rejectionReason = null
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->preferred_language ?? 'ar';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($this->getSubject($locale))
|
||||
->markdown('emails.booking.rejected', [
|
||||
'consultation' => $this->consultation,
|
||||
'rejectionReason' => $this->rejectionReason,
|
||||
'locale' => $locale,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getSubject(string $locale): string
|
||||
{
|
||||
return $locale === 'ar'
|
||||
? 'بخصوص طلب الاستشارة الخاص بك'
|
||||
: 'Regarding Your Consultation Request';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
```php
|
||||
// In the approve() method, wrap calendar generation with error handling
|
||||
public function approve(): void
|
||||
{
|
||||
// ... validation ...
|
||||
|
||||
try {
|
||||
$calendarService = app(CalendarService::class);
|
||||
$icsContent = $calendarService->generateIcs($this->consultation);
|
||||
} catch (\Exception $e) {
|
||||
// Log error but don't block approval
|
||||
Log::error('Failed to generate calendar file', [
|
||||
'consultation_id' => $this->consultation->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$icsContent = null;
|
||||
}
|
||||
|
||||
// Update consultation status regardless
|
||||
$this->consultation->update([
|
||||
'status' => 'approved',
|
||||
'type' => $this->consultationType,
|
||||
'payment_amount' => $this->consultationType === 'paid' ? $this->paymentAmount : null,
|
||||
'payment_status' => $this->consultationType === 'paid' ? 'pending' : 'not_applicable',
|
||||
]);
|
||||
|
||||
// Send notification (with or without .ics)
|
||||
$this->consultation->user->notify(
|
||||
new BookingApproved(
|
||||
$this->consultation,
|
||||
$icsContent ?? '',
|
||||
$this->paymentInstructions
|
||||
)
|
||||
);
|
||||
|
||||
// ... audit log and redirect ...
|
||||
}
|
||||
```
|
||||
|
||||
### Edge Cases
|
||||
- **Already approved booking:** The approve/reject buttons should only appear for `status = 'pending'`. Add guard clause:
|
||||
```php
|
||||
if ($this->consultation->status !== 'pending') {
|
||||
session()->flash('error', __('messages.booking_already_processed'));
|
||||
return;
|
||||
}
|
||||
```
|
||||
- **Concurrent approval:** Use database transaction with locking to prevent race conditions
|
||||
- **Missing user:** Check `$this->consultation->user` exists before sending notification
|
||||
|
||||
### Testing Examples
|
||||
|
||||
```php
|
||||
use App\Models\Consultation;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BookingApproved;
|
||||
use App\Notifications\BookingRejected;
|
||||
use App\Services\CalendarService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
// Test: Admin can view pending bookings list
|
||||
it('displays pending bookings list for admin', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultations = Consultation::factory()->count(3)->pending()->create();
|
||||
|
||||
Volt::test('admin.bookings.pending-list')
|
||||
->actingAs($admin)
|
||||
->assertSee($consultations->first()->user->name)
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
// Test: Admin can approve booking as free consultation
|
||||
it('can approve booking as free consultation', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.bookings.review', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('consultationType', 'free')
|
||||
->call('approve')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('admin.bookings.pending'));
|
||||
|
||||
expect($consultation->fresh())
|
||||
->status->toBe('approved')
|
||||
->type->toBe('free')
|
||||
->payment_status->toBe('not_applicable');
|
||||
|
||||
Notification::assertSentTo($consultation->user, BookingApproved::class);
|
||||
});
|
||||
|
||||
// Test: Admin can approve booking as paid consultation with amount
|
||||
it('can approve booking as paid consultation with amount', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.bookings.review', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('consultationType', 'paid')
|
||||
->set('paymentAmount', 150.00)
|
||||
->set('paymentInstructions', 'Bank transfer to account XYZ')
|
||||
->call('approve')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($consultation->fresh())
|
||||
->status->toBe('approved')
|
||||
->type->toBe('paid')
|
||||
->payment_amount->toBe(150.00)
|
||||
->payment_status->toBe('pending');
|
||||
});
|
||||
|
||||
// Test: Paid consultation requires payment amount
|
||||
it('requires payment amount for paid consultation', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.bookings.review', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('consultationType', 'paid')
|
||||
->set('paymentAmount', null)
|
||||
->call('approve')
|
||||
->assertHasErrors(['paymentAmount']);
|
||||
});
|
||||
|
||||
// Test: Admin can reject booking with reason
|
||||
it('can reject booking with optional reason', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.bookings.review', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('rejectionReason', 'Schedule conflict')
|
||||
->call('reject')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('admin.bookings.pending'));
|
||||
|
||||
expect($consultation->fresh())->status->toBe('rejected');
|
||||
|
||||
Notification::assertSentTo($consultation->user, BookingRejected::class);
|
||||
});
|
||||
|
||||
// Test: Quick approve from list
|
||||
it('can quick approve booking from list', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.bookings.pending-list')
|
||||
->actingAs($admin)
|
||||
->call('quickApprove', $consultation->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($consultation->fresh())
|
||||
->status->toBe('approved')
|
||||
->type->toBe('free');
|
||||
});
|
||||
|
||||
// Test: Quick reject from list
|
||||
it('can quick reject booking from list', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.bookings.pending-list')
|
||||
->actingAs($admin)
|
||||
->call('quickReject', $consultation->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($consultation->fresh())->status->toBe('rejected');
|
||||
});
|
||||
|
||||
// Test: Audit log entry created on approval
|
||||
it('creates audit log entry on approval', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.bookings.review', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('consultationType', 'free')
|
||||
->call('approve');
|
||||
|
||||
$this->assertDatabaseHas('admin_logs', [
|
||||
'admin_id' => $admin->id,
|
||||
'action_type' => 'approve',
|
||||
'target_type' => 'consultation',
|
||||
'target_id' => $consultation->id,
|
||||
]);
|
||||
});
|
||||
|
||||
// Test: Cannot approve already processed booking
|
||||
it('cannot approve already approved booking', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
Volt::test('admin.bookings.review', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->call('approve')
|
||||
->assertHasErrors();
|
||||
});
|
||||
|
||||
// Test: Filter bookings by date range
|
||||
it('can filter pending bookings by date range', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$oldBooking = Consultation::factory()->pending()->create([
|
||||
'scheduled_date' => now()->subDays(10),
|
||||
]);
|
||||
$newBooking = Consultation::factory()->pending()->create([
|
||||
'scheduled_date' => now()->addDays(5),
|
||||
]);
|
||||
|
||||
Volt::test('admin.bookings.pending-list')
|
||||
->actingAs($admin)
|
||||
->set('dateFrom', now()->toDateString())
|
||||
->assertSee($newBooking->user->name)
|
||||
->assertDontSee($oldBooking->user->name);
|
||||
});
|
||||
|
||||
// Test: Bilingual notification (Arabic)
|
||||
it('sends approval notification in client preferred language', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$arabicUser = User::factory()->create(['preferred_language' => 'ar']);
|
||||
$consultation = Consultation::factory()->pending()->create(['user_id' => $arabicUser->id]);
|
||||
|
||||
Volt::test('admin.bookings.review', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('consultationType', 'free')
|
||||
->call('approve');
|
||||
|
||||
Notification::assertSentTo($arabicUser, BookingApproved::class, function ($notification) {
|
||||
return $notification->consultation->user->preferred_language === 'ar';
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] Pending bookings list displays correctly
|
||||
@@ -312,9 +662,10 @@ new class extends Component {
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Story 3.4:** Booking submission (creates pending bookings)
|
||||
- **Story 3.6:** Calendar file generation (.ics)
|
||||
- **Epic 8:** Email notifications
|
||||
- **Story 3.4:** `docs/stories/story-3.4-booking-request-submission.md` - Creates pending bookings to review
|
||||
- **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation
|
||||
- **Epic 8:** `docs/epics/epic-8-email-notifications.md#story-84-booking-approved-email` - BookingApproved notification
|
||||
- **Epic 8:** `docs/epics/epic-8-email-notifications.md#story-85-booking-rejected-email` - BookingRejected notification
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
|
||||
Reference in New Issue
Block a user