From ad2d9604b81cbdc3fee8704eb190bceb1f1aa9a4 Mon Sep 17 00:00:00 2001 From: Naser Mansour Date: Sat, 20 Dec 2025 23:57:39 +0200 Subject: [PATCH] reviewed epic 4 --- docs/stories/story-4.1-timeline-creation.md | 147 +++++- .../story-4.2-timeline-updates-management.md | 99 +++- docs/stories/story-4.3-timeline-archiving.md | 85 +++- .../story-4.4-admin-timeline-dashboard.md | 442 ++++++++++++++++-- .../stories/story-4.5-client-timeline-view.md | 399 +++++++++++++++- ...story-4.6-timeline-update-notifications.md | 89 +++- 6 files changed, 1144 insertions(+), 117 deletions(-) diff --git a/docs/stories/story-4.1-timeline-creation.md b/docs/stories/story-4.1-timeline-creation.md index 694ab5c..8d827df 100644 --- a/docs/stories/story-4.1-timeline-creation.md +++ b/docs/stories/story-4.1-timeline-creation.md @@ -11,15 +11,20 @@ So that **I can track and communicate progress on their legal matters**. ## Story Context ### Existing System Integration -- **Integrates with:** timelines table, users table +- **Integrates with:** timelines table, timeline_updates table, users table, admin_logs table - **Technology:** Livewire Volt, Flux UI -- **Follows pattern:** Admin CRUD pattern -- **Touch points:** User relationship, client dashboard +- **Follows pattern:** Admin CRUD pattern (see existing admin components) +- **Touch points:** User relationship, client dashboard, audit logging + +### Reference Documents +- **Epic:** `docs/epics/epic-4-case-timeline.md` +- **Database Schema:** `docs/stories/story-1.1-project-setup-database-schema.md#database-schema-reference` +- **User Model:** Requires users with `user_type` of 'individual' or 'company' (from Epic 2) ## Acceptance Criteria ### Timeline Creation Form -- [ ] Select client (search by name/email) +- [ ] Select client (search by name/email) - only users with user_type 'individual' or 'company' - [ ] Case name/title (required) - [ ] Case reference number (optional, unique if provided) - [ ] Initial notes (optional) @@ -38,15 +43,30 @@ So that **I can track and communicate progress on their legal matters**. - [ ] Client must exist ### Quality Requirements -- [ ] Audit log entry created +- [ ] Audit log entry created via AdminLog model - [ ] Bilingual labels and messages - [ ] Tests for creation flow ## Technical Notes +### File Structure +``` +Routes: + GET /admin/timelines/create -> admin.timelines.create + POST handled by Livewire component + +Files to Create: + resources/views/livewire/pages/admin/timelines/create.blade.php (Volt component) + +Models Required (from Story 1.1): + app/Models/Timeline.php + app/Models/TimelineUpdate.php + app/Models/AdminLog.php +``` + ### Database Schema ```php -// timelines table +// timelines table (from Story 1.1) Schema::create('timelines', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); @@ -55,13 +75,49 @@ Schema::create('timelines', function (Blueprint $table) { $table->enum('status', ['active', 'archived'])->default('active'); $table->timestamps(); }); + +// timeline_updates table (from Story 1.1) +Schema::create('timeline_updates', function (Blueprint $table) { + $table->id(); + $table->foreignId('timeline_id')->constrained()->cascadeOnDelete(); + $table->foreignId('admin_id')->constrained('users')->cascadeOnDelete(); + $table->text('update_text'); + $table->timestamps(); +}); +``` + +### Required Translation Keys +```php +// resources/lang/en/messages.php +'timeline_created' => 'Timeline created successfully.', + +// resources/lang/ar/messages.php +'timeline_created' => 'تم إنشاء الجدول الزمني بنجاح.', + +// resources/lang/en/labels.php +'case_name' => 'Case Name', +'case_reference' => 'Case Reference', +'initial_notes' => 'Initial Notes', +'select_client' => 'Select Client', +'search_client' => 'Search by name or email...', +'create_timeline' => 'Create Timeline', + +// resources/lang/ar/labels.php +'case_name' => 'اسم القضية', +'case_reference' => 'رقم مرجع القضية', +'initial_notes' => 'ملاحظات أولية', +'select_client' => 'اختر العميل', +'search_client' => 'البحث بالاسم أو البريد الإلكتروني...', +'create_timeline' => 'إنشاء جدول زمني', ``` ### Volt Component ```php selectedUser && ! str_contains(strtolower($this->selectedUser->name), strtolower($this->search))) { + $this->selectedUserId = null; + $this->selectedUser = null; + } + } + + public function getClientsProperty() + { + if (strlen($this->search) < 2) { + return collect(); + } + + return User::query() + ->whereIn('user_type', ['individual', 'company']) + ->where('status', 'active') + ->where(function ($query) { + $query->where('name', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%"); + }) + ->limit(10) + ->get(); + } + public function selectUser(int $userId): void { $this->selectedUserId = $userId; $this->selectedUser = User::find($userId); + $this->search = $this->selectedUser->name; } public function create(): void @@ -113,27 +196,63 @@ new class extends Component { session()->flash('success', __('messages.timeline_created')); $this->redirect(route('admin.timelines.show', $timeline)); } -}; +}; ?> + +
+ {{-- Component template here using Flux UI --}} +
``` +## Test Scenarios + +All tests should use Pest and be placed in `tests/Feature/Admin/TimelineCreationTest.php`. + +### Happy Path Tests +- [ ] `test_admin_can_view_timeline_creation_form` - Admin can access /admin/timelines/create +- [ ] `test_admin_can_search_clients_by_name` - Search returns matching users +- [ ] `test_admin_can_search_clients_by_email` - Search returns matching users +- [ ] `test_admin_can_create_timeline_with_required_fields` - Timeline created with case_name only +- [ ] `test_admin_can_create_timeline_with_case_reference` - Timeline created with optional reference +- [ ] `test_initial_notes_creates_first_timeline_update` - TimelineUpdate record created +- [ ] `test_audit_log_created_on_timeline_creation` - AdminLog entry exists + +### Validation Tests +- [ ] `test_case_name_is_required` - Validation error without case_name +- [ ] `test_case_reference_must_be_unique` - Validation error on duplicate reference +- [ ] `test_case_reference_allows_multiple_nulls` - Multiple timelines without reference allowed +- [ ] `test_client_selection_is_required` - Validation error without selecting client +- [ ] `test_selected_client_must_exist` - Validation error for non-existent user_id + +### Authorization Tests +- [ ] `test_non_admin_cannot_access_timeline_creation` - Redirect or 403 for non-admin users +- [ ] `test_guest_cannot_access_timeline_creation` - Redirect to login + +### Edge Case Tests +- [ ] `test_search_only_returns_individual_and_company_users` - Admin users not in results +- [ ] `test_search_only_returns_active_users` - Deactivated users not in results +- [ ] `test_can_create_multiple_timelines_for_same_client` - No unique constraint on user_id + ## Definition of Done -- [ ] Can search and select client +- [ ] Volt component created at `resources/views/livewire/pages/admin/timelines/create.blade.php` +- [ ] Route registered for admin timeline creation +- [ ] Can search and select client (individual/company only) - [ ] Can enter case name and reference - [ ] Timeline created with correct data - [ ] Initial notes saved as first update - [ ] Unique reference validation works -- [ ] Client can view timeline immediately +- [ ] Client can view timeline immediately (verified via client dashboard) - [ ] Audit log created -- [ ] Tests pass +- [ ] All translation keys added (AR/EN) +- [ ] All tests pass - [ ] Code formatted with Pint ## Dependencies -- **Epic 1:** Database schema, authentication -- **Epic 2:** User accounts +- **Story 1.1:** Database schema (timelines, timeline_updates, admin_logs tables and models) +- **Story 1.2:** Authentication & role system (admin role check) +- **Story 2.1/2.2:** User accounts exist to assign timelines to ## Estimation **Complexity:** Low-Medium -**Estimated Effort:** 2-3 hours diff --git a/docs/stories/story-4.2-timeline-updates-management.md b/docs/stories/story-4.2-timeline-updates-management.md index b185abe..dd897a4 100644 --- a/docs/stories/story-4.2-timeline-updates-management.md +++ b/docs/stories/story-4.2-timeline-updates-management.md @@ -12,10 +12,31 @@ So that **I can keep clients informed about their case progress**. ### Existing System Integration - **Integrates with:** timeline_updates table, timelines table -- **Technology:** Livewire Volt, rich text editor +- **Technology:** Livewire Volt, rich text editor (Trix recommended) - **Follows pattern:** Nested CRUD pattern - **Touch points:** Client notifications, timeline view +### Previous Story Context (Story 4.1) +Story 4.1 established: +- `Timeline` model with fields: `user_id`, `case_name`, `case_reference`, `status` +- `updates()` hasMany relationship on Timeline model +- `timelines` table with foreign key to users +- Admin can create timelines for any client +- Initial notes saved as first timeline update + +### Prerequisites +- `Timeline` model with `updates()` relationship (from Story 4.1) +- `TimelineUpdate` model (create in this story) +- `AdminLog` model for audit logging (from Epic 1) +- HTML sanitization: use `mews/purifier` package with `clean()` helper +- Rich text editor: Trix (ships with Laravel) or similar + +### Implementation Files +- **Volt Component:** `resources/views/livewire/pages/admin/timelines/updates.blade.php` +- **Model:** `app/Models/TimelineUpdate.php` +- **Notification:** `app/Notifications/TimelineUpdateNotification.php` +- **Route:** `admin.timelines.show` (timeline detail page with updates section) + ## Acceptance Criteria ### Add Update @@ -43,9 +64,22 @@ So that **I can keep clients informed about their case progress**. - [ ] Visual timeline representation ### Quality Requirements -- [ ] HTML sanitization for security -- [ ] Audit log for edits -- [ ] Tests for add/edit operations +- [ ] HTML sanitization using `mews/purifier` package +- [ ] Audit log for add/edit operations via AdminLog +- [ ] Feature tests for all operations + +### Test Scenarios +- [ ] Can add update with valid text (min 10 chars) +- [ ] Cannot add update with empty text - validation error +- [ ] Cannot add update with text < 10 chars - validation error +- [ ] HTML is sanitized (script tags, XSS vectors removed) +- [ ] Client receives notification email on new update +- [ ] Audit log created when update is added +- [ ] Audit log created when update is edited (includes old/new values) +- [ ] Updates display in chronological order (oldest first) +- [ ] Admin name and timestamp automatically recorded +- [ ] Edit preserves original created_at, updates updated_at +- [ ] Cannot change timestamp or admin on edit ## Technical Notes @@ -60,11 +94,48 @@ Schema::create('timeline_updates', function (Blueprint $table) { }); ``` +### TimelineUpdate Model +```php +// app/Models/TimelineUpdate.php +class TimelineUpdate extends Model +{ + protected $fillable = ['timeline_id', 'admin_id', 'update_text']; + + public function timeline(): BelongsTo + { + return $this->belongsTo(Timeline::class); + } + + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } +} +``` + +### Timeline Model Relationship (add to existing model) +```php +// In app/Models/Timeline.php - add this relationship +public function updates(): HasMany +{ + return $this->hasMany(TimelineUpdate::class)->orderBy('created_at', 'asc'); +} +``` + +### Setup Requirements +```bash +# Install HTML Purifier for sanitization +composer require mews/purifier + +# Publish config (optional, defaults are secure) +php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider" +``` + ### Volt Component ```php ['required', 'string', 'min:10'], ]); + // clean() is from mews/purifier - sanitizes HTML, removes XSS vectors $update = $this->timeline->updates()->create([ 'admin_id' => auth()->id(), - 'update_text' => clean($this->updateText), // Sanitize HTML + 'update_text' => clean($this->updateText), ]); - // Notify client + // Notify client (queued - works when Epic 8 email is ready) $this->timeline->user->notify(new TimelineUpdateNotification($update)); AdminLog::create([ @@ -131,6 +203,12 @@ new class extends Component { $this->updateText = ''; session()->flash('success', __('messages.update_edited')); } + + public function cancelEdit(): void + { + $this->editingUpdate = null; + $this->updateText = ''; + } }; ``` @@ -148,8 +226,11 @@ new class extends Component { ## Dependencies -- **Story 4.1:** Timeline creation -- **Epic 8:** Email notifications +- **Story 4.1:** Timeline creation (REQUIRED - must be complete) +- **Epic 8:** Email notifications (SOFT DEPENDENCY) + - If Epic 8 is not complete, implement notification dispatch but skip email tests + - Create `TimelineUpdateNotification` class with queued email + - Notification will work once Epic 8 email infrastructure is ready ## Estimation diff --git a/docs/stories/story-4.3-timeline-archiving.md b/docs/stories/story-4.3-timeline-archiving.md index 09b6bb7..38fafd0 100644 --- a/docs/stories/story-4.3-timeline-archiving.md +++ b/docs/stories/story-4.3-timeline-archiving.md @@ -11,19 +11,32 @@ So that **I can organize active and completed case timelines**. ## Story Context ### Existing System Integration -- **Integrates with:** timelines table +- **Integrates with:** timelines table (status field from Story 4.1) - **Technology:** Livewire Volt - **Follows pattern:** Soft state change pattern -- **Touch points:** Timeline list views +- **Touch points:** Timeline list views, timeline detail view + +### Prerequisites (from Story 4.1) +The `timelines` table includes: +```php +$table->enum('status', ['active', 'archived'])->default('active'); +``` + +### Files to Modify +- `app/Models/Timeline.php` - add archive/unarchive methods and scopes +- `resources/views/livewire/admin/timelines/show.blade.php` - add archive/unarchive button +- `resources/views/livewire/admin/timelines/index.blade.php` - add status filter dropdown +- `resources/views/livewire/client/timelines/index.blade.php` - separate archived timelines display ## Acceptance Criteria ### Archive Timeline -- [ ] Archive button on timeline detail +- [ ] Archive button on timeline detail view (admin only) +- [ ] Confirmation modal before archiving ("Are you sure?") - [ ] Status changes to 'archived' -- [ ] Timeline remains visible to client -- [ ] No further updates can be added (until unarchived) -- [ ] Visual indicator shows archived status +- [ ] Timeline remains visible to client (read-only as always) +- [ ] No further updates can be added (show disabled state with tooltip) +- [ ] Visual indicator shows archived status (badge + muted styling) ### Unarchive Timeline - [ ] Unarchive button on archived timelines @@ -31,14 +44,41 @@ So that **I can organize active and completed case timelines**. - [ ] Updates can be added again ### List Filtering -- [ ] Filter timelines by status (active/archived/all) -- [ ] Archived timelines sorted separately in client view -- [ ] Bulk archive option for multiple timelines +- [ ] Filter dropdown with options: Active (default), Archived, All +- [ ] Archived timelines shown in separate section in client view (below active) +- [ ] Bulk archive via checkbox selection + "Archive Selected" button +- [ ] Bulk archive shows count confirmation ("Archive 3 timelines?") + +### Edge Cases +- [ ] Archiving already-archived timeline: No-op, no error shown +- [ ] Unarchiving already-active timeline: No-op, no error shown +- [ ] Adding update to archived timeline: Prevented with error message +- [ ] Bulk archive mixed selection: Skip already-archived, archive only active ones +- [ ] Empty bulk selection: "Archive Selected" button disabled ### Quality Requirements -- [ ] Audit log for status changes -- [ ] Bilingual labels -- [ ] Tests for archive/unarchive +- [ ] Audit log for status changes (action_type: 'archive' or 'unarchive') +- [ ] Bilingual labels (AR/EN for buttons, messages, filters) +- [ ] Feature tests covering all scenarios below + +### Test Scenarios +``` +tests/Feature/Timeline/TimelineArchivingTest.php +- test_admin_can_archive_active_timeline +- test_admin_can_unarchive_archived_timeline +- test_archiving_archived_timeline_is_noop +- test_unarchiving_active_timeline_is_noop +- test_cannot_add_update_to_archived_timeline +- test_client_can_view_archived_timeline +- test_admin_can_filter_by_active_status +- test_admin_can_filter_by_archived_status +- test_admin_can_filter_all_statuses +- test_bulk_archive_updates_multiple_timelines +- test_bulk_archive_skips_already_archived +- test_audit_log_created_on_archive +- test_audit_log_created_on_unarchive +- test_guest_cannot_archive_timeline +``` ## Technical Notes @@ -133,20 +173,21 @@ public function bulkArchive(array $ids): void ## Definition of Done -- [ ] Can archive active timeline -- [ ] Can unarchive archived timeline -- [ ] Cannot add updates to archived timeline -- [ ] Filter by status works -- [ ] Bulk archive works -- [ ] Visual indicators correct -- [ ] Audit log created -- [ ] Tests pass +- [ ] Archive button on timeline detail view works with confirmation modal +- [ ] Unarchive button on archived timelines works +- [ ] Update form disabled/hidden on archived timelines with clear messaging +- [ ] Status filter dropdown functional (Active/Archived/All) +- [ ] Bulk archive with checkbox selection works +- [ ] Visual indicators (badge + muted styling) display correctly +- [ ] Client can still view archived timelines in separate section +- [ ] Audit log entries created for all archive/unarchive actions +- [ ] All test scenarios in `tests/Feature/Timeline/TimelineArchivingTest.php` pass - [ ] Code formatted with Pint ## Dependencies -- **Story 4.1:** Timeline creation -- **Story 4.2:** Timeline updates +- **Story 4.1:** Timeline creation (`docs/stories/story-4.1-timeline-creation.md`) - provides Timeline model and schema +- **Story 4.2:** Timeline updates (`docs/stories/story-4.2-timeline-updates-management.md`) - provides update blocking context ## Estimation diff --git a/docs/stories/story-4.4-admin-timeline-dashboard.md b/docs/stories/story-4.4-admin-timeline-dashboard.md index 094eab1..860e9c2 100644 --- a/docs/stories/story-4.4-admin-timeline-dashboard.md +++ b/docs/stories/story-4.4-admin-timeline-dashboard.md @@ -15,6 +15,26 @@ So that **I can efficiently track and update case progress**. - **Technology:** Livewire Volt with pagination - **Follows pattern:** Admin list/dashboard pattern - **Touch points:** All timeline operations +- **Authorization:** Admin middleware protects route (defined in `routes/web.php`) + +### Prerequisites from Previous Stories + +**From Story 4.1 (`docs/stories/story-4.1-timeline-creation.md`):** +- Timeline model exists with fields: `user_id`, `case_name`, `case_reference`, `status` +- Relationships: `belongsTo(User::class)` as `user`, `hasMany(TimelineUpdate::class)` as `updates` +- Database schema: + ```php + // timelines table + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('case_name'); + $table->string('case_reference')->nullable()->unique(); + $table->enum('status', ['active', 'archived'])->default('active'); + ``` + +**From Story 4.3 (`docs/stories/story-4.3-timeline-archiving.md`):** +- Timeline model has `archive()`, `unarchive()`, `isArchived()` methods +- Scopes available: `scopeActive()`, `scopeArchived()` +- AdminLog is created on status changes ## Acceptance Criteria @@ -26,23 +46,27 @@ So that **I can efficiently track and update case progress**. - Last update date - Update count - [ ] Pagination (15/25/50 per page) +- [ ] Empty state message when no timelines exist ### Filtering - [ ] Filter by client (search/select) - [ ] Filter by status (active/archived/all) - [ ] Filter by date range (created/updated) - [ ] Search by case name or reference +- [ ] Clear filters option +- [ ] Show "No results" message when filters return empty ### Sorting - [ ] Sort by client name - [ ] Sort by case name - [ ] Sort by last updated - [ ] Sort by created date +- [ ] Visual indicator for current sort column/direction ### Quick Actions - [ ] View timeline details - [ ] Add update (inline or link) -- [ ] Archive/unarchive toggle +- [ ] Archive/unarchive toggle with confirmation ### Quality Requirements - [ ] Fast loading with eager loading @@ -51,11 +75,22 @@ So that **I can efficiently track and update case progress**. ## Technical Notes +### File Location +`resources/views/livewire/admin/timelines/index.blade.php` + +### Route Definition +```php +// routes/web.php (within admin middleware group) +Route::get('/admin/timelines', \App\Livewire\Admin\Timelines\Index::class) + ->name('admin.timelines.index'); +``` + ### Volt Component ```php resetPage(); + } + + public function updatedClientFilter(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void { $this->resetPage(); } @@ -86,6 +131,37 @@ new class extends Component { } } + public function toggleArchive(int $id): void + { + $timeline = Timeline::findOrFail($id); + + if ($timeline->isArchived()) { + $timeline->unarchive(); + $action = 'unarchive'; + $message = __('messages.timeline_unarchived'); + } else { + $timeline->archive(); + $action = 'archive'; + $message = __('messages.timeline_archived'); + } + + AdminLog::create([ + 'admin_id' => auth()->id(), + 'action_type' => $action, + 'target_type' => 'timeline', + 'target_id' => $timeline->id, + 'ip_address' => request()->ip(), + ]); + + session()->flash('success', $message); + } + + public function clearFilters(): void + { + $this->reset(['search', 'clientFilter', 'statusFilter', 'dateFrom', 'dateTo']); + $this->resetPage(); + } + public function with(): array { return [ @@ -102,6 +178,10 @@ new class extends Component { ->when($this->dateTo, fn($q) => $q->where('created_at', '<=', $this->dateTo)) ->orderBy($this->sortBy, $this->sortDir) ->paginate($this->perPage), + 'clients' => \App\Models\User::query() + ->whereHas('timelines') + ->orderBy('name') + ->get(['id', 'name']), ]; } }; @@ -113,74 +193,338 @@ new class extends Component {
+ + + + @foreach($clients as $client) + + @endforeach + + - + + + + + + {{ __('admin.clear_filters') }} + + + + + + +
- - - - - - - - - - - - @foreach($timelines as $timeline) + @if($timelines->isEmpty()) +
+ {{ __('admin.no_timelines_found') }} +
+ @else +
{{ __('admin.case_name') }}{{ __('admin.client') }}{{ __('admin.status') }}{{ __('admin.last_update') }}{{ __('admin.actions') }}
+ - - - - - + + + + + + - @endforeach - -
{{ $timeline->case_name }}{{ $timeline->user->name }} - - {{ __('admin.' . $timeline->status) }} - - {{ $timeline->updated_at->diffForHumans() }} - - {{ __('admin.actions') }} - - - {{ __('admin.view') }} - - - {{ $timeline->status === 'active' ? __('admin.archive') : __('admin.unarchive') }} - - - - + {{ __('admin.case_name') }} + @if($sortBy === 'case_name') + + @endif + + {{ __('admin.client') }} + {{ __('admin.status') }} + {{ __('admin.last_update') }} + @if($sortBy === 'updated_at') + + @endif + {{ __('admin.updates') }}{{ __('admin.actions') }}
+ + + @foreach($timelines as $timeline) + + {{ $timeline->case_name }} + {{ $timeline->user->name }} + + + {{ __('admin.' . $timeline->status) }} + + + {{ $timeline->updated_at->diffForHumans() }} + {{ $timeline->updates_count }} + + + {{ __('admin.actions') }} + + + {{ __('admin.view') }} + + + {{ __('admin.add_update') }} + + + {{ $timeline->status === 'active' ? __('admin.archive') : __('admin.unarchive') }} + + + + + + @endforeach + + - {{ $timelines->links() }} +
+ {{ $timelines->links() }} +
+ @endif ``` +## Test Scenarios + +### Feature Tests +Create test file: `tests/Feature/Admin/TimelineDashboardTest.php` + +```php +use App\Models\{User, Timeline}; +use Livewire\Volt\Volt; + +// List View Tests +test('admin can view timeline dashboard', function () { + $admin = User::factory()->admin()->create(); + Timeline::factory()->count(5)->create(); + + $this->actingAs($admin) + ->get(route('admin.timelines.index')) + ->assertOk() + ->assertSeeLivewire('admin.timelines.index'); +}); + +test('timeline dashboard displays all timelines with correct data', function () { + $admin = User::factory()->admin()->create(); + $timeline = Timeline::factory()->create(['case_name' => 'Test Case']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->assertSee('Test Case') + ->assertSee($timeline->user->name); +}); + +test('dashboard shows empty state when no timelines exist', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->assertSee(__('admin.no_timelines_found')); +}); + +// Filtering Tests +test('can filter timelines by status', function () { + $admin = User::factory()->admin()->create(); + $active = Timeline::factory()->create(['status' => 'active', 'case_name' => 'Active Case']); + $archived = Timeline::factory()->create(['status' => 'archived', 'case_name' => 'Archived Case']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->set('statusFilter', 'active') + ->assertSee('Active Case') + ->assertDontSee('Archived Case'); +}); + +test('can filter timelines by client', function () { + $admin = User::factory()->admin()->create(); + $client1 = User::factory()->create(); + $client2 = User::factory()->create(); + Timeline::factory()->create(['user_id' => $client1->id, 'case_name' => 'Client1 Case']); + Timeline::factory()->create(['user_id' => $client2->id, 'case_name' => 'Client2 Case']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->set('clientFilter', $client1->id) + ->assertSee('Client1 Case') + ->assertDontSee('Client2 Case'); +}); + +test('can search timelines by case name', function () { + $admin = User::factory()->admin()->create(); + Timeline::factory()->create(['case_name' => 'Contract Dispute']); + Timeline::factory()->create(['case_name' => 'Property Issue']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->set('search', 'Contract') + ->assertSee('Contract Dispute') + ->assertDontSee('Property Issue'); +}); + +test('can search timelines by case reference', function () { + $admin = User::factory()->admin()->create(); + Timeline::factory()->create(['case_reference' => 'REF-123', 'case_name' => 'Case A']); + Timeline::factory()->create(['case_reference' => 'REF-456', 'case_name' => 'Case B']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->set('search', 'REF-123') + ->assertSee('Case A') + ->assertDontSee('Case B'); +}); + +test('clear filters resets all filter values', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->set('search', 'test') + ->set('statusFilter', 'active') + ->call('clearFilters') + ->assertSet('search', '') + ->assertSet('statusFilter', ''); +}); + +// Sorting Tests +test('can sort timelines by case name', function () { + $admin = User::factory()->admin()->create(); + Timeline::factory()->create(['case_name' => 'Zebra Case']); + Timeline::factory()->create(['case_name' => 'Alpha Case']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->call('sort', 'case_name') + ->assertSet('sortBy', 'case_name') + ->assertSet('sortDir', 'asc'); +}); + +test('clicking same sort column toggles direction', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->call('sort', 'case_name') + ->assertSet('sortDir', 'asc') + ->call('sort', 'case_name') + ->assertSet('sortDir', 'desc'); +}); + +// Quick Actions Tests +test('can archive timeline from dashboard', function () { + $admin = User::factory()->admin()->create(); + $timeline = Timeline::factory()->create(['status' => 'active']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->call('toggleArchive', $timeline->id); + + expect($timeline->fresh()->status)->toBe('archived'); +}); + +test('can unarchive timeline from dashboard', function () { + $admin = User::factory()->admin()->create(); + $timeline = Timeline::factory()->create(['status' => 'archived']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->call('toggleArchive', $timeline->id); + + expect($timeline->fresh()->status)->toBe('active'); +}); + +test('toggle archive creates admin log entry', function () { + $admin = User::factory()->admin()->create(); + $timeline = Timeline::factory()->create(['status' => 'active']); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->call('toggleArchive', $timeline->id); + + $this->assertDatabaseHas('admin_logs', [ + 'admin_id' => $admin->id, + 'action_type' => 'archive', + 'target_type' => 'timeline', + 'target_id' => $timeline->id, + ]); +}); + +// Pagination Tests +test('pagination displays correct number of items', function () { + $admin = User::factory()->admin()->create(); + Timeline::factory()->count(20)->create(); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->assertSet('perPage', 15); +}); + +test('can change items per page', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.timelines.index') + ->actingAs($admin) + ->set('perPage', 25) + ->assertSet('perPage', 25); +}); + +// N+1 Query Prevention Test +test('dashboard uses eager loading to prevent N+1 queries', function () { + $admin = User::factory()->admin()->create(); + Timeline::factory()->count(10)->create(); + + // This test verifies the query count stays reasonable + // The component should make ~3-4 queries regardless of timeline count + $this->actingAs($admin); + + Volt::test('admin.timelines.index'); + + // If N+1 exists, this would be 10+ queries + // With eager loading, should be ~4 queries (timelines, users, updates, clients) +}); + +// Authorization Tests +test('non-admin cannot access timeline dashboard', function () { + $client = User::factory()->create(); // Regular client + + $this->actingAs($client) + ->get(route('admin.timelines.index')) + ->assertForbidden(); +}); + +test('guest cannot access timeline dashboard', function () { + $this->get(route('admin.timelines.index')) + ->assertRedirect(route('login')); +}); +``` + ## Definition of Done - [ ] List displays all timelines -- [ ] All filters working -- [ ] All sorts working -- [ ] Quick actions functional -- [ ] Pagination working -- [ ] No N+1 queries -- [ ] Bilingual support -- [ ] Tests pass +- [ ] All filters working (status, client, search, date range) +- [ ] Clear filters button resets all filters +- [ ] All sorts working with visual indicators +- [ ] Quick actions functional (view, add update, archive/unarchive) +- [ ] Archive/unarchive has confirmation dialog +- [ ] Pagination working with per-page selector +- [ ] Empty state displayed when no results +- [ ] No N+1 queries (verified with eager loading) +- [ ] Bilingual support for all labels +- [ ] All tests pass - [ ] Code formatted with Pint ## Dependencies -- **Story 4.1:** Timeline creation -- **Story 4.3:** Archive functionality +- **Story 4.1:** Timeline creation (`docs/stories/story-4.1-timeline-creation.md`) - Timeline model and relationships +- **Story 4.3:** Archive functionality (`docs/stories/story-4.3-timeline-archiving.md`) - Archive/unarchive methods on Timeline model ## Estimation diff --git a/docs/stories/story-4.5-client-timeline-view.md b/docs/stories/story-4.5-client-timeline-view.md index d009aed..85e3b88 100644 --- a/docs/stories/story-4.5-client-timeline-view.md +++ b/docs/stories/story-4.5-client-timeline-view.md @@ -15,6 +15,43 @@ So that **I can track the progress of my legal matters**. - **Technology:** Livewire Volt (read-only) - **Follows pattern:** Client dashboard pattern - **Touch points:** Client portal navigation +- **Authorization:** Client middleware protects routes (defined in `routes/web.php`) + +### Relationship to Story 7.3 +This story (4.5) implements the **core timeline viewing functionality** for clients as part of the Case Timeline epic. Story 7.3 (My Cases/Timelines View) in Epic 7 focuses on the **client dashboard integration** and will reuse the components created here. Implement this story first; Story 7.3 will integrate these components into the dashboard layout. + +### Prerequisites from Previous Stories + +**From Story 4.1 (`docs/stories/story-4.1-timeline-creation.md`):** +- `Timeline` model exists with fields: `user_id`, `case_name`, `case_reference`, `status` +- Database schema: + ```php + Schema::create('timelines', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('case_name'); + $table->string('case_reference')->nullable()->unique(); + $table->enum('status', ['active', 'archived'])->default('active'); + $table->timestamps(); + }); + ``` + +**From Story 4.2 (`docs/stories/story-4.2-timeline-updates-management.md`):** +- `TimelineUpdate` model with fields: `timeline_id`, `admin_id`, `update_text` +- Timeline has `updates()` HasMany relationship + +**From Story 4.3 (`docs/stories/story-4.3-timeline-archiving.md`):** +- Timeline model has scopes: `scopeActive()`, `scopeArchived()` +- Timeline model has methods: `isArchived()` + +**User Model Requirement:** +The `User` model must have: +```php +public function timelines(): HasMany +{ + return $this->hasMany(Timeline::class); +} +``` ## Acceptance Criteria @@ -40,7 +77,7 @@ So that **I can track the progress of my legal matters**. ### Restrictions - [ ] Read-only (no edit/comment) - [ ] No ability to archive/delete -- [ ] Only see own timelines +- [ ] Only see own timelines (403 for unauthorized access) ### UX Features - [ ] Recent updates indicator (new since last view, optional) @@ -49,9 +86,38 @@ So that **I can track the progress of my legal matters**. ## Technical Notes +### File Structure +``` +Routes (add to routes/web.php within client middleware group): + GET /client/timelines -> client.timelines.index + GET /client/timelines/{timeline} -> client.timelines.show + +Files to Create: + resources/views/livewire/pages/client/timelines/index.blade.php (List component) + resources/views/livewire/pages/client/timelines/show.blade.php (Detail component) + +Tests: + tests/Feature/Client/TimelineViewTest.php +``` + +### Route Definition +```php +// routes/web.php - within client middleware group +Route::middleware(['auth', 'verified', 'client'])->prefix('client')->name('client.')->group(function () { + Route::get('/timelines', function () { + return view('livewire.pages.client.timelines.index'); + })->name('timelines.index'); + + Route::get('/timelines/{timeline}', function (Timeline $timeline) { + return view('livewire.pages.client.timelines.show', ['timeline' => $timeline]); + })->name('timelines.show'); +}); +``` + ### Volt Component for List ```php get(), ]; } -}; +}; ?> + +
+ {{ __('client.my_cases') }} + + {{-- Active Timelines --}} + @if($activeTimelines->isNotEmpty()) +
+

{{ __('client.active_cases') }}

+
+ @foreach($activeTimelines as $timeline) +
+
+
+

{{ $timeline->case_name }}

+ @if($timeline->case_reference) +

{{ __('client.reference') }}: {{ $timeline->case_reference }}

+ @endif +

+ {{ __('client.updates') }}: {{ $timeline->updates_count }} + @if($timeline->updates->first()) + · {{ __('client.last_update') }}: {{ $timeline->updates->first()->created_at->diffForHumans() }} + @endif +

+
+
+ {{ __('client.active') }} + + {{ __('client.view') }} + +
+
+
+ @endforeach +
+
+ @endif + + {{-- Archived Timelines --}} + @if($archivedTimelines->isNotEmpty()) +
+

{{ __('client.archived_cases') }}

+
+ @foreach($archivedTimelines as $timeline) +
+
+
+

{{ $timeline->case_name }}

+ @if($timeline->case_reference) +

{{ __('client.reference') }}: {{ $timeline->case_reference }}

+ @endif +

+ {{ __('client.updates') }}: {{ $timeline->updates_count }} +

+
+
+ {{ __('client.archived') }} + + {{ __('client.view') }} + +
+
+
+ @endforeach +
+
+ @endif + + {{-- Empty State --}} + @if($activeTimelines->isEmpty() && $archivedTimelines->isEmpty()) +
+ +

{{ __('client.no_cases_yet') }}

+
+ @endif +
``` ### Timeline Detail View ```php user_id === auth()->id(), 403); $this->timeline = $timeline->load(['updates' => fn($q) => $q->oldest()]); } -}; -``` +}; ?> -### Template -```blade
@@ -121,7 +260,7 @@ new class extends Component {
@forelse($timeline->updates as $update) -
+
@@ -150,23 +289,251 @@ new class extends Component {
``` +### Required Translation Keys +```php +// resources/lang/en/client.php +'my_cases' => 'My Cases', +'active_cases' => 'Active Cases', +'archived_cases' => 'Archived Cases', +'reference' => 'Reference', +'updates' => 'Updates', +'last_update' => 'Last update', +'active' => 'Active', +'archived' => 'Archived', +'view' => 'View', +'back_to_cases' => 'Back to Cases', +'no_cases_yet' => 'You don\'t have any cases yet.', +'no_updates_yet' => 'No updates yet.', + +// resources/lang/ar/client.php +'my_cases' => 'قضاياي', +'active_cases' => 'القضايا النشطة', +'archived_cases' => 'القضايا المؤرشفة', +'reference' => 'المرجع', +'updates' => 'التحديثات', +'last_update' => 'آخر تحديث', +'active' => 'نشط', +'archived' => 'مؤرشف', +'view' => 'عرض', +'back_to_cases' => 'العودة للقضايا', +'no_cases_yet' => 'لا توجد لديك قضايا بعد.', +'no_updates_yet' => 'لا توجد تحديثات بعد.', +``` + +## Test Scenarios + +All tests should use Pest and be placed in `tests/Feature/Client/TimelineViewTest.php`. + +```php +create(['user_type' => 'individual']); + Timeline::factory()->count(3)->create(['user_id' => $client->id]); + + $this->actingAs($client) + ->get(route('client.timelines.index')) + ->assertOk() + ->assertSeeLivewire('pages.client.timelines.index'); +}); + +test('client cannot view other clients timelines in list', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $otherClient = User::factory()->create(['user_type' => 'individual']); + $otherTimeline = Timeline::factory()->create([ + 'user_id' => $otherClient->id, + 'case_name' => 'Other Client Case', + ]); + + Volt::test('pages.client.timelines.index') + ->actingAs($client) + ->assertDontSee('Other Client Case'); +}); + +test('client can view own timeline detail', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $timeline = Timeline::factory()->create([ + 'user_id' => $client->id, + 'case_name' => 'My Contract Case', + ]); + + $this->actingAs($client) + ->get(route('client.timelines.show', $timeline)) + ->assertOk() + ->assertSee('My Contract Case'); +}); + +test('client cannot view other clients timeline detail', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $otherClient = User::factory()->create(['user_type' => 'individual']); + $otherTimeline = Timeline::factory()->create(['user_id' => $otherClient->id]); + + $this->actingAs($client) + ->get(route('client.timelines.show', $otherTimeline)) + ->assertForbidden(); +}); + +test('guest cannot access timelines', function () { + $this->get(route('client.timelines.index')) + ->assertRedirect(route('login')); +}); + +test('admin cannot access client timeline routes', function () { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get(route('client.timelines.index')) + ->assertForbidden(); +}); + +// List View Tests +test('active timelines displayed separately from archived', function () { + $client = User::factory()->create(['user_type' => 'individual']); + Timeline::factory()->create([ + 'user_id' => $client->id, + 'case_name' => 'Active Case', + 'status' => 'active', + ]); + Timeline::factory()->create([ + 'user_id' => $client->id, + 'case_name' => 'Archived Case', + 'status' => 'archived', + ]); + + Volt::test('pages.client.timelines.index') + ->actingAs($client) + ->assertSee('Active Case') + ->assertSee('Archived Case') + ->assertSeeInOrder([__('client.active_cases'), 'Active Case', __('client.archived_cases'), 'Archived Case']); +}); + +test('timeline list shows update count', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $timeline = Timeline::factory()->create(['user_id' => $client->id]); + TimelineUpdate::factory()->count(5)->create(['timeline_id' => $timeline->id]); + + Volt::test('pages.client.timelines.index') + ->actingAs($client) + ->assertSee('5'); +}); + +test('empty state shown when no timelines', function () { + $client = User::factory()->create(['user_type' => 'individual']); + + Volt::test('pages.client.timelines.index') + ->actingAs($client) + ->assertSee(__('client.no_cases_yet')); +}); + +// Detail View Tests +test('timeline detail shows all updates chronologically', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $timeline = Timeline::factory()->create(['user_id' => $client->id]); + + $oldUpdate = TimelineUpdate::factory()->create([ + 'timeline_id' => $timeline->id, + 'update_text' => 'First Update', + 'created_at' => now()->subDays(2), + ]); + $newUpdate = TimelineUpdate::factory()->create([ + 'timeline_id' => $timeline->id, + 'update_text' => 'Second Update', + 'created_at' => now()->subDay(), + ]); + + Volt::test('pages.client.timelines.show', ['timeline' => $timeline]) + ->actingAs($client) + ->assertSeeInOrder(['First Update', 'Second Update']); +}); + +test('timeline detail shows case name and reference', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $timeline = Timeline::factory()->create([ + 'user_id' => $client->id, + 'case_name' => 'Property Dispute', + 'case_reference' => 'REF-2024-001', + ]); + + Volt::test('pages.client.timelines.show', ['timeline' => $timeline]) + ->actingAs($client) + ->assertSee('Property Dispute') + ->assertSee('REF-2024-001'); +}); + +test('timeline detail shows status badge', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $activeTimeline = Timeline::factory()->create([ + 'user_id' => $client->id, + 'status' => 'active', + ]); + + Volt::test('pages.client.timelines.show', ['timeline' => $activeTimeline]) + ->actingAs($client) + ->assertSee(__('client.active')); +}); + +test('empty updates shows no updates message', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $timeline = Timeline::factory()->create(['user_id' => $client->id]); + + Volt::test('pages.client.timelines.show', ['timeline' => $timeline]) + ->actingAs($client) + ->assertSee(__('client.no_updates_yet')); +}); + +// Read-Only Enforcement Tests +test('client cannot edit timeline or updates', function () { + $client = User::factory()->create(['user_type' => 'individual']); + $timeline = Timeline::factory()->create(['user_id' => $client->id]); + + // Verify no edit methods exist on the component + Volt::test('pages.client.timelines.show', ['timeline' => $timeline]) + ->actingAs($client) + ->assertMethodDoesNotExist('edit') + ->assertMethodDoesNotExist('update') + ->assertMethodDoesNotExist('delete') + ->assertMethodDoesNotExist('archive'); +}); + +// N+1 Query Prevention Test +test('timeline list uses eager loading', function () { + $client = User::factory()->create(['user_type' => 'individual']); + Timeline::factory()->count(10)->create(['user_id' => $client->id]); + + // Component should load with reasonable query count + $this->actingAs($client); + Volt::test('pages.client.timelines.index'); + // With eager loading: ~3-4 queries (timelines, updates count, latest update) +}); +``` + ## Definition of Done +- [ ] Volt components created at specified file locations +- [ ] Routes registered for client timeline views - [ ] Client can view list of their timelines -- [ ] Active/archived clearly separated +- [ ] Active/archived clearly separated with visual distinction - [ ] Can view individual timeline details -- [ ] All updates displayed chronologically -- [ ] Read-only (no edit capabilities) -- [ ] Cannot view other clients' timelines +- [ ] All updates displayed chronologically (oldest first) +- [ ] Read-only enforced (no edit/delete methods) +- [ ] Cannot view other clients' timelines (403 response) +- [ ] Empty state displayed when no timelines - [ ] Mobile responsive -- [ ] RTL support -- [ ] Tests pass +- [ ] RTL support with proper positioning +- [ ] All translation keys added (AR/EN) +- [ ] All tests pass - [ ] Code formatted with Pint ## Dependencies -- **Story 4.1-4.3:** Timeline management -- **Epic 7:** Client dashboard structure +- **Story 4.1:** Timeline creation (`docs/stories/story-4.1-timeline-creation.md`) - Timeline model and database +- **Story 4.2:** Timeline updates (`docs/stories/story-4.2-timeline-updates-management.md`) - TimelineUpdate model +- **Story 4.3:** Timeline archiving (`docs/stories/story-4.3-timeline-archiving.md`) - Active/archived scopes +- **Story 7.3:** Will integrate these components into client dashboard (`docs/stories/story-7.3-my-cases-timelines-view.md`) ## Estimation diff --git a/docs/stories/story-4.6-timeline-update-notifications.md b/docs/stories/story-4.6-timeline-update-notifications.md index 9112255..534f2cd 100644 --- a/docs/stories/story-4.6-timeline-update-notifications.md +++ b/docs/stories/story-4.6-timeline-update-notifications.md @@ -16,6 +16,31 @@ So that **I stay informed about my case progress without checking the portal**. - **Follows pattern:** Event-driven notification pattern - **Touch points:** Timeline update creation +### Previous Story Context (Story 4.2) +Story 4.2 established: +- `TimelineUpdate` model with fields: `timeline_id`, `admin_id`, `update_text` +- `TimelineUpdate` belongs to `Timeline` and `User` (admin) +- `Timeline` has many `TimelineUpdate` records +- Updates created via `addUpdate()` method in timeline management Volt component +- Notification trigger point: after `$this->timeline->updates()->create([...])` + +### Prerequisites +The following must exist before implementing this story: +- **User model requirements:** + - `preferred_language` field (string, defaults to 'ar') - from Epic 2 user registration + - `isActive()` method returning boolean (checks `status !== 'deactivated'`) - from Epic 2 +- **Route requirement:** + - `client.timelines.show` route must exist (from Story 4.5) +- **Models available:** + - `Timeline` model with `user()` relationship + - `TimelineUpdate` model with `timeline()` relationship + +### Implementation Files +- **Notification:** `app/Notifications/TimelineUpdateNotification.php` +- **Arabic Template:** `resources/views/emails/timeline/update/ar.blade.php` +- **English Template:** `resources/views/emails/timeline/update/en.blade.php` +- **Modify:** Story 4.2 Volt component to trigger notification + ## Acceptance Criteria ### Notification Trigger @@ -168,18 +193,33 @@ if ($this->timeline->user->isActive()) { } ``` -### Testing +### Test Scenarios +- [ ] Notification sent when timeline update created +- [ ] No notification to deactivated user (`status === 'deactivated'`) +- [ ] Arabic template renders with correct content (case name, update text, date) +- [ ] English template renders with correct content +- [ ] Uses client's `preferred_language` for template selection (defaults to 'ar') +- [ ] Email is queued (uses `ShouldQueue` interface) +- [ ] Email contains valid link to timeline view +- [ ] Subject line includes case name in correct language + +### Testing Examples ```php +use App\Models\{User, Timeline, TimelineUpdate}; use App\Notifications\TimelineUpdateNotification; use Illuminate\Support\Facades\Notification; it('sends notification when timeline update created', function () { Notification::fake(); - $timeline = Timeline::factory()->create(); + $user = User::factory()->create(['status' => 'active']); + $timeline = Timeline::factory()->for($user)->create(); $update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]); - $timeline->user->notify(new TimelineUpdateNotification($update)); + // Simulate the trigger from Story 4.2 + if ($timeline->user->isActive()) { + $timeline->user->notify(new TimelineUpdateNotification($update)); + } Notification::assertSentTo($timeline->user, TimelineUpdateNotification::class); }); @@ -189,12 +229,45 @@ it('does not send notification to deactivated user', function () { $user = User::factory()->create(['status' => 'deactivated']); $timeline = Timeline::factory()->for($user)->create(); + $update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]); - // Add update (should check user status) - // ... + // Simulate the trigger from Story 4.2 - should NOT notify + if ($timeline->user->isActive()) { + $timeline->user->notify(new TimelineUpdateNotification($update)); + } Notification::assertNotSentTo($user, TimelineUpdateNotification::class); }); + +it('uses arabic template for arabic-preferred user', function () { + Notification::fake(); + + $user = User::factory()->create(['preferred_language' => 'ar']); + $timeline = Timeline::factory()->for($user)->create(['case_name' => 'Test Case']); + $update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]); + + $user->notify(new TimelineUpdateNotification($update)); + + Notification::assertSentTo($user, TimelineUpdateNotification::class, function ($notification, $channels, $notifiable) { + $mail = $notification->toMail($notifiable); + return str_contains($mail->subject, 'تحديث جديد على قضيتك'); + }); +}); + +it('uses english template for english-preferred user', function () { + Notification::fake(); + + $user = User::factory()->create(['preferred_language' => 'en']); + $timeline = Timeline::factory()->for($user)->create(['case_name' => 'Test Case']); + $update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]); + + $user->notify(new TimelineUpdateNotification($update)); + + Notification::assertSentTo($user, TimelineUpdateNotification::class, function ($notification, $channels, $notifiable) { + $mail = $notification->toMail($notifiable); + return str_contains($mail->subject, 'New update on your case'); + }); +}); ``` ## Definition of Done @@ -211,8 +284,10 @@ it('does not send notification to deactivated user', function () { ## Dependencies -- **Story 4.2:** Timeline updates management -- **Epic 8:** Email infrastructure +- **Story 4.2:** Timeline updates management (REQUIRED - notification triggered from addUpdate method) +- **Story 4.5:** Client timeline view (REQUIRED - provides `client.timelines.show` route used in email link) +- **Epic 2:** User model with `preferred_language` field and `isActive()` method +- **Epic 8:** Email infrastructure (SOFT DEPENDENCY - notification will queue but email delivery requires Epic 8) ## Estimation