From 261a528578922eae2ab353ab00be7d56e89818f1 Mon Sep 17 00:00:00 2001 From: Naser Mansour Date: Sun, 21 Dec 2025 00:30:29 +0200 Subject: [PATCH] reviewed epic 8 stories --- .../story-8.1-email-infrastructure-setup.md | 200 +++++++-- ...y-8.10-admin-notification-system-events.md | 403 ++++++++++++++++-- docs/stories/story-8.2-welcome-email.md | 131 +++++- ...tory-8.3-booking-submitted-confirmation.md | 249 ++++++++++- .../story-8.4-booking-approved-email.md | 185 +++++++- .../story-8.5-booking-rejected-email.md | 228 +++++++++- .../story-8.6-consultation-reminder-24h.md | 250 +++++++++-- .../story-8.7-consultation-reminder-2h.md | 262 ++++++++++-- .../story-8.8-timeline-update-notification.md | 352 +++++++++++++-- ...tory-8.9-admin-notification-new-booking.md | 347 +++++++++++++-- 10 files changed, 2391 insertions(+), 216 deletions(-) diff --git a/docs/stories/story-8.1-email-infrastructure-setup.md b/docs/stories/story-8.1-email-infrastructure-setup.md index b49785f..453bbdd 100644 --- a/docs/stories/story-8.1-email-infrastructure-setup.md +++ b/docs/stories/story-8.1-email-infrastructure-setup.md @@ -3,6 +3,9 @@ ## Epic Reference **Epic 8:** Email Notification System +## Story Context +This is the **foundational story** for Epic 8. All subsequent email stories (8.2-8.10) depend on this infrastructure being complete. No other stories in Epic 8 can be implemented until this story is done. + ## User Story As a **developer**, I want **to configure email sending infrastructure and base templates**, @@ -25,52 +28,195 @@ So that **all emails have consistent branding and reliable delivery**. - [ ] Mobile-responsive layout ### Technical Setup -- [ ] Plain text fallback generation -- [ ] Queue configuration for async sending -- [ ] Email logging for debugging +- [ ] Plain text fallback generation (auto-generated from HTML) +- [ ] Queue configuration for async sending (database driver) +- [ ] Email logging for debugging (log channel) -## Technical Notes +## Implementation Steps + +### Step 1: Publish Laravel Mail Views +```bash +php artisan vendor:publish --tag=laravel-mail +``` +This creates `resources/views/vendor/mail/` with customizable templates. + +### Step 2: Create Base Mailable Class +Create `app/Mail/BaseMailable.php`: ```php -// config/mail.php - from .env -'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'no-reply@libra.ps'), - 'name' => env('MAIL_FROM_NAME', 'Libra Law Firm'), -], + - - - Libra Law Firm - - - +namespace App\Mail; -// Base Mailable -abstract class BaseMailable extends Mailable +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Content; +use Illuminate\Mail\Mailables\Envelope; +use Illuminate\Queue\SerializesModels; + +abstract class BaseMailable extends Mailable implements ShouldQueue { use Queueable, SerializesModels; - public function build() + public function envelope(): Envelope { - return $this->from(config('mail.from.address'), $this->getFromName()); + return new Envelope( + from: new \Illuminate\Mail\Mailables\Address( + config('mail.from.address'), + $this->getFromName() + ), + ); } protected function getFromName(): string { - $locale = $this->getLocale(); + $locale = $this->locale ?? app()->getLocale(); return $locale === 'ar' ? 'مكتب ليبرا للمحاماة' : 'Libra Law Firm'; } } ``` +### Step 3: Configure Queue for Email +Ensure `config/queue.php` uses the database driver and run: +```bash +php artisan queue:table +php artisan migrate +``` + +### Step 4: Update Environment Variables +Add to `.env.example`: +```env +MAIL_MAILER=smtp +MAIL_HOST= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS=no-reply@libra.ps +MAIL_FROM_NAME="Libra Law Firm" + +QUEUE_CONNECTION=database +``` + +### Step 5: Create Email Logo Asset +Place the email logo at `public/images/logo-email.png` (120px height recommended for email clients). + +### Step 6: Customize Mail Templates +Modify `resources/views/vendor/mail/html/header.blade.php`: +```blade + + + + Libra Law Firm + + + +``` + +Modify `resources/views/vendor/mail/html/themes/default.css` for brand colors: +- Primary background: #0A1F44 (navy) +- Accent/buttons: #D4AF37 (gold) +- Button text: #0A1F44 (navy on gold) + +### Step 7: Configure Email Logging +In `config/logging.php`, ensure a channel exists for mail debugging. Emails are automatically logged when using the `log` mail driver for local testing. + +## Error Handling + +- **SMTP Connection Failures**: Queue will retry failed jobs automatically (3 attempts by default) +- **Configure retry delay** in `config/queue.php` under `retry_after` +- **Failed jobs** go to `failed_jobs` table for inspection +- **Monitor queue** with `php artisan queue:failed` to see failed emails + +## Technical Notes + +### Files to Create/Modify +| File | Action | +|------|--------| +| `app/Mail/BaseMailable.php` | Create | +| `resources/views/vendor/mail/html/header.blade.php` | Modify | +| `resources/views/vendor/mail/html/footer.blade.php` | Modify | +| `resources/views/vendor/mail/html/themes/default.css` | Modify | +| `public/images/logo-email.png` | Create/Add | +| `.env.example` | Update | +| `config/mail.php` | Verify defaults | + +### Queue Configuration +This project uses the **database** queue driver for reliability. Ensure queue worker runs in production: +```bash +php artisan queue:work --queue=default +``` + +### Local Testing +For local development, use the `log` mail driver: +```env +MAIL_MAILER=log +``` +Emails will appear in `storage/logs/laravel.log`. + +For visual testing, consider Mailpit or similar (optional): +```env +MAIL_MAILER=smtp +MAIL_HOST=localhost +MAIL_PORT=1025 +``` + +## Testing Requirements + +### Test Scenarios +Create `tests/Feature/Mail/BaseMailableTest.php`: + +- [ ] **SMTP configuration validates** - Verify mail config loads correctly +- [ ] **Base template renders with branding** - Logo, colors visible in HTML output +- [ ] **Plain text fallback generates** - HTML converts to readable plain text +- [ ] **Emails queue successfully** - Job dispatches to queue, not sent synchronously +- [ ] **Arabic sender name works** - "مكتب ليبرا للمحاماة" when locale is 'ar' +- [ ] **English sender name works** - "Libra Law Firm" when locale is 'en' +- [ ] **Failed emails retry** - Queue retries on temporary failure + +### Example Test Structure +```php +setLocale('ar'); + + // Create mailable and check from name + // expect($mailable->getFromName())->toBe('مكتب ليبرا للمحاماة'); +}); + +test('sender name is english when locale is en', function () { + app()->setLocale('en'); + + // Create mailable and check from name + // expect($mailable->getFromName())->toBe('Libra Law Firm'); +}); +``` + ## Definition of Done -- [ ] SMTP sending works -- [ ] Base template with branding -- [ ] Plain text fallback -- [ ] Queued delivery works -- [ ] Tests pass +- [ ] SMTP sending works (verified with real credentials or log driver) +- [ ] Base template displays Libra branding (logo, navy/gold colors) +- [ ] Plain text fallback auto-generates from HTML +- [ ] Emails dispatch to queue (not sent synchronously) +- [ ] Queue worker processes emails successfully +- [ ] All tests pass +- [ ] Code formatted with Pint + +## Dependencies +- **Requires**: None (foundational story) +- **Blocks**: Stories 8.2-8.10 (all other email stories) ## Estimation **Complexity:** Medium | **Effort:** 3-4 hours diff --git a/docs/stories/story-8.10-admin-notification-system-events.md b/docs/stories/story-8.10-admin-notification-system-events.md index c0dfa06..0cd6ce8 100644 --- a/docs/stories/story-8.10-admin-notification-system-events.md +++ b/docs/stories/story-8.10-admin-notification-system-events.md @@ -8,35 +8,184 @@ As an **admin**, I want **to be notified of critical system events**, So that **I can address issues promptly**. +## Dependencies +- **Story 8.1:** Email Infrastructure Setup (base template, SMTP configuration, queue setup) + ## Acceptance Criteria ### Events to Notify -- [ ] Email delivery failures -- [ ] Scheduled job failures -- [ ] Critical application errors +- [ ] Email delivery failures (SMTP errors, bounces) +- [ ] Scheduled job failures (Laravel scheduler) +- [ ] Queue job failures +- [ ] Critical application errors (database, payment, etc.) -### Content +### Content Requirements - [ ] Event type and description -- [ ] Timestamp -- [ ] Relevant details -- [ ] Recommended action (if any) +- [ ] Timestamp (formatted per locale) +- [ ] Relevant details (error message, stack trace summary, job name) +- [ ] Recommended action (if applicable) +- [ ] Environment indicator (production/staging) -### Delivery -- [ ] Sent immediately (not queued) -- [ ] Clear subject line indicating urgency +### Delivery Requirements +- [ ] Sent immediately using sync mail driver (not queued) +- [ ] Clear subject line indicating urgency with event type +- [ ] Rate limited: max 1 notification per error type per 15 minutes +- [ ] Uses base email template from Story 8.1 + +### Critical Error Definition + +**DO Notify for:** +- Database connection failures (`QueryException` with connection errors) +- Queue connection failures +- Mail delivery failures (`Swift_TransportException`, mail exceptions) +- Scheduled command failures +- Any exception marked with `ShouldNotifyAdmin` interface + +**DO NOT Notify for:** +- Validation exceptions (`ValidationException`) +- Authentication failures (`AuthenticationException`) +- Authorization failures (`AuthorizationException`) +- Model not found (`ModelNotFoundException`) +- HTTP 404 errors (`NotFoundHttpException`) +- CSRF token mismatches (`TokenMismatchException`) ## Technical Notes +### Files to Create +- `app/Notifications/SystemErrorNotification.php` +- `app/Notifications/QueueFailureNotification.php` +- `app/Contracts/ShouldNotifyAdmin.php` (marker interface) +- `app/Services/AdminNotificationService.php` (rate limiting logic) + +### Files to Modify +- `bootstrap/app.php` (exception handling) +- `app/Providers/AppServiceProvider.php` (queue failure listener) + +### Environment Variables +```env +# Admin notification settings (add to .env.example) +ADMIN_NOTIFICATION_EMAIL=admin@libra.ps +SYSTEM_NOTIFICATIONS_ENABLED=true +ERROR_NOTIFICATION_THROTTLE_MINUTES=15 +``` + +### Configuration ```php -// In App\Exceptions\Handler or bootstrap/app.php +// config/libra.php (add section) +'notifications' => [ + 'admin_email' => env('ADMIN_NOTIFICATION_EMAIL'), + 'system_notifications_enabled' => env('SYSTEM_NOTIFICATIONS_ENABLED', true), + 'throttle_minutes' => env('ERROR_NOTIFICATION_THROTTLE_MINUTES', 15), +], +``` + +### Admin Notification Service +```php +// app/Services/AdminNotificationService.php +namespace App\Services; + +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Notification; +use App\Notifications\SystemErrorNotification; + +class AdminNotificationService +{ + public function notifyError(Throwable $exception): void + { + if (!config('libra.notifications.system_notifications_enabled')) { + return; + } + + $errorKey = $this->getErrorKey($exception); + $throttleMinutes = config('libra.notifications.throttle_minutes', 15); + + // Rate limiting: skip if we've notified about this error type recently + if (Cache::has($errorKey)) { + return; + } + + Cache::put($errorKey, true, now()->addMinutes($throttleMinutes)); + + $adminEmail = config('libra.notifications.admin_email'); + + // Send immediately (sync) - not queued + Notification::route('mail', $adminEmail) + ->notifyNow(new SystemErrorNotification($exception)); + } + + protected function getErrorKey(Throwable $exception): string + { + return 'admin_notified:' . md5(get_class($exception) . $exception->getMessage()); + } + + public function shouldNotifyAdmin(Throwable $exception): bool + { + // Check if exception implements marker interface + if ($exception instanceof \App\Contracts\ShouldNotifyAdmin) { + return true; + } + + // Check against excluded exception types + $excludedTypes = [ + \Illuminate\Validation\ValidationException::class, + \Illuminate\Auth\AuthenticationException::class, + \Illuminate\Auth\Access\AuthorizationException::class, + \Illuminate\Database\Eloquent\ModelNotFoundException::class, + \Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class, + \Illuminate\Session\TokenMismatchException::class, + ]; + + foreach ($excludedTypes as $type) { + if ($exception instanceof $type) { + return false; + } + } + + // Check for critical database errors + if ($exception instanceof \Illuminate\Database\QueryException) { + return $this->isCriticalDatabaseError($exception); + } + + // Notify for mail exceptions + if ($exception instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface) { + return true; + } + + return false; + } + + protected function isCriticalDatabaseError(\Illuminate\Database\QueryException $e): bool + { + $criticalCodes = ['2002', '1045', '1049']; // Connection refused, access denied, unknown DB + return in_array($e->getCode(), $criticalCodes); + } +} +``` + +### Exception Handler Integration +```php +// bootstrap/app.php +use App\Services\AdminNotificationService; + ->withExceptions(function (Exceptions $exceptions) { $exceptions->reportable(function (Throwable $e) { - if ($this->shouldNotifyAdmin($e)) { - $admin = User::where('user_type', 'admin')->first(); - $admin?->notify(new SystemErrorNotification($e)); + $service = app(AdminNotificationService::class); + + if ($service->shouldNotifyAdmin($e)) { + $service->notifyError($e); } }); -}); +}) +``` + +### System Error Notification +```php +// app/Notifications/SystemErrorNotification.php +namespace App\Notifications; + +use Illuminate\Notifications\Notification; +use Illuminate\Notifications\Messages\MailMessage; +use Throwable; class SystemErrorNotification extends Notification { @@ -51,29 +200,223 @@ class SystemErrorNotification extends Notification public function toMail(object $notifiable): MailMessage { + $environment = app()->environment(); + $errorType = class_basename($this->exception); + return (new MailMessage) - ->subject('[URGENT] System Error - Libra Law Firm') + ->subject("[URGENT] [{$environment}] System Error: {$errorType} - Libra Law Firm") + ->greeting('System Alert') ->line('A critical error occurred on the platform.') - ->line('Error: ' . $this->exception->getMessage()) - ->line('Time: ' . now()->format('Y-m-d H:i:s')) - ->line('Please check the logs for more details.'); + ->line("**Error Type:** {$errorType}") + ->line("**Message:** " . $this->exception->getMessage()) + ->line("**File:** " . $this->exception->getFile() . ':' . $this->exception->getLine()) + ->line("**Time:** " . now()->format('Y-m-d H:i:s')) + ->line("**Environment:** {$environment}") + ->line('Please check the application logs for full stack trace and details.') + ->salutation('— Libra System Monitor'); } } +``` -// Queue failure notification -Queue::failing(function (JobFailed $event) { - $admin = User::where('user_type', 'admin')->first(); - $admin?->notify(new QueueFailureNotification($event)); +### Queue Failure Notification +```php +// app/Notifications/QueueFailureNotification.php +namespace App\Notifications; + +use Illuminate\Notifications\Notification; +use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Queue\Events\JobFailed; + +class QueueFailureNotification extends Notification +{ + public function __construct( + public JobFailed $event + ) {} + + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $jobName = $this->event->job->resolveName(); + $environment = app()->environment(); + + return (new MailMessage) + ->subject("[URGENT] [{$environment}] Queue Job Failed: {$jobName} - Libra Law Firm") + ->greeting('Queue Failure Alert') + ->line('A queued job has failed.') + ->line("**Job:** {$jobName}") + ->line("**Queue:** " . $this->event->job->getQueue()) + ->line("**Error:** " . $this->event->exception->getMessage()) + ->line("**Time:** " . now()->format('Y-m-d H:i:s')) + ->line("**Attempts:** " . $this->event->job->attempts()) + ->line('Please check the failed_jobs table and application logs for details.') + ->salutation('— Libra System Monitor'); + } +} +``` + +### Queue Failure Listener Registration +```php +// app/Providers/AppServiceProvider.php boot() method +use Illuminate\Support\Facades\Queue; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Support\Facades\Notification; +use App\Notifications\QueueFailureNotification; + +public function boot(): void +{ + Queue::failing(function (JobFailed $event) { + if (!config('libra.notifications.system_notifications_enabled')) { + return; + } + + $adminEmail = config('libra.notifications.admin_email'); + + Notification::route('mail', $adminEmail) + ->notifyNow(new QueueFailureNotification($event)); + }); +} +``` + +### Marker Interface +```php +// app/Contracts/ShouldNotifyAdmin.php +namespace App\Contracts; + +/** + * Marker interface for exceptions that should trigger admin notification. + * Implement this interface on custom exceptions that require admin attention. + */ +interface ShouldNotifyAdmin +{ +} +``` + +## Testing Approach + +### Unit Tests +```php +// tests/Unit/Services/AdminNotificationServiceTest.php +test('shouldNotifyAdmin returns false for validation exceptions', function () { + $service = new AdminNotificationService(); + $exception = new \Illuminate\Validation\ValidationException(validator([], [])); + + expect($service->shouldNotifyAdmin($exception))->toBeFalse(); +}); + +test('shouldNotifyAdmin returns true for exceptions implementing ShouldNotifyAdmin', function () { + $service = new AdminNotificationService(); + $exception = new class extends Exception implements \App\Contracts\ShouldNotifyAdmin {}; + + expect($service->shouldNotifyAdmin($exception))->toBeTrue(); +}); + +test('rate limiting prevents duplicate notifications', function () { + $service = new AdminNotificationService(); + $exception = new RuntimeException('Test error'); + + Notification::fake(); + + $service->notifyError($exception); + $service->notifyError($exception); // Should be throttled + + Notification::assertSentTimes(SystemErrorNotification::class, 1); +}); +``` + +### Feature Tests +```php +// tests/Feature/Notifications/SystemErrorNotificationTest.php +test('critical database error triggers admin notification', function () { + Notification::fake(); + + // Simulate a critical database error being reported + $exception = new \Illuminate\Database\QueryException( + 'mysql', + 'SELECT 1', + [], + new \Exception('Connection refused', 2002) + ); + + $service = app(AdminNotificationService::class); + $service->notifyError($exception); + + Notification::assertSentOnDemand( + SystemErrorNotification::class, + function ($notification, $channels, $notifiable) { + return $notifiable->routes['mail'] === config('libra.notifications.admin_email'); + } + ); +}); + +test('queue failure triggers admin notification', function () { + Notification::fake(); + + // Dispatch a job that will fail + $job = new class implements ShouldQueue { + use Dispatchable, InteractsWithQueue, Queueable; + public function handle() { throw new Exception('Test failure'); } + }; + + // Process the job (will fail and trigger notification) + // Note: This requires queue to be sync for testing + + Notification::assertSentOnDemand(QueueFailureNotification::class); +}); + +test('validation exception does not trigger admin notification', function () { + Notification::fake(); + + try { + validator([], ['email' => 'required'])->validate(); + } catch (\Illuminate\Validation\ValidationException $e) { + $service = app(AdminNotificationService::class); + + if ($service->shouldNotifyAdmin($e)) { + $service->notifyError($e); + } + } + + Notification::assertNothingSent(); +}); + +test('system error notification contains required information', function () { + $exception = new RuntimeException('Test error message'); + $notification = new SystemErrorNotification($exception); + + $mail = $notification->toMail(new AnonymousNotifiable()); + + expect($mail->subject)->toContain('[URGENT]'); + expect($mail->subject)->toContain('RuntimeException'); + expect((string) $mail->render())->toContain('Test error message'); }); ``` ## Definition of Done -- [ ] Email failures notified -- [ ] Job failures notified -- [ ] Critical errors notified -- [ ] Sent immediately -- [ ] Clear urgency indication -- [ ] Tests pass +- [ ] `AdminNotificationService` created with rate limiting +- [ ] `SystemErrorNotification` created and styled +- [ ] `QueueFailureNotification` created and styled +- [ ] `ShouldNotifyAdmin` marker interface created +- [ ] Exception handler integration in `bootstrap/app.php` +- [ ] Queue failure listener in `AppServiceProvider` +- [ ] Environment variables documented in `.env.example` +- [ ] Configuration added to `config/libra.php` +- [ ] Notifications sent immediately (sync, not queued) +- [ ] Rate limiting prevents notification spam +- [ ] Excluded exceptions do not trigger notifications +- [ ] Unit tests for `shouldNotifyAdmin()` logic +- [ ] Feature tests for notification triggers +- [ ] All tests pass +- [ ] Code formatted with Pint ## Estimation -**Complexity:** Medium | **Effort:** 3 hours +**Complexity:** Medium | **Effort:** 4 hours + +## Notes +- Admin email should be configured in production before deployment +- Consider monitoring the `failed_jobs` table separately for recurring failures +- Rate limiting uses cache - ensure cache driver is configured properly +- In local development, set `SYSTEM_NOTIFICATIONS_ENABLED=false` to avoid noise diff --git a/docs/stories/story-8.2-welcome-email.md b/docs/stories/story-8.2-welcome-email.md index 4ed5e74..6e0e473 100644 --- a/docs/stories/story-8.2-welcome-email.md +++ b/docs/stories/story-8.2-welcome-email.md @@ -3,6 +3,9 @@ ## Epic Reference **Epic 8:** Email Notification System +## Dependencies +**Requires:** Story 8.1 (Email Infrastructure Setup) - base template, queue configuration, SMTP setup + ## User Story As a **new client**, I want **to receive a welcome email with my login credentials**, @@ -12,7 +15,8 @@ So that **I can access the platform**. ### Trigger - [ ] Sent automatically on user creation by admin -- [ ] Queued for performance +- [ ] Queued for performance (implements `ShouldQueue`) +- [ ] Triggered via model observer on User created event ### Content - [ ] Personalized greeting (name/company) @@ -23,18 +27,45 @@ So that **I can access the platform**. - [ ] Contact info for questions ### Language -- [ ] Email in user's preferred_language +- [ ] Email in user's `preferred_language` field +- [ ] Default to Arabic ('ar') if `preferred_language` is null - [ ] Arabic template - [ ] English template ### Design -- [ ] Professional branding -- [ ] Call-to-action button: "Login Now" +- [ ] Professional branding (inherits from base template in Story 8.1) +- [ ] Call-to-action button: "Login Now" / "تسجيل الدخول" ## Technical Notes +### Prerequisites from Story 8.1 +- Base email template with Libra branding (navy #0A1F44, gold #D4AF37) +- Queue configuration for async email delivery +- SMTP configuration via `.env` + +### User Model Requirement +The User model requires a `preferred_language` field. If not already present, add: +- Migration: `$table->string('preferred_language', 2)->default('ar');` +- Fillable: Add `'preferred_language'` to `$fillable` array + +### Files to Create + +**Mailable Class:** +- `app/Mail/WelcomeEmail.php` + +**View Templates:** +- `resources/views/emails/welcome/ar.blade.php` (Arabic) +- `resources/views/emails/welcome/en.blade.php` (English) + +**Observer:** +- `app/Observers/UserObserver.php` (if not exists) +- Register in `AppServiceProvider` boot method + +### Implementation + ```php -class WelcomeEmail extends Mailable +// app/Mail/WelcomeEmail.php +class WelcomeEmail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; @@ -46,9 +77,9 @@ class WelcomeEmail extends Mailable public function envelope(): Envelope { return new Envelope( - subject: $this->user->preferred_language === 'ar' - ? 'مرحباً بك في مكتب ليبرا للمحاماة' - : 'Welcome to Libra Law Firm', + subject: $this->user->preferred_language === 'en' + ? 'Welcome to Libra Law Firm' + : 'مرحباً بك في مكتب ليبرا للمحاماة', ); } @@ -56,18 +87,90 @@ class WelcomeEmail extends Mailable { return new Content( markdown: 'emails.welcome.' . ($this->user->preferred_language ?? 'ar'), + with: [ + 'loginUrl' => route('login'), + 'password' => $this->password, + ], ); } } ``` +```php +// app/Observers/UserObserver.php +class UserObserver +{ + public function created(User $user): void + { + // Only send if password was set (admin creation scenario) + // The plain password must be passed from the creation context + } +} +``` + +### Trigger Mechanism +The welcome email requires the plain-text password, which is only available at creation time. Options: +1. **Recommended:** Dispatch from the admin user creation action/controller after creating user +2. Alternative: Use a custom event `UserCreatedWithPassword` that carries both user and password + +## Edge Cases + +- **Missing `preferred_language`:** Default to Arabic ('ar') +- **Email delivery failure:** Handled by queue retry mechanism (Story 8.1) +- **Password in email:** This is intentional for admin-created accounts; password is shown once + +## Testing Requirements + +### Unit Tests +- [ ] `WelcomeEmail` mailable contains correct subject for Arabic user +- [ ] `WelcomeEmail` mailable contains correct subject for English user +- [ ] `WelcomeEmail` uses correct template based on language +- [ ] Default language is Arabic when `preferred_language` is null + +### Feature Tests +- [ ] Email is queued when user is created +- [ ] Arabic template renders without errors +- [ ] English template renders without errors +- [ ] Email contains login URL +- [ ] Email contains user's password +- [ ] Email contains user's name + +### Test Example +```php +use App\Mail\WelcomeEmail; +use App\Models\User; +use Illuminate\Support\Facades\Mail; + +test('welcome email is sent when user is created', function () { + Mail::fake(); + + $user = User::factory()->create(['preferred_language' => 'ar']); + + Mail::to($user)->send(new WelcomeEmail($user, 'test-password')); + + Mail::assertQueued(WelcomeEmail::class, function ($mail) use ($user) { + return $mail->user->id === $user->id; + }); +}); + +test('welcome email uses arabic template by default', function () { + $user = User::factory()->create(['preferred_language' => null]); + $mailable = new WelcomeEmail($user, 'password123'); + + $mailable->assertSeeInHtml('تسجيل الدخول'); +}); +``` + ## Definition of Done -- [ ] Email sent on user creation -- [ ] Credentials included -- [ ] Arabic template works -- [ ] English template works -- [ ] Login button works -- [ ] Tests pass +- [ ] `WelcomeEmail` mailable class created +- [ ] Arabic template (`emails/welcome/ar.blade.php`) created +- [ ] English template (`emails/welcome/en.blade.php`) created +- [ ] Email triggered on user creation by admin +- [ ] Email is queued (not sent synchronously) +- [ ] Credentials included in email +- [ ] Login button links to correct URL +- [ ] All tests pass +- [ ] Code formatted with Pint ## Estimation **Complexity:** Low | **Effort:** 2-3 hours diff --git a/docs/stories/story-8.3-booking-submitted-confirmation.md b/docs/stories/story-8.3-booking-submitted-confirmation.md index d2b95b8..505923e 100644 --- a/docs/stories/story-8.3-booking-submitted-confirmation.md +++ b/docs/stories/story-8.3-booking-submitted-confirmation.md @@ -3,35 +3,67 @@ ## Epic Reference **Epic 8:** Email Notification System +## Dependencies +- **Story 8.1:** Email Infrastructure Setup (base templates, SMTP config, queue setup) + - Provides: Base Mailable layout with Libra branding (navy #0A1F44 / gold #D4AF37), logo header, footer with firm contact info, mobile-responsive design, and queue configuration + ## User Story As a **client**, I want **to receive confirmation when I submit a booking request**, -So that **I know my request was received**. +So that **I know my request was received and what to expect next**. ## Acceptance Criteria ### Trigger -- [ ] Sent on booking submission -- [ ] Status: pending +- [ ] Sent immediately after successful consultation creation +- [ ] Consultation status: pending +- [ ] Email queued for async delivery ### Content -- [ ] "Your consultation request has been submitted" -- [ ] Requested date and time -- [ ] Problem summary preview -- [ ] "Pending Review" status note -- [ ] Expected response timeframe (general) +- [ ] Subject line: "Your consultation request has been submitted" / "تم استلام طلب الاستشارة" +- [ ] Personalized greeting with client name +- [ ] "Your consultation request has been submitted" message +- [ ] Requested date and time (formatted per user's language preference) +- [ ] Problem summary preview (first 200 characters, with "..." if truncated) +- [ ] "Pending Review" status note with visual indicator +- [ ] Expected response timeframe: "We will review your request and respond within 1-2 business days" +- [ ] Contact information for questions ### Language -- [ ] Email in client's preferred language +- [ ] Email sent in client's `preferred_language` (default: 'ar') +- [ ] Arabic template for Arabic users +- [ ] English template for English users ### Design -- [ ] No action required message -- [ ] Professional template +- [ ] Uses base email template from Story 8.1 +- [ ] No action required message (informational only) +- [ ] Professional template with Libra branding +- [ ] Gold call-to-action style for "View My Bookings" link (optional) ## Technical Notes +### Files to Create +``` +app/Mail/BookingSubmittedEmail.php +resources/views/emails/booking/submitted/ar.blade.php +resources/views/emails/booking/submitted/en.blade.php +``` + +### Mailable Implementation ```php -class BookingSubmittedEmail extends Mailable +consultation->user->preferred_language ?? 'ar'; + + return new Envelope( + subject: $locale === 'ar' + ? 'تم استلام طلب الاستشارة' + : 'Your Consultation Request Has Been Submitted', + ); + } + public function content(): Content { $locale = $this->consultation->user->preferred_language ?? 'ar'; @@ -48,19 +91,189 @@ class BookingSubmittedEmail extends Mailable with: [ 'consultation' => $this->consultation, 'user' => $this->consultation->user, + 'summaryPreview' => $this->getSummaryPreview(), + 'formattedDate' => $this->getFormattedDate($locale), + 'formattedTime' => $this->getFormattedTime($locale), ], ); } + + private function getSummaryPreview(): string + { + $summary = $this->consultation->problem_summary ?? ''; + return strlen($summary) > 200 + ? substr($summary, 0, 200) . '...' + : $summary; + } + + private function getFormattedDate(string $locale): string + { + $date = $this->consultation->booking_date; + return $locale === 'ar' + ? $date->format('d/m/Y') + : $date->format('m/d/Y'); + } + + private function getFormattedTime(string $locale): string + { + return $this->consultation->booking_time->format('h:i A'); + } } ``` +### Dispatch Point +Send the email after successful consultation creation. Typical location: + +```php +// In app/Actions/Consultation/CreateConsultationAction.php +// OR in the controller handling booking submission + +use App\Mail\BookingSubmittedEmail; +use Illuminate\Support\Facades\Mail; + +// After consultation is created successfully: +Mail::to($consultation->user->email) + ->send(new BookingSubmittedEmail($consultation)); +``` + +### Edge Cases +- If `preferred_language` is null, default to 'ar' (Arabic) +- If `problem_summary` is null or empty, show "No summary provided" +- Ensure consultation has valid `booking_date` and `booking_time` before sending + +## Testing Requirements + +### Unit Tests +```php +test('booking submitted email has correct subject in Arabic', function () { + $user = User::factory()->create(['preferred_language' => 'ar']); + $consultation = Consultation::factory()->create(['user_id' => $user->id]); + + $mailable = new BookingSubmittedEmail($consultation); + + expect($mailable->envelope()->subject) + ->toBe('تم استلام طلب الاستشارة'); +}); + +test('booking submitted email has correct subject in English', function () { + $user = User::factory()->create(['preferred_language' => 'en']); + $consultation = Consultation::factory()->create(['user_id' => $user->id]); + + $mailable = new BookingSubmittedEmail($consultation); + + expect($mailable->envelope()->subject) + ->toBe('Your Consultation Request Has Been Submitted'); +}); + +test('problem summary is truncated at 200 characters', function () { + $longSummary = str_repeat('a', 250); + $user = User::factory()->create(); + $consultation = Consultation::factory()->create([ + 'user_id' => $user->id, + 'problem_summary' => $longSummary, + ]); + + $mailable = new BookingSubmittedEmail($consultation); + $content = $mailable->content(); + + expect($content->with['summaryPreview']) + ->toHaveLength(203) // 200 + '...' + ->toEndWith('...'); +}); + +test('date is formatted as d/m/Y for Arabic users', function () { + $user = User::factory()->create(['preferred_language' => 'ar']); + $consultation = Consultation::factory()->create([ + 'user_id' => $user->id, + 'booking_date' => '2025-03-15', + ]); + + $mailable = new BookingSubmittedEmail($consultation); + $content = $mailable->content(); + + expect($content->with['formattedDate'])->toBe('15/03/2025'); +}); + +test('date is formatted as m/d/Y for English users', function () { + $user = User::factory()->create(['preferred_language' => 'en']); + $consultation = Consultation::factory()->create([ + 'user_id' => $user->id, + 'booking_date' => '2025-03-15', + ]); + + $mailable = new BookingSubmittedEmail($consultation); + $content = $mailable->content(); + + expect($content->with['formattedDate'])->toBe('03/15/2025'); +}); + +test('defaults to Arabic when preferred_language is null', function () { + $user = User::factory()->create(['preferred_language' => null]); + $consultation = Consultation::factory()->create(['user_id' => $user->id]); + + $mailable = new BookingSubmittedEmail($consultation); + + expect($mailable->envelope()->subject) + ->toBe('تم استلام طلب الاستشارة'); +}); + +test('empty problem summary returns empty string', function () { + $user = User::factory()->create(); + $consultation = Consultation::factory()->create([ + 'user_id' => $user->id, + 'problem_summary' => '', + ]); + + $mailable = new BookingSubmittedEmail($consultation); + $content = $mailable->content(); + + expect($content->with['summaryPreview'])->toBe(''); +}); +``` + +### Feature Tests +```php +test('email is sent when consultation is created', function () { + Mail::fake(); + + $user = User::factory()->create(); + // Trigger consultation creation... + + Mail::assertSent(BookingSubmittedEmail::class, function ($mail) use ($user) { + return $mail->hasTo($user->email); + }); +}); + +test('email is queued for async delivery', function () { + Mail::fake(); + + $user = User::factory()->create(); + $consultation = Consultation::factory()->create(['user_id' => $user->id]); + + Mail::to($user->email)->send(new BookingSubmittedEmail($consultation)); + + Mail::assertQueued(BookingSubmittedEmail::class); +}); +``` + +## References +- **PRD Section 8.2:** Email Templates - "Booking Confirmation - Request submitted successfully" +- **PRD Section 5.4:** Booking Flow - Step 2 "Request Status: Pending" +- **Story 8.1:** Base email template structure and branding +- **Story 8.2:** Welcome email pattern (similar Mailable structure) + ## Definition of Done -- [ ] Email sent on submission -- [ ] Date/time included -- [ ] Summary preview shown -- [ ] Pending status clear -- [ ] Bilingual templates -- [ ] Tests pass +- [ ] `BookingSubmittedEmail` Mailable class created +- [ ] Arabic template created and renders correctly +- [ ] English template created and renders correctly +- [ ] Email dispatched on consultation creation +- [ ] Email queued (implements ShouldQueue) +- [ ] Date/time formatted per user language +- [ ] Summary preview truncated at 200 chars +- [ ] Pending status clearly communicated +- [ ] Response timeframe included +- [ ] Unit tests pass +- [ ] Feature tests pass ## Estimation **Complexity:** Low | **Effort:** 2 hours diff --git a/docs/stories/story-8.4-booking-approved-email.md b/docs/stories/story-8.4-booking-approved-email.md index b03adfe..5d1cade 100644 --- a/docs/stories/story-8.4-booking-approved-email.md +++ b/docs/stories/story-8.4-booking-approved-email.md @@ -3,6 +3,10 @@ ## Epic Reference **Epic 8:** Email Notification System +## Dependencies +- **Story 8.1:** Email infrastructure setup (base template, queue config, SMTP) +- **Story 3.6:** CalendarService for .ics file generation + ## User Story As a **client**, I want **to receive notification when my booking is approved**, @@ -31,7 +35,37 @@ So that **I can confirm the appointment and add it to my calendar**. ## Technical Notes +### Required Consultation Model Fields +This story assumes the following fields exist on the `Consultation` model (from Epic 3): +- `id` - Unique identifier (booking reference) +- `user_id` - Foreign key to User +- `scheduled_date` - Date of consultation +- `scheduled_time` - Time of consultation +- `duration` - Duration in minutes (default: 45) +- `status` - Consultation status ('pending', 'approved', 'rejected', etc.) +- `type` - 'free' or 'paid' +- `payment_amount` - Amount for paid consultations (nullable) + +### Views to Create +- `resources/views/emails/booking/approved/ar.blade.php` - Arabic template +- `resources/views/emails/booking/approved/en.blade.php` - English template + +### Mailable Class +Create `app/Mail/BookingApprovedEmail.php`: + ```php +consultation->user->preferred_language ?? 'ar'; + $subject = $locale === 'ar' + ? 'تمت الموافقة على استشارتك' + : 'Your Consultation Has Been Approved'; + + return new Envelope(subject: $subject); + } + public function content(): Content { $locale = $this->consultation->user->preferred_language ?? 'ar'; @@ -50,6 +94,7 @@ class BookingApprovedEmail extends Mailable markdown: "emails.booking.approved.{$locale}", with: [ 'consultation' => $this->consultation, + 'user' => $this->consultation->user, 'paymentInstructions' => $this->paymentInstructions, ], ); @@ -65,13 +110,147 @@ class BookingApprovedEmail extends Mailable } ``` +### Trigger Mechanism +Add observer or listener to send email when consultation status changes to 'approved': + +```php +// Option 1: In Consultation model boot method or observer +use App\Mail\BookingApprovedEmail; +use App\Services\CalendarService; +use Illuminate\Support\Facades\Mail; + +// In ConsultationObserver or model event +public function updated(Consultation $consultation): void +{ + if ($consultation->wasChanged('status') && $consultation->status === 'approved') { + $icsContent = app(CalendarService::class)->generateIcs($consultation); + + $paymentInstructions = null; + if ($consultation->type === 'paid') { + $paymentInstructions = $this->getPaymentInstructions($consultation); + } + + Mail::to($consultation->user) + ->queue(new BookingApprovedEmail($consultation, $icsContent, $paymentInstructions)); + } +} +``` + +### Payment Instructions +For paid consultations, include payment details: +- Amount to pay +- Payment methods accepted +- Payment deadline (before consultation) +- Bank transfer details or payment link + +## Testing Guidance + +### Test Approach +- Unit tests for Mailable class +- Feature tests for trigger mechanism (observer) +- Integration tests for email queue + +### Key Test Scenarios + +```php +use App\Mail\BookingApprovedEmail; +use App\Models\Consultation; +use App\Models\User; +use App\Services\CalendarService; +use Illuminate\Support\Facades\Mail; + +it('queues email when consultation is approved', function () { + Mail::fake(); + + $consultation = Consultation::factory()->create(['status' => 'pending']); + $consultation->update(['status' => 'approved']); + + Mail::assertQueued(BookingApprovedEmail::class, function ($mail) use ($consultation) { + return $mail->consultation->id === $consultation->id; + }); +}); + +it('does not send email when status changes to non-approved', function () { + Mail::fake(); + + $consultation = Consultation::factory()->create(['status' => 'pending']); + $consultation->update(['status' => 'rejected']); + + Mail::assertNotQueued(BookingApprovedEmail::class); +}); + +it('includes ics attachment', function () { + $consultation = Consultation::factory()->approved()->create(); + $icsContent = app(CalendarService::class)->generateIcs($consultation); + + $mailable = new BookingApprovedEmail($consultation, $icsContent); + + expect($mailable->attachments())->toHaveCount(1); + expect($mailable->attachments()[0]->as)->toBe('consultation.ics'); +}); + +it('uses Arabic template for Arabic-preferring users', function () { + $user = User::factory()->create(['preferred_language' => 'ar']); + $consultation = Consultation::factory()->approved()->for($user)->create(); + $icsContent = app(CalendarService::class)->generateIcs($consultation); + + $mailable = new BookingApprovedEmail($consultation, $icsContent); + + expect($mailable->content()->markdown)->toBe('emails.booking.approved.ar'); +}); + +it('uses English template for English-preferring users', function () { + $user = User::factory()->create(['preferred_language' => 'en']); + $consultation = Consultation::factory()->approved()->for($user)->create(); + $icsContent = app(CalendarService::class)->generateIcs($consultation); + + $mailable = new BookingApprovedEmail($consultation, $icsContent); + + expect($mailable->content()->markdown)->toBe('emails.booking.approved.en'); +}); + +it('includes payment instructions for paid consultations', function () { + $consultation = Consultation::factory()->approved()->create([ + 'type' => 'paid', + 'payment_amount' => 150.00, + ]); + $icsContent = app(CalendarService::class)->generateIcs($consultation); + $paymentInstructions = 'Please pay 150 ILS before your consultation.'; + + $mailable = new BookingApprovedEmail($consultation, $icsContent, $paymentInstructions); + + expect($mailable->paymentInstructions)->toBe($paymentInstructions); +}); + +it('excludes payment instructions for free consultations', function () { + $consultation = Consultation::factory()->approved()->create(['type' => 'free']); + $icsContent = app(CalendarService::class)->generateIcs($consultation); + + $mailable = new BookingApprovedEmail($consultation, $icsContent); + + expect($mailable->paymentInstructions)->toBeNull(); +}); +``` + +### Edge Cases to Test +- User with null `preferred_language` defaults to 'ar' +- Consultation without payment_amount for paid type (handle gracefully) +- Email render test with `$mailable->render()` + +## References +- `docs/stories/story-8.1-email-infrastructure-setup.md` - Base email template and queue config +- `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation +- `docs/epics/epic-8-email-notifications.md#story-84-booking-approved-email` - Epic acceptance criteria + ## Definition of Done - [ ] Email sent on approval -- [ ] All details included +- [ ] All details included (date, time, duration, type) - [ ] Payment info for paid consultations - [ ] .ics file attached -- [ ] Bilingual templates -- [ ] Tests pass +- [ ] Bilingual templates (Arabic/English) +- [ ] Observer/listener triggers on status change +- [ ] Tests pass (all scenarios above) +- [ ] Code formatted with Pint ## Estimation **Complexity:** Medium | **Effort:** 3 hours diff --git a/docs/stories/story-8.5-booking-rejected-email.md b/docs/stories/story-8.5-booking-rejected-email.md index e0461dc..48b9adb 100644 --- a/docs/stories/story-8.5-booking-rejected-email.md +++ b/docs/stories/story-8.5-booking-rejected-email.md @@ -8,15 +8,26 @@ As a **client**, I want **to be notified when my booking is rejected**, So that **I can understand why and request a new consultation if needed**. +## Dependencies +- **Story 8.1:** Email Infrastructure Setup (provides base template, branding, queue configuration) +- **Epic 3:** Consultation/booking system with status management + +## Assumptions +- `Consultation` model exists with `user` relationship (belongsTo User) +- `User` model has `preferred_language` field (defaults to `'ar'` if null) +- Admin rejection action captures optional `reason` field +- Consultation status changes to `'rejected'` when admin rejects +- Base email template and branding from Story 8.1 are available + ## Acceptance Criteria ### Trigger -- [ ] Sent on booking rejection by admin +- [ ] Sent when consultation status changes to `'rejected'` ### Content - [ ] "Your consultation request could not be approved" - [ ] Original requested date and time -- [ ] Rejection reason (if provided by admin) +- [ ] Rejection reason (conditionally shown if provided by admin) - [ ] Invitation to request new consultation - [ ] Contact info for questions @@ -24,12 +35,35 @@ So that **I can understand why and request a new consultation if needed**. - [ ] Empathetic, professional ### Language -- [ ] Email in client's preferred language +- [ ] Email in client's preferred language (Arabic or English) +- [ ] Default to Arabic if no preference set ## Technical Notes +### Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `app/Mail/BookingRejectedEmail.php` | Create | Mailable class | +| `resources/views/emails/booking/rejected/ar.blade.php` | Create | Arabic template (RTL) | +| `resources/views/emails/booking/rejected/en.blade.php` | Create | English template (LTR) | +| `app/Listeners/SendBookingRejectedEmail.php` | Create | Event listener | +| `app/Events/ConsultationRejected.php` | Create | Event class (if not exists) | + +### Mailable Implementation + ```php -class BookingRejectedEmail extends Mailable +namespace App\Mail; + +use App\Models\Consultation; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Content; +use Illuminate\Mail\Mailables\Envelope; +use Illuminate\Queue\SerializesModels; + +class BookingRejectedEmail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; @@ -38,6 +72,16 @@ class BookingRejectedEmail extends Mailable public ?string $reason = null ) {} + public function envelope(): Envelope + { + $locale = $this->consultation->user->preferred_language ?? 'ar'; + $subject = $locale === 'ar' + ? 'تعذر الموافقة على طلب الاستشارة' + : 'Your Consultation Request Could Not Be Approved'; + + return new Envelope(subject: $subject); + } + public function content(): Content { $locale = $this->consultation->user->preferred_language ?? 'ar'; @@ -47,18 +91,182 @@ class BookingRejectedEmail extends Mailable with: [ 'consultation' => $this->consultation, 'reason' => $this->reason, + 'hasReason' => !empty($this->reason), ], ); } } ``` +### Event/Listener Trigger + +```php +// In admin controller or service when rejecting consultation: +use App\Events\ConsultationRejected; + +$consultation->update(['status' => 'rejected']); +event(new ConsultationRejected($consultation, $reason)); + +// app/Events/ConsultationRejected.php +class ConsultationRejected +{ + public function __construct( + public Consultation $consultation, + public ?string $reason = null + ) {} +} + +// app/Listeners/SendBookingRejectedEmail.php +class SendBookingRejectedEmail +{ + public function handle(ConsultationRejected $event): void + { + Mail::to($event->consultation->user->email) + ->send(new BookingRejectedEmail( + $event->consultation, + $event->reason + )); + } +} + +// Register in EventServiceProvider boot() or use event discovery +``` + +### Template Structure (Arabic Example) + +```blade +{{-- resources/views/emails/booking/rejected/ar.blade.php --}} + +# تعذر الموافقة على طلب الاستشارة + +عزيزي/عزيزتي {{ $consultation->user->name }}, + +نأسف لإبلاغك بأنه تعذر علينا الموافقة على طلب الاستشارة الخاص بك. + +**التاريخ المطلوب:** {{ $consultation->scheduled_at->format('Y-m-d') }} +**الوقت المطلوب:** {{ $consultation->scheduled_at->format('H:i') }} + +@if($hasReason) +**السبب:** {{ $reason }} +@endif + +نرحب بك لتقديم طلب استشارة جديد في وقت آخر يناسبك. + + +طلب استشارة جديدة + + +للاستفسارات، تواصل معنا على: info@libra.ps + +مع أطيب التحيات, +مكتب ليبرا للمحاماة + +``` + +## Edge Cases + +| Scenario | Handling | +|----------|----------| +| Reason is null/empty | Hide reason section in template using `@if($hasReason)` | +| User has no preferred_language | Default to Arabic (`'ar'`) | +| Queue failure | Standard Laravel queue retry (3 attempts) | +| User email invalid | Queue will fail, logged for admin review | + +## Testing Requirements + +### Unit Tests +```php +// tests/Unit/Mail/BookingRejectedEmailTest.php + +test('booking rejected email renders with reason', function () { + $consultation = Consultation::factory()->create(); + $reason = 'Schedule conflict'; + + $mailable = new BookingRejectedEmail($consultation, $reason); + + $mailable->assertSeeInHtml($reason); + $mailable->assertSeeInHtml($consultation->scheduled_at->format('Y-m-d')); +}); + +test('booking rejected email renders without reason', function () { + $consultation = Consultation::factory()->create(); + + $mailable = new BookingRejectedEmail($consultation, null); + + $mailable->assertDontSeeInHtml('السبب:'); + $mailable->assertDontSeeInHtml('Reason:'); +}); + +test('booking rejected email uses arabic template for arabic preference', function () { + $user = User::factory()->create(['preferred_language' => 'ar']); + $consultation = Consultation::factory()->for($user)->create(); + + $mailable = new BookingRejectedEmail($consultation); + + expect($mailable->content()->markdown)->toBe('emails.booking.rejected.ar'); +}); + +test('booking rejected email uses english template for english preference', function () { + $user = User::factory()->create(['preferred_language' => 'en']); + $consultation = Consultation::factory()->for($user)->create(); + + $mailable = new BookingRejectedEmail($consultation); + + expect($mailable->content()->markdown)->toBe('emails.booking.rejected.en'); +}); + +test('booking rejected email defaults to arabic when no language preference', function () { + $user = User::factory()->create(['preferred_language' => null]); + $consultation = Consultation::factory()->for($user)->create(); + + $mailable = new BookingRejectedEmail($consultation); + + expect($mailable->content()->markdown)->toBe('emails.booking.rejected.ar'); +}); +``` + +### Feature Tests +```php +// tests/Feature/Mail/BookingRejectedEmailTest.php + +test('email is queued when consultation is rejected', function () { + Mail::fake(); + + $consultation = Consultation::factory()->create(['status' => 'pending']); + $reason = 'Not available'; + + event(new ConsultationRejected($consultation, $reason)); + + Mail::assertQueued(BookingRejectedEmail::class, function ($mail) use ($consultation) { + return $mail->consultation->id === $consultation->id; + }); +}); + +test('email is sent to correct recipient', function () { + Mail::fake(); + + $user = User::factory()->create(['email' => 'client@example.com']); + $consultation = Consultation::factory()->for($user)->create(); + + event(new ConsultationRejected($consultation)); + + Mail::assertQueued(BookingRejectedEmail::class, function ($mail) { + return $mail->hasTo('client@example.com'); + }); +}); +``` + ## Definition of Done -- [ ] Email sent on rejection -- [ ] Reason included if provided -- [ ] Empathetic tone -- [ ] Bilingual templates -- [ ] Tests pass +- [ ] `BookingRejectedEmail` mailable class created +- [ ] Arabic template created with RTL layout and empathetic tone +- [ ] English template created with LTR layout and empathetic tone +- [ ] Event and listener wired for consultation rejection +- [ ] Reason conditionally displayed when provided +- [ ] Defaults to Arabic when no language preference +- [ ] Email queued (not sent synchronously) +- [ ] All unit tests pass +- [ ] All feature tests pass +- [ ] Code formatted with Pint ## Estimation -**Complexity:** Low | **Effort:** 2 hours +**Complexity:** Low | **Effort:** 2-3 hours diff --git a/docs/stories/story-8.6-consultation-reminder-24h.md b/docs/stories/story-8.6-consultation-reminder-24h.md index e7a5350..b347dd1 100644 --- a/docs/stories/story-8.6-consultation-reminder-24h.md +++ b/docs/stories/story-8.6-consultation-reminder-24h.md @@ -3,6 +3,12 @@ ## Epic Reference **Epic 8:** Email Notification System +## Dependencies +- **Story 8.1:** Email infrastructure setup (base template, queue config, SMTP) +- **Story 8.4:** BookingApprovedEmail pattern and CalendarService integration +- **Consultation Model:** Must have `status`, `scheduled_date`, `scheduled_time`, `consultation_type`, `payment_status` fields +- **User Model:** Must have `preferred_language` field + ## User Story As a **client**, I want **to receive a reminder 24 hours before my consultation**, @@ -11,54 +17,250 @@ So that **I don't forget my appointment**. ## Acceptance Criteria ### Trigger -- [ ] Scheduled job runs daily -- [ ] Find consultations 24 hours away -- [ ] Only for approved consultations -- [ ] Skip cancelled/no-show +- [ ] Scheduled artisan command runs hourly +- [ ] Find consultations approximately 24 hours away (within 30-minute window) +- [ ] Only for approved consultations (`status = 'approved'`) +- [ ] Skip cancelled/no-show/completed consultations +- [ ] Track sent reminders to prevent duplicates ### Content -- [ ] "Reminder: Your consultation is tomorrow" -- [ ] Date and time -- [ ] Consultation type -- [ ] Payment reminder (if paid and not received) -- [ ] Calendar file link -- [ ] Any preparation notes +- [ ] Subject: "Reminder: Your consultation is tomorrow" / "تذكير: استشارتك غدًا" +- [ ] Consultation date and time (formatted per locale) +- [ ] Consultation type (free/paid) +- [ ] Payment reminder: Show if `consultation_type = 'paid'` AND `payment_status != 'received'` +- [ ] Calendar file download link (using route to CalendarService) +- [ ] Office contact information for questions ### Language -- [ ] Email in client's preferred language +- [ ] Email rendered in client's `preferred_language` (ar/en) +- [ ] Date/time formatted according to locale ## Technical Notes +### Database Migration +Add tracking column to prevent duplicate reminders: + ```php -// Command: php artisan reminders:send-24h +// database/migrations/xxxx_add_reminder_sent_columns_to_consultations_table.php +Schema::table('consultations', function (Blueprint $table) { + $table->timestamp('reminder_24h_sent_at')->nullable()->after('status'); + $table->timestamp('reminder_2h_sent_at')->nullable()->after('reminder_24h_sent_at'); +}); +``` + +### Artisan Command +```php +// app/Console/Commands/Send24HourReminders.php +namespace App\Console\Commands; + +use App\Models\Consultation; +use App\Notifications\ConsultationReminder24h; +use Carbon\Carbon; +use Illuminate\Console\Command; + class Send24HourReminders extends Command { + protected $signature = 'reminders:send-24h'; + protected $description = 'Send 24-hour consultation reminders'; + public function handle(): int { $targetTime = now()->addHours(24); + $windowStart = $targetTime->copy()->subMinutes(30); + $windowEnd = $targetTime->copy()->addMinutes(30); - Consultation::where('status', 'approved') + $consultations = Consultation::query() + ->where('status', 'approved') ->whereNull('reminder_24h_sent_at') ->whereDate('scheduled_date', $targetTime->toDateString()) - ->each(function ($consultation) { - $consultation->user->notify(new ConsultationReminder24h($consultation)); - $consultation->update(['reminder_24h_sent_at' => now()]); + ->get() + ->filter(function ($consultation) use ($windowStart, $windowEnd) { + $consultationDateTime = Carbon::parse( + $consultation->scheduled_date->format('Y-m-d') . ' ' . $consultation->scheduled_time + ); + return $consultationDateTime->between($windowStart, $windowEnd); }); + $count = 0; + foreach ($consultations as $consultation) { + $consultation->user->notify(new ConsultationReminder24h($consultation)); + $consultation->update(['reminder_24h_sent_at' => now()]); + $count++; + } + + $this->info("Sent {$count} reminder(s)."); + return Command::SUCCESS; } } - -// Schedule: hourly ``` +### Notification Class +```php +// app/Notifications/ConsultationReminder24h.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 ConsultationReminder24h extends Notification implements ShouldQueue +{ + use Queueable; + + public function __construct( + public Consultation $consultation + ) {} + + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $locale = $notifiable->preferred_language ?? 'ar'; + $consultation = $this->consultation; + + $subject = $locale === 'ar' + ? 'تذكير: استشارتك غدًا' + : 'Reminder: Your consultation is tomorrow'; + + $message = (new MailMessage) + ->subject($subject) + ->markdown("emails.reminders.consultation-24h.{$locale}", [ + 'consultation' => $consultation, + 'user' => $notifiable, + 'showPaymentReminder' => $this->shouldShowPaymentReminder(), + 'calendarUrl' => route('consultations.calendar', $consultation), + ]); + + return $message; + } + + private function shouldShowPaymentReminder(): bool + { + return $this->consultation->consultation_type === 'paid' + && $this->consultation->payment_status !== 'received'; + } +} +``` + +### Email Templates + +**Arabic Template:** `resources/views/emails/reminders/consultation-24h/ar.blade.php` +**English Template:** `resources/views/emails/reminders/consultation-24h/en.blade.php` + +Template content should include: +- Greeting with client name +- Reminder message ("Your consultation is scheduled for tomorrow") +- Date/time (formatted: `scheduled_date->translatedFormat()`) +- Consultation type badge +- Payment reminder section (conditional) +- "Add to Calendar" button linking to `$calendarUrl` +- Office contact information +- Branded footer (from Story 8.1 base template) + +### Schedule Registration +```php +// routes/console.php or bootstrap/app.php +Schedule::command('reminders:send-24h')->hourly(); +``` + +## Edge Cases & Error Handling + +| Scenario | Handling | +|----------|----------| +| Notification fails to send | Queue will retry; failed jobs logged to `failed_jobs` table | +| Consultation rescheduled after reminder | New datetime won't trigger duplicate (24h check resets) | +| Consultation cancelled after reminder sent | No action needed - reminder already sent | +| User has no email | Notification skipped (Laravel handles gracefully) | +| Timezone considerations | All times stored/compared in app timezone (configured in `config/app.php`) | + +## Test Scenarios + +### Unit Tests (`tests/Unit/Commands/Send24HourRemindersTest.php`) +```php +test('command finds consultations approximately 24 hours away', function () { + // Create consultation 24 hours from now + // Run command + // Assert notification sent +}); + +test('command skips consultations with reminder already sent', function () { + // Create consultation with reminder_24h_sent_at set + // Run command + // Assert no notification sent +}); + +test('command skips non-approved consultations', function () { + // Create cancelled, no-show, pending consultations + // Run command + // Assert no notifications sent +}); +``` + +### Feature Tests (`tests/Feature/Notifications/ConsultationReminder24hTest.php`) +```php +test('reminder email contains correct consultation details', function () { + // Create consultation + // Send notification + // Assert email contains date, time, type +}); + +test('payment reminder shown for unpaid paid consultations', function () { + // Create paid consultation with payment_status = 'pending' + // Assert email contains payment reminder section +}); + +test('payment reminder hidden when payment received', function () { + // Create paid consultation with payment_status = 'received' + // Assert email does NOT contain payment reminder +}); + +test('email uses client preferred language', function () { + // Create user with preferred_language = 'en' + // Assert email template is English version +}); + +test('calendar download link is included', function () { + // Assert email contains route('consultations.calendar', $consultation) +}); +``` + +## Files to Create/Modify + +| File | Action | +|------|--------| +| `database/migrations/xxxx_add_reminder_sent_columns_to_consultations_table.php` | CREATE | +| `app/Console/Commands/Send24HourReminders.php` | CREATE | +| `app/Notifications/ConsultationReminder24h.php` | CREATE | +| `resources/views/emails/reminders/consultation-24h/ar.blade.php` | CREATE | +| `resources/views/emails/reminders/consultation-24h/en.blade.php` | CREATE | +| `routes/console.php` | MODIFY (add schedule) | +| `tests/Unit/Commands/Send24HourRemindersTest.php` | CREATE | +| `tests/Feature/Notifications/ConsultationReminder24hTest.php` | CREATE | + ## Definition of Done -- [ ] Command runs successfully -- [ ] Reminders sent to correct consultations -- [ ] Payment reminder for unpaid -- [ ] No duplicate reminders -- [ ] Bilingual templates -- [ ] Tests pass +- [ ] Migration adds `reminder_24h_sent_at` column to consultations table +- [ ] Artisan command `reminders:send-24h` created and works +- [ ] Command scheduled to run hourly +- [ ] Notification class implements `ShouldQueue` +- [ ] Reminders only sent for approved consultations within 24h window +- [ ] No duplicate reminders (tracking column updated) +- [ ] Payment reminder shown only when `paid` AND `payment_status != 'received'` +- [ ] Calendar download link included +- [ ] Bilingual email templates (Arabic/English) +- [ ] All unit and feature tests pass +- [ ] Code formatted with `vendor/bin/pint` + +## References +- **PRD Section 5.4:** Email Notifications - "Consultation reminder (24 hours before)" +- **PRD Section 8.2:** Email Templates - Template requirements and branding +- **Story 8.1:** Base email template and queue configuration +- **Story 8.4:** Pattern for calendar file attachment/linking ## Estimation **Complexity:** Medium | **Effort:** 3 hours diff --git a/docs/stories/story-8.7-consultation-reminder-2h.md b/docs/stories/story-8.7-consultation-reminder-2h.md index d65b7b9..1800082 100644 --- a/docs/stories/story-8.7-consultation-reminder-2h.md +++ b/docs/stories/story-8.7-consultation-reminder-2h.md @@ -3,66 +3,276 @@ ## Epic Reference **Epic 8:** Email Notification System +## Dependencies +- **Story 8.1:** Email infrastructure setup (base template, queue config, SMTP) +- **Story 8.4:** BookingApprovedEmail pattern and CalendarService integration +- **Story 8.6:** Migration that adds `reminder_2h_sent_at` column to consultations table +- **Consultation Model:** Must have `status`, `scheduled_date`, `scheduled_time`, `consultation_type`, `payment_status` fields +- **User Model:** Must have `preferred_language` field + ## User Story As a **client**, I want **to receive a reminder 2 hours before my consultation**, -So that **I'm prepared and ready**. +So that **I'm prepared and ready for my appointment with final details and contact information**. ## Acceptance Criteria ### Trigger -- [ ] Scheduled job runs every 15 minutes -- [ ] Find consultations 2 hours away -- [ ] Only for approved consultations -- [ ] Skip cancelled/no-show +- [ ] Scheduled artisan command runs every 15 minutes +- [ ] Find consultations approximately 2 hours away (within 7-minute window) +- [ ] Only for approved consultations (`status = 'approved'`) +- [ ] Skip cancelled/no-show/completed consultations +- [ ] Track sent reminders to prevent duplicates via `reminder_2h_sent_at` ### Content -- [ ] "Your consultation is in 2 hours" -- [ ] Date and time -- [ ] Final payment reminder (if applicable) -- [ ] Contact info for last-minute issues +- [ ] Subject: "Your consultation is in 2 hours" / "استشارتك بعد ساعتين" +- [ ] Consultation date and time (formatted per locale) +- [ ] Final payment reminder: Show if `consultation_type = 'paid'` AND `payment_status != 'received'` +- [ ] Office contact information for last-minute issues/questions ### Language -- [ ] Email in client's preferred language +- [ ] Email rendered in client's `preferred_language` (ar/en) +- [ ] Date/time formatted according to locale ## Technical Notes +### Artisan Command ```php -// Command: php artisan reminders:send-2h -// Schedule: everyFifteenMinutes +// app/Console/Commands/Send2HourReminders.php +namespace App\Console\Commands; + +use App\Models\Consultation; +use App\Notifications\ConsultationReminder2h; +use Carbon\Carbon; +use Illuminate\Console\Command; + class Send2HourReminders extends Command { + protected $signature = 'reminders:send-2h'; + protected $description = 'Send 2-hour consultation reminders'; + public function handle(): int { $targetTime = now()->addHours(2); $windowStart = $targetTime->copy()->subMinutes(7); $windowEnd = $targetTime->copy()->addMinutes(7); - Consultation::where('status', 'approved') + $consultations = Consultation::query() + ->where('status', 'approved') ->whereNull('reminder_2h_sent_at') ->whereDate('scheduled_date', today()) ->get() - ->filter(function ($c) use ($windowStart, $windowEnd) { - $time = Carbon::parse($c->scheduled_date->format('Y-m-d') . ' ' . $c->scheduled_time); - return $time->between($windowStart, $windowEnd); - }) - ->each(function ($consultation) { - $consultation->user->notify(new ConsultationReminder2h($consultation)); - $consultation->update(['reminder_2h_sent_at' => now()]); + ->filter(function ($consultation) use ($windowStart, $windowEnd) { + $consultationDateTime = Carbon::parse( + $consultation->scheduled_date->format('Y-m-d') . ' ' . $consultation->scheduled_time + ); + return $consultationDateTime->between($windowStart, $windowEnd); }); + $count = 0; + foreach ($consultations as $consultation) { + $consultation->user->notify(new ConsultationReminder2h($consultation)); + $consultation->update(['reminder_2h_sent_at' => now()]); + $count++; + } + + $this->info("Sent {$count} 2-hour reminder(s)."); + return Command::SUCCESS; } } ``` +### Notification Class +```php +// app/Notifications/ConsultationReminder2h.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 ConsultationReminder2h extends Notification implements ShouldQueue +{ + use Queueable; + + public function __construct( + public Consultation $consultation + ) {} + + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $locale = $notifiable->preferred_language ?? 'ar'; + $consultation = $this->consultation; + + $subject = $locale === 'ar' + ? 'استشارتك بعد ساعتين' + : 'Your consultation is in 2 hours'; + + return (new MailMessage) + ->subject($subject) + ->markdown("emails.reminders.consultation-2h.{$locale}", [ + 'consultation' => $consultation, + 'user' => $notifiable, + 'showPaymentReminder' => $this->shouldShowPaymentReminder(), + ]); + } + + private function shouldShowPaymentReminder(): bool + { + return $this->consultation->consultation_type === 'paid' + && $this->consultation->payment_status !== 'received'; + } +} +``` + +### Email Templates + +**Arabic Template:** `resources/views/emails/reminders/consultation-2h/ar.blade.php` +**English Template:** `resources/views/emails/reminders/consultation-2h/en.blade.php` + +Template content should include: +- Greeting with client name +- Urgent reminder message ("Your consultation is in 2 hours") +- Date/time (formatted: `scheduled_date->translatedFormat()`) +- **Final payment reminder section** (conditional) - more urgent tone than 24h reminder +- Office contact information for last-minute issues (phone, email) +- Branded footer (from Story 8.1 base template) + +**Note:** Unlike the 24-hour reminder, this template does NOT include a calendar download link (client should already have it from approval email and 24h reminder). + +### Schedule Registration +```php +// routes/console.php or bootstrap/app.php +Schedule::command('reminders:send-2h')->everyFifteenMinutes(); +``` + +**Why 15 minutes?** The 2-hour reminder uses a 7-minute window (tighter than 24h's 30-minute window) because timing is more critical close to the appointment. Running every 15 minutes ensures consultations are caught within the window while balancing server load. + +## Edge Cases & Error Handling + +| Scenario | Handling | +|----------|----------| +| Notification fails to send | Queue will retry; failed jobs logged to `failed_jobs` table | +| Consultation rescheduled after 24h reminder but before 2h | New datetime will trigger 2h reminder (tracking column is separate) | +| Consultation cancelled after reminder sent | No action needed - reminder already sent | +| User has no email | Notification skipped (Laravel handles gracefully) | +| Timezone considerations | All times stored/compared in app timezone (configured in `config/app.php`) | +| 24h reminder not sent (e.g., booking made same day) | 2h reminder still sends independently | +| Consultation scheduled less than 2 hours away | Won't receive 2h reminder (outside window) | + +## Test Scenarios + +### Unit Tests (`tests/Unit/Commands/Send2HourRemindersTest.php`) +```php +test('command finds consultations approximately 2 hours away', function () { + // Create consultation 2 hours from now + // Run command + // Assert notification sent + // Assert reminder_2h_sent_at is set +}); + +test('command skips consultations with reminder already sent', function () { + // Create consultation with reminder_2h_sent_at already set + // Run command + // Assert no notification sent +}); + +test('command skips non-approved consultations', function () { + // Create cancelled, no-show, pending, completed consultations + // Run command + // Assert no notifications sent +}); + +test('command uses 7-minute window for matching', function () { + // Create consultation at exactly 2h + 8 minutes (outside window) + // Run command + // Assert no notification sent + + // Create consultation at exactly 2h + 6 minutes (inside window) + // Run command + // Assert notification sent +}); + +test('command only checks consultations scheduled for today', function () { + // Create consultation 2 hours from now but tomorrow's date + // Run command + // Assert no notification sent +}); +``` + +### Feature Tests (`tests/Feature/Notifications/ConsultationReminder2hTest.php`) +```php +test('reminder email contains correct consultation details', function () { + // Create consultation + // Send notification + // Assert email contains date, time +}); + +test('final payment reminder shown for unpaid paid consultations', function () { + // Create paid consultation with payment_status = 'pending' + // Assert email contains payment reminder section +}); + +test('payment reminder hidden when payment received', function () { + // Create paid consultation with payment_status = 'received' + // Assert email does NOT contain payment reminder +}); + +test('email uses client preferred language', function () { + // Create user with preferred_language = 'en' + // Assert email template is English version +}); + +test('email includes office contact information', function () { + // Send notification + // Assert email contains contact phone/email +}); + +test('email does not include calendar download link', function () { + // Send notification + // Assert email does NOT contain calendar route +}); +``` + +## Files to Create/Modify + +| File | Action | +|------|--------| +| `app/Console/Commands/Send2HourReminders.php` | CREATE | +| `app/Notifications/ConsultationReminder2h.php` | CREATE | +| `resources/views/emails/reminders/consultation-2h/ar.blade.php` | CREATE | +| `resources/views/emails/reminders/consultation-2h/en.blade.php` | CREATE | +| `routes/console.php` | MODIFY (add schedule) | +| `tests/Unit/Commands/Send2HourRemindersTest.php` | CREATE | +| `tests/Feature/Notifications/ConsultationReminder2hTest.php` | CREATE | + +**Note:** Migration for `reminder_2h_sent_at` column is handled in Story 8.6. + ## Definition of Done -- [ ] Command runs successfully -- [ ] Correct timing (2 hours before) -- [ ] Payment reminder if unpaid -- [ ] No duplicate reminders -- [ ] Bilingual templates -- [ ] Tests pass +- [ ] Artisan command `reminders:send-2h` created and works +- [ ] Command scheduled to run every 15 minutes +- [ ] Notification class implements `ShouldQueue` +- [ ] Reminders only sent for approved consultations within 2h window (7-min tolerance) +- [ ] No duplicate reminders (tracking column `reminder_2h_sent_at` updated) +- [ ] Payment reminder shown only when `paid` AND `payment_status != 'received'` +- [ ] Contact information for last-minute issues included +- [ ] Bilingual email templates (Arabic/English) +- [ ] All unit and feature tests pass +- [ ] Code formatted with `vendor/bin/pint` + +## References +- **PRD Section 5.4:** Email Notifications - "Consultation reminder (2 hours before)" +- **PRD Section 8.2:** Email Templates - Template requirements and branding +- **Story 8.1:** Base email template and queue configuration +- **Story 8.6:** Migration for reminder columns, similar command/notification pattern ## Estimation **Complexity:** Medium | **Effort:** 2-3 hours diff --git a/docs/stories/story-8.8-timeline-update-notification.md b/docs/stories/story-8.8-timeline-update-notification.md index a119be3..1cfbf34 100644 --- a/docs/stories/story-8.8-timeline-update-notification.md +++ b/docs/stories/story-8.8-timeline-update-notification.md @@ -3,68 +3,352 @@ ## Epic Reference **Epic 8:** Email Notification System +## Story Context +This story implements email notifications when admin adds updates to a client's case timeline (from Epic 4). When admin creates a `TimelineUpdate` record, the associated client automatically receives an email with the update details, keeping them informed of their case progress without needing to manually check the portal. + ## User Story As a **client**, -I want **to be notified when my case timeline is updated**, -So that **I stay informed about my case progress**. +I want **to be notified via email when my case timeline is updated**, +So that **I stay informed about my case progress without having to repeatedly check the portal**. + +## Dependencies +- **Requires**: Story 8.1 (Email Infrastructure - `BaseMailable` class and templates) +- **Requires**: Epic 4 Stories 4.1-4.2 (Timeline and TimelineUpdate models must exist) +- **Blocks**: None + +## Data Model Reference + +From Epic 4, the relevant models are: + +``` +Timeline +├── id +├── user_id (FK → users.id, the client) +├── case_name (string, required) +├── case_reference (string, optional, unique if provided) +├── status (enum: 'active', 'archived') +├── created_at +└── updated_at + +TimelineUpdate +├── id +├── timeline_id (FK → timelines.id) +├── admin_id (FK → users.id, the admin who created it) +├── update_text (text, the update content) +├── created_at +└── updated_at + +Relationships: +- TimelineUpdate belongsTo Timeline +- Timeline belongsTo User (client) +- Timeline hasMany TimelineUpdate +- Access client: $timelineUpdate->timeline->user +``` ## Acceptance Criteria ### Trigger -- [ ] Sent on timeline update creation -- [ ] Queued for performance +- [ ] Email sent automatically when `TimelineUpdate` is created +- [ ] Uses model observer pattern for clean separation +- [ ] Email queued for performance (not sent synchronously) +- [ ] Only triggered for active timelines (not archived) ### Content -- [ ] "Update on your case: [Case Name]" -- [ ] Case reference number -- [ ] Update content (full or summary) -- [ ] Date of update -- [ ] "View Timeline" button/link +- [ ] Subject: "Update on your case: [Case Name]" / "تحديث على قضيتك: [اسم القضية]" +- [ ] Case reference number (if exists) +- [ ] Full update content text +- [ ] Date of update (formatted for locale) +- [ ] "View Timeline" button linking to client dashboard timeline view ### Language -- [ ] Email in client's preferred language +- [ ] Email template selected based on client's `preferred_language` +- [ ] Default to Arabic ('ar') if no preference set +- [ ] Date formatting appropriate for locale ### Design +- [ ] Uses base email template from Story 8.1 (Libra branding) - [ ] Professional, informative tone +- [ ] Clear visual hierarchy: case name → update content → action button -## Technical Notes +## Technical Implementation + +### Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `app/Mail/TimelineUpdateEmail.php` | Create | Mailable extending BaseMailable | +| `resources/views/emails/timeline/update-ar.blade.php` | Create | Arabic email template | +| `resources/views/emails/timeline/update-en.blade.php` | Create | English email template | +| `app/Observers/TimelineUpdateObserver.php` | Create | Observer to trigger email | +| `app/Providers/AppServiceProvider.php` | Modify | Register the observer | + +### Mailable Implementation ```php -class TimelineUpdateNotification extends Notification -{ - use Queueable; +locale = $this->update->timeline->user->preferred_language ?? 'ar'; } - public function toMail(object $notifiable): MailMessage + public function envelope(): Envelope { - $locale = $notifiable->preferred_language ?? 'ar'; - $timeline = $this->update->timeline; + $caseName = $this->update->timeline->case_name; + $subject = $this->locale === 'ar' + ? "تحديث على قضيتك: {$caseName}" + : "Update on your case: {$caseName}"; - return (new MailMessage) - ->subject($this->getSubject($locale, $timeline->case_name)) - ->markdown("emails.timeline.update.{$locale}", [ + return new Envelope( + subject: $subject, + ); + } + + public function content(): Content + { + return new Content( + markdown: "emails.timeline.update-{$this->locale}", + with: [ 'update' => $this->update, - 'timeline' => $timeline, - ]); + 'timeline' => $this->update->timeline, + 'client' => $this->update->timeline->user, + 'viewUrl' => route('client.timelines.show', $this->update->timeline), + ], + ); } } ``` +### Observer Implementation + +```php +timeline->status !== 'active') { + return; + } + + $client = $update->timeline->user; + + Mail::to($client->email)->queue( + new TimelineUpdateEmail($update) + ); + } +} +``` + +### Register Observer + +In `AppServiceProvider::boot()`: + +```php +use App\Models\TimelineUpdate; +use App\Observers\TimelineUpdateObserver; + +public function boot(): void +{ + TimelineUpdate::observe(TimelineUpdateObserver::class); +} +``` + +### Arabic Template Structure (`update-ar.blade.php`) + +```blade + +# تحديث على قضيتك + +**اسم القضية:** {{ $timeline->case_name }} +@if($timeline->case_reference) +**رقم المرجع:** {{ $timeline->case_reference }} +@endif + +--- + +## التحديث + +{{ $update->update_text }} + +**تاريخ التحديث:** {{ $update->created_at->locale('ar')->isoFormat('LL') }} + + +عرض الجدول الزمني + + +مع تحياتنا,
+{{ config('app.name') }} +
+``` + +### English Template Structure (`update-en.blade.php`) + +```blade + +# Update on Your Case + +**Case Name:** {{ $timeline->case_name }} +@if($timeline->case_reference) +**Reference:** {{ $timeline->case_reference }} +@endif + +--- + +## Update + +{{ $update->update_text }} + +**Date:** {{ $update->created_at->format('F j, Y') }} + + +View Timeline + + +Best regards,
+{{ config('app.name') }} +
+``` + +## Edge Cases & Error Handling + +| Scenario | Handling | +|----------|----------| +| Archived timeline gets update | No email sent (observer checks status) | +| Client has no `preferred_language` | Default to Arabic ('ar') | +| Client email is null/invalid | Mail will fail gracefully, logged to failed_jobs | +| Timeline has no case_reference | Template conditionally hides reference line | +| Multiple rapid updates | Each triggers separate email (acceptable per requirements) | + +## Testing Requirements + +### Test File +Create `tests/Feature/Mail/TimelineUpdateEmailTest.php` + +### Test Scenarios + +```php +create(['preferred_language' => 'en']); + $timeline = Timeline::factory()->for($client)->create(['status' => 'active']); + + TimelineUpdate::factory()->for($timeline)->create(); + + Mail::assertQueued(TimelineUpdateEmail::class); +}); + +test('email is not sent for archived timeline updates', function () { + Mail::fake(); + + $client = User::factory()->create(); + $timeline = Timeline::factory()->for($client)->create(['status' => 'archived']); + + TimelineUpdate::factory()->for($timeline)->create(); + + Mail::assertNothingQueued(); +}); + +test('email uses arabic template when client prefers arabic', function () { + $client = User::factory()->create(['preferred_language' => 'ar']); + $timeline = Timeline::factory()->for($client)->create(); + $update = TimelineUpdate::factory()->for($timeline)->create(); + + $mailable = new TimelineUpdateEmail($update); + + expect($mailable->locale)->toBe('ar'); +}); + +test('email uses english template when client prefers english', function () { + $client = User::factory()->create(['preferred_language' => 'en']); + $timeline = Timeline::factory()->for($client)->create(); + $update = TimelineUpdate::factory()->for($timeline)->create(); + + $mailable = new TimelineUpdateEmail($update); + + expect($mailable->locale)->toBe('en'); +}); + +test('email defaults to arabic when no language preference', function () { + $client = User::factory()->create(['preferred_language' => null]); + $timeline = Timeline::factory()->for($client)->create(); + $update = TimelineUpdate::factory()->for($timeline)->create(); + + $mailable = new TimelineUpdateEmail($update); + + expect($mailable->locale)->toBe('ar'); +}); + +test('email contains case name in subject', function () { + $client = User::factory()->create(['preferred_language' => 'en']); + $timeline = Timeline::factory()->for($client)->create(['case_name' => 'Smith vs Jones']); + $update = TimelineUpdate::factory()->for($timeline)->create(); + + $mailable = new TimelineUpdateEmail($update); + + expect($mailable->envelope()->subject)->toContain('Smith vs Jones'); +}); + +test('email contains update content', function () { + $client = User::factory()->create(['preferred_language' => 'en']); + $timeline = Timeline::factory()->for($client)->create(); + $update = TimelineUpdate::factory()->for($timeline)->create([ + 'update_text' => 'Court date scheduled for next month.', + ]); + + $mailable = new TimelineUpdateEmail($update); + + $mailable->assertSeeInHtml('Court date scheduled for next month.'); +}); + +test('email contains view timeline link', function () { + $client = User::factory()->create(['preferred_language' => 'en']); + $timeline = Timeline::factory()->for($client)->create(); + $update = TimelineUpdate::factory()->for($timeline)->create(); + + $mailable = new TimelineUpdateEmail($update); + + $mailable->assertSeeInHtml(route('client.timelines.show', $timeline)); +}); +``` + ## Definition of Done -- [ ] Email sent on update -- [ ] Case info included -- [ ] Update content shown -- [ ] View link works -- [ ] Bilingual templates -- [ ] Tests pass +- [ ] `TimelineUpdateEmail` mailable created extending `BaseMailable` +- [ ] Arabic and English templates created with proper formatting +- [ ] Observer registered and triggers on `TimelineUpdate` creation +- [ ] Email only sent for active timelines (not archived) +- [ ] Email queued (not sent synchronously) +- [ ] Subject includes case name in appropriate language +- [ ] Email body includes case reference (if exists), update content, and date +- [ ] View Timeline button links to correct client dashboard route +- [ ] All tests pass +- [ ] Code formatted with Pint ## Estimation -**Complexity:** Low | **Effort:** 2 hours +**Complexity:** Low | **Effort:** 2-3 hours diff --git a/docs/stories/story-8.9-admin-notification-new-booking.md b/docs/stories/story-8.9-admin-notification-new-booking.md index b23201d..dd567e9 100644 --- a/docs/stories/story-8.9-admin-notification-new-booking.md +++ b/docs/stories/story-8.9-admin-notification-new-booking.md @@ -3,6 +3,12 @@ ## Epic Reference **Epic 8:** Email Notification System +## Dependencies +- **Story 8.1:** Email Infrastructure Setup (base templates, SMTP config, queue setup) + +## Story Context +This notification is triggered during **Step 2 of the Booking Flow** (PRD Section 5.4). When a client submits a consultation request, it enters the pending queue and the admin must be notified immediately so they can review and respond promptly. This email works alongside Story 8.3 (client confirmation) - both are sent on the same trigger but to different recipients. + ## User Story As an **admin**, I want **to be notified when a client submits a booking request**, @@ -11,64 +17,345 @@ So that **I can review and respond promptly**. ## Acceptance Criteria ### Trigger -- [ ] Sent on booking submission by client +- [ ] Sent immediately after successful consultation creation (same trigger as Story 8.3) +- [ ] Consultation status: pending +- [ ] Email queued for async delivery ### Recipient -- [ ] Admin email address +- [ ] First user with `user_type = 'admin'` in the database +- [ ] If no admin exists, log error but don't fail the booking process ### Content -- [ ] "New Consultation Request" -- [ ] Client name (individual or company) -- [ ] Requested date and time -- [ ] Problem summary (full) -- [ ] Client contact info -- [ ] "Review Request" button/link +- [ ] Subject line: "[Action Required] New Consultation Request" / "[إجراء مطلوب] طلب استشارة جديد" +- [ ] "New Consultation Request" heading +- [ ] Client name: + - Individual: `full_name` + - Company: `company_name` (with contact person: `contact_person_name`) +- [ ] Requested date and time (formatted per admin's language preference) +- [ ] Problem summary (full text, no truncation) +- [ ] Client contact information: + - Email address + - Phone number + - Client type indicator (Individual/Company) +- [ ] "Review Request" button linking to consultation detail in admin dashboard ### Priority -- [ ] Clear indicator in subject line +- [ ] "[Action Required]" prefix in subject line (English) +- [ ] "[إجراء مطلوب]" prefix in subject line (Arabic) ### Language -- [ ] Admin language preference (or default) +- [ ] Email sent in admin's `preferred_language` +- [ ] Default to 'en' (English) if admin has no preference set (admin-facing communications default to English) ## Technical Notes +### Files to Create +``` +app/Mail/NewBookingAdminEmail.php +resources/views/emails/admin/new-booking/ar.blade.php +resources/views/emails/admin/new-booking/en.blade.php +``` + +### Mailable Implementation +Using `Mailable` pattern to align with sibling stories (8.2-8.8): + ```php -class NewBookingAdminNotification extends Notification +getAdminUser(); + $locale = $admin?->preferred_language ?? 'en'; + + return new Envelope( + subject: $locale === 'ar' + ? '[إجراء مطلوب] طلب استشارة جديد' + : '[Action Required] New Consultation Request', + ); } - public function toMail(object $notifiable): MailMessage + public function content(): Content { - return (new MailMessage) - ->subject('[Action Required] New Consultation Request') - ->markdown('emails.admin.new-booking', [ + $admin = $this->getAdminUser(); + $locale = $admin?->preferred_language ?? 'en'; + + return new Content( + markdown: "emails.admin.new-booking.{$locale}", + with: [ 'consultation' => $this->consultation, 'client' => $this->consultation->user, - ]); + 'formattedDate' => $this->getFormattedDate($locale), + 'formattedTime' => $this->getFormattedTime(), + 'reviewUrl' => $this->getReviewUrl(), + ], + ); + } + + private function getAdminUser(): ?User + { + return User::where('user_type', 'admin')->first(); + } + + private function getFormattedDate(string $locale): string + { + $date = $this->consultation->booking_date; + return $locale === 'ar' + ? $date->format('d/m/Y') + : $date->format('m/d/Y'); + } + + private function getFormattedTime(): string + { + return $this->consultation->booking_time->format('h:i A'); + } + + private function getReviewUrl(): string + { + return route('admin.consultations.show', $this->consultation); } } - -// Trigger in booking submission -$admin = User::where('user_type', 'admin')->first(); -$admin?->notify(new NewBookingAdminNotification($consultation)); ``` +### Dispatch Point +Same location as Story 8.3 - after consultation creation: + +```php +// In the controller or action handling booking submission +// Dispatch AFTER the client confirmation email (Story 8.3) + +use App\Mail\NewBookingAdminEmail; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; + +// Send admin notification +$admin = User::where('user_type', 'admin')->first(); + +if ($admin) { + Mail::to($admin->email)->send(new NewBookingAdminEmail($consultation)); +} else { + Log::warning('No admin user found to notify about new booking', [ + 'consultation_id' => $consultation->id, + ]); +} +``` + +### Edge Cases +- **No admin user exists:** Log warning, continue without sending (booking should not fail) +- **Admin has no email:** Skip sending, log error +- **Admin `preferred_language` is null:** Default to 'en' (English) +- **Client is company type:** Display company name prominently, include contact person name +- **Client is individual type:** Display full name +- **Consultation missing `booking_date` or `booking_time`:** Should not happen (validation), but handle gracefully + +### Client Information Display Logic +```php +// In the email template +@if($client->user_type === 'company') + {{ $client->company_name }} +
Contact: {{ $client->contact_person_name }} +@else + {{ $client->full_name }} +@endif + +Email: {{ $client->email }} +Phone: {{ $client->phone }} +``` + +## Testing Requirements + +### Unit Tests +```php +create([ + 'user_type' => 'admin', + 'preferred_language' => 'en', + ]); + $client = User::factory()->create(['user_type' => 'individual']); + $consultation = Consultation::factory()->create(['user_id' => $client->id]); + + $mailable = new NewBookingAdminEmail($consultation); + + expect($mailable->envelope()->subject) + ->toBe('[Action Required] New Consultation Request'); +}); + +test('admin email has action required prefix in Arabic subject', function () { + $admin = User::factory()->create([ + 'user_type' => 'admin', + 'preferred_language' => 'ar', + ]); + $client = User::factory()->create(['user_type' => 'individual']); + $consultation = Consultation::factory()->create(['user_id' => $client->id]); + + $mailable = new NewBookingAdminEmail($consultation); + + expect($mailable->envelope()->subject) + ->toBe('[إجراء مطلوب] طلب استشارة جديد'); +}); + +test('admin email defaults to English when admin has no language preference', function () { + $admin = User::factory()->create([ + 'user_type' => 'admin', + 'preferred_language' => null, + ]); + $client = User::factory()->create(['user_type' => 'individual']); + $consultation = Consultation::factory()->create(['user_id' => $client->id]); + + $mailable = new NewBookingAdminEmail($consultation); + + expect($mailable->envelope()->subject) + ->toContain('[Action Required]'); +}); + +test('admin email includes full problem summary', function () { + $admin = User::factory()->create(['user_type' => 'admin']); + $client = User::factory()->create(['user_type' => 'individual']); + $longSummary = str_repeat('Legal issue description. ', 50); + $consultation = Consultation::factory()->create([ + 'user_id' => $client->id, + 'problem_summary' => $longSummary, + ]); + + $mailable = new NewBookingAdminEmail($consultation); + $content = $mailable->content(); + + // Full summary passed, not truncated + expect($content->with['consultation']->problem_summary) + ->toBe($longSummary); +}); + +test('admin email includes review URL', function () { + $admin = User::factory()->create(['user_type' => 'admin']); + $client = User::factory()->create(['user_type' => 'individual']); + $consultation = Consultation::factory()->create(['user_id' => $client->id]); + + $mailable = new NewBookingAdminEmail($consultation); + $content = $mailable->content(); + + expect($content->with['reviewUrl']) + ->toContain('consultations') + ->toContain((string) $consultation->id); +}); +``` + +### Feature Tests +```php +create(['user_type' => 'admin']); + $client = User::factory()->create(['user_type' => 'individual']); + $consultation = Consultation::factory()->create(['user_id' => $client->id]); + + Mail::to($admin->email)->send(new NewBookingAdminEmail($consultation)); + + Mail::assertSent(NewBookingAdminEmail::class, function ($mail) use ($admin) { + return $mail->hasTo($admin->email); + }); +}); + +test('admin email is queued for async delivery', function () { + Mail::fake(); + + $admin = User::factory()->create(['user_type' => 'admin']); + $client = User::factory()->create(['user_type' => 'individual']); + $consultation = Consultation::factory()->create(['user_id' => $client->id]); + + Mail::to($admin->email)->send(new NewBookingAdminEmail($consultation)); + + Mail::assertQueued(NewBookingAdminEmail::class); +}); + +test('warning is logged when no admin exists', function () { + Log::shouldReceive('warning') + ->once() + ->with('No admin user found to notify about new booking', \Mockery::any()); + + $client = User::factory()->create(['user_type' => 'individual']); + $consultation = Consultation::factory()->create(['user_id' => $client->id]); + + $admin = User::where('user_type', 'admin')->first(); + + if (!$admin) { + Log::warning('No admin user found to notify about new booking', [ + 'consultation_id' => $consultation->id, + ]); + } +}); + +test('admin email displays company client information correctly', function () { + Mail::fake(); + + $admin = User::factory()->create(['user_type' => 'admin']); + $companyClient = User::factory()->create([ + 'user_type' => 'company', + 'company_name' => 'Acme Corp', + 'contact_person_name' => 'John Doe', + ]); + $consultation = Consultation::factory()->create(['user_id' => $companyClient->id]); + + $mailable = new NewBookingAdminEmail($consultation); + + expect($mailable->content()->with['client']->company_name)->toBe('Acme Corp'); + expect($mailable->content()->with['client']->contact_person_name)->toBe('John Doe'); +}); +``` + +## References +- **PRD Section 5.4:** Booking Flow - Step 2 "Admin receives email notification at no-reply@libra.ps" +- **PRD Section 8.2:** Admin Emails - "New Booking Request - With client details and problem summary" +- **Story 8.1:** Base email template structure, SMTP config, queue setup +- **Story 8.3:** Similar trigger pattern (booking submission) - client-facing counterpart + ## Definition of Done -- [ ] Email sent to admin on new booking -- [ ] All client info included -- [ ] Problem summary shown -- [ ] Review link works -- [ ] Priority clear in subject -- [ ] Tests pass +- [ ] `NewBookingAdminEmail` Mailable class created +- [ ] Arabic template created and renders correctly +- [ ] English template created and renders correctly +- [ ] Email dispatched on consultation creation (after Story 8.3 client email) +- [ ] Email queued (implements ShouldQueue) +- [ ] Subject contains "[Action Required]" / "[إجراء مطلوب]" prefix +- [ ] All client information included (name, email, phone, type) +- [ ] Company clients show company name and contact person +- [ ] Full problem summary displayed (no truncation) +- [ ] Review link navigates to admin consultation detail page +- [ ] Date/time formatted per admin language preference +- [ ] Graceful handling when no admin exists (log warning, don't fail) +- [ ] Unit tests pass +- [ ] Feature tests pass +- [ ] Code formatted with Pint ## Estimation -**Complexity:** Low | **Effort:** 2 hours +**Complexity:** Low | **Effort:** 2-3 hours