484 lines
17 KiB
Markdown
484 lines
17 KiB
Markdown
# Story 5.2: Post Management Dashboard
|
|
|
|
## Epic Reference
|
|
**Epic 5:** Posts/Blog System
|
|
|
|
## User Story
|
|
As an **admin**,
|
|
I want **a dashboard to manage all blog posts**,
|
|
So that **I can organize, publish, and maintain content efficiently**.
|
|
|
|
## Story Context
|
|
|
|
### Existing System Integration
|
|
- **Integrates with:** posts table, admin_logs table
|
|
- **Technology:** Livewire Volt with pagination
|
|
- **Follows pattern:** Admin list pattern (see similar components in `resources/views/livewire/admin/`)
|
|
- **Touch points:** Post CRUD operations, AdminLog audit trail
|
|
- **Note:** HTML sanitization uses `clean()` helper (from mews/purifier package, configured in Story 5.1)
|
|
|
|
## Acceptance Criteria
|
|
|
|
### List View
|
|
- [x] Display all posts with:
|
|
- Title (in current admin language)
|
|
- Status (draft/published)
|
|
- Created date
|
|
- Last updated date
|
|
- [x] Pagination (10/25/50 per page)
|
|
|
|
### Filtering & Search
|
|
- [x] Filter by status (draft/published/all)
|
|
- [x] Search by title or body content
|
|
- [x] Sort by date (newest/oldest)
|
|
|
|
### Quick Actions
|
|
- [x] Edit post
|
|
- [x] Delete (with confirmation)
|
|
- [x] Publish/unpublish toggle
|
|
- [ ] Bulk delete option (optional) - **OUT OF SCOPE**
|
|
|
|
### Quality Requirements
|
|
- [x] Bilingual labels
|
|
- [x] Audit log for delete and status change actions
|
|
- [x] Authorization check (admin only) on all actions
|
|
- [x] Tests for list operations
|
|
|
|
### Edge Cases
|
|
- [x] Handle post not found gracefully (race condition on delete)
|
|
- [x] Per-page selector visible in UI (10/25/50 options)
|
|
- [x] Bulk delete is **out of scope** for this story (marked optional)
|
|
|
|
## Technical Notes
|
|
|
|
### File Location
|
|
Create Volt component at: `resources/views/livewire/admin/posts/index.blade.php`
|
|
|
|
### Route
|
|
```php
|
|
// routes/web.php (admin group)
|
|
Route::get('/admin/posts', function () {
|
|
return view('livewire.admin.posts.index');
|
|
})->middleware(['auth', 'admin'])->name('admin.posts.index');
|
|
```
|
|
|
|
### Volt Component
|
|
```php
|
|
<?php
|
|
|
|
use App\Models\Post;
|
|
use App\Models\AdminLog;
|
|
use Livewire\Volt\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
new class extends Component {
|
|
use WithPagination;
|
|
|
|
public string $search = '';
|
|
public string $statusFilter = '';
|
|
public string $sortBy = 'created_at';
|
|
public string $sortDir = 'desc';
|
|
public int $perPage = 10;
|
|
|
|
public function updatedSearch()
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function sort(string $column): void
|
|
{
|
|
if ($this->sortBy === $column) {
|
|
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
$this->sortBy = $column;
|
|
$this->sortDir = 'desc';
|
|
}
|
|
}
|
|
|
|
public function togglePublish(int $id): void
|
|
{
|
|
$post = Post::findOrFail($id);
|
|
$newStatus = $post->status === 'published' ? 'draft' : 'published';
|
|
|
|
$post->update(['status' => $newStatus]);
|
|
|
|
AdminLog::create([
|
|
'admin_id' => auth()->id(),
|
|
'action_type' => 'status_change',
|
|
'target_type' => 'post',
|
|
'target_id' => $post->id,
|
|
'old_values' => ['status' => $post->getOriginal('status')],
|
|
'new_values' => ['status' => $newStatus],
|
|
'ip_address' => request()->ip(),
|
|
]);
|
|
|
|
session()->flash('success', __('messages.post_status_updated'));
|
|
}
|
|
|
|
public function delete(int $id): void
|
|
{
|
|
$post = Post::findOrFail($id);
|
|
|
|
AdminLog::create([
|
|
'admin_id' => auth()->id(),
|
|
'action_type' => 'delete',
|
|
'target_type' => 'post',
|
|
'target_id' => $post->id,
|
|
'old_values' => $post->toArray(),
|
|
'ip_address' => request()->ip(),
|
|
]);
|
|
|
|
$post->delete();
|
|
|
|
session()->flash('success', __('messages.post_deleted'));
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
$locale = app()->getLocale();
|
|
|
|
return [
|
|
'posts' => Post::query()
|
|
->when($this->search, fn($q) => $q->where(function($q) use ($locale) {
|
|
$q->where("title_{$locale}", 'like', "%{$this->search}%")
|
|
->orWhere("body_{$locale}", 'like', "%{$this->search}%");
|
|
}))
|
|
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
|
|
->orderBy($this->sortBy, $this->sortDir)
|
|
->paginate($this->perPage),
|
|
];
|
|
}
|
|
};
|
|
```
|
|
|
|
### Template
|
|
```blade
|
|
<div>
|
|
<div class="flex justify-between items-center mb-6">
|
|
<flux:heading>{{ __('admin.posts') }}</flux:heading>
|
|
<flux:button href="{{ route('admin.posts.create') }}">
|
|
{{ __('admin.create_post') }}
|
|
</flux:button>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="flex gap-4 mb-4">
|
|
<flux:input
|
|
wire:model.live.debounce="search"
|
|
placeholder="{{ __('admin.search_posts') }}"
|
|
class="w-64"
|
|
/>
|
|
<flux:select wire:model.live="statusFilter">
|
|
<option value="">{{ __('admin.all_statuses') }}</option>
|
|
<option value="draft">{{ __('admin.draft') }}</option>
|
|
<option value="published">{{ __('admin.published') }}</option>
|
|
</flux:select>
|
|
<flux:select wire:model.live="perPage">
|
|
<option value="10">10 {{ __('admin.per_page') }}</option>
|
|
<option value="25">25 {{ __('admin.per_page') }}</option>
|
|
<option value="50">50 {{ __('admin.per_page') }}</option>
|
|
</flux:select>
|
|
</div>
|
|
|
|
<!-- Posts Table -->
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr>
|
|
<th wire:click="sort('title_{{ app()->getLocale() }}')" class="cursor-pointer">
|
|
{{ __('admin.title') }}
|
|
</th>
|
|
<th>{{ __('admin.status') }}</th>
|
|
<th wire:click="sort('created_at')" class="cursor-pointer">
|
|
{{ __('admin.created') }}
|
|
</th>
|
|
<th wire:click="sort('updated_at')" class="cursor-pointer">
|
|
{{ __('admin.updated') }}
|
|
</th>
|
|
<th>{{ __('admin.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@forelse($posts as $post)
|
|
<tr>
|
|
<td>{{ $post->title }}</td>
|
|
<td>
|
|
<flux:badge :variant="$post->status === 'published' ? 'success' : 'secondary'">
|
|
{{ __('admin.' . $post->status) }}
|
|
</flux:badge>
|
|
</td>
|
|
<td>{{ $post->created_at->format('d/m/Y') }}</td>
|
|
<td>{{ $post->updated_at->diffForHumans() }}</td>
|
|
<td>
|
|
<div class="flex gap-2">
|
|
<flux:button size="sm" href="{{ route('admin.posts.edit', $post) }}">
|
|
{{ __('admin.edit') }}
|
|
</flux:button>
|
|
<flux:button
|
|
size="sm"
|
|
wire:click="togglePublish({{ $post->id }})"
|
|
>
|
|
{{ $post->status === 'published' ? __('admin.unpublish') : __('admin.publish') }}
|
|
</flux:button>
|
|
<flux:button
|
|
size="sm"
|
|
variant="danger"
|
|
wire:click="delete({{ $post->id }})"
|
|
wire:confirm="{{ __('admin.confirm_delete_post') }}"
|
|
>
|
|
{{ __('admin.delete') }}
|
|
</flux:button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
@empty
|
|
<tr>
|
|
<td colspan="5" class="text-center py-8 text-charcoal/70">
|
|
{{ __('admin.no_posts') }}
|
|
</td>
|
|
</tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
|
|
{{ $posts->links() }}
|
|
</div>
|
|
```
|
|
|
|
## Testing Requirements
|
|
|
|
### Test Scenarios
|
|
```php
|
|
// tests/Feature/Admin/PostManagementTest.php
|
|
|
|
test('admin can view posts list', function () {
|
|
// Create admin, create posts, visit index, assert sees posts
|
|
});
|
|
|
|
test('admin can filter posts by status', function () {
|
|
// Create draft and published posts
|
|
// Filter by 'draft' - only drafts visible
|
|
// Filter by 'published' - only published visible
|
|
// Filter by '' (all) - all visible
|
|
});
|
|
|
|
test('admin can search posts by title in current locale', function () {
|
|
// Create posts with known titles
|
|
// Search by partial title - matches appear
|
|
// Search with no matches - empty state shown
|
|
});
|
|
|
|
test('admin can search posts by body content', function () {
|
|
// Create posts with known body content
|
|
// Search by body text - matches appear
|
|
});
|
|
|
|
test('admin can sort posts by date', function () {
|
|
// Create posts with different dates
|
|
// Sort desc - newest first
|
|
// Sort asc - oldest first
|
|
});
|
|
|
|
test('admin can toggle post publish status', function () {
|
|
// Create draft post
|
|
// Toggle - becomes published, AdminLog created
|
|
// Toggle again - becomes draft, AdminLog created
|
|
});
|
|
|
|
test('admin can delete post with confirmation', function () {
|
|
// Create post
|
|
// Delete - post removed, AdminLog created
|
|
});
|
|
|
|
test('pagination works correctly', function () {
|
|
// Create 15 posts
|
|
// Page 1 shows 10, page 2 shows 5
|
|
});
|
|
|
|
test('audit log records status changes', function () {
|
|
// Toggle status, verify AdminLog entry with old/new values
|
|
});
|
|
|
|
test('audit log records deletions', function () {
|
|
// Delete post, verify AdminLog entry with old values
|
|
});
|
|
```
|
|
|
|
## Definition of Done
|
|
|
|
- [x] List displays all posts
|
|
- [x] Filter by status works
|
|
- [x] Search by title/body works
|
|
- [x] Sort by date works
|
|
- [x] Quick publish/unpublish toggle works
|
|
- [x] Delete with confirmation works
|
|
- [x] Pagination works
|
|
- [x] Per-page selector works (10/25/50)
|
|
- [x] Audit log for actions
|
|
- [x] All test scenarios pass
|
|
- [x] Code formatted with Pint
|
|
|
|
## Dependencies
|
|
|
|
- **Story 5.1:** Post creation - `docs/stories/story-5.1-post-creation-editing.md`
|
|
- **Requires:** Post model with bilingual accessors, AdminLog model
|
|
|
|
## Estimation
|
|
|
|
**Complexity:** Medium
|
|
**Estimated Effort:** 3-4 hours
|
|
|
|
---
|
|
|
|
## Dev Agent Record
|
|
|
|
### Status
|
|
**Ready for Review**
|
|
|
|
### Agent Model Used
|
|
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
|
|
|
### File List
|
|
| File | Action |
|
|
|------|--------|
|
|
| `resources/views/livewire/admin/posts/index.blade.php` | Modified - Added togglePublish, delete methods, body search, per-page 10/25/50 |
|
|
| `lang/en/posts.php` | Modified - Added post_status_updated, post_not_found, confirm_delete keys |
|
|
| `lang/ar/posts.php` | Modified - Added post_status_updated, post_not_found, confirm_delete keys |
|
|
| `tests/Feature/Admin/PostManagementTest.php` | Modified - Added 13 new tests for index page features |
|
|
|
|
### Completion Notes
|
|
- Post management dashboard index page was already partially implemented from Story 5.1
|
|
- Added missing features: togglePublish, delete with confirmation, body search, audit logging
|
|
- Changed per-page default from 15 to 10 and options from 15/25/50 to 10/25/50 per story requirements
|
|
- Uses DB transaction with lockForUpdate for race condition protection
|
|
- All 41 post management tests pass (13 new tests added for index page features)
|
|
- Full test suite (682 tests) passes
|
|
- Code formatted with Pint
|
|
|
|
### Change Log
|
|
| Date | Change |
|
|
|------|--------|
|
|
| 2025-12-27 | Initial implementation of Story 5.2 features |
|
|
|
|
## QA Results
|
|
|
|
### Review Date: 2025-12-27
|
|
|
|
### Reviewed By: Quinn (Test Architect)
|
|
|
|
### Risk Assessment
|
|
- **Risk Level**: Low-Medium
|
|
- **Escalation Triggers**: None triggered
|
|
- No auth/payment/security-sensitive changes (post management is admin-only)
|
|
- Tests added: 13 new tests for index page features
|
|
- Diff size: Reasonable (~300 lines of Livewire component + tests)
|
|
- Previous gate: N/A (first review)
|
|
- Acceptance criteria count: 9 items (moderate complexity)
|
|
|
|
### Code Quality Assessment
|
|
|
|
**Overall Grade: Excellent**
|
|
|
|
The implementation demonstrates high-quality Laravel/Livewire practices:
|
|
|
|
1. **Architecture & Design**: Clean Volt class-based component following project conventions
|
|
2. **Database Safety**: Proper use of `DB::transaction()` with `lockForUpdate()` for race condition protection
|
|
3. **Error Handling**: Graceful handling of "post not found" scenarios with user-friendly error messages
|
|
4. **Bilingual Support**: Search properly queries both Arabic and English JSON fields
|
|
5. **State Management**: Proper use of `updatedX()` lifecycle hooks for pagination reset
|
|
6. **Code Organization**: Clear separation of concerns between filtering, sorting, and CRUD operations
|
|
|
|
### Refactoring Performed
|
|
|
|
None required. The code is well-structured and follows project conventions.
|
|
|
|
### Requirements Traceability (Given-When-Then Mapping)
|
|
|
|
| AC# | Acceptance Criteria | Test Coverage | Status |
|
|
|-----|---------------------|---------------|--------|
|
|
| 1 | Display posts with title, status, dates | `admin can see list of posts`, `posts list shows status badges` | ✓ |
|
|
| 2 | Pagination (10/25/50 per page) | `pagination works correctly with per page selector` | ✓ |
|
|
| 3 | Filter by status | `admin can filter posts by status` | ✓ |
|
|
| 4 | Search by title/body | `admin can search posts by title`, `admin can search posts by body content` | ✓ |
|
|
| 5 | Sort by date | `admin can sort posts by created date`, `admin can sort posts by updated date` | ✓ |
|
|
| 6 | Edit post | `admin can view post edit form`, `edit existing post updates content` | ✓ |
|
|
| 7 | Delete with confirmation | `admin can delete post from index`, `delete handles post not found gracefully` | ✓ |
|
|
| 8 | Publish/unpublish toggle | `admin can toggle post publish status from draft to published`, `...from published to draft` | ✓ |
|
|
| 9 | Audit logging | `toggle publish creates audit log`, `delete post creates audit log` | ✓ |
|
|
| 10 | Authorization (admin only) | `non-admin cannot access posts index`, `guest cannot access posts index` | ✓ |
|
|
|
|
### Compliance Check
|
|
|
|
- Coding Standards: ✓ Code formatted with Pint
|
|
- Project Structure: ✓ Follows Volt class-based component pattern in correct location
|
|
- Testing Strategy: ✓ Comprehensive feature tests using Pest with Volt::test()
|
|
- All ACs Met: ✓ All acceptance criteria have corresponding test coverage
|
|
|
|
### Improvements Checklist
|
|
|
|
All items handled - no required changes:
|
|
|
|
- [x] DB transactions with lockForUpdate() for race condition protection
|
|
- [x] Graceful error handling for "not found" scenarios
|
|
- [x] Bilingual search across all JSON fields
|
|
- [x] Pagination reset on filter changes
|
|
- [x] Audit logging for status changes and deletions
|
|
- [x] Authorization via admin middleware
|
|
- [x] Per-page selector with 10/25/50 options per story requirements
|
|
|
|
### Test Architecture Assessment
|
|
|
|
**Tests Reviewed**: 41 tests (13 specific to index page features)
|
|
**Coverage Quality**: Excellent
|
|
|
|
| Category | Count | Quality |
|
|
|----------|-------|---------|
|
|
| Index page CRUD | 16 | ✓ Complete |
|
|
| Authorization | 4 | ✓ Complete |
|
|
| Validation | 2 | ✓ Complete |
|
|
| Create/Edit pages | 15 | ✓ Complete |
|
|
| Model tests | 4 | ✓ Complete |
|
|
|
|
**Test Design Strengths**:
|
|
- Uses factory states (`->draft()`, `->published()`) appropriately
|
|
- Tests both happy paths and edge cases (not found scenarios)
|
|
- Verifies audit log creation with correct old/new values
|
|
- Tests all filter/sort combinations
|
|
|
|
### Security Review
|
|
|
|
- ✓ Authorization: Admin middleware protects all routes
|
|
- ✓ Input Validation: Search input used in parameterized queries (no SQL injection)
|
|
- ✓ XSS Prevention: HTML sanitization via `clean()` helper (mews/purifier)
|
|
- ✓ CSRF: Handled by Livewire automatically
|
|
- ✓ Race Conditions: Protected with `lockForUpdate()` in transactions
|
|
|
|
### Performance Considerations
|
|
|
|
- ✓ Pagination implemented with configurable per-page limit
|
|
- ✓ JSON search uses MySQL JSON_EXTRACT (appropriate for moderate data volumes)
|
|
- ✓ Sorting uses indexed columns (updated_at, created_at)
|
|
- Note: For very large datasets, consider adding full-text search index on JSON columns
|
|
|
|
### Non-Functional Requirements Validation
|
|
|
|
| NFR | Status | Notes |
|
|
|-----|--------|-------|
|
|
| Security | PASS | Admin-only access, parameterized queries, XSS protection |
|
|
| Performance | PASS | Pagination, indexed sorting columns |
|
|
| Reliability | PASS | Transaction protection, graceful error handling |
|
|
| Maintainability | PASS | Clean code, comprehensive tests, bilingual support |
|
|
|
|
### Files Modified During Review
|
|
|
|
None - no refactoring was needed.
|
|
|
|
### Gate Status
|
|
|
|
Gate: **PASS** → docs/qa/gates/5.2-post-management-dashboard.yml
|
|
|
|
### Recommended Status
|
|
|
|
✓ **Ready for Done**
|
|
|
|
All acceptance criteria are met, tests pass, code quality is excellent, and security considerations are properly addressed. The implementation follows Laravel/Livewire best practices and project conventions.
|