# 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: ```bash composer require mews/purifier php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider" ``` Configure `config/purifier.php` to allow only safe rich text tags: ```php '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 ```php 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 ```php 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 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 ```blade {{-- wire:poll.60s triggers autoSave every 60 seconds for draft posts --}}
{{ __('admin.arabic_content') }} {{ __('admin.title') }} (عربي) * {{ __('admin.body') }} (عربي) *
{!! $body_ar !!}
{{ __('admin.english_content') }} {{ __('admin.title') }} (English) * {{ __('admin.body') }} (English) *
{!! $body_en !!}
{{ __('admin.save_draft') }} {{ __('admin.preview') }} {{ __('admin.publish') }}
{{-- Preview Modal --}} {{ __('admin.preview') }}

{{ __('admin.arabic_content') }}

{{ $title_ar }}

{!! clean($body_ar) !!}

{{ __('admin.english_content') }}

{{ $title_en }}

{!! clean($body_en) !!}
{{ __('admin.close') }}
``` ### Trix Editor Setup Include Trix editor assets in your layout or component: ```blade {{-- In your layout head --}} ``` ## 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) ```php 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' => '

' . str_repeat('a', 200) . '

']); expect(strlen($post->excerpt))->toBeLessThanOrEqual(153); // 150 + '...' expect($post->excerpt)->not->toContain('

'); }); 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) ```php 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', '

محتوى المقال

') ->set('body_en', '

Article content

') ->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', '

نص

') ->set('body_en', '

Safe

Bold') ->call('saveDraft'); $post = Post::first(); expect($post->body_en)->not->toContain('