649 lines
25 KiB
Markdown
649 lines
25 KiB
Markdown
# Story 4.5: Client Timeline View
|
|
|
|
## Epic Reference
|
|
**Epic 4:** Case Timeline System
|
|
|
|
## User Story
|
|
As a **client**,
|
|
I want **to view my case timelines and updates**,
|
|
So that **I can track the progress of my legal matters**.
|
|
|
|
## Story Context
|
|
|
|
### Existing System Integration
|
|
- **Integrates with:** timelines, timeline_updates tables
|
|
- **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
|
|
|
|
### Timeline List
|
|
- [ ] Display all client's timelines
|
|
- [ ] Active timelines prominently displayed
|
|
- [ ] Archived timelines clearly separated
|
|
- [ ] Visual distinction (color/icon) for status
|
|
- [ ] Show for each:
|
|
- Case name and reference
|
|
- Status indicator
|
|
- Last update date
|
|
- Update count
|
|
|
|
### Individual Timeline View
|
|
- [ ] Case name and reference
|
|
- [ ] Status indicator
|
|
- [ ] All updates in chronological order
|
|
- [ ] Each update shows:
|
|
- Date and time
|
|
- Update content (formatted)
|
|
|
|
### Restrictions
|
|
- [ ] Read-only (no edit/comment)
|
|
- [ ] No ability to archive/delete
|
|
- [ ] Only see own timelines (403 for unauthorized access)
|
|
|
|
### UX Features
|
|
- [ ] Recent updates indicator (new since last view, optional)
|
|
- [ ] Responsive design for mobile
|
|
- [ ] Bilingual labels and dates
|
|
|
|
## 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
|
|
<?php
|
|
// resources/views/livewire/pages/client/timelines/index.blade.php
|
|
|
|
use Livewire\Volt\Component;
|
|
|
|
new class extends Component {
|
|
public function with(): array
|
|
{
|
|
return [
|
|
'activeTimelines' => auth()->user()
|
|
->timelines()
|
|
->active()
|
|
->withCount('updates')
|
|
->with(['updates' => fn($q) => $q->latest()->limit(1)])
|
|
->latest('updated_at')
|
|
->get(),
|
|
|
|
'archivedTimelines' => auth()->user()
|
|
->timelines()
|
|
->archived()
|
|
->withCount('updates')
|
|
->latest('updated_at')
|
|
->get(),
|
|
];
|
|
}
|
|
}; ?>
|
|
|
|
<div>
|
|
<flux:heading class="mb-6">{{ __('client.my_cases') }}</flux:heading>
|
|
|
|
{{-- Active Timelines --}}
|
|
@if($activeTimelines->isNotEmpty())
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-charcoal mb-4">{{ __('client.active_cases') }}</h2>
|
|
<div class="space-y-4">
|
|
@foreach($activeTimelines as $timeline)
|
|
<div wire:key="timeline-{{ $timeline->id }}" class="bg-white p-4 rounded-lg shadow-sm border-l-4 border-gold">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<h3 class="font-medium text-charcoal">{{ $timeline->case_name }}</h3>
|
|
@if($timeline->case_reference)
|
|
<p class="text-sm text-charcoal/70">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
|
|
@endif
|
|
<p class="text-sm text-charcoal/60 mt-1">
|
|
{{ __('client.updates') }}: {{ $timeline->updates_count }}
|
|
@if($timeline->updates->first())
|
|
· {{ __('client.last_update') }}: {{ $timeline->updates->first()->created_at->diffForHumans() }}
|
|
@endif
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<flux:badge variant="success">{{ __('client.active') }}</flux:badge>
|
|
<flux:button size="sm" href="{{ route('client.timelines.show', $timeline) }}">
|
|
{{ __('client.view') }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Archived Timelines --}}
|
|
@if($archivedTimelines->isNotEmpty())
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-charcoal/70 mb-4">{{ __('client.archived_cases') }}</h2>
|
|
<div class="space-y-4 opacity-75">
|
|
@foreach($archivedTimelines as $timeline)
|
|
<div wire:key="timeline-{{ $timeline->id }}" class="bg-gray-50 p-4 rounded-lg shadow-sm">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<h3 class="font-medium text-charcoal/80">{{ $timeline->case_name }}</h3>
|
|
@if($timeline->case_reference)
|
|
<p class="text-sm text-charcoal/60">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
|
|
@endif
|
|
<p class="text-sm text-charcoal/50 mt-1">
|
|
{{ __('client.updates') }}: {{ $timeline->updates_count }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<flux:badge variant="secondary">{{ __('client.archived') }}</flux:badge>
|
|
<flux:button size="sm" variant="ghost" href="{{ route('client.timelines.show', $timeline) }}">
|
|
{{ __('client.view') }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Empty State --}}
|
|
@if($activeTimelines->isEmpty() && $archivedTimelines->isEmpty())
|
|
<div class="text-center py-12">
|
|
<flux:icon name="folder-open" class="w-12 h-12 text-charcoal/30 mx-auto mb-4" />
|
|
<p class="text-charcoal/70">{{ __('client.no_cases_yet') }}</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
```
|
|
|
|
### Timeline Detail View
|
|
```php
|
|
<?php
|
|
// resources/views/livewire/pages/client/timelines/show.blade.php
|
|
|
|
use App\Models\Timeline;
|
|
use Livewire\Volt\Component;
|
|
|
|
new class extends Component {
|
|
public Timeline $timeline;
|
|
|
|
public function mount(Timeline $timeline): void
|
|
{
|
|
// Authorization: Ensure client owns this timeline
|
|
abort_unless($timeline->user_id === auth()->id(), 403);
|
|
|
|
$this->timeline = $timeline->load(['updates' => fn($q) => $q->oldest()]);
|
|
}
|
|
}; ?>
|
|
|
|
<div class="max-w-3xl mx-auto">
|
|
<!-- Header -->
|
|
<div class="flex justify-between items-start mb-6">
|
|
<div>
|
|
<flux:heading>{{ $timeline->case_name }}</flux:heading>
|
|
@if($timeline->case_reference)
|
|
<p class="text-charcoal/70">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
|
|
@endif
|
|
</div>
|
|
<flux:badge :variant="$timeline->status === 'active' ? 'success' : 'secondary'">
|
|
{{ __('client.' . $timeline->status) }}
|
|
</flux:badge>
|
|
</div>
|
|
|
|
<!-- Timeline Updates -->
|
|
<div class="relative">
|
|
<!-- Vertical line -->
|
|
<div class="absolute {{ app()->getLocale() === 'ar' ? 'right-4' : 'left-4' }} top-0 bottom-0 w-0.5 bg-gold/30"></div>
|
|
|
|
<div class="space-y-6">
|
|
@forelse($timeline->updates as $update)
|
|
<div wire:key="update-{{ $update->id }}" class="relative {{ app()->getLocale() === 'ar' ? 'pr-12' : 'pl-12' }}">
|
|
<!-- Dot -->
|
|
<div class="absolute {{ app()->getLocale() === 'ar' ? 'right-2' : 'left-2' }} top-2 w-4 h-4 rounded-full bg-gold border-4 border-cream"></div>
|
|
|
|
<div class="bg-white p-4 rounded-lg shadow-sm">
|
|
<div class="text-sm text-charcoal/70 mb-2">
|
|
{{ $update->created_at->translatedFormat('l, d M Y - g:i A') }}
|
|
</div>
|
|
<div class="prose prose-sm">
|
|
{!! $update->update_text !!}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@empty
|
|
<p class="text-center text-charcoal/70 py-8">
|
|
{{ __('client.no_updates_yet') }}
|
|
</p>
|
|
@endforelse
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6">
|
|
<flux:button href="{{ route('client.timelines.index') }}">
|
|
{{ __('client.back_to_cases') }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### 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
|
|
<?php
|
|
|
|
use App\Models\{User, Timeline, TimelineUpdate};
|
|
use Livewire\Volt\Volt;
|
|
|
|
// Authorization Tests
|
|
test('client can view own timelines list', function () {
|
|
$client = User::factory()->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 with visual distinction
|
|
- [ ] Can view individual timeline details
|
|
- [ ] 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 with proper positioning
|
|
- [ ] All translation keys added (AR/EN)
|
|
- [ ] All tests pass
|
|
- [ ] Code formatted with Pint
|
|
|
|
## Dependencies
|
|
|
|
- **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
|
|
|
|
**Complexity:** Medium
|
|
**Estimated Effort:** 3-4 hours
|
|
|
|
## QA Results
|
|
|
|
### Review Date: 2025-12-27
|
|
|
|
### Reviewed By: Quinn (Test Architect)
|
|
|
|
### Code Quality Assessment
|
|
|
|
**Overall: Excellent** - The implementation follows established project patterns consistently. Both Volt components use class-based architecture as required by coding standards. The code is clean, well-organized, and matches sibling component patterns (e.g., `client/consultations/index.blade.php`).
|
|
|
|
**Strengths:**
|
|
- Routes correctly registered with `client` middleware in `routes/web.php:113-117`
|
|
- Authorization properly enforced via `abort_unless()` in show component at `show.blade.php:13`
|
|
- Eager loading used appropriately to prevent N+1 queries (`withCount`, `with`)
|
|
- Read-only enforcement - no edit/delete/archive methods exist in components
|
|
- RTL support with locale-aware positioning (`app()->getLocale() === 'ar'`)
|
|
- Dark mode support with proper Tailwind classes
|
|
- Bilingual translations complete in both `lang/en/client.php` and `lang/ar/client.php`
|
|
- Consistent use of Flux UI components (badges, buttons, headings, icons)
|
|
|
|
**File Structure:**
|
|
- `resources/views/livewire/client/timelines/index.blade.php` - List component
|
|
- `resources/views/livewire/client/timelines/show.blade.php` - Detail component
|
|
- `tests/Feature/Client/TimelineViewTest.php` - 15 comprehensive tests
|
|
- `lang/en/client.php` - English translations
|
|
- `lang/ar/client.php` - Arabic translations
|
|
- `app/Http/Middleware/EnsureUserIsClient.php` - Client middleware
|
|
- `bootstrap/app.php` - Middleware alias registration
|
|
|
|
### Refactoring Performed
|
|
|
|
None required - implementation follows project conventions correctly.
|
|
|
|
### Compliance Check
|
|
|
|
- Coding Standards: ✓ Class-based Volt components, Flux UI, proper testing patterns
|
|
- Project Structure: ✓ Files at correct locations, routes properly defined
|
|
- Testing Strategy: ✓ 15 Pest tests covering authorization, display, and read-only enforcement
|
|
- All ACs Met: ✓ See detailed trace below
|
|
|
|
### Acceptance Criteria Trace
|
|
|
|
| AC | Description | Test Coverage | Status |
|
|
|----|-------------|---------------|--------|
|
|
| 1 | Display all client's timelines | `client can view own timelines list` | ✓ |
|
|
| 2 | Active timelines prominently displayed | `active timelines displayed separately from archived` | ✓ |
|
|
| 3 | Archived timelines clearly separated | `active timelines displayed separately from archived` | ✓ |
|
|
| 4 | Visual distinction for status | `timeline detail shows status badge`, badges used in views | ✓ |
|
|
| 5 | Show case name and reference | `timeline detail shows case name and reference` | ✓ |
|
|
| 6 | Show status indicator | `timeline detail shows status badge` | ✓ |
|
|
| 7 | Show last update date | `index.blade.php:47` shows `diffForHumans()` | ✓ |
|
|
| 8 | Show update count | `timeline list shows update count` | ✓ |
|
|
| 9 | Individual view: case name/reference | `timeline detail shows case name and reference` | ✓ |
|
|
| 10 | Individual view: status indicator | `timeline detail shows status badge` | ✓ |
|
|
| 11 | Updates in chronological order | `timeline detail shows all updates chronologically` | ✓ |
|
|
| 12 | Update shows date/time | `show.blade.php:46` with `translatedFormat()` | ✓ |
|
|
| 13 | Update shows formatted content | `show.blade.php:48-49` with prose styling | ✓ |
|
|
| 14 | Read-only (no edit/comment) | `client timeline view is read-only with no edit actions` | ✓ |
|
|
| 15 | No archive/delete ability | No such methods in components | ✓ |
|
|
| 16 | Only own timelines (403) | `client cannot view other clients timeline detail` | ✓ |
|
|
| 17 | Responsive design | Tailwind responsive classes throughout | ✓ |
|
|
| 18 | Bilingual labels/dates | Translation keys + `translatedFormat()` | ✓ |
|
|
|
|
### Improvements Checklist
|
|
|
|
- [x] All acceptance criteria implemented
|
|
- [x] All 15 tests passing
|
|
- [x] Pint formatting verified
|
|
- [x] Authorization via middleware and component-level checks
|
|
- [x] N+1 query prevention with eager loading
|
|
- [x] RTL/LTR support implemented
|
|
- [x] Dark mode support implemented
|
|
- [ ] Consider pagination for clients with many timelines (future enhancement)
|
|
|
|
### Security Review
|
|
|
|
**Authorization:** ✓
|
|
- Route-level: `client` middleware enforces client-only access (`EnsureUserIsClient`)
|
|
- Component-level: `abort_unless($timeline->user_id === auth()->id(), 403)` in show component
|
|
- Tests verify: guest redirect, admin forbidden, other client forbidden
|
|
|
|
**XSS Protection:** ✓
|
|
- `{!! $update->update_text !!}` uses unescaped output, BUT:
|
|
- Input is sanitized with `clean()` helper (HTMLPurifier) on admin input side (`admin/timelines/show.blade.php:46,82`)
|
|
- This matches the established pattern in the codebase (same as email templates)
|
|
|
|
### Performance Considerations
|
|
|
|
**Eager Loading:** ✓
|
|
- Index: `withCount('updates')`, `with(['updates' => fn($q) => $q->latest()->limit(1)])`
|
|
- Show: `$timeline->load(['updates' => fn($q) => $q->oldest()])`
|
|
|
|
**Potential Future Optimization:**
|
|
- If clients accumulate many timelines, consider adding pagination to index view
|
|
|
|
### Files Modified During Review
|
|
|
|
None - implementation is complete and follows standards.
|
|
|
|
### Gate Status
|
|
|
|
Gate: **PASS** → `docs/qa/gates/4.5-client-timeline-view.yml`
|
|
|
|
### Recommended Status
|
|
|
|
✓ **Ready for Done** - All acceptance criteria met, all tests passing, code follows project patterns.
|