diff --git a/resources/views/admin/posts/create.php b/resources/views/admin/posts/create.php index 97c5e87..3123f60 100644 --- a/resources/views/admin/posts/create.php +++ b/resources/views/admin/posts/create.php @@ -16,8 +16,8 @@
- - URL-friendly version of the title + + URL-friendly version. Leave blank to auto-generate from title.
@@ -39,9 +39,16 @@
+ +
@@ -202,6 +209,15 @@ class: Embed, document.getElementById('post-form').addEventListener('submit', async (e) => { e.preventDefault(); + // Validate scheduled posts have a published date + const status = document.getElementById('status').value; + const publishedAt = document.getElementById('published_at').value; + + if (status === 'scheduled' && !publishedAt) { + alert('Scheduled posts require a published date/time.'); + return; + } + try { const savedData = await editor.save(); console.log('Saved data:', savedData); @@ -218,6 +234,46 @@ class: Embed, } } +// Auto-generate slug from title +document.getElementById('title').addEventListener('input', function() { + const slugInput = document.getElementById('slug'); + if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') { + const slug = this.value.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + slugInput.value = slug; + slugInput.dataset.autoGenerated = 'true'; + } +}); + +// Mark slug as manually edited +document.getElementById('slug').addEventListener('input', function() { + if (this.value) { + this.dataset.autoGenerated = 'false'; + } +}); + +// Show/hide published date based on status +function updatePublishedAtVisibility() { + const status = document.getElementById('status').value; + const publishedAtWrapper = document.getElementById('published-at-wrapper'); + const publishedAtInput = document.getElementById('published_at'); + + if (status === 'published' || status === 'scheduled') { + publishedAtWrapper.style.display = 'block'; + publishedAtInput.disabled = false; + } else { + publishedAtWrapper.style.display = 'none'; + publishedAtInput.value = ''; + publishedAtInput.disabled = true; + } +} + +document.getElementById('status').addEventListener('change', updatePublishedAtVisibility); + +// Initialize on page load +document.addEventListener('DOMContentLoaded', updatePublishedAtVisibility); + // Featured image preview document.getElementById('featured_image').addEventListener('change', function() { const preview = document.getElementById('featured_image_preview'); diff --git a/resources/views/admin/posts/edit.php b/resources/views/admin/posts/edit.php index 4d1403d..ff080d3 100644 --- a/resources/views/admin/posts/edit.php +++ b/resources/views/admin/posts/edit.php @@ -17,8 +17,8 @@
- - URL-friendly version of the title + + URL-friendly version. Leave blank to auto-generate from title.
@@ -40,9 +40,16 @@
+
+ + + Required for scheduled posts. Leave blank to use current date/time when publishing. +
+
@@ -228,6 +235,15 @@ class: Embed, document.getElementById('post-form').addEventListener('submit', async (e) => { e.preventDefault(); + // Validate scheduled posts have a published date + const status = document.getElementById('status').value; + const publishedAt = document.getElementById('published_at').value; + + if (status === 'scheduled' && !publishedAt) { + alert('Scheduled posts require a published date/time.'); + return; + } + try { const savedData = await editor.save(); console.log('Saved data:', savedData); @@ -244,6 +260,46 @@ class: Embed, } } +// Auto-generate slug from title +document.getElementById('title').addEventListener('input', function() { + const slugInput = document.getElementById('slug'); + if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') { + const slug = this.value.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + slugInput.value = slug; + slugInput.dataset.autoGenerated = 'true'; + } +}); + +// Mark slug as manually edited +document.getElementById('slug').addEventListener('input', function() { + if (this.value) { + this.dataset.autoGenerated = 'false'; + } +}); + +// Show/hide published date based on status +function updatePublishedAtVisibility() { + const status = document.getElementById('status').value; + const publishedAtWrapper = document.getElementById('published-at-wrapper'); + const publishedAtInput = document.getElementById('published_at'); + + if (status === 'published' || status === 'scheduled') { + publishedAtWrapper.style.display = 'block'; + publishedAtInput.disabled = false; + } else { + publishedAtWrapper.style.display = 'none'; + // Don't clear the value - preserve existing published dates from database + publishedAtInput.disabled = true; + } +} + +document.getElementById('status').addEventListener('change', updatePublishedAtVisibility); + +// Initialize on page load +document.addEventListener('DOMContentLoaded', updatePublishedAtVisibility); + // Featured image preview document.getElementById('featured_image').addEventListener('change', function() { const preview = document.getElementById('featured_image_preview'); diff --git a/src/Cms/Dtos/posts/create-post-request.yaml b/src/Cms/Dtos/posts/create-post-request.yaml index 286b087..e7eab9b 100644 --- a/src/Cms/Dtos/posts/create-post-request.yaml +++ b/src/Cms/Dtos/posts/create-post-request.yaml @@ -39,3 +39,8 @@ dto: type: string required: true enum: ['draft', 'published', 'scheduled'] + + published_at: + type: string + required: false + pattern: '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$|^$/' # datetime-local format: YYYY-MM-DDTHH:MM or empty diff --git a/src/Cms/Dtos/posts/update-post-request.yaml b/src/Cms/Dtos/posts/update-post-request.yaml index 59d9f94..6646f70 100644 --- a/src/Cms/Dtos/posts/update-post-request.yaml +++ b/src/Cms/Dtos/posts/update-post-request.yaml @@ -39,3 +39,8 @@ dto: type: string required: true enum: ['draft', 'published', 'scheduled'] + + published_at: + type: string + required: false + pattern: '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$|^$/' # datetime-local format: YYYY-MM-DDTHH:MM or empty diff --git a/src/Cms/Services/Post/Creator.php b/src/Cms/Services/Post/Creator.php index 2779a26..8201198 100644 --- a/src/Cms/Services/Post/Creator.php +++ b/src/Cms/Services/Post/Creator.php @@ -56,6 +56,7 @@ public function create( Dto $request, array $categoryIds = [], string $tagNames $slug = $request->slug ?? null; $excerpt = $request->excerpt ?? null; $featuredImage = $request->featured_image ?? null; + $publishedAt = $request->published_at ?? null; $post = new Post(); $post->setTitle( $title ); @@ -67,9 +68,24 @@ public function create( Dto $request, array $categoryIds = [], string $tagNames $post->setStatus( $status ); $post->setCreatedAt( new DateTimeImmutable() ); - // Business rule: auto-set published date for published posts - if( $status === ContentStatus::PUBLISHED->value ) + // Business rule: set published date + if( $status === ContentStatus::SCHEDULED->value ) { + // Scheduled posts MUST have a published date + if( !$publishedAt || trim( $publishedAt ) === '' ) + { + throw new \InvalidArgumentException( 'Scheduled posts require a published date' ); + } + $post->setPublishedAt( $this->parseDateTime( $publishedAt ) ); + } + elseif( $publishedAt && trim( $publishedAt ) !== '' ) + { + // Use provided published date + $post->setPublishedAt( $this->parseDateTime( $publishedAt ) ); + } + elseif( $status === ContentStatus::PUBLISHED->value ) + { + // Auto-set to now for published posts when not provided $post->setPublishedAt( new DateTimeImmutable() ); } @@ -97,4 +113,27 @@ private function generateSlug( string $title ): string { return $this->_slugGenerator->generate( $title, 'post' ); } + + /** + * Safely parse a datetime string into DateTimeImmutable + * + * @param string $dateTimeString The datetime string to parse + * @return DateTimeImmutable + * @throws \InvalidArgumentException If the datetime string is invalid + */ + private function parseDateTime( string $dateTimeString ): DateTimeImmutable + { + try + { + return new DateTimeImmutable( $dateTimeString ); + } + catch( \DateMalformedStringException | \Exception $e ) + { + throw new \InvalidArgumentException( + "Invalid published date format: '{$dateTimeString}'. Please provide a valid datetime.", + 0, + $e + ); + } + } } diff --git a/src/Cms/Services/Post/Updater.php b/src/Cms/Services/Post/Updater.php index c13fbde..856c515 100644 --- a/src/Cms/Services/Post/Updater.php +++ b/src/Cms/Services/Post/Updater.php @@ -56,6 +56,7 @@ public function update( Dto $request, array $categoryIds = [], string $tagNames $slug = $request->slug ?? null; $excerpt = $request->excerpt ?? null; $featuredImage = $request->featured_image ?? null; + $publishedAt = $request->published_at ?? null; // Look up the post $post = $this->_postRepository->findById( $id ); @@ -71,9 +72,24 @@ public function update( Dto $request, array $categoryIds = [], string $tagNames $post->setFeaturedImage( $featuredImage ); $post->setStatus( $status ); - // Business rule: auto-set published date when changing to published status - if( $status === ContentStatus::PUBLISHED->value && !$post->getPublishedAt() ) + // Business rule: set published date + if( $status === ContentStatus::SCHEDULED->value ) { + // Scheduled posts MUST have a published date + if( !$publishedAt || trim( $publishedAt ) === '' ) + { + throw new \InvalidArgumentException( 'Scheduled posts require a published date' ); + } + $post->setPublishedAt( $this->parseDateTime( $publishedAt ) ); + } + elseif( $publishedAt && trim( $publishedAt ) !== '' ) + { + // Use provided published date + $post->setPublishedAt( $this->parseDateTime( $publishedAt ) ); + } + elseif( $status === ContentStatus::PUBLISHED->value && !$post->getPublishedAt() ) + { + // Auto-set to now for published posts when not provided and not already set $post->setPublishedAt( new \DateTimeImmutable() ); } @@ -102,4 +118,27 @@ private function generateSlug( string $title ): string { return $this->_slugGenerator->generate( $title, 'post' ); } + + /** + * Safely parse a datetime string into DateTimeImmutable + * + * @param string $dateTimeString The datetime string to parse + * @return \DateTimeImmutable + * @throws \InvalidArgumentException If the datetime string is invalid + */ + private function parseDateTime( string $dateTimeString ): \DateTimeImmutable + { + try + { + return new \DateTimeImmutable( $dateTimeString ); + } + catch( \DateMalformedStringException | \Exception $e ) + { + throw new \InvalidArgumentException( + "Invalid published date format: '{$dateTimeString}'. Please provide a valid datetime.", + 0, + $e + ); + } + } } diff --git a/tests/Unit/Cms/Services/Post/CreatorTest.php b/tests/Unit/Cms/Services/Post/CreatorTest.php index 63e3091..45d2b78 100644 --- a/tests/Unit/Cms/Services/Post/CreatorTest.php +++ b/tests/Unit/Cms/Services/Post/CreatorTest.php @@ -44,7 +44,8 @@ private function createDto( string $status, ?string $slug = null, ?string $excerpt = null, - ?string $featuredImage = null + ?string $featuredImage = null, + ?string $publishedAt = null ): Dto { $factory = new Factory( __DIR__ . "/../../../../../src/Cms/Dtos/posts/create-post-request.yaml" ); @@ -67,6 +68,10 @@ private function createDto( { $dto->featured_image = $featuredImage; } + if( $publishedAt !== null ) + { + $dto->published_at = $publishedAt; + } return $dto; } @@ -353,4 +358,118 @@ public function testSetsOptionalFields(): void $this->assertEquals( 'Test excerpt', $result->getExcerpt() ); $this->assertEquals( 'image.jpg', $result->getFeaturedImage() ); } + + public function testScheduledPostRequiresPublishedDate(): void + { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Scheduled posts require a published date' ); + + $dto = $this->createDto( + 'Scheduled Post', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 1, + 'scheduled', // Scheduled status + null, // No slug + null, // No excerpt + null, // No featured image + null // No published date - THIS SHOULD THROW EXCEPTION + ); + + $this->_creator->create( $dto ); + } + + public function testScheduledPostWithPublishedDateSucceeds(): void + { + $this->_mockCategoryRepository + ->method( 'findByIds' ) + ->willReturn( [] ); + + $this->_mockTagResolver + ->method( 'resolveFromString' ) + ->willReturn( [] ); + + $publishedDate = '2025-12-31T23:59'; + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'create' ) + ->with( $this->callback( function( Post $post ) { + return $post->getStatus() === 'scheduled' + && $post->getPublishedAt() instanceof DateTimeImmutable; + } ) ) + ->willReturnArgument( 0 ); + + $dto = $this->createDto( + 'Scheduled Post', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 1, + 'scheduled', + null, + null, + null, + $publishedDate + ); + + $result = $this->_creator->create( $dto ); + + $this->assertEquals( 'scheduled', $result->getStatus() ); + $this->assertInstanceOf( DateTimeImmutable::class, $result->getPublishedAt() ); + } + + public function testInvalidPublishedDateThrowsException(): void + { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid published date format' ); + + $dto = $this->createDto( + 'Test Post', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 1, + 'published', + null, + null, + null, + '2024-13-01T10:00' // Invalid month + ); + + $this->_creator->create( $dto ); + } + + public function testInvalidDayThrowsException(): void + { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid published date format' ); + + $dto = $this->createDto( + 'Test Post', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 1, + 'published', + null, + null, + null, + '2024-01-32T10:00' // Invalid day + ); + + $this->_creator->create( $dto ); + } + + public function testInvalidHourThrowsException(): void + { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid published date format' ); + + $dto = $this->createDto( + 'Test Post', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 1, + 'published', + null, + null, + null, + '2024-01-01T25:00' // Invalid hour + ); + + $this->_creator->create( $dto ); + } } diff --git a/tests/Unit/Cms/Services/Post/UpdaterTest.php b/tests/Unit/Cms/Services/Post/UpdaterTest.php index 32dd2ed..b5149c1 100644 --- a/tests/Unit/Cms/Services/Post/UpdaterTest.php +++ b/tests/Unit/Cms/Services/Post/UpdaterTest.php @@ -43,7 +43,8 @@ private function createDto( string $status, ?string $slug = null, ?string $excerpt = null, - ?string $featuredImage = null + ?string $featuredImage = null, + ?string $publishedAt = null ): Dto { $factory = new Factory( __DIR__ . "/../../../../../src/Cms/Dtos/posts/update-post-request.yaml" ); @@ -66,6 +67,10 @@ private function createDto( { $dto->featured_image = $featuredImage; } + if( $publishedAt !== null ) + { + $dto->published_at = $publishedAt; + } return $dto; } @@ -485,4 +490,237 @@ public function testThrowsExceptionWhenPostNotFound(): void $this->_updater->update( $dto ); } + + public function testScheduledPostRequiresPublishedDate(): void + { + $post = new Post(); + $post->setId( 1 ); + $post->setTitle( 'Original Title' ); + $post->setStatus( Post::STATUS_DRAFT ); + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 1 ) + ->willReturn( $post ); + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Scheduled posts require a published date' ); + + $dto = $this->createDto( + 1, + 'Updated Title', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 'scheduled', // Scheduled status + null, // No slug + null, // No excerpt + null, // No featured image + null // No published date - THIS SHOULD THROW EXCEPTION + ); + + $this->_updater->update( $dto ); + } + + public function testScheduledPostWithPublishedDateSucceeds(): void + { + $post = new Post(); + $post->setId( 1 ); + $post->setTitle( 'Original Title' ); + $post->setStatus( Post::STATUS_DRAFT ); + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 1 ) + ->willReturn( $post ); + + $this->_mockCategoryRepository + ->method( 'findByIds' ) + ->willReturn( [] ); + + $this->_mockTagResolver + ->method( 'resolveFromString' ) + ->willReturn( [] ); + + $publishedDate = '2025-12-31T23:59'; + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'update' ) + ->with( $this->callback( function( Post $post ) { + return $post->getStatus() === 'scheduled' + && $post->getPublishedAt() instanceof \DateTimeImmutable; + } ) ); + + $dto = $this->createDto( + 1, + 'Updated Title', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 'scheduled', + null, + null, + null, + $publishedDate + ); + + $result = $this->_updater->update( $dto ); + + $this->assertEquals( 'scheduled', $result->getStatus() ); + $this->assertInstanceOf( \DateTimeImmutable::class, $result->getPublishedAt() ); + } + + public function testInvalidPublishedDateThrowsException(): void + { + $post = new Post(); + $post->setId( 1 ); + $post->setTitle( 'Original Title' ); + $post->setStatus( Post::STATUS_DRAFT ); + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 1 ) + ->willReturn( $post ); + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid published date format' ); + + $dto = $this->createDto( + 1, + 'Updated Title', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 'published', + null, + null, + null, + '2024-13-01T10:00' // Invalid month + ); + + $this->_updater->update( $dto ); + } + + public function testInvalidDayThrowsException(): void + { + $post = new Post(); + $post->setId( 1 ); + $post->setTitle( 'Original Title' ); + $post->setStatus( Post::STATUS_DRAFT ); + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 1 ) + ->willReturn( $post ); + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid published date format' ); + + $dto = $this->createDto( + 1, + 'Updated Title', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 'published', + null, + null, + null, + '2024-01-32T10:00' // Invalid day + ); + + $this->_updater->update( $dto ); + } + + public function testInvalidHourThrowsException(): void + { + $post = new Post(); + $post->setId( 1 ); + $post->setTitle( 'Original Title' ); + $post->setStatus( Post::STATUS_DRAFT ); + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 1 ) + ->willReturn( $post ); + + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid published date format' ); + + $dto = $this->createDto( + 1, + 'Updated Title', + '{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}', + 'published', + null, + null, + null, + '2024-01-01T25:00' // Invalid hour + ); + + $this->_updater->update( $dto ); + } + + /** + * Test that when changing a previously published post to draft status, + * the historical published_at date is preserved in the database. + * + * This prevents data loss when users: + * 1. Have a published post with a published_at date + * 2. Change the status back to 'draft' for editing + * 3. Later republish the post + * + * Bug scenario: The published_at date should NOT be cleared when + * changing to draft, so users can see and edit the existing date. + */ + public function testPreservesPublishedAtWhenChangingPublishedPostToDraft(): void + { + // Create a post that was previously published + $post = new Post(); + $post->setId( 1 ); + $post->setTitle( 'Previously Published Post' ); + $post->setStatus( Post::STATUS_PUBLISHED ); + $historicalPublishedAt = new \DateTimeImmutable( '2024-06-15 10:30:00' ); + $post->setPublishedAt( $historicalPublishedAt ); + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 1 ) + ->willReturn( $post ); + + $this->_mockCategoryRepository + ->method( 'findByIds' ) + ->willReturn( [] ); + + $this->_mockTagResolver + ->method( 'resolveFromString' ) + ->willReturn( [] ); + + $this->_mockPostRepository + ->expects( $this->once() ) + ->method( 'update' ) + ->with( $this->callback( function( Post $p ) use ( $historicalPublishedAt ) { + // Verify the post is now draft AND the published_at is preserved + return $p->getStatus() === Post::STATUS_DRAFT + && $p->getPublishedAt() === $historicalPublishedAt; + } ) ); + + // Update the post to draft status WITHOUT providing a published_at value + // (simulating the form submission where the field is hidden for drafts) + $dto = $this->createDto( + 1, + 'Updated Draft Post', + '{"blocks":[{"type":"paragraph","data":{"text":"Updated content"}}]}', + Post::STATUS_DRAFT + // Note: No published_at provided - field is hidden in UI for drafts + ); + + $result = $this->_updater->update( $dto ); + + // Assert the status changed to draft + $this->assertEquals( Post::STATUS_DRAFT, $result->getStatus() ); + + // Assert the historical published_at date is preserved (not cleared) + $this->assertSame( $historicalPublishedAt, $result->getPublishedAt() ); + $this->assertEquals( '2024-06-15 10:30:00', $result->getPublishedAt()->format( 'Y-m-d H:i:s' ) ); + } }