libra/docs/stories/story-5.1-post-creation-edi...

21 KiB

Story 5.1: Post Creation & Editing

Epic Reference

Epic 5: Posts/Blog System

User Story

As an admin, I want to create and edit blog posts with rich text formatting, So that I can publish professional legal content for website visitors.

Story Context

Existing System Integration

  • Integrates with: posts table
  • Technology: Livewire Volt, TinyMCE or similar rich text editor
  • Follows pattern: Admin CRUD pattern
  • Touch points: Public posts display

Acceptance Criteria

Post Creation Form

  • Title (required, bilingual: Arabic and English)
  • Body content (required, bilingual)
  • Status (draft/published)

Rich Text Editor

  • Bold, italic, underline
  • Headings (H2, H3)
  • Bullet and numbered lists
  • Links
  • Blockquotes

Saving Features

  • Save as draft functionality
  • Preview post before publishing
  • Edit published posts
  • Auto-save draft periodically (every 60 seconds)
  • Immediate publishing (no scheduling)

Timestamps

  • created_at recorded on creation
  • updated_at updated on edit

Quality Requirements

  • HTML sanitization for XSS prevention
  • Bilingual form labels
  • Audit log for create/edit
  • Tests for CRUD operations

Technical Notes

HTML Sanitization

Use the mews/purifier package for HTML sanitization via the clean() helper:

composer require mews/purifier
php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider"

Configure config/purifier.php to allow only safe rich text tags:

'default' => [
    'HTML.Allowed' => 'h2,h3,p,br,b,strong,i,em,u,ul,ol,li,a[href|title],blockquote',
    'AutoFormat.AutoParagraph' => true,
    'AutoFormat.RemoveEmpty' => true,
],

Database Schema

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title_ar');
    $table->string('title_en');
    $table->text('body_ar');
    $table->text('body_en');
    $table->enum('status', ['draft', 'published'])->default('draft');
    $table->timestamps();
});

Post Model

class Post extends Model
{
    protected $fillable = [
        'title_ar', 'title_en', 'body_ar', 'body_en', 'status',
    ];

    public function getTitleAttribute(): string
    {
        $locale = app()->getLocale();
        return $this->{"title_{$locale}"} ?? $this->title_en;
    }

    public function getBodyAttribute(): string
    {
        $locale = app()->getLocale();
        return $this->{"body_{$locale}"} ?? $this->body_en;
    }

    public function getExcerptAttribute(): string
    {
        return Str::limit(strip_tags($this->body), 150);
    }

    public function scopePublished($query)
    {
        return $query->where('status', 'published');
    }

    public function scopeDraft($query)
    {
        return $query->where('status', 'draft');
    }
}

Volt Component

<?php

use App\Models\Post;
use Livewire\Volt\Component;

new class extends Component {
    public ?Post $post = null;

    public string $title_ar = '';
    public string $title_en = '';
    public string $body_ar = '';
    public string $body_en = '';
    public string $status = 'draft';

    public function mount(?Post $post = null): void
    {
        if ($post?->exists) {
            $this->post = $post;
            $this->fill($post->only([
                'title_ar', 'title_en', 'body_ar', 'body_en', 'status'
            ]));
        }
    }

    public function save(): void
    {
        $validated = $this->validate([
            'title_ar' => ['required', 'string', 'max:255'],
            'title_en' => ['required', 'string', 'max:255'],
            'body_ar' => ['required', 'string'],
            'body_en' => ['required', 'string'],
            'status' => ['required', 'in:draft,published'],
        ]);

        // Sanitize HTML
        $validated['body_ar'] = clean($validated['body_ar']);
        $validated['body_en'] = clean($validated['body_en']);

        if ($this->post) {
            $this->post->update($validated);
            $action = 'update';
        } else {
            $this->post = Post::create($validated);
            $action = 'create';
        }

        // AdminLog model exists from Epic 1 (Story 1.1) - see admin_logs table in schema
        \App\Models\AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => $action,
            'target_type' => 'post',
            'target_id' => $this->post->id,
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.post_saved'));
    }

    public function saveDraft(): void
    {
        $this->status = 'draft';
        $this->save();
    }

    public function publish(): void
    {
        $this->status = 'published';
        $this->save();
    }

    public function autoSave(): void
    {
        if ($this->post && $this->status === 'draft') {
            $this->post->update([
                'title_ar' => $this->title_ar,
                'title_en' => $this->title_en,
                'body_ar' => clean($this->body_ar),
                'body_en' => clean($this->body_en),
            ]);
        }
    }

    // Preview opens modal with sanitized rendered content
    public bool $showPreview = false;

    public function preview(): void
    {
        $this->showPreview = true;
    }

    public function closePreview(): void
    {
        $this->showPreview = false;
    }
};

Template with Rich Text Editor

{{-- wire:poll.60s triggers autoSave every 60 seconds for draft posts --}}
<div wire:poll.60s="autoSave">
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <!-- Arabic Fields -->
        <div class="space-y-4">
            <flux:heading size="sm">{{ __('admin.arabic_content') }}</flux:heading>

            <flux:field>
                <flux:label>{{ __('admin.title') }} (عربي) *</flux:label>
                <flux:input wire:model="title_ar" dir="rtl" />
                <flux:error name="title_ar" />
            </flux:field>

            <flux:field>
                <flux:label>{{ __('admin.body') }} (عربي) *</flux:label>
                <div wire:ignore>
                    <trix-editor
                        x-data
                        x-on:trix-change="$wire.set('body_ar', $event.target.value)"
                        dir="rtl"
                    >{!! $body_ar !!}</trix-editor>
                </div>
                <flux:error name="body_ar" />
            </flux:field>
        </div>

        <!-- English Fields -->
        <div class="space-y-4">
            <flux:heading size="sm">{{ __('admin.english_content') }}</flux:heading>

            <flux:field>
                <flux:label>{{ __('admin.title') }} (English) *</flux:label>
                <flux:input wire:model="title_en" />
                <flux:error name="title_en" />
            </flux:field>

            <flux:field>
                <flux:label>{{ __('admin.body') }} (English) *</flux:label>
                <div wire:ignore>
                    <trix-editor
                        x-data
                        x-on:trix-change="$wire.set('body_en', $event.target.value)"
                    >{!! $body_en !!}</trix-editor>
                </div>
                <flux:error name="body_en" />
            </flux:field>
        </div>
    </div>

    <div class="flex gap-3 mt-6">
        <flux:button wire:click="saveDraft">
            {{ __('admin.save_draft') }}
        </flux:button>
        <flux:button wire:click="preview">
            {{ __('admin.preview') }}
        </flux:button>
        <flux:button variant="primary" wire:click="publish">
            {{ __('admin.publish') }}
        </flux:button>
    </div>

    {{-- Preview Modal --}}
    <flux:modal wire:model="showPreview" class="max-w-4xl">
        <flux:heading>{{ __('admin.preview') }}</flux:heading>
        <div class="mt-4 space-y-6">
            <div class="border-b pb-4">
                <h3 class="font-semibold text-lg mb-2">{{ __('admin.arabic_content') }}</h3>
                <h2 class="text-xl font-bold" dir="rtl">{{ $title_ar }}</h2>
                <div class="prose mt-2" dir="rtl">{!! clean($body_ar) !!}</div>
            </div>
            <div>
                <h3 class="font-semibold text-lg mb-2">{{ __('admin.english_content') }}</h3>
                <h2 class="text-xl font-bold">{{ $title_en }}</h2>
                <div class="prose mt-2">{!! clean($body_en) !!}</div>
            </div>
        </div>
        <div class="mt-6 flex justify-end">
            <flux:button wire:click="closePreview">{{ __('admin.close') }}</flux:button>
        </div>
    </flux:modal>
</div>

Trix Editor Setup

Include Trix editor assets in your layout or component:

{{-- In your layout head --}}
<link rel="stylesheet" href="https://unpkg.com/trix@2.0.0/dist/trix.css">
<script src="https://unpkg.com/trix@2.0.0/dist/trix.umd.min.js"></script>

Definition of Done

  • Can create post with bilingual content
  • Rich text editor works
  • Can save as draft
  • Can publish directly
  • Can edit existing posts
  • Auto-save works for drafts
  • Preview modal displays sanitized content
  • HTML properly sanitized
  • Audit log created
  • Tests pass
  • Code formatted with Pint

Test Scenarios

Unit Tests (Post Model)

test('post has bilingual title accessor', function () {
    $post = Post::factory()->create(['title_ar' => 'عنوان', 'title_en' => 'Title']);
    app()->setLocale('ar');
    expect($post->title)->toBe('عنوان');
    app()->setLocale('en');
    expect($post->title)->toBe('Title');
});

test('post excerpt strips HTML and limits to 150 chars', function () {
    $post = Post::factory()->create(['body_en' => '<p>' . str_repeat('a', 200) . '</p>']);
    expect(strlen($post->excerpt))->toBeLessThanOrEqual(153); // 150 + '...'
    expect($post->excerpt)->not->toContain('<p>');
});

test('published scope returns only published posts', function () {
    Post::factory()->create(['status' => 'draft']);
    Post::factory()->create(['status' => 'published']);
    expect(Post::published()->count())->toBe(1);
});

Feature Tests (Volt Component)

use Livewire\Volt\Volt;

test('admin can create post with valid bilingual content', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.posts.create')
        ->actingAs($admin)
        ->set('title_ar', 'عنوان المقال')
        ->set('title_en', 'Article Title')
        ->set('body_ar', '<p>محتوى المقال</p>')
        ->set('body_en', '<p>Article content</p>')
        ->call('saveDraft')
        ->assertHasNoErrors();

    expect(Post::where('title_en', 'Article Title')->exists())->toBeTrue();
});

test('create post fails with missing required fields', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.posts.create')
        ->actingAs($admin)
        ->set('title_ar', '')
        ->set('title_en', 'Title')
        ->call('save')
        ->assertHasErrors(['title_ar']);
});

test('save draft preserves draft status', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.posts.create')
        ->actingAs($admin)
        ->set('title_ar', 'عنوان')
        ->set('title_en', 'Title')
        ->set('body_ar', 'محتوى')
        ->set('body_en', 'Content')
        ->call('saveDraft');

    expect(Post::first()->status)->toBe('draft');
});

test('publish changes status to published', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.posts.create')
        ->actingAs($admin)
        ->set('title_ar', 'عنوان')
        ->set('title_en', 'Title')
        ->set('body_ar', 'محتوى')
        ->set('body_en', 'Content')
        ->call('publish');

    expect(Post::first()->status)->toBe('published');
});

test('edit existing post updates content', function () {
    $admin = User::factory()->admin()->create();
    $post = Post::factory()->create(['title_en' => 'Original']);

    Volt::test('admin.posts.edit', ['post' => $post])
        ->actingAs($admin)
        ->set('title_en', 'Updated')
        ->call('save')
        ->assertHasNoErrors();

    expect($post->fresh()->title_en)->toBe('Updated');
});

test('auto-save only fires for draft posts', function () {
    $admin = User::factory()->admin()->create();
    $post = Post::factory()->create(['status' => 'published', 'title_en' => 'Original']);

    Volt::test('admin.posts.edit', ['post' => $post])
        ->actingAs($admin)
        ->set('title_en', 'Changed')
        ->call('autoSave');

    // Published post should NOT be auto-saved
    expect($post->fresh()->title_en)->toBe('Original');
});

test('HTML sanitization removes script tags but keeps allowed formatting', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.posts.create')
        ->actingAs($admin)
        ->set('title_ar', 'عنوان')
        ->set('title_en', 'Title')
        ->set('body_ar', '<p>نص</p>')
        ->set('body_en', '<p>Safe</p><script>alert("xss")</script><strong>Bold</strong>')
        ->call('saveDraft');

    $post = Post::first();
    expect($post->body_en)->not->toContain('<script>');
    expect($post->body_en)->toContain('<strong>Bold</strong>');
});

test('admin log created on post create', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.posts.create')
        ->actingAs($admin)
        ->set('title_ar', 'عنوان')
        ->set('title_en', 'Title')
        ->set('body_ar', 'محتوى')
        ->set('body_en', 'Content')
        ->call('saveDraft');

    expect(AdminLog::where('action_type', 'create')->where('target_type', 'post')->exists())->toBeTrue();
});

test('preview modal can be opened and closed', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.posts.create')
        ->actingAs($admin)
        ->assertSet('showPreview', false)
        ->call('preview')
        ->assertSet('showPreview', true)
        ->call('closePreview')
        ->assertSet('showPreview', false);
});

Dependencies

  • Epic 1: Database schema, admin authentication

Estimation

Complexity: Medium Estimated Effort: 4-5 hours


Dev Agent Record

Status

Ready for Review

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

File List

New Files:

  • config/purifier.php - HTML Purifier configuration for XSS prevention
  • resources/views/livewire/admin/posts/index.blade.php - Posts listing Volt component
  • resources/views/livewire/admin/posts/create.blade.php - Post creation Volt component with Trix editor
  • resources/views/livewire/admin/posts/edit.blade.php - Post editing Volt component with Trix editor
  • lang/en/posts.php - English translations for posts module
  • lang/ar/posts.php - Arabic translations for posts module
  • tests/Feature/Admin/PostManagementTest.php - Feature tests for post CRUD operations (30 tests)

Modified Files:

  • app/Models/Post.php - Added getExcerpt() method
  • routes/web.php - Added admin posts routes
  • lang/en/common.php - Added 'close' translation
  • lang/ar/common.php - Added 'close' translation
  • lang/en/enums.php - Added post_status translations
  • lang/ar/enums.php - Added post_status translations

Change Log

  1. Published and configured mews/purifier for HTML sanitization with allowed tags: h2, h3, p, br, b, strong, i, em, u, ul, ol, li, a[href|title], blockquote
  2. Added getExcerpt() method to Post model for generating text excerpts
  3. Created admin posts index page with filtering by status and search functionality
  4. Created admin posts create page with Trix rich text editor, preview modal, and save draft/publish actions
  5. Created admin posts edit page with auto-save (60s) for drafts, preview modal, and publish/unpublish actions
  6. Added admin posts routes under /admin/posts prefix
  7. Added bilingual translations (ar/en) for all post-related UI strings
  8. Implemented audit logging via AdminLog for create/update actions
  9. Wrote comprehensive feature tests covering CRUD, validation, authorization, and HTML sanitization

Completion Notes

  • Post model already existed with JSON columns for bilingual title/body (different from story's suggested schema with separate columns - existing implementation is better)
  • PostFactory and PostStatus enum already existed
  • Used Trix editor (as suggested in story) via CDN with file upload toolbar hidden
  • Auto-save only triggers for draft posts to prevent accidental changes to published content
  • All 671 tests pass (30 new tests added for this story)

QA Results

Review Date: 2025-12-27

Reviewed By: Quinn (Test Architect)

Code Quality Assessment

Overall Grade: Excellent

The implementation demonstrates high quality across all dimensions. The code follows established patterns in the codebase, uses appropriate architectural decisions (JSON columns for bilingual content instead of separate columns), and includes comprehensive test coverage with 30 passing tests.

Key Strengths:

  1. Architecture: JSON columns for bilingual content is cleaner than separate _ar/_en columns
  2. Security: HTML sanitization via mews/purifier is properly configured with appropriate allowed tags
  3. Testing: Comprehensive 30-test suite covering CRUD, authorization, validation, and model behavior
  4. UX: Auto-save for drafts (60s interval), preview modal, and proper status management
  5. Bilingual Support: Complete translations in both English and Arabic for all UI strings

Refactoring Performed

None required - code quality is excellent as-is.

Compliance Check

  • Coding Standards: ✓ Code follows Laravel/Livewire conventions, uses Volt class-based pattern
  • Project Structure: ✓ Files placed correctly in standard locations
  • Testing Strategy: ✓ Feature tests using Pest and Volt::test() pattern
  • All ACs Met: ✓ All 20 acceptance criteria items checked in story file

Improvements Checklist

No mandatory improvements required. Optional considerations for future:

  • Consider adding delete functionality (not in current AC scope)
  • Consider adding image upload capability for blog posts (not in current AC scope)
  • Consider extracting common Trix editor setup to a reusable Blade component (minor DRY improvement)

Requirements Traceability

Acceptance Criteria Test Coverage Status
AC1: Title (bilingual) admin can create post with valid bilingual content, post has bilingual title accessor
AC2: Body content (bilingual) admin can create post with valid bilingual content, post has bilingual body accessor
AC3: Status (draft/published) save draft preserves draft status, publish changes status to published, posts list shows status badges
AC4-9: Rich Text Editor (bold, italic, headings, lists, links, blockquotes) HTML sanitization removes script tags but keeps allowed formatting
AC10: Save as draft save draft preserves draft status
AC11: Preview post preview modal can be opened and closed
AC12: Edit published posts edit existing post updates content
AC13: Auto-save (60s) auto-save only fires for draft posts, auto-save updates draft posts
AC14: Immediate publishing publish changes status to published
AC15-16: Timestamps Verified in model via $timestamps = true
AC17: HTML sanitization HTML sanitization removes script tags but keeps allowed formatting
AC18: Bilingual form labels Verified in lang/en/posts.php, lang/ar/posts.php
AC19: Audit log admin log created on post create, admin log created on post update
AC20: Tests for CRUD 30 comprehensive tests covering all operations

Security Review

Status: PASS

  1. XSS Prevention: HTML Purifier configured with strict whitelist (h2,h3,p,br,b,strong,i,em,u,ul,ol,li,a[href|title],blockquote)
  2. Authorization: Admin middleware protects all post management routes
  3. CSRF: Livewire handles CSRF protection automatically
  4. Input Validation: Server-side validation rules for all fields
  5. Audit Logging: All create/update actions logged with IP address

Security Tests Verified:

  • non-admin cannot access posts index
  • non-admin cannot access post creation
  • non-admin cannot access post edit
  • guest cannot access posts index

Performance Considerations

Status: PASS

  1. Auto-save optimization: Only fires for draft posts, preventing unnecessary database writes
  2. Pagination: Index page uses Laravel pagination with configurable per-page count
  3. Debounced search: 300ms debounce on search input prevents excessive queries
  4. No N+1: No eager loading needed as post data is self-contained

Files Modified During Review

None - no modifications required.

Gate Status

Gate: PASSdocs/qa/gates/5.1-post-creation-editing.yml

✓ Ready for Done

All acceptance criteria met, comprehensive test coverage (30 tests), proper security measures in place, and code quality is excellent. Story owner may proceed with marking as Done.