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