reviewed epic 8 stories

This commit is contained in:
Naser Mansour 2025-12-21 00:30:29 +02:00
parent f6c06ec3e1
commit 261a528578
10 changed files with 2391 additions and 216 deletions

View File

@ -3,6 +3,9 @@
## Epic Reference ## Epic Reference
**Epic 8:** Email Notification System **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 ## User Story
As a **developer**, As a **developer**,
I want **to configure email sending infrastructure and base templates**, 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 - [ ] Mobile-responsive layout
### Technical Setup ### Technical Setup
- [ ] Plain text fallback generation - [ ] Plain text fallback generation (auto-generated from HTML)
- [ ] Queue configuration for async sending - [ ] Queue configuration for async sending (database driver)
- [ ] Email logging for debugging - [ ] 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 ```php
// config/mail.php - from .env <?php
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'no-reply@libra.ps'),
'name' => env('MAIL_FROM_NAME', 'Libra Law Firm'),
],
// resources/views/vendor/mail/html/header.blade.php namespace App\Mail;
<tr>
<td class="header" style="background-color: #0A1F44; padding: 25px;">
<a href="{{ config('app.url') }}">
<img src="{{ asset('images/logo-email.png') }}" alt="Libra Law Firm" height="45">
</a>
</td>
</tr>
// Base Mailable use Illuminate\Bus\Queueable;
abstract class BaseMailable extends Mailable 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; 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 protected function getFromName(): string
{ {
$locale = $this->getLocale(); $locale = $this->locale ?? app()->getLocale();
return $locale === 'ar' ? 'مكتب ليبرا للمحاماة' : 'Libra Law Firm'; 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
<tr>
<td class="header" style="background-color: #0A1F44; padding: 25px; text-align: center;">
<a href="{{ config('app.url') }}" style="display: inline-block;">
<img src="{{ asset('images/logo-email.png') }}" alt="Libra Law Firm" height="45" style="height: 45px;">
</a>
</td>
</tr>
```
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
<?php
use App\Mail\BaseMailable;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
test('emails are queued not sent synchronously', function () {
Queue::fake();
// Send a test email extending BaseMailable
// Assert job was pushed to queue
});
test('sender name is arabic when locale is ar', function () {
app()->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 ## Definition of Done
- [ ] SMTP sending works - [ ] SMTP sending works (verified with real credentials or log driver)
- [ ] Base template with branding - [ ] Base template displays Libra branding (logo, navy/gold colors)
- [ ] Plain text fallback - [ ] Plain text fallback auto-generates from HTML
- [ ] Queued delivery works - [ ] Emails dispatch to queue (not sent synchronously)
- [ ] Tests pass - [ ] 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 ## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours **Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -8,35 +8,184 @@ As an **admin**,
I want **to be notified of critical system events**, I want **to be notified of critical system events**,
So that **I can address issues promptly**. So that **I can address issues promptly**.
## Dependencies
- **Story 8.1:** Email Infrastructure Setup (base template, SMTP configuration, queue setup)
## Acceptance Criteria ## Acceptance Criteria
### Events to Notify ### Events to Notify
- [ ] Email delivery failures - [ ] Email delivery failures (SMTP errors, bounces)
- [ ] Scheduled job failures - [ ] Scheduled job failures (Laravel scheduler)
- [ ] Critical application errors - [ ] Queue job failures
- [ ] Critical application errors (database, payment, etc.)
### Content ### Content Requirements
- [ ] Event type and description - [ ] Event type and description
- [ ] Timestamp - [ ] Timestamp (formatted per locale)
- [ ] Relevant details - [ ] Relevant details (error message, stack trace summary, job name)
- [ ] Recommended action (if any) - [ ] Recommended action (if applicable)
- [ ] Environment indicator (production/staging)
### Delivery ### Delivery Requirements
- [ ] Sent immediately (not queued) - [ ] Sent immediately using sync mail driver (not queued)
- [ ] Clear subject line indicating urgency - [ ] 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 ## 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 ```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) { ->withExceptions(function (Exceptions $exceptions) {
$exceptions->reportable(function (Throwable $e) { $exceptions->reportable(function (Throwable $e) {
if ($this->shouldNotifyAdmin($e)) { $service = app(AdminNotificationService::class);
$admin = User::where('user_type', 'admin')->first();
$admin?->notify(new SystemErrorNotification($e)); 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 class SystemErrorNotification extends Notification
{ {
@ -51,29 +200,223 @@ class SystemErrorNotification extends Notification
public function toMail(object $notifiable): MailMessage public function toMail(object $notifiable): MailMessage
{ {
$environment = app()->environment();
$errorType = class_basename($this->exception);
return (new MailMessage) 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('A critical error occurred on the platform.')
->line('Error: ' . $this->exception->getMessage()) ->line("**Error Type:** {$errorType}")
->line('Time: ' . now()->format('Y-m-d H:i:s')) ->line("**Message:** " . $this->exception->getMessage())
->line('Please check the logs for more details.'); ->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 Failure Notification
Queue::failing(function (JobFailed $event) { ```php
$admin = User::where('user_type', 'admin')->first(); // app/Notifications/QueueFailureNotification.php
$admin?->notify(new QueueFailureNotification($event)); 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 ## Definition of Done
- [ ] Email failures notified - [ ] `AdminNotificationService` created with rate limiting
- [ ] Job failures notified - [ ] `SystemErrorNotification` created and styled
- [ ] Critical errors notified - [ ] `QueueFailureNotification` created and styled
- [ ] Sent immediately - [ ] `ShouldNotifyAdmin` marker interface created
- [ ] Clear urgency indication - [ ] Exception handler integration in `bootstrap/app.php`
- [ ] Tests pass - [ ] 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 ## 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

View File

@ -3,6 +3,9 @@
## Epic Reference ## Epic Reference
**Epic 8:** Email Notification System **Epic 8:** Email Notification System
## Dependencies
**Requires:** Story 8.1 (Email Infrastructure Setup) - base template, queue configuration, SMTP setup
## User Story ## User Story
As a **new client**, As a **new client**,
I want **to receive a welcome email with my login credentials**, I want **to receive a welcome email with my login credentials**,
@ -12,7 +15,8 @@ So that **I can access the platform**.
### Trigger ### Trigger
- [ ] Sent automatically on user creation by admin - [ ] Sent automatically on user creation by admin
- [ ] Queued for performance - [ ] Queued for performance (implements `ShouldQueue`)
- [ ] Triggered via model observer on User created event
### Content ### Content
- [ ] Personalized greeting (name/company) - [ ] Personalized greeting (name/company)
@ -23,18 +27,45 @@ So that **I can access the platform**.
- [ ] Contact info for questions - [ ] Contact info for questions
### Language ### 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 - [ ] Arabic template
- [ ] English template - [ ] English template
### Design ### Design
- [ ] Professional branding - [ ] Professional branding (inherits from base template in Story 8.1)
- [ ] Call-to-action button: "Login Now" - [ ] Call-to-action button: "Login Now" / "تسجيل الدخول"
## Technical Notes ## 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 ```php
class WelcomeEmail extends Mailable // app/Mail/WelcomeEmail.php
class WelcomeEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; use Queueable, SerializesModels;
@ -46,9 +77,9 @@ class WelcomeEmail extends Mailable
public function envelope(): Envelope public function envelope(): Envelope
{ {
return new Envelope( return new Envelope(
subject: $this->user->preferred_language === 'ar' subject: $this->user->preferred_language === 'en'
? 'مرحباً بك في مكتب ليبرا للمحاماة' ? 'Welcome to Libra Law Firm'
: 'Welcome to Libra Law Firm', : 'مرحباً بك في مكتب ليبرا للمحاماة',
); );
} }
@ -56,18 +87,90 @@ class WelcomeEmail extends Mailable
{ {
return new Content( return new Content(
markdown: 'emails.welcome.' . ($this->user->preferred_language ?? 'ar'), 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 ## Definition of Done
- [ ] Email sent on user creation - [ ] `WelcomeEmail` mailable class created
- [ ] Credentials included - [ ] Arabic template (`emails/welcome/ar.blade.php`) created
- [ ] Arabic template works - [ ] English template (`emails/welcome/en.blade.php`) created
- [ ] English template works - [ ] Email triggered on user creation by admin
- [ ] Login button works - [ ] Email is queued (not sent synchronously)
- [ ] Tests pass - [ ] Credentials included in email
- [ ] Login button links to correct URL
- [ ] All tests pass
- [ ] Code formatted with Pint
## Estimation ## Estimation
**Complexity:** Low | **Effort:** 2-3 hours **Complexity:** Low | **Effort:** 2-3 hours

View File

@ -3,35 +3,67 @@
## Epic Reference ## Epic Reference
**Epic 8:** Email Notification System **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 ## User Story
As a **client**, As a **client**,
I want **to receive confirmation when I submit a booking request**, 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 ## Acceptance Criteria
### Trigger ### Trigger
- [ ] Sent on booking submission - [ ] Sent immediately after successful consultation creation
- [ ] Status: pending - [ ] Consultation status: pending
- [ ] Email queued for async delivery
### Content ### Content
- [ ] "Your consultation request has been submitted" - [ ] Subject line: "Your consultation request has been submitted" / "تم استلام طلب الاستشارة"
- [ ] Requested date and time - [ ] Personalized greeting with client name
- [ ] Problem summary preview - [ ] "Your consultation request has been submitted" message
- [ ] "Pending Review" status note - [ ] Requested date and time (formatted per user's language preference)
- [ ] Expected response timeframe (general) - [ ] 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 ### 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 ### Design
- [ ] No action required message - [ ] Uses base email template from Story 8.1
- [ ] Professional template - [ ] No action required message (informational only)
- [ ] Professional template with Libra branding
- [ ] Gold call-to-action style for "View My Bookings" link (optional)
## Technical Notes ## 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 ```php
class BookingSubmittedEmail extends Mailable <?php
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 BookingSubmittedEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; use Queueable, SerializesModels;
@ -39,6 +71,17 @@ class BookingSubmittedEmail extends Mailable
public Consultation $consultation public Consultation $consultation
) {} ) {}
public function envelope(): Envelope
{
$locale = $this->consultation->user->preferred_language ?? 'ar';
return new Envelope(
subject: $locale === 'ar'
? 'تم استلام طلب الاستشارة'
: 'Your Consultation Request Has Been Submitted',
);
}
public function content(): Content public function content(): Content
{ {
$locale = $this->consultation->user->preferred_language ?? 'ar'; $locale = $this->consultation->user->preferred_language ?? 'ar';
@ -48,19 +91,189 @@ class BookingSubmittedEmail extends Mailable
with: [ with: [
'consultation' => $this->consultation, 'consultation' => $this->consultation,
'user' => $this->consultation->user, '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 ## Definition of Done
- [ ] Email sent on submission - [ ] `BookingSubmittedEmail` Mailable class created
- [ ] Date/time included - [ ] Arabic template created and renders correctly
- [ ] Summary preview shown - [ ] English template created and renders correctly
- [ ] Pending status clear - [ ] Email dispatched on consultation creation
- [ ] Bilingual templates - [ ] Email queued (implements ShouldQueue)
- [ ] Tests pass - [ ] 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 ## Estimation
**Complexity:** Low | **Effort:** 2 hours **Complexity:** Low | **Effort:** 2 hours

View File

@ -3,6 +3,10 @@
## Epic Reference ## Epic Reference
**Epic 8:** Email Notification System **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 ## User Story
As a **client**, As a **client**,
I want **to receive notification when my booking is approved**, 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 ## 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 ```php
<?php
namespace App\Mail;
use App\Models\Consultation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class BookingApprovedEmail extends Mailable class BookingApprovedEmail extends Mailable
{ {
use Queueable, SerializesModels; use Queueable, SerializesModels;
@ -42,6 +76,16 @@ class BookingApprovedEmail extends Mailable
public ?string $paymentInstructions = null public ?string $paymentInstructions = null
) {} ) {}
public function envelope(): Envelope
{
$locale = $this->consultation->user->preferred_language ?? 'ar';
$subject = $locale === 'ar'
? 'تمت الموافقة على استشارتك'
: 'Your Consultation Has Been Approved';
return new Envelope(subject: $subject);
}
public function content(): Content public function content(): Content
{ {
$locale = $this->consultation->user->preferred_language ?? 'ar'; $locale = $this->consultation->user->preferred_language ?? 'ar';
@ -50,6 +94,7 @@ class BookingApprovedEmail extends Mailable
markdown: "emails.booking.approved.{$locale}", markdown: "emails.booking.approved.{$locale}",
with: [ with: [
'consultation' => $this->consultation, 'consultation' => $this->consultation,
'user' => $this->consultation->user,
'paymentInstructions' => $this->paymentInstructions, '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 ## Definition of Done
- [ ] Email sent on approval - [ ] Email sent on approval
- [ ] All details included - [ ] All details included (date, time, duration, type)
- [ ] Payment info for paid consultations - [ ] Payment info for paid consultations
- [ ] .ics file attached - [ ] .ics file attached
- [ ] Bilingual templates - [ ] Bilingual templates (Arabic/English)
- [ ] Tests pass - [ ] Observer/listener triggers on status change
- [ ] Tests pass (all scenarios above)
- [ ] Code formatted with Pint
## Estimation ## Estimation
**Complexity:** Medium | **Effort:** 3 hours **Complexity:** Medium | **Effort:** 3 hours

View File

@ -8,15 +8,26 @@ As a **client**,
I want **to be notified when my booking is rejected**, I want **to be notified when my booking is rejected**,
So that **I can understand why and request a new consultation if needed**. 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 ## Acceptance Criteria
### Trigger ### Trigger
- [ ] Sent on booking rejection by admin - [ ] Sent when consultation status changes to `'rejected'`
### Content ### Content
- [ ] "Your consultation request could not be approved" - [ ] "Your consultation request could not be approved"
- [ ] Original requested date and time - [ ] Original requested date and time
- [ ] Rejection reason (if provided by admin) - [ ] Rejection reason (conditionally shown if provided by admin)
- [ ] Invitation to request new consultation - [ ] Invitation to request new consultation
- [ ] Contact info for questions - [ ] Contact info for questions
@ -24,12 +35,35 @@ So that **I can understand why and request a new consultation if needed**.
- [ ] Empathetic, professional - [ ] Empathetic, professional
### Language ### 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 ## 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 ```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; use Queueable, SerializesModels;
@ -38,6 +72,16 @@ class BookingRejectedEmail extends Mailable
public ?string $reason = null 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 public function content(): Content
{ {
$locale = $this->consultation->user->preferred_language ?? 'ar'; $locale = $this->consultation->user->preferred_language ?? 'ar';
@ -47,18 +91,182 @@ class BookingRejectedEmail extends Mailable
with: [ with: [
'consultation' => $this->consultation, 'consultation' => $this->consultation,
'reason' => $this->reason, '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 --}}
<x-mail::message>
# تعذر الموافقة على طلب الاستشارة
عزيزي/عزيزتي {{ $consultation->user->name }},
نأسف لإبلاغك بأنه تعذر علينا الموافقة على طلب الاستشارة الخاص بك.
**التاريخ المطلوب:** {{ $consultation->scheduled_at->format('Y-m-d') }}
**الوقت المطلوب:** {{ $consultation->scheduled_at->format('H:i') }}
@if($hasReason)
**السبب:** {{ $reason }}
@endif
نرحب بك لتقديم طلب استشارة جديد في وقت آخر يناسبك.
<x-mail::button :url="route('client.consultations.create')">
طلب استشارة جديدة
</x-mail::button>
للاستفسارات، تواصل معنا على: info@libra.ps
مع أطيب التحيات,
مكتب ليبرا للمحاماة
</x-mail::message>
```
## 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 ## Definition of Done
- [ ] Email sent on rejection - [ ] `BookingRejectedEmail` mailable class created
- [ ] Reason included if provided - [ ] Arabic template created with RTL layout and empathetic tone
- [ ] Empathetic tone - [ ] English template created with LTR layout and empathetic tone
- [ ] Bilingual templates - [ ] Event and listener wired for consultation rejection
- [ ] Tests pass - [ ] 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 ## Estimation
**Complexity:** Low | **Effort:** 2 hours **Complexity:** Low | **Effort:** 2-3 hours

View File

@ -3,6 +3,12 @@
## Epic Reference ## Epic Reference
**Epic 8:** Email Notification System **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 ## User Story
As a **client**, As a **client**,
I want **to receive a reminder 24 hours before my consultation**, 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 ## Acceptance Criteria
### Trigger ### Trigger
- [ ] Scheduled job runs daily - [ ] Scheduled artisan command runs hourly
- [ ] Find consultations 24 hours away - [ ] Find consultations approximately 24 hours away (within 30-minute window)
- [ ] Only for approved consultations - [ ] Only for approved consultations (`status = 'approved'`)
- [ ] Skip cancelled/no-show - [ ] Skip cancelled/no-show/completed consultations
- [ ] Track sent reminders to prevent duplicates
### Content ### Content
- [ ] "Reminder: Your consultation is tomorrow" - [ ] Subject: "Reminder: Your consultation is tomorrow" / "تذكير: استشارتك غدًا"
- [ ] Date and time - [ ] Consultation date and time (formatted per locale)
- [ ] Consultation type - [ ] Consultation type (free/paid)
- [ ] Payment reminder (if paid and not received) - [ ] Payment reminder: Show if `consultation_type = 'paid'` AND `payment_status != 'received'`
- [ ] Calendar file link - [ ] Calendar file download link (using route to CalendarService)
- [ ] Any preparation notes - [ ] Office contact information for questions
### Language ### Language
- [ ] Email in client's preferred language - [ ] Email rendered in client's `preferred_language` (ar/en)
- [ ] Date/time formatted according to locale
## Technical Notes ## Technical Notes
### Database Migration
Add tracking column to prevent duplicate reminders:
```php ```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 class Send24HourReminders extends Command
{ {
protected $signature = 'reminders:send-24h';
protected $description = 'Send 24-hour consultation reminders';
public function handle(): int public function handle(): int
{ {
$targetTime = now()->addHours(24); $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') ->whereNull('reminder_24h_sent_at')
->whereDate('scheduled_date', $targetTime->toDateString()) ->whereDate('scheduled_date', $targetTime->toDateString())
->each(function ($consultation) { ->get()
$consultation->user->notify(new ConsultationReminder24h($consultation)); ->filter(function ($consultation) use ($windowStart, $windowEnd) {
$consultation->update(['reminder_24h_sent_at' => now()]); $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; 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 ## Definition of Done
- [ ] Command runs successfully - [ ] Migration adds `reminder_24h_sent_at` column to consultations table
- [ ] Reminders sent to correct consultations - [ ] Artisan command `reminders:send-24h` created and works
- [ ] Payment reminder for unpaid - [ ] Command scheduled to run hourly
- [ ] No duplicate reminders - [ ] Notification class implements `ShouldQueue`
- [ ] Bilingual templates - [ ] Reminders only sent for approved consultations within 24h window
- [ ] Tests pass - [ ] 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 ## Estimation
**Complexity:** Medium | **Effort:** 3 hours **Complexity:** Medium | **Effort:** 3 hours

View File

@ -3,66 +3,276 @@
## Epic Reference ## Epic Reference
**Epic 8:** Email Notification System **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 ## User Story
As a **client**, As a **client**,
I want **to receive a reminder 2 hours before my consultation**, 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 ## Acceptance Criteria
### Trigger ### Trigger
- [ ] Scheduled job runs every 15 minutes - [ ] Scheduled artisan command runs every 15 minutes
- [ ] Find consultations 2 hours away - [ ] Find consultations approximately 2 hours away (within 7-minute window)
- [ ] Only for approved consultations - [ ] Only for approved consultations (`status = 'approved'`)
- [ ] Skip cancelled/no-show - [ ] Skip cancelled/no-show/completed consultations
- [ ] Track sent reminders to prevent duplicates via `reminder_2h_sent_at`
### Content ### Content
- [ ] "Your consultation is in 2 hours" - [ ] Subject: "Your consultation is in 2 hours" / "استشارتك بعد ساعتين"
- [ ] Date and time - [ ] Consultation date and time (formatted per locale)
- [ ] Final payment reminder (if applicable) - [ ] Final payment reminder: Show if `consultation_type = 'paid'` AND `payment_status != 'received'`
- [ ] Contact info for last-minute issues - [ ] Office contact information for last-minute issues/questions
### Language ### Language
- [ ] Email in client's preferred language - [ ] Email rendered in client's `preferred_language` (ar/en)
- [ ] Date/time formatted according to locale
## Technical Notes ## Technical Notes
### Artisan Command
```php ```php
// Command: php artisan reminders:send-2h // app/Console/Commands/Send2HourReminders.php
// Schedule: everyFifteenMinutes namespace App\Console\Commands;
use App\Models\Consultation;
use App\Notifications\ConsultationReminder2h;
use Carbon\Carbon;
use Illuminate\Console\Command;
class Send2HourReminders extends Command class Send2HourReminders extends Command
{ {
protected $signature = 'reminders:send-2h';
protected $description = 'Send 2-hour consultation reminders';
public function handle(): int public function handle(): int
{ {
$targetTime = now()->addHours(2); $targetTime = now()->addHours(2);
$windowStart = $targetTime->copy()->subMinutes(7); $windowStart = $targetTime->copy()->subMinutes(7);
$windowEnd = $targetTime->copy()->addMinutes(7); $windowEnd = $targetTime->copy()->addMinutes(7);
Consultation::where('status', 'approved') $consultations = Consultation::query()
->where('status', 'approved')
->whereNull('reminder_2h_sent_at') ->whereNull('reminder_2h_sent_at')
->whereDate('scheduled_date', today()) ->whereDate('scheduled_date', today())
->get() ->get()
->filter(function ($c) use ($windowStart, $windowEnd) { ->filter(function ($consultation) use ($windowStart, $windowEnd) {
$time = Carbon::parse($c->scheduled_date->format('Y-m-d') . ' ' . $c->scheduled_time); $consultationDateTime = Carbon::parse(
return $time->between($windowStart, $windowEnd); $consultation->scheduled_date->format('Y-m-d') . ' ' . $consultation->scheduled_time
}) );
->each(function ($consultation) { return $consultationDateTime->between($windowStart, $windowEnd);
$consultation->user->notify(new ConsultationReminder2h($consultation));
$consultation->update(['reminder_2h_sent_at' => now()]);
}); });
$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; 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 ## Definition of Done
- [ ] Command runs successfully - [ ] Artisan command `reminders:send-2h` created and works
- [ ] Correct timing (2 hours before) - [ ] Command scheduled to run every 15 minutes
- [ ] Payment reminder if unpaid - [ ] Notification class implements `ShouldQueue`
- [ ] No duplicate reminders - [ ] Reminders only sent for approved consultations within 2h window (7-min tolerance)
- [ ] Bilingual templates - [ ] No duplicate reminders (tracking column `reminder_2h_sent_at` updated)
- [ ] Tests pass - [ ] 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 ## Estimation
**Complexity:** Medium | **Effort:** 2-3 hours **Complexity:** Medium | **Effort:** 2-3 hours

View File

@ -3,68 +3,352 @@
## Epic Reference ## Epic Reference
**Epic 8:** Email Notification System **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 ## User Story
As a **client**, As a **client**,
I want **to be notified when my case timeline is updated**, I want **to be notified via email when my case timeline is updated**,
So that **I stay informed about my case progress**. 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 ## Acceptance Criteria
### Trigger ### Trigger
- [ ] Sent on timeline update creation - [ ] Email sent automatically when `TimelineUpdate` is created
- [ ] Queued for performance - [ ] Uses model observer pattern for clean separation
- [ ] Email queued for performance (not sent synchronously)
- [ ] Only triggered for active timelines (not archived)
### Content ### Content
- [ ] "Update on your case: [Case Name]" - [ ] Subject: "Update on your case: [Case Name]" / "تحديث على قضيتك: [اسم القضية]"
- [ ] Case reference number - [ ] Case reference number (if exists)
- [ ] Update content (full or summary) - [ ] Full update content text
- [ ] Date of update - [ ] Date of update (formatted for locale)
- [ ] "View Timeline" button/link - [ ] "View Timeline" button linking to client dashboard timeline view
### Language ### 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 ### Design
- [ ] Uses base email template from Story 8.1 (Libra branding)
- [ ] Professional, informative tone - [ ] 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 ```php
class TimelineUpdateNotification extends Notification <?php
{
use Queueable;
namespace App\Mail;
use App\Models\TimelineUpdate;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class TimelineUpdateEmail extends BaseMailable
{
public function __construct( public function __construct(
public TimelineUpdate $update public TimelineUpdate $update
) {} ) {
$this->locale = $this->update->timeline->user->preferred_language ?? 'ar';
public function via(object $notifiable): array
{
return ['mail'];
} }
public function toMail(object $notifiable): MailMessage public function envelope(): Envelope
{ {
$locale = $notifiable->preferred_language ?? 'ar'; $caseName = $this->update->timeline->case_name;
$timeline = $this->update->timeline; $subject = $this->locale === 'ar'
? "تحديث على قضيتك: {$caseName}"
: "Update on your case: {$caseName}";
return (new MailMessage) return new Envelope(
->subject($this->getSubject($locale, $timeline->case_name)) subject: $subject,
->markdown("emails.timeline.update.{$locale}", [ );
}
public function content(): Content
{
return new Content(
markdown: "emails.timeline.update-{$this->locale}",
with: [
'update' => $this->update, '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
<?php
namespace App\Observers;
use App\Mail\TimelineUpdateEmail;
use App\Models\TimelineUpdate;
use Illuminate\Support\Facades\Mail;
class TimelineUpdateObserver
{
public function created(TimelineUpdate $update): void
{
// Only send for active timelines
if ($update->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
<x-mail::message>
# تحديث على قضيتك
**اسم القضية:** {{ $timeline->case_name }}
@if($timeline->case_reference)
**رقم المرجع:** {{ $timeline->case_reference }}
@endif
---
## التحديث
{{ $update->update_text }}
**تاريخ التحديث:** {{ $update->created_at->locale('ar')->isoFormat('LL') }}
<x-mail::button :url="$viewUrl">
عرض الجدول الزمني
</x-mail::button>
مع تحياتنا,<br>
{{ config('app.name') }}
</x-mail::message>
```
### English Template Structure (`update-en.blade.php`)
```blade
<x-mail::message>
# 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') }}
<x-mail::button :url="$viewUrl">
View Timeline
</x-mail::button>
Best regards,<br>
{{ config('app.name') }}
</x-mail::message>
```
## 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
<?php
use App\Mail\TimelineUpdateEmail;
use App\Models\Timeline;
use App\Models\TimelineUpdate;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
test('email is queued when timeline update is created', function () {
Mail::fake();
$client = User::factory()->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 ## Definition of Done
- [ ] Email sent on update - [ ] `TimelineUpdateEmail` mailable created extending `BaseMailable`
- [ ] Case info included - [ ] Arabic and English templates created with proper formatting
- [ ] Update content shown - [ ] Observer registered and triggers on `TimelineUpdate` creation
- [ ] View link works - [ ] Email only sent for active timelines (not archived)
- [ ] Bilingual templates - [ ] Email queued (not sent synchronously)
- [ ] Tests pass - [ ] 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 ## Estimation
**Complexity:** Low | **Effort:** 2 hours **Complexity:** Low | **Effort:** 2-3 hours

View File

@ -3,6 +3,12 @@
## Epic Reference ## Epic Reference
**Epic 8:** Email Notification System **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 ## User Story
As an **admin**, As an **admin**,
I want **to be notified when a client submits a booking request**, 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 ## Acceptance Criteria
### Trigger ### 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 ### 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 ### Content
- [ ] "New Consultation Request" - [ ] Subject line: "[Action Required] New Consultation Request" / "[إجراء مطلوب] طلب استشارة جديد"
- [ ] Client name (individual or company) - [ ] "New Consultation Request" heading
- [ ] Requested date and time - [ ] Client name:
- [ ] Problem summary (full) - Individual: `full_name`
- [ ] Client contact info - Company: `company_name` (with contact person: `contact_person_name`)
- [ ] "Review Request" button/link - [ ] 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 ### Priority
- [ ] Clear indicator in subject line - [ ] "[Action Required]" prefix in subject line (English)
- [ ] "[إجراء مطلوب]" prefix in subject line (Arabic)
### Language ### 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 ## 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 ```php
class NewBookingAdminNotification extends Notification <?php
namespace App\Mail;
use App\Models\Consultation;
use App\Models\User;
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 NewBookingAdminEmail extends Mailable implements ShouldQueue
{ {
use Queueable; use Queueable, SerializesModels;
public function __construct( public function __construct(
public Consultation $consultation public Consultation $consultation
) {} ) {}
public function via(object $notifiable): array public function envelope(): Envelope
{ {
return ['mail']; $admin = $this->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) $admin = $this->getAdminUser();
->subject('[Action Required] New Consultation Request') $locale = $admin?->preferred_language ?? 'en';
->markdown('emails.admin.new-booking', [
return new Content(
markdown: "emails.admin.new-booking.{$locale}",
with: [
'consultation' => $this->consultation, 'consultation' => $this->consultation,
'client' => $this->consultation->user, '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')
<strong>{{ $client->company_name }}</strong>
<br>Contact: {{ $client->contact_person_name }}
@else
<strong>{{ $client->full_name }}</strong>
@endif
Email: {{ $client->email }}
Phone: {{ $client->phone }}
```
## Testing Requirements
### Unit Tests
```php
<?php
use App\Mail\NewBookingAdminEmail;
use App\Models\Consultation;
use App\Models\User;
test('admin email has action required prefix in English subject', function () {
$admin = User::factory()->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
<?php
use App\Mail\NewBookingAdminEmail;
use App\Models\Consultation;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
test('admin email is sent when consultation is created', 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::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 ## Definition of Done
- [ ] Email sent to admin on new booking - [ ] `NewBookingAdminEmail` Mailable class created
- [ ] All client info included - [ ] Arabic template created and renders correctly
- [ ] Problem summary shown - [ ] English template created and renders correctly
- [ ] Review link works - [ ] Email dispatched on consultation creation (after Story 8.3 client email)
- [ ] Priority clear in subject - [ ] Email queued (implements ShouldQueue)
- [ ] Tests pass - [ ] 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 ## Estimation
**Complexity:** Low | **Effort:** 2 hours **Complexity:** Low | **Effort:** 2-3 hours