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