@@ -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 @@
@@ -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' ) );
+ }
}