reviewed epic 8 stories
This commit is contained in:
@@ -3,68 +3,352 @@
|
||||
## Epic Reference
|
||||
**Epic 8:** Email Notification System
|
||||
|
||||
## Story Context
|
||||
This story implements email notifications when admin adds updates to a client's case timeline (from Epic 4). When admin creates a `TimelineUpdate` record, the associated client automatically receives an email with the update details, keeping them informed of their case progress without needing to manually check the portal.
|
||||
|
||||
## User Story
|
||||
As a **client**,
|
||||
I want **to be notified when my case timeline is updated**,
|
||||
So that **I stay informed about my case progress**.
|
||||
I want **to be notified via email when my case timeline is updated**,
|
||||
So that **I stay informed about my case progress without having to repeatedly check the portal**.
|
||||
|
||||
## Dependencies
|
||||
- **Requires**: Story 8.1 (Email Infrastructure - `BaseMailable` class and templates)
|
||||
- **Requires**: Epic 4 Stories 4.1-4.2 (Timeline and TimelineUpdate models must exist)
|
||||
- **Blocks**: None
|
||||
|
||||
## Data Model Reference
|
||||
|
||||
From Epic 4, the relevant models are:
|
||||
|
||||
```
|
||||
Timeline
|
||||
├── id
|
||||
├── user_id (FK → users.id, the client)
|
||||
├── case_name (string, required)
|
||||
├── case_reference (string, optional, unique if provided)
|
||||
├── status (enum: 'active', 'archived')
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
TimelineUpdate
|
||||
├── id
|
||||
├── timeline_id (FK → timelines.id)
|
||||
├── admin_id (FK → users.id, the admin who created it)
|
||||
├── update_text (text, the update content)
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
Relationships:
|
||||
- TimelineUpdate belongsTo Timeline
|
||||
- Timeline belongsTo User (client)
|
||||
- Timeline hasMany TimelineUpdate
|
||||
- Access client: $timelineUpdate->timeline->user
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Trigger
|
||||
- [ ] Sent on timeline update creation
|
||||
- [ ] Queued for performance
|
||||
- [ ] Email sent automatically when `TimelineUpdate` is created
|
||||
- [ ] Uses model observer pattern for clean separation
|
||||
- [ ] Email queued for performance (not sent synchronously)
|
||||
- [ ] Only triggered for active timelines (not archived)
|
||||
|
||||
### Content
|
||||
- [ ] "Update on your case: [Case Name]"
|
||||
- [ ] Case reference number
|
||||
- [ ] Update content (full or summary)
|
||||
- [ ] Date of update
|
||||
- [ ] "View Timeline" button/link
|
||||
- [ ] Subject: "Update on your case: [Case Name]" / "تحديث على قضيتك: [اسم القضية]"
|
||||
- [ ] Case reference number (if exists)
|
||||
- [ ] Full update content text
|
||||
- [ ] Date of update (formatted for locale)
|
||||
- [ ] "View Timeline" button linking to client dashboard timeline view
|
||||
|
||||
### Language
|
||||
- [ ] Email in client's preferred language
|
||||
- [ ] Email template selected based on client's `preferred_language`
|
||||
- [ ] Default to Arabic ('ar') if no preference set
|
||||
- [ ] Date formatting appropriate for locale
|
||||
|
||||
### Design
|
||||
- [ ] Uses base email template from Story 8.1 (Libra branding)
|
||||
- [ ] Professional, informative tone
|
||||
- [ ] Clear visual hierarchy: case name → update content → action button
|
||||
|
||||
## Technical Notes
|
||||
## Technical Implementation
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `app/Mail/TimelineUpdateEmail.php` | Create | Mailable extending BaseMailable |
|
||||
| `resources/views/emails/timeline/update-ar.blade.php` | Create | Arabic email template |
|
||||
| `resources/views/emails/timeline/update-en.blade.php` | Create | English email template |
|
||||
| `app/Observers/TimelineUpdateObserver.php` | Create | Observer to trigger email |
|
||||
| `app/Providers/AppServiceProvider.php` | Modify | Register the observer |
|
||||
|
||||
### Mailable Implementation
|
||||
|
||||
```php
|
||||
class TimelineUpdateNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
<?php
|
||||
|
||||
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 TimelineUpdate $update
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
) {
|
||||
$this->locale = $this->update->timeline->user->preferred_language ?? 'ar';
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$locale = $notifiable->preferred_language ?? 'ar';
|
||||
$timeline = $this->update->timeline;
|
||||
$caseName = $this->update->timeline->case_name;
|
||||
$subject = $this->locale === 'ar'
|
||||
? "تحديث على قضيتك: {$caseName}"
|
||||
: "Update on your case: {$caseName}";
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($this->getSubject($locale, $timeline->case_name))
|
||||
->markdown("emails.timeline.update.{$locale}", [
|
||||
return new Envelope(
|
||||
subject: $subject,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: "emails.timeline.update-{$this->locale}",
|
||||
with: [
|
||||
'update' => $this->update,
|
||||
'timeline' => $timeline,
|
||||
]);
|
||||
'timeline' => $this->update->timeline,
|
||||
'client' => $this->update->timeline->user,
|
||||
'viewUrl' => route('client.timelines.show', $this->update->timeline),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Observer Implementation
|
||||
|
||||
```php
|
||||
<?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
|
||||
- [ ] Email sent on update
|
||||
- [ ] Case info included
|
||||
- [ ] Update content shown
|
||||
- [ ] View link works
|
||||
- [ ] Bilingual templates
|
||||
- [ ] Tests pass
|
||||
- [ ] `TimelineUpdateEmail` mailable created extending `BaseMailable`
|
||||
- [ ] Arabic and English templates created with proper formatting
|
||||
- [ ] Observer registered and triggers on `TimelineUpdate` creation
|
||||
- [ ] Email only sent for active timelines (not archived)
|
||||
- [ ] Email queued (not sent synchronously)
|
||||
- [ ] Subject includes case name in appropriate language
|
||||
- [ ] Email body includes case reference (if exists), update content, and date
|
||||
- [ ] View Timeline button links to correct client dashboard route
|
||||
- [ ] All tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Low | **Effort:** 2 hours
|
||||
**Complexity:** Low | **Effort:** 2-3 hours
|
||||
|
||||
Reference in New Issue
Block a user