diff --git a/app/Console/Commands/PublishPost.php b/app/Console/Commands/PublishPost.php
new file mode 100644
index 0000000..d0b3305
--- /dev/null
+++ b/app/Console/Commands/PublishPost.php
@@ -0,0 +1,85 @@
+find($this->argument('post'));
+
+ if (! $post) {
+ return $this->failWith("Post {$this->argument('post')} not found.");
+ }
+
+ if ($post->type !== 'blog') {
+ return $this->failWith("Post {$post->id} has type '{$post->type}', expected 'blog' to publish to a CMS.");
+ }
+
+ if (! $post->title) {
+ return $this->failWith("Post {$post->id} has no title, required to publish to WordPress.");
+ }
+
+ $site = $post->site;
+
+ if ($site->cms_type !== 'wordpress') {
+ return $this->failWith("Site '{$site->name}' is not configured as WordPress (cms_type={$site->cms_type}).");
+ }
+
+ if (! $site->wp_username || ! $site->wp_app_password) {
+ return $this->failWith("Site '{$site->name}' is missing WordPress credentials.");
+ }
+
+ try {
+ $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
+ ->post(rtrim($site->url, '/') . '/wp-json/wp/v2/posts', [
+ 'title' => $post->title,
+ 'content' => $post->body,
+ 'status' => 'publish',
+ ]);
+ } catch (ConnectionException $e) {
+ return $this->publishFailed($post, "could not connect to site: {$e->getMessage()}");
+ }
+
+ if ($response->failed()) {
+ $message = $response->json('message') ?? 'unexpected response from WordPress';
+
+ return $this->publishFailed($post, $message);
+ }
+
+ $link = $response->json('link');
+
+ if (! $link) {
+ return $this->publishFailed($post, 'unexpected response from WordPress: missing post link');
+ }
+
+ $post->update(['status' => 'published', 'published_url' => $link]);
+
+ $this->line(json_encode(['success' => true, 'url' => $link]));
+
+ return self::SUCCESS;
+ }
+
+ private function failWith(string $message): int
+ {
+ $this->line(json_encode(['success' => false, 'error' => $message]));
+
+ return self::FAILURE;
+ }
+
+ private function publishFailed(Post $post, string $message): int
+ {
+ $post->update(['status' => 'failed']);
+
+ return $this->failWith($message);
+ }
+}
diff --git a/app/Http/Resources/PostResource.php b/app/Http/Resources/PostResource.php
index b10ebd0..8981fa0 100644
--- a/app/Http/Resources/PostResource.php
+++ b/app/Http/Resources/PostResource.php
@@ -18,6 +18,7 @@ class PostResource extends JsonResource
'body' => $this->body,
'status' => $this->status,
'scheduled_at' => $this->scheduled_at,
+ 'published_url' => $this->published_url,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
diff --git a/app/Http/Resources/SiteResource.php b/app/Http/Resources/SiteResource.php
index 2319a5a..049823e 100644
--- a/app/Http/Resources/SiteResource.php
+++ b/app/Http/Resources/SiteResource.php
@@ -14,6 +14,9 @@ class SiteResource extends JsonResource
'name' => $this->name,
'url' => $this->url,
'description' => $this->description,
+ 'cms_type' => $this->cms_type,
+ 'wp_username' => $this->wp_username,
+ 'wp_app_password' => $this->wp_app_password,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
diff --git a/app/Livewire/Sites/Index.php b/app/Livewire/Sites/Index.php
index 9b9906f..b29b8e8 100644
--- a/app/Livewire/Sites/Index.php
+++ b/app/Livewire/Sites/Index.php
@@ -13,16 +13,25 @@ class Index extends Component
public ?int $editingId = null;
+ public ?int $revealedId = null;
+
public string $name = '';
public string $url = '';
public ?string $description = null;
+ public string $cmsType = 'custom';
+
+ public string $wpUsername = '';
+
+ public string $wpAppPassword = '';
+
public function render()
{
return view('livewire.sites.index', [
'sites' => Site::orderBy('name')->get(),
+ 'cmsTypes' => Site::CMS_TYPES,
]);
}
@@ -39,6 +48,9 @@ class Index extends Component
$this->name = $site->name;
$this->url = $site->url;
$this->description = $site->description;
+ $this->cmsType = $site->cms_type;
+ $this->wpUsername = $site->wp_username ?? '';
+ $this->wpAppPassword = $site->wp_app_password ?? '';
$this->showForm = true;
}
@@ -48,9 +60,19 @@ class Index extends Component
'name' => ['required', 'string', 'max:255'],
'url' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
+ 'cmsType' => ['required', 'string', 'in:' . implode(',', Site::CMS_TYPES)],
+ 'wpUsername' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'],
+ 'wpAppPassword' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'],
]);
- Site::updateOrCreate(['id' => $this->editingId], $validated);
+ Site::updateOrCreate(['id' => $this->editingId], [
+ 'name' => $validated['name'],
+ 'url' => $validated['url'],
+ 'description' => $validated['description'],
+ 'cms_type' => $validated['cmsType'],
+ 'wp_username' => $validated['cmsType'] === 'wordpress' ? $validated['wpUsername'] : null,
+ 'wp_app_password' => $validated['cmsType'] === 'wordpress' ? $validated['wpAppPassword'] : null,
+ ]);
$this->resetForm();
$this->showForm = false;
@@ -61,12 +83,20 @@ class Index extends Component
Site::findOrFail($id)->delete();
}
+ public function toggleReveal(int $id): void
+ {
+ $this->revealedId = $this->revealedId === $id ? null : $id;
+ }
+
public function resetForm(): void
{
$this->editingId = null;
$this->name = '';
$this->url = '';
$this->description = null;
+ $this->cmsType = 'custom';
+ $this->wpUsername = '';
+ $this->wpAppPassword = '';
$this->resetValidation();
}
}
diff --git a/app/Models/Post.php b/app/Models/Post.php
index a1e9043..c1802a1 100644
--- a/app/Models/Post.php
+++ b/app/Models/Post.php
@@ -25,6 +25,7 @@ class Post extends Model
'body',
'status',
'scheduled_at',
+ 'published_url',
];
protected $casts = [
diff --git a/app/Models/Site.php b/app/Models/Site.php
index cd39e75..c26a0c6 100644
--- a/app/Models/Site.php
+++ b/app/Models/Site.php
@@ -9,9 +9,14 @@ class Site extends Model
{
use HasFactory;
+ public const CMS_TYPES = ['wordpress', 'custom', 'other'];
+
protected $fillable = [
'name',
'url',
'description',
+ 'cms_type',
+ 'wp_username',
+ 'wp_app_password',
];
}
diff --git a/database/factories/SiteFactory.php b/database/factories/SiteFactory.php
index c23064b..d62e2f8 100644
--- a/database/factories/SiteFactory.php
+++ b/database/factories/SiteFactory.php
@@ -15,6 +15,18 @@ class SiteFactory extends Factory
'name' => fake()->company(),
'url' => fake()->url(),
'description' => fake()->optional()->sentence(),
+ 'cms_type' => 'custom',
+ 'wp_username' => null,
+ 'wp_app_password' => null,
];
}
+
+ public function wordpress(): static
+ {
+ return $this->state(fn () => [
+ 'cms_type' => 'wordpress',
+ 'wp_username' => fake()->userName(),
+ 'wp_app_password' => fake()->password(),
+ ]);
+ }
}
diff --git a/database/migrations/2026_07_19_134201_add_cms_fields_to_sites_table.php b/database/migrations/2026_07_19_134201_add_cms_fields_to_sites_table.php
new file mode 100644
index 0000000..0bb3811
--- /dev/null
+++ b/database/migrations/2026_07_19_134201_add_cms_fields_to_sites_table.php
@@ -0,0 +1,24 @@
+string('cms_type')->default('custom')->after('description');
+ $table->string('wp_username')->nullable()->after('cms_type');
+ $table->string('wp_app_password')->nullable()->after('wp_username');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('sites', function (Blueprint $table) {
+ $table->dropColumn(['cms_type', 'wp_username', 'wp_app_password']);
+ });
+ }
+};
diff --git a/database/migrations/2026_07_19_134706_add_published_url_to_posts_table.php b/database/migrations/2026_07_19_134706_add_published_url_to_posts_table.php
new file mode 100644
index 0000000..e505253
--- /dev/null
+++ b/database/migrations/2026_07_19_134706_add_published_url_to_posts_table.php
@@ -0,0 +1,22 @@
+string('published_url')->nullable()->after('scheduled_at');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('posts', function (Blueprint $table) {
+ $table->dropColumn('published_url');
+ });
+ }
+};
diff --git a/resources/views/livewire/sites/index.blade.php b/resources/views/livewire/sites/index.blade.php
index b0223dd..c21f917 100644
--- a/resources/views/livewire/sites/index.blade.php
+++ b/resources/views/livewire/sites/index.blade.php
@@ -10,6 +10,7 @@
Name |
URL |
Description |
+ CMS Type |
|
@@ -19,6 +20,22 @@
{{ $site->name }} |
{{ $site->url }} |
{{ $site->description }} |
+
+ {{ ucfirst($site->cms_type) }}
+ @if ($site->cms_type === 'wordpress')
+
+ {{ $site->wp_username }} /
+ @if ($revealedId === $site->id)
+ {{ $site->wp_app_password }}
+ @else
+ ••••••••
+ @endif
+
+
+ @endif
+ |
@@ -50,6 +67,30 @@
+
+
+
+ @error('cmsType') {{ $message }} @enderror
+
+
+ @if ($cmsType === 'wordpress')
+
+
+
+ @error('wpUsername') {{ $message }} @enderror
+
+
+
+
+
+ @error('wpAppPassword') {{ $message }} @enderror
+
+ @endif
+
diff --git a/tests/Feature/Api/PostsApiTest.php b/tests/Feature/Api/PostsApiTest.php
index 0328c9a..25101ce 100644
--- a/tests/Feature/Api/PostsApiTest.php
+++ b/tests/Feature/Api/PostsApiTest.php
@@ -134,3 +134,11 @@ test('it deletes a post', function () {
$response->assertNoContent();
expect(Post::find($post->id))->toBeNull();
});
+
+test('it includes published_url in the post response', function () {
+ $post = Post::factory()->create(['published_url' => 'https://example.com/my-post']);
+
+ $response = $this->getJson("/api/posts/{$post->id}");
+
+ $response->assertOk()->assertJsonPath('data.published_url', 'https://example.com/my-post');
+});
diff --git a/tests/Feature/Api/SitesApiTest.php b/tests/Feature/Api/SitesApiTest.php
index 720f9c0..e434686 100644
--- a/tests/Feature/Api/SitesApiTest.php
+++ b/tests/Feature/Api/SitesApiTest.php
@@ -32,3 +32,17 @@ test('it shows a single site', function () {
test('it returns 404 for a missing site', function () {
$this->getJson('/api/sites/999')->assertNotFound();
});
+
+test('it includes WordPress fields in the site response', function () {
+ $site = Site::factory()->wordpress()->create([
+ 'wp_username' => 'admin',
+ 'wp_app_password' => 'abcd 1234 efgh 5678',
+ ]);
+
+ $response = $this->getJson("/api/sites/{$site->id}");
+
+ $response->assertOk()
+ ->assertJsonPath('data.cms_type', 'wordpress')
+ ->assertJsonPath('data.wp_username', 'admin')
+ ->assertJsonPath('data.wp_app_password', 'abcd 1234 efgh 5678');
+});
diff --git a/tests/Feature/Livewire/SitesTest.php b/tests/Feature/Livewire/SitesTest.php
index 8055b5b..373a18d 100644
--- a/tests/Feature/Livewire/SitesTest.php
+++ b/tests/Feature/Livewire/SitesTest.php
@@ -48,3 +48,74 @@ test('a site can be deleted', function () {
expect(Site::find($site->id))->toBeNull();
});
+
+test('a new WordPress site can be created with credentials', function () {
+ Livewire::test(Index::class)
+ ->set('name', 'WP Site')
+ ->set('url', 'https://wp.example.com')
+ ->set('cmsType', 'wordpress')
+ ->set('wpUsername', 'admin')
+ ->set('wpAppPassword', 'abcd 1234 efgh 5678')
+ ->call('save');
+
+ $site = Site::where('name', 'WP Site')->first();
+
+ expect($site)
+ ->cms_type->toBe('wordpress')
+ ->wp_username->toBe('admin')
+ ->wp_app_password->toBe('abcd 1234 efgh 5678');
+});
+
+test('WordPress username and app password are required together when cms_type is wordpress', function () {
+ Livewire::test(Index::class)
+ ->set('name', 'WP Site')
+ ->set('url', 'https://wp.example.com')
+ ->set('cmsType', 'wordpress')
+ ->set('wpUsername', '')
+ ->set('wpAppPassword', '')
+ ->call('save')
+ ->assertHasErrors(['wpUsername', 'wpAppPassword']);
+});
+
+test('a site defaults to custom cms_type with no WordPress fields required', function () {
+ Livewire::test(Index::class)
+ ->set('name', 'Plain Site')
+ ->set('url', 'https://plain.example.com')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ expect(Site::where('name', 'Plain Site')->first()->cms_type)->toBe('custom');
+});
+
+test('editing a site loads its WordPress fields', function () {
+ $site = Site::factory()->wordpress()->create(['wp_username' => 'editor']);
+
+ Livewire::test(Index::class)
+ ->call('edit', $site->id)
+ ->assertSet('cmsType', 'wordpress')
+ ->assertSet('wpUsername', 'editor');
+});
+
+test('WordPress app password can be revealed and hidden in the table', function () {
+ $site = Site::factory()->wordpress()->create();
+
+ Livewire::test(Index::class)
+ ->call('toggleReveal', $site->id)
+ ->assertSet('revealedId', $site->id)
+ ->call('toggleReveal', $site->id)
+ ->assertSet('revealedId', null);
+});
+
+test('switching a WordPress site to a non-WordPress cms_type clears its credentials', function () {
+ $site = Site::factory()->wordpress()->create(['wp_username' => 'admin']);
+
+ Livewire::test(Index::class)
+ ->call('edit', $site->id)
+ ->set('cmsType', 'custom')
+ ->call('save');
+
+ expect($site->fresh())
+ ->cms_type->toBe('custom')
+ ->wp_username->toBeNull()
+ ->wp_app_password->toBeNull();
+});
diff --git a/tests/Feature/PostModelTest.php b/tests/Feature/PostModelTest.php
index f473e3a..0c9ae21 100644
--- a/tests/Feature/PostModelTest.php
+++ b/tests/Feature/PostModelTest.php
@@ -83,3 +83,17 @@ test('deleting a social account nulls out the post link but keeps the post', fun
->not->toBeNull()
->social_account_id->toBeNull();
});
+
+test('a post can store a published_url', function () {
+ $post = Post::factory()->create(['published_url' => null]);
+
+ $post->update(['published_url' => 'https://example.com/2026/07/my-post']);
+
+ expect($post->fresh()->published_url)->toBe('https://example.com/2026/07/my-post');
+});
+
+test('published_url defaults to null', function () {
+ $post = Post::factory()->create();
+
+ expect($post->fresh()->published_url)->toBeNull();
+});
diff --git a/tests/Feature/PublishPostCommandTest.php b/tests/Feature/PublishPostCommandTest.php
new file mode 100644
index 0000000..f1ed47f
--- /dev/null
+++ b/tests/Feature/PublishPostCommandTest.php
@@ -0,0 +1,139 @@
+artisan('posts:publish', ['post' => 999])
+ ->assertExitCode(1);
+});
+
+test('it fails when the post type is not blog', function () {
+ $site = Site::factory()->wordpress()->create();
+ $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'twitter', 'title' => 'Hi']);
+
+ $this->artisan('posts:publish', ['post' => $post->id])
+ ->expectsOutputToContain("expected 'blog'")
+ ->assertExitCode(1);
+
+ expect($post->fresh()->status)->toBe($post->status);
+});
+
+test('it fails when the post has no title', function () {
+ $site = Site::factory()->wordpress()->create();
+ $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => null]);
+
+ $this->artisan('posts:publish', ['post' => $post->id])
+ ->expectsOutputToContain('no title')
+ ->assertExitCode(1);
+});
+
+test('it fails when the site is not configured as WordPress', function () {
+ $site = Site::factory()->create(['cms_type' => 'custom']);
+ $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);
+
+ $this->artisan('posts:publish', ['post' => $post->id])
+ ->expectsOutputToContain('not configured as WordPress')
+ ->assertExitCode(1);
+});
+
+test('it fails when the WordPress site is missing credentials', function () {
+ $site = Site::factory()->create(['cms_type' => 'wordpress', 'wp_username' => null, 'wp_app_password' => null]);
+ $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);
+
+ $this->artisan('posts:publish', ['post' => $post->id])
+ ->expectsOutputToContain('missing WordPress credentials')
+ ->assertExitCode(1);
+});
+
+test('it publishes successfully and updates the post', function () {
+ Http::fake([
+ '*/wp-json/wp/v2/posts' => Http::response([
+ 'id' => 42,
+ 'link' => 'https://wp.example.com/2026/07/hello-world/',
+ ], 201),
+ ]);
+
+ $site = Site::factory()->wordpress()->create(['url' => 'https://wp.example.com']);
+ $post = Post::factory()->create([
+ 'site_id' => $site->id,
+ 'type' => 'blog',
+ 'title' => 'Hello World',
+ 'body' => 'Post content',
+ 'status' => 'scheduled',
+ ]);
+
+ $this->artisan('posts:publish', ['post' => $post->id])
+ ->expectsOutputToContain('"success":true')
+ ->assertExitCode(0);
+
+ expect($post->fresh())
+ ->status->toBe('published')
+ ->published_url->toBe('https://wp.example.com/2026/07/hello-world/');
+
+ Http::assertSent(function ($request) use ($site) {
+ return $request->url() === "{$site->url}/wp-json/wp/v2/posts"
+ && $request['title'] === 'Hello World'
+ && $request['content'] === 'Post content'
+ && $request['status'] === 'publish';
+ });
+});
+
+test('it marks the post as failed when WordPress returns an error', function () {
+ Http::fake([
+ '*/wp-json/wp/v2/posts' => Http::response([
+ 'code' => 'rest_cannot_create',
+ 'message' => 'Sorry, you are not allowed to create posts.',
+ ], 401),
+ ]);
+
+ $site = Site::factory()->wordpress()->create();
+ $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);
+
+ $this->artisan('posts:publish', ['post' => $post->id])
+ ->expectsOutputToContain('not allowed to create posts')
+ ->assertExitCode(1);
+
+ expect($post->fresh()->status)->toBe('failed');
+});
+
+test('it marks the post as failed when the site is unreachable', function () {
+ Http::fake(function () {
+ throw new \Illuminate\Http\Client\ConnectionException('Could not connect');
+ });
+
+ $site = Site::factory()->wordpress()->create();
+ $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);
+
+ $this->artisan('posts:publish', ['post' => $post->id])
+ ->expectsOutputToContain('could not connect')
+ ->assertExitCode(1);
+
+ expect($post->fresh()->status)->toBe('failed');
+});
+
+test('it correctly builds the WordPress endpoint URL when the site URL has a trailing slash', function () {
+ Http::fake([
+ '*/wp-json/wp/v2/posts' => Http::response([
+ 'id' => 42,
+ 'link' => 'https://wp.example.com/2026/07/hello-world/',
+ ], 201),
+ ]);
+
+ $site = Site::factory()->wordpress()->create(['url' => 'https://wp.example.com/']);
+ $post = Post::factory()->create([
+ 'site_id' => $site->id,
+ 'type' => 'blog',
+ 'title' => 'Hello World',
+ 'body' => 'Post content',
+ 'status' => 'draft',
+ ]);
+
+ $this->artisan('posts:publish', ['post' => $post->id])
+ ->assertExitCode(0);
+
+ Http::assertSent(function ($request) {
+ return $request->url() === 'https://wp.example.com/wp-json/wp/v2/posts';
+ });
+});
diff --git a/tests/Feature/SiteModelTest.php b/tests/Feature/SiteModelTest.php
index 889b0a9..6c9b7ff 100644
--- a/tests/Feature/SiteModelTest.php
+++ b/tests/Feature/SiteModelTest.php
@@ -23,3 +23,30 @@ test('a site can be created without a description', function () {
expect($site->fresh()->description)->toBeNull();
});
+
+test('a site defaults to cms_type custom with null WordPress credentials', function () {
+ $site = Site::create([
+ 'name' => 'My Blog',
+ 'url' => 'https://example.com',
+ ]);
+
+ expect($site->fresh())
+ ->cms_type->toBe('custom')
+ ->wp_username->toBeNull()
+ ->wp_app_password->toBeNull();
+});
+
+test('a site can be created as WordPress with credentials', function () {
+ $site = Site::create([
+ 'name' => 'My WP Blog',
+ 'url' => 'https://wp.example.com',
+ 'cms_type' => 'wordpress',
+ 'wp_username' => 'admin',
+ 'wp_app_password' => 'abcd 1234 efgh 5678',
+ ]);
+
+ expect($site->fresh())
+ ->cms_type->toBe('wordpress')
+ ->wp_username->toBe('admin')
+ ->wp_app_password->toBe('abcd 1234 efgh 5678');
+});
|