reviewed epic 5 & 6 stories
This commit is contained in:
@@ -8,30 +8,129 @@ As an **admin**,
|
||||
I want **to view admin action history**,
|
||||
So that **I can maintain accountability and track changes**.
|
||||
|
||||
## Dependencies
|
||||
- **Story 1.1:** Project Setup & Database Schema (admin_logs table and AdminLog model)
|
||||
- **Story 1.2:** Authentication & Role System (admin auth middleware)
|
||||
- **Story 6.1:** Dashboard Overview & Statistics (admin layout, navigation patterns)
|
||||
|
||||
## Navigation Context
|
||||
- Accessible from admin dashboard sidebar/navigation
|
||||
- Route: `/admin/audit-logs`
|
||||
- Named route: `admin.audit-logs`
|
||||
|
||||
## Background Context
|
||||
|
||||
### What Gets Logged
|
||||
The `admin_logs` table captures significant admin actions for accountability:
|
||||
- **User Management:** Create, update, delete, deactivate/reactivate users
|
||||
- **Consultation Management:** Approve, reject, mark complete, mark no-show, cancel bookings
|
||||
- **Timeline Management:** Create, update, archive timelines and timeline entries
|
||||
- **Posts Management:** Create, update, delete posts
|
||||
- **Settings Changes:** System configuration modifications
|
||||
|
||||
### AdminLog Model Schema
|
||||
Reference: PRD §16.1 Database Schema
|
||||
|
||||
```
|
||||
admin_logs table:
|
||||
- id: bigint (primary key)
|
||||
- admin_id: bigint (nullable, foreign key to users - null for system actions)
|
||||
- action: string (create, update, delete, approve, reject, etc.)
|
||||
- target_type: string (user, consultation, timeline, timeline_update, post, setting)
|
||||
- target_id: bigint (ID of the affected record)
|
||||
- old_values: json (nullable - previous state for updates)
|
||||
- new_values: json (nullable - new state for creates/updates)
|
||||
- ip_address: string (IPv4/IPv6 address of admin)
|
||||
- created_at: timestamp
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Display
|
||||
- [ ] Action type (create, update, delete)
|
||||
- [ ] Target (user, consultation, timeline, etc.)
|
||||
- [ ] Old and new values (for updates)
|
||||
- [ ] Timestamp
|
||||
- [ ] IP address
|
||||
### Display Requirements
|
||||
- [ ] Paginated table of audit log entries (25 per page)
|
||||
- [ ] Each row displays:
|
||||
- Timestamp (formatted per locale: d/m/Y H:i for AR, m/d/Y H:i for EN)
|
||||
- Admin name (or "System" for automated actions)
|
||||
- Action type badge (color-coded: green=create, blue=update, red=delete)
|
||||
- Target type and ID (e.g., "User #123")
|
||||
- IP address
|
||||
- Details button to view old/new values
|
||||
|
||||
### Filtering
|
||||
- [ ] Filter by action type
|
||||
- [ ] Filter by target type
|
||||
- [ ] Filter by date range
|
||||
- [ ] Filter by action type (dropdown with available actions)
|
||||
- [ ] Filter by target type (dropdown with available targets)
|
||||
- [ ] Filter by date range (date pickers for from/to)
|
||||
- [ ] Filters apply without page reload (Livewire)
|
||||
- [ ] Reset filters button
|
||||
|
||||
### Search
|
||||
- [ ] Search by target name/ID
|
||||
- [ ] Search by target ID (exact match)
|
||||
- [ ] Search updates results in real-time with debounce
|
||||
|
||||
### Features
|
||||
- [ ] Pagination
|
||||
- [ ] Export audit log (CSV)
|
||||
### Detail Modal
|
||||
- [ ] Modal displays full log entry details
|
||||
- [ ] Shows old values in readable format (JSON pretty-printed)
|
||||
- [ ] Shows new values in readable format
|
||||
- [ ] Highlights differences between old/new for updates
|
||||
- [ ] Bilingual labels
|
||||
|
||||
### CSV Export
|
||||
- [ ] Export current filtered results to CSV
|
||||
- [ ] CSV includes all displayed columns
|
||||
- [ ] CSV filename includes export date
|
||||
- [ ] Bilingual column headers based on admin's language preference
|
||||
|
||||
### Empty State
|
||||
- [ ] Friendly message when no logs exist
|
||||
- [ ] Friendly message when filters return no results
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Files to Create/Modify
|
||||
- `resources/views/livewire/admin/audit-logs.blade.php` - Main Volt component
|
||||
- `app/Models/AdminLog.php` - Eloquent model (if not already created in Story 1.1)
|
||||
- `routes/web.php` - Add admin route (or admin routes file)
|
||||
- `lang/ar/audit.php` - Arabic translations
|
||||
- `lang/en/audit.php` - English translations
|
||||
|
||||
### AdminLog Model
|
||||
```php
|
||||
class AdminLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'admin_id',
|
||||
'action',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'old_values',
|
||||
'new_values',
|
||||
'ip_address',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'old_values' => 'array',
|
||||
'new_values' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function admin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'admin_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Volt Component Structure
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\AdminLog;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
new class extends Component {
|
||||
use WithPagination;
|
||||
|
||||
@@ -40,59 +139,348 @@ new class extends Component {
|
||||
public string $dateFrom = '';
|
||||
public string $dateTo = '';
|
||||
public string $search = '';
|
||||
public ?int $selectedLogId = null;
|
||||
|
||||
public function updatedActionFilter(): void { $this->resetPage(); }
|
||||
public function updatedTargetFilter(): void { $this->resetPage(); }
|
||||
public function updatedDateFrom(): void { $this->resetPage(); }
|
||||
public function updatedDateTo(): void { $this->resetPage(); }
|
||||
public function updatedSearch(): void { $this->resetPage(); }
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->reset(['actionFilter', 'targetFilter', 'dateFrom', 'dateTo', 'search']);
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function showDetails(int $logId): void
|
||||
{
|
||||
$this->selectedLogId = $logId;
|
||||
$this->dispatch('open-modal', name: 'log-details');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'logs' => AdminLog::query()
|
||||
->with('admin')
|
||||
->when($this->actionFilter, fn($q) => $q->where('action_type', $this->actionFilter))
|
||||
->when($this->targetFilter, fn($q) => $q->where('target_type', $this->targetFilter))
|
||||
->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
|
||||
->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo))
|
||||
->when($this->search, fn($q) => $q->where('target_id', $this->search))
|
||||
->latest()
|
||||
->paginate(25),
|
||||
'actionTypes' => AdminLog::distinct()->pluck('action_type'),
|
||||
'logs' => $this->getFilteredQuery()->paginate(25),
|
||||
'actionTypes' => AdminLog::distinct()->pluck('action'),
|
||||
'targetTypes' => AdminLog::distinct()->pluck('target_type'),
|
||||
'selectedLog' => $this->selectedLogId
|
||||
? AdminLog::with('admin')->find($this->selectedLogId)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
public function exportCsv()
|
||||
public function exportCsv(): StreamedResponse
|
||||
{
|
||||
// Export filtered logs to CSV
|
||||
$logs = $this->getFilteredQuery()->get();
|
||||
|
||||
return response()->streamDownload(function () use ($logs) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// Header row (bilingual based on locale)
|
||||
fputcsv($handle, [
|
||||
__('audit.timestamp'),
|
||||
__('audit.admin'),
|
||||
__('audit.action'),
|
||||
__('audit.target_type'),
|
||||
__('audit.target_id'),
|
||||
__('audit.ip_address'),
|
||||
]);
|
||||
|
||||
foreach ($logs as $log) {
|
||||
fputcsv($handle, [
|
||||
$log->created_at->format('Y-m-d H:i:s'),
|
||||
$log->admin?->name ?? __('audit.system'),
|
||||
$log->action,
|
||||
$log->target_type,
|
||||
$log->target_id,
|
||||
$log->ip_address,
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, 'audit-log-' . now()->format('Y-m-d') . '.csv');
|
||||
}
|
||||
};
|
||||
|
||||
private function getFilteredQuery()
|
||||
{
|
||||
return AdminLog::query()
|
||||
->with('admin')
|
||||
->when($this->actionFilter, fn($q) => $q->where('action', $this->actionFilter))
|
||||
->when($this->targetFilter, fn($q) => $q->where('target_type', $this->targetFilter))
|
||||
->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
|
||||
->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo))
|
||||
->when($this->search, fn($q) => $q->where('target_id', $this->search))
|
||||
->latest();
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<!-- Filters Section -->
|
||||
<!-- Table Section -->
|
||||
<!-- Pagination -->
|
||||
<!-- Detail Modal -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Template
|
||||
### Template Structure
|
||||
```blade
|
||||
@foreach($logs as $log)
|
||||
<tr>
|
||||
<td>{{ $log->created_at->format('d/m/Y H:i') }}</td>
|
||||
<td>{{ $log->admin?->name ?? __('admin.system') }}</td>
|
||||
<td>
|
||||
<flux:badge>{{ $log->action_type }}</flux:badge>
|
||||
</td>
|
||||
<td>{{ $log->target_type }} #{{ $log->target_id }}</td>
|
||||
<td>{{ $log->ip_address }}</td>
|
||||
<td>
|
||||
<flux:button size="sm" wire:click="showDetails({{ $log->id }})">
|
||||
{{ __('admin.details') }}
|
||||
</flux:button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<div class="space-y-6">
|
||||
{{-- Filters --}}
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<flux:select wire:model.live="actionFilter" placeholder="{{ __('audit.filter_action') }}">
|
||||
<flux:select.option value="">{{ __('audit.all_actions') }}</flux:select.option>
|
||||
@foreach($actionTypes as $type)
|
||||
<flux:select.option value="{{ $type }}">{{ __("audit.action_{$type}") }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="targetFilter" placeholder="{{ __('audit.filter_target') }}">
|
||||
<flux:select.option value="">{{ __('audit.all_targets') }}</flux:select.option>
|
||||
@foreach($targetTypes as $type)
|
||||
<flux:select.option value="{{ $type }}">{{ __("audit.target_{$type}") }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:input type="date" wire:model.live="dateFrom" />
|
||||
<flux:input type="date" wire:model.live="dateTo" />
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('audit.search_target_id') }}" />
|
||||
|
||||
<flux:button wire:click="resetFilters" variant="ghost">{{ __('audit.reset') }}</flux:button>
|
||||
<flux:button wire:click="exportCsv">{{ __('audit.export_csv') }}</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
@if($logs->isEmpty())
|
||||
<flux:callout>{{ __('audit.no_logs_found') }}</flux:callout>
|
||||
@else
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('audit.timestamp') }}</th>
|
||||
<th>{{ __('audit.admin') }}</th>
|
||||
<th>{{ __('audit.action') }}</th>
|
||||
<th>{{ __('audit.target') }}</th>
|
||||
<th>{{ __('audit.ip_address') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($logs as $log)
|
||||
<tr wire:key="log-{{ $log->id }}">
|
||||
<td>{{ $log->created_at->translatedFormat(app()->getLocale() === 'ar' ? 'd/m/Y H:i' : 'm/d/Y H:i') }}</td>
|
||||
<td>{{ $log->admin?->name ?? __('audit.system') }}</td>
|
||||
<td>
|
||||
<flux:badge
|
||||
color="{{ match($log->action) {
|
||||
'create' => 'green',
|
||||
'update' => 'blue',
|
||||
'delete' => 'red',
|
||||
default => 'zinc'
|
||||
} }}">
|
||||
{{ __("audit.action_{$log->action}") }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td>{{ __("audit.target_{$log->target_type}") }} #{{ $log->target_id }}</td>
|
||||
<td>{{ $log->ip_address }}</td>
|
||||
<td>
|
||||
<flux:button size="sm" wire:click="showDetails({{ $log->id }})">
|
||||
{{ __('audit.details') }}
|
||||
</flux:button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{ $logs->links() }}
|
||||
@endif
|
||||
|
||||
{{-- Detail Modal --}}
|
||||
<flux:modal name="log-details">
|
||||
@if($selectedLog)
|
||||
<flux:heading>{{ __('audit.log_details') }}</flux:heading>
|
||||
|
||||
@if($selectedLog->old_values)
|
||||
<div>
|
||||
<strong>{{ __('audit.old_values') }}:</strong>
|
||||
<pre class="bg-zinc-100 p-2 rounded text-sm overflow-auto">{{ json_encode($selectedLog->old_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($selectedLog->new_values)
|
||||
<div>
|
||||
<strong>{{ __('audit.new_values') }}:</strong>
|
||||
<pre class="bg-zinc-100 p-2 rounded text-sm overflow-auto">{{ json_encode($selectedLog->new_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</flux:modal>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Action Badge Colors
|
||||
| Action | Color | Meaning |
|
||||
|--------|-------|---------|
|
||||
| create | green | New record created |
|
||||
| update | blue | Record modified |
|
||||
| delete | red | Record removed |
|
||||
| approve | green | Booking approved |
|
||||
| reject | red | Booking rejected |
|
||||
| archive | zinc | Timeline archived |
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Test Scenarios
|
||||
```php
|
||||
// tests/Feature/Admin/AuditLogTest.php
|
||||
|
||||
test('audit log page requires admin authentication', function () {
|
||||
$this->get(route('admin.audit-logs'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('audit log page loads for admin', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.audit-logs'))
|
||||
->assertOk()
|
||||
->assertSeeLivewire('admin.audit-logs');
|
||||
});
|
||||
|
||||
test('audit logs display in table', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
AdminLog::factory()->count(5)->create();
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->assertSee('audit.timestamp');
|
||||
});
|
||||
|
||||
test('can filter by action type', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
AdminLog::factory()->create(['action' => 'create']);
|
||||
AdminLog::factory()->create(['action' => 'delete']);
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->set('actionFilter', 'create')
|
||||
->assertSee('create')
|
||||
->assertDontSee('delete');
|
||||
});
|
||||
|
||||
test('can filter by target type', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
AdminLog::factory()->create(['target_type' => 'user']);
|
||||
AdminLog::factory()->create(['target_type' => 'consultation']);
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->set('targetFilter', 'user')
|
||||
->assertSee('user');
|
||||
});
|
||||
|
||||
test('can filter by date range', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
AdminLog::factory()->create(['created_at' => now()->subDays(10)]);
|
||||
AdminLog::factory()->create(['created_at' => now()]);
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->set('dateFrom', now()->subDays(5)->format('Y-m-d'))
|
||||
->set('dateTo', now()->format('Y-m-d'))
|
||||
->assertViewHas('logs', fn($logs) => $logs->count() === 1);
|
||||
});
|
||||
|
||||
test('can search by target id', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
AdminLog::factory()->create(['target_id' => 123]);
|
||||
AdminLog::factory()->create(['target_id' => 456]);
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->set('search', '123')
|
||||
->assertViewHas('logs', fn($logs) => $logs->count() === 1);
|
||||
});
|
||||
|
||||
test('can view log details in modal', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$log = AdminLog::factory()->create([
|
||||
'old_values' => ['name' => 'Old Name'],
|
||||
'new_values' => ['name' => 'New Name'],
|
||||
]);
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->call('showDetails', $log->id)
|
||||
->assertSet('selectedLogId', $log->id);
|
||||
});
|
||||
|
||||
test('can export filtered logs to csv', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
AdminLog::factory()->count(3)->create();
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->call('exportCsv')
|
||||
->assertFileDownloaded();
|
||||
});
|
||||
|
||||
test('displays empty state when no logs', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->assertSee(__('audit.no_logs_found'));
|
||||
});
|
||||
|
||||
test('pagination works correctly', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
AdminLog::factory()->count(50)->create();
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->assertViewHas('logs', fn($logs) => $logs->count() === 25);
|
||||
});
|
||||
|
||||
test('reset filters clears all filters', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.audit-logs')
|
||||
->actingAs($admin)
|
||||
->set('actionFilter', 'create')
|
||||
->set('targetFilter', 'user')
|
||||
->set('search', '123')
|
||||
->call('resetFilters')
|
||||
->assertSet('actionFilter', '')
|
||||
->assertSet('targetFilter', '')
|
||||
->assertSet('search', '');
|
||||
});
|
||||
```
|
||||
|
||||
## Edge Cases & Error Handling
|
||||
- **Empty state:** Display friendly message when no logs exist or filters return nothing
|
||||
- **Deleted admin:** Show "System" or "Deleted User" if admin_id references deleted user
|
||||
- **Large JSON values:** Truncate display in table, show full in modal with scroll
|
||||
- **IPv6 addresses:** Ensure column width accommodates longer IP addresses
|
||||
- **Missing target:** Handle gracefully if target record was deleted (show ID only)
|
||||
- **Pagination reset:** Reset to page 1 when filters change
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Logs display correctly
|
||||
- [ ] All filters work
|
||||
- [ ] Search works
|
||||
- [ ] Pagination works
|
||||
- [ ] CSV export works
|
||||
- [ ] Old/new values viewable
|
||||
- [ ] Tests pass
|
||||
- [ ] Audit logs page accessible at `/admin/audit-logs`
|
||||
- [ ] Logs display with all required columns
|
||||
- [ ] All filters work correctly (action, target, date range)
|
||||
- [ ] Search by target ID works
|
||||
- [ ] Pagination displays 25 items per page
|
||||
- [ ] CSV export downloads with filtered data
|
||||
- [ ] Detail modal shows old/new values formatted
|
||||
- [ ] Empty state displays when appropriate
|
||||
- [ ] Bilingual support (AR/EN) complete
|
||||
- [ ] Admin-only access enforced
|
||||
- [ ] All tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
||||
|
||||
Reference in New Issue
Block a user