libra/docs/stories/story-5.2-post-management-d...

17 KiB

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

  • Display all posts with:
    • Title (in current admin language)
    • Status (draft/published)
    • Created date
    • Last updated date
  • Pagination (10/25/50 per page)
  • Filter by status (draft/published/all)
  • Search by title or body content
  • Sort by date (newest/oldest)

Quick Actions

  • Edit post
  • Delete (with confirmation)
  • Publish/unpublish toggle
  • Bulk delete option (optional) - OUT OF SCOPE

Quality Requirements

  • Bilingual labels
  • Audit log for delete and status change actions
  • Authorization check (admin only) on all actions
  • Tests for list operations

Edge Cases

  • Handle post not found gracefully (race condition on delete)
  • Per-page selector visible in UI (10/25/50 options)
  • 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

// 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

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

<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

// 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

  • List displays all posts
  • Filter by status works
  • Search by title/body works
  • Sort by date works
  • Quick publish/unpublish toggle works
  • Delete with confirmation works
  • Pagination works
  • Per-page selector works (10/25/50)
  • Audit log for actions
  • All test scenarios pass
  • 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:

  • DB transactions with lockForUpdate() for race condition protection
  • Graceful error handling for "not found" scenarios
  • Bilingual search across all JSON fields
  • Pagination reset on filter changes
  • Audit logging for status changes and deletions
  • Authorization via admin middleware
  • 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

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.