Add an image field, required for WordPress posts
Posts get a nullable image_url. WordPressPublisher now downloads it and uploads it to WordPress's media endpoint before creating the post, setting it as featured_media — and rejects publishing (without touching status) if a blog post has no image set, mirroring the existing title requirement. Every other network treats it as optional. Not enforced at save time, same as title — you can still draft an incomplete WordPress post, it just can't be published until an image is set. Exposed through the UI form (with a thumbnail/""missing"" indicator in the table), the JSON API, and the Python CLI wrapper. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,8 @@ SocialAccount (login for a social network)
|
|||||||
Post (one piece of content, belongs to a Site, optionally a SocialAccount)
|
Post (one piece of content, belongs to a Site, optionally a SocialAccount)
|
||||||
└─ type: same vocabulary as SocialAccount.network, plus "blog"
|
└─ type: same vocabulary as SocialAccount.network, plus "blog"
|
||||||
└─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit)
|
└─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit)
|
||||||
|
└─ image_url: nullable URL. Required to publish type=blog (WordPress) —
|
||||||
|
used as the featured image. Optional for every other type.
|
||||||
└─ status: draft -> scheduled -> publishing -> published | failed
|
└─ status: draft -> scheduled -> publishing -> published | failed
|
||||||
└─ published_url: set once it's actually live somewhere
|
└─ published_url: set once it's actually live somewhere
|
||||||
└─ publish_error: set to the failure reason when status=failed (or when a
|
└─ publish_error: set to the failure reason when status=failed (or when a
|
||||||
@@ -145,6 +147,7 @@ Body:
|
|||||||
"title": "Optional title",
|
"title": "Optional title",
|
||||||
"body": "Required content",
|
"body": "Required content",
|
||||||
"target": null,
|
"target": null,
|
||||||
|
"image_url": null,
|
||||||
"status": "draft",
|
"status": "draft",
|
||||||
"scheduled_at": null
|
"scheduled_at": null
|
||||||
}
|
}
|
||||||
@@ -153,8 +156,10 @@ Validation: `site_id` required, must exist. `social_account_id` nullable,
|
|||||||
must exist if given. `type` required, one of `twitter`, `instagram`,
|
must exist if given. `type` required, one of `twitter`, `instagram`,
|
||||||
`facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`.
|
`facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`.
|
||||||
`body` required. `title` nullable. `target` nullable — a network-specific
|
`body` required. `title` nullable. `target` nullable — a network-specific
|
||||||
destination, e.g. the subreddit name for `type=reddit`. `status` defaults to
|
destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable
|
||||||
`draft` if omitted. `scheduled_at` nullable datetime.
|
URL — not enforced at save time (you can draft a `type=blog` post without one),
|
||||||
|
but `WordPressPublisher` will reject publishing it until it's set. `status`
|
||||||
|
defaults to `draft` if omitted. `scheduled_at` nullable datetime.
|
||||||
|
|
||||||
Note: `type` and the linked Social Account's `network` are **not**
|
Note: `type` and the linked Social Account's `network` are **not**
|
||||||
cross-validated — you can link a `type=blog` post to a Twitter account, or
|
cross-validated — you can link a `type=blog` post to a Twitter account, or
|
||||||
@@ -192,7 +197,7 @@ who/what is driving:
|
|||||||
- **Manually**, for anything without a wired-in publisher yet: do it
|
- **Manually**, for anything without a wired-in publisher yet: do it
|
||||||
yourself and `PATCH` the result back (see below).
|
yourself and `PATCH` the result back (see below).
|
||||||
|
|
||||||
Note the asymmetry: WordPress is synchronous (a single HTTP call, fast
|
Note the asymmetry: WordPress is synchronous (a few plain HTTP calls, fast
|
||||||
enough to run inline), Reddit is not (it's real browser automation via
|
enough to run inline), Reddit is not (it's real browser automation via
|
||||||
Playwright, tens of seconds) — which is exactly why the UI path is
|
Playwright, tens of seconds) — which is exactly why the UI path is
|
||||||
queue-backed rather than a direct synchronous call.
|
queue-backed rather than a direct synchronous call.
|
||||||
@@ -212,17 +217,20 @@ php artisan posts:publish {post_id}
|
|||||||
What it does:
|
What it does:
|
||||||
1. Loads the Post and its Site.
|
1. Loads the Post and its Site.
|
||||||
2. Validates: `post.type` must be `blog`, `post.title` must be set,
|
2. Validates: `post.type` must be `blog`, `post.title` must be set,
|
||||||
`site.cms_type` must be `wordpress`, and the site must have
|
`post.image_url` must be set, `site.cms_type` must be `wordpress`, and
|
||||||
`wp_username`/`wp_app_password` set. Any failure here exits 1 with a
|
the site must have `wp_username`/`wp_app_password` set. Any failure here
|
||||||
descriptive message and **does not touch** `Post.status`.
|
exits 1 with a descriptive message and **does not touch** `Post.status`.
|
||||||
3. POSTs to `{site.url}/wp-json/wp/v2/posts` using HTTP Basic Auth with
|
3. Downloads `post.image_url`, then POSTs it to
|
||||||
the site's WordPress Application Password, `status: publish` (goes live
|
`{site.url}/wp-json/wp/v2/media` (HTTP Basic Auth with the site's
|
||||||
immediately, not a WP draft).
|
WordPress Application Password) to upload it as media and get back a
|
||||||
4. On success: sets `Post.status = published`, `Post.published_url` to the
|
media id.
|
||||||
|
4. POSTs to `{site.url}/wp-json/wp/v2/posts` with `status: publish` (goes
|
||||||
|
live immediately, not a WP draft) and `featured_media` set to that id.
|
||||||
|
5. On success: sets `Post.status = published`, `Post.published_url` to the
|
||||||
new post's permalink, prints `{"success": true, "url": "..."}`, exits 0.
|
new post's permalink, prints `{"success": true, "url": "..."}`, exits 0.
|
||||||
5. On failure (bad credentials, unreachable site, WordPress error): sets
|
6. On failure at either HTTP step (bad credentials, unreachable site,
|
||||||
`Post.status = failed`, prints `{"success": false, "error": "..."}`,
|
unreachable/rejected image, WordPress error): sets `Post.status = failed`,
|
||||||
exits 1.
|
prints `{"success": false, "error": "..."}`, exits 1.
|
||||||
|
|
||||||
This is a **shell command**, not an HTTP endpoint — run it from wherever
|
This is a **shell command**, not an HTTP endpoint — run it from wherever
|
||||||
you have shell access to this app (e.g. `php artisan posts:publish 42`).
|
you have shell access to this app (e.g. `php artisan posts:publish 42`).
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ Built with Laravel 13, Livewire, and Tailwind CSS.
|
|||||||
(e.g. Reddit's browser automation) don't block the page.
|
(e.g. Reddit's browser automation) don't block the page.
|
||||||
- Built-in publishers (`app/Support/Publishing/`), one per network, resolved
|
- Built-in publishers (`app/Support/Publishing/`), one per network, resolved
|
||||||
by `Post.type`:
|
by `Post.type`:
|
||||||
- **WordPress**: posts directly to a site's WordPress REST API. Also
|
- **WordPress**: posts directly to a site's WordPress REST API, uploading
|
||||||
available synchronously from the CLI: `php artisan posts:publish {post_id}`.
|
`Post.image_url` as the featured image first — required to publish a
|
||||||
|
WordPress post, optional for every other post type. Also available
|
||||||
|
synchronously from the CLI: `php artisan posts:publish {post_id}`.
|
||||||
- **Reddit**: drives a standalone Playwright sidecar at
|
- **Reddit**: drives a standalone Playwright sidecar at
|
||||||
`sidecars/reddit-post/` that logs in and creates a post.
|
`sidecars/reddit-post/` that logs in and creates a post.
|
||||||
- Anything else: no publisher yet — publish however fits, then report
|
- Anything else: no publisher yet — publish however fits, then report
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class StorePostRequest extends FormRequest
|
|||||||
'title' => ['nullable', 'string', 'max:255'],
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
'body' => ['required', 'string'],
|
'body' => ['required', 'string'],
|
||||||
'target' => ['nullable', 'string', 'max:255'],
|
'target' => ['nullable', 'string', 'max:255'],
|
||||||
|
'image_url' => ['nullable', 'url', 'max:2048'],
|
||||||
'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
|
'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
|
||||||
'scheduled_at' => ['nullable', 'date'],
|
'scheduled_at' => ['nullable', 'date'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class UpdatePostRequest extends FormRequest
|
|||||||
'title' => ['nullable', 'string', 'max:255'],
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
'body' => ['sometimes', 'required', 'string'],
|
'body' => ['sometimes', 'required', 'string'],
|
||||||
'target' => ['nullable', 'string', 'max:255'],
|
'target' => ['nullable', 'string', 'max:255'],
|
||||||
|
'image_url' => ['nullable', 'url', 'max:2048'],
|
||||||
'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
|
'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
|
||||||
'scheduled_at' => ['nullable', 'date'],
|
'scheduled_at' => ['nullable', 'date'],
|
||||||
'published_url' => ['nullable', 'string', 'max:255'],
|
'published_url' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class PostResource extends JsonResource
|
|||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'body' => $this->body,
|
'body' => $this->body,
|
||||||
'target' => $this->target,
|
'target' => $this->target,
|
||||||
|
'image_url' => $this->image_url,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'scheduled_at' => $this->scheduled_at,
|
'scheduled_at' => $this->scheduled_at,
|
||||||
'published_url' => $this->published_url,
|
'published_url' => $this->published_url,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class Index extends Component
|
|||||||
|
|
||||||
public string $target = '';
|
public string $target = '';
|
||||||
|
|
||||||
|
public string $imageUrl = '';
|
||||||
|
|
||||||
public string $status = 'draft';
|
public string $status = 'draft';
|
||||||
|
|
||||||
public string $scheduledAt = '';
|
public string $scheduledAt = '';
|
||||||
@@ -82,6 +84,7 @@ class Index extends Component
|
|||||||
$this->title = (string) $post->title;
|
$this->title = (string) $post->title;
|
||||||
$this->body = $post->body;
|
$this->body = $post->body;
|
||||||
$this->target = (string) $post->target;
|
$this->target = (string) $post->target;
|
||||||
|
$this->imageUrl = (string) $post->image_url;
|
||||||
$this->status = $post->status;
|
$this->status = $post->status;
|
||||||
$this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? '';
|
$this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? '';
|
||||||
$this->showForm = true;
|
$this->showForm = true;
|
||||||
@@ -96,6 +99,7 @@ class Index extends Component
|
|||||||
'title' => ['nullable', 'string', 'max:255'],
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
'body' => ['required', 'string'],
|
'body' => ['required', 'string'],
|
||||||
'target' => ['nullable', 'string', 'max:255'],
|
'target' => ['nullable', 'string', 'max:255'],
|
||||||
|
'imageUrl' => ['nullable', 'url', 'max:2048'],
|
||||||
'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)],
|
'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)],
|
||||||
'scheduledAt' => ['nullable', 'date'],
|
'scheduledAt' => ['nullable', 'date'],
|
||||||
]);
|
]);
|
||||||
@@ -107,6 +111,7 @@ class Index extends Component
|
|||||||
'title' => $validated['title'] ?: null,
|
'title' => $validated['title'] ?: null,
|
||||||
'body' => $validated['body'],
|
'body' => $validated['body'],
|
||||||
'target' => $validated['target'] ?: null,
|
'target' => $validated['target'] ?: null,
|
||||||
|
'image_url' => $validated['imageUrl'] ?: null,
|
||||||
'status' => $validated['status'],
|
'status' => $validated['status'],
|
||||||
'scheduled_at' => $validated['scheduledAt'] ?: null,
|
'scheduled_at' => $validated['scheduledAt'] ?: null,
|
||||||
]);
|
]);
|
||||||
@@ -129,6 +134,7 @@ class Index extends Component
|
|||||||
$this->title = '';
|
$this->title = '';
|
||||||
$this->body = '';
|
$this->body = '';
|
||||||
$this->target = '';
|
$this->target = '';
|
||||||
|
$this->imageUrl = '';
|
||||||
$this->status = 'draft';
|
$this->status = 'draft';
|
||||||
$this->scheduledAt = '';
|
$this->scheduledAt = '';
|
||||||
$this->resetValidation();
|
$this->resetValidation();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class Post extends Model
|
|||||||
'title',
|
'title',
|
||||||
'body',
|
'body',
|
||||||
'target',
|
'target',
|
||||||
|
'image_url',
|
||||||
'status',
|
'status',
|
||||||
'scheduled_at',
|
'scheduled_at',
|
||||||
'published_url',
|
'published_url',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Support\Publishing;
|
namespace App\Support\Publishing;
|
||||||
|
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
|
use App\Models\Site;
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ class WordPressPublisher implements Publisher
|
|||||||
return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress.");
|
return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $post->image_url) {
|
||||||
|
return PublishResult::rejected("Post {$post->id} has no image, required to publish to WordPress.");
|
||||||
|
}
|
||||||
|
|
||||||
$site = $post->site;
|
$site = $post->site;
|
||||||
|
|
||||||
if ($site->cms_type !== 'wordpress') {
|
if ($site->cms_type !== 'wordpress') {
|
||||||
@@ -29,12 +34,19 @@ class WordPressPublisher implements Publisher
|
|||||||
return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials.");
|
return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$mediaId = $this->uploadFeaturedImage($post, $site);
|
||||||
|
|
||||||
|
if ($mediaId instanceof PublishResult) {
|
||||||
|
return $mediaId;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
|
$response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
|
||||||
->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [
|
->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [
|
||||||
'title' => $post->title,
|
'title' => $post->title,
|
||||||
'content' => $post->body,
|
'content' => $post->body,
|
||||||
'status' => 'publish',
|
'status' => 'publish',
|
||||||
|
'featured_media' => $mediaId,
|
||||||
]);
|
]);
|
||||||
} catch (ConnectionException $e) {
|
} catch (ConnectionException $e) {
|
||||||
return PublishResult::failed("could not connect to site: {$e->getMessage()}");
|
return PublishResult::failed("could not connect to site: {$e->getMessage()}");
|
||||||
@@ -52,4 +64,41 @@ class WordPressPublisher implements Publisher
|
|||||||
|
|
||||||
return PublishResult::success($link);
|
return PublishResult::success($link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function uploadFeaturedImage(Post $post, Site $site): int|PublishResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$image = Http::get($post->image_url);
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
return PublishResult::failed("could not download image: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($image->failed()) {
|
||||||
|
return PublishResult::failed('could not download image: unexpected response from image URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = basename(parse_url($post->image_url, PHP_URL_PATH) ?: '') ?: 'image.jpg';
|
||||||
|
$contentType = $image->header('Content-Type') ?: 'application/octet-stream';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
|
||||||
|
->withBody($image->body(), $contentType)
|
||||||
|
->withHeaders(['Content-Disposition' => "attachment; filename=\"{$filename}\""])
|
||||||
|
->post(rtrim($site->url, '/').'/wp-json/wp/v2/media');
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
return PublishResult::failed("could not connect to site: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return PublishResult::failed($response->json('message') ?? 'unexpected response from WordPress while uploading the image');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaId = $response->json('id');
|
||||||
|
|
||||||
|
if (! $mediaId) {
|
||||||
|
return PublishResult::failed('unexpected response from WordPress: missing media id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mediaId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->string('image_url')->nullable()->after('target');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('image_url');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,10 +6,10 @@ Usage:
|
|||||||
python3 posts.py get POST_ID
|
python3 posts.py get POST_ID
|
||||||
python3 posts.py create --site-id ID --type TYPE --body BODY
|
python3 posts.py create --site-id ID --type TYPE --body BODY
|
||||||
[--social-account-id ID] [--title TITLE] [--target TARGET]
|
[--social-account-id ID] [--title TITLE] [--target TARGET]
|
||||||
[--status STATUS] [--scheduled-at DATETIME]
|
[--image-url URL] [--status STATUS] [--scheduled-at DATETIME]
|
||||||
python3 posts.py update POST_ID [--site-id ID] [--social-account-id ID]
|
python3 posts.py update POST_ID [--site-id ID] [--social-account-id ID]
|
||||||
[--type TYPE] [--title TITLE] [--body BODY] [--target TARGET]
|
[--type TYPE] [--title TITLE] [--body BODY] [--target TARGET]
|
||||||
[--status STATUS] [--scheduled-at DATETIME]
|
[--image-url URL] [--status STATUS] [--scheduled-at DATETIME]
|
||||||
[--published-url URL]
|
[--published-url URL]
|
||||||
python3 posts.py delete POST_ID
|
python3 posts.py delete POST_ID
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ def main() -> None:
|
|||||||
create_parser.add_argument("--social-account-id", type=int)
|
create_parser.add_argument("--social-account-id", type=int)
|
||||||
create_parser.add_argument("--title")
|
create_parser.add_argument("--title")
|
||||||
create_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts")
|
create_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts")
|
||||||
|
create_parser.add_argument("--image-url", help="Image URL — required for WordPress (type=blog), optional otherwise")
|
||||||
create_parser.add_argument("--status", choices=STATUSES)
|
create_parser.add_argument("--status", choices=STATUSES)
|
||||||
create_parser.add_argument("--scheduled-at", help="ISO 8601 datetime, e.g. 2026-07-20T09:00:00")
|
create_parser.add_argument("--scheduled-at", help="ISO 8601 datetime, e.g. 2026-07-20T09:00:00")
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ def main() -> None:
|
|||||||
update_parser.add_argument("--title")
|
update_parser.add_argument("--title")
|
||||||
update_parser.add_argument("--body")
|
update_parser.add_argument("--body")
|
||||||
update_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts")
|
update_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts")
|
||||||
|
update_parser.add_argument("--image-url", help="Image URL — required for WordPress (type=blog), optional otherwise")
|
||||||
update_parser.add_argument("--status", choices=STATUSES)
|
update_parser.add_argument("--status", choices=STATUSES)
|
||||||
update_parser.add_argument("--scheduled-at", help="ISO 8601 datetime")
|
update_parser.add_argument("--scheduled-at", help="ISO 8601 datetime")
|
||||||
update_parser.add_argument("--published-url")
|
update_parser.add_argument("--published-url")
|
||||||
@@ -88,6 +90,7 @@ def main() -> None:
|
|||||||
"title": args.title,
|
"title": args.title,
|
||||||
"body": args.body,
|
"body": args.body,
|
||||||
"target": args.target,
|
"target": args.target,
|
||||||
|
"image_url": args.image_url,
|
||||||
"status": args.status,
|
"status": args.status,
|
||||||
"scheduled_at": args.scheduled_at,
|
"scheduled_at": args.scheduled_at,
|
||||||
}
|
}
|
||||||
@@ -102,6 +105,7 @@ def main() -> None:
|
|||||||
"title": args.title,
|
"title": args.title,
|
||||||
"body": args.body,
|
"body": args.body,
|
||||||
"target": args.target,
|
"target": args.target,
|
||||||
|
"image_url": args.image_url,
|
||||||
"status": args.status,
|
"status": args.status,
|
||||||
"scheduled_at": args.scheduled_at,
|
"scheduled_at": args.scheduled_at,
|
||||||
"published_url": args.published_url,
|
"published_url": args.published_url,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<table class="w-full bg-white border border-gray-200 rounded">
|
<table class="w-full bg-white border border-gray-200 rounded">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left border-b border-gray-200">
|
<tr class="text-left border-b border-gray-200">
|
||||||
|
<th class="p-3">Image</th>
|
||||||
<th class="p-3">Site</th>
|
<th class="p-3">Site</th>
|
||||||
<th class="p-3">Type</th>
|
<th class="p-3">Type</th>
|
||||||
<th class="p-3">Title</th>
|
<th class="p-3">Title</th>
|
||||||
@@ -23,6 +24,15 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach ($posts as $post)
|
@foreach ($posts as $post)
|
||||||
<tr wire:key="post-{{ $post->id }}" class="border-b border-gray-100">
|
<tr wire:key="post-{{ $post->id }}" class="border-b border-gray-100">
|
||||||
|
<td class="p-3">
|
||||||
|
@if ($post->image_url)
|
||||||
|
<img src="{{ $post->image_url }}" alt="" class="w-10 h-10 object-cover rounded">
|
||||||
|
@elseif ($post->type === 'blog')
|
||||||
|
<span class="text-xs text-amber-600">missing</span>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
<td class="p-3">{{ $post->site->name }}</td>
|
<td class="p-3">{{ $post->site->name }}</td>
|
||||||
<td class="p-3">{{ ucfirst($post->type) }}</td>
|
<td class="p-3">{{ ucfirst($post->type) }}</td>
|
||||||
<td class="p-3">{{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }}</td>
|
<td class="p-3">{{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }}</td>
|
||||||
@@ -106,6 +116,14 @@
|
|||||||
@error('target') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
@error('target') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">
|
||||||
|
Image URL {{ $type === 'blog' ? '(required to publish to WordPress)' : '(optional)' }}
|
||||||
|
</label>
|
||||||
|
<input type="url" wire:model="imageUrl" placeholder="https://..." class="w-full border rounded p-2">
|
||||||
|
@error('imageUrl') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium">Status</label>
|
<label class="block text-sm font-medium">Status</label>
|
||||||
<select wire:model="status" class="w-full border rounded p-2">
|
<select wire:model="status" class="w-full border rounded p-2">
|
||||||
|
|||||||
@@ -142,3 +142,22 @@ test('it includes published_url in the post response', function () {
|
|||||||
|
|
||||||
$response->assertOk()->assertJsonPath('data.published_url', 'https://example.com/my-post');
|
$response->assertOk()->assertJsonPath('data.published_url', 'https://example.com/my-post');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('image_url can be set on create and update', function () {
|
||||||
|
$site = Site::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/posts', [
|
||||||
|
'site_id' => $site->id,
|
||||||
|
'type' => 'blog',
|
||||||
|
'body' => 'content',
|
||||||
|
'image_url' => 'https://example.com/photo.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated()->assertJsonPath('data.image_url', 'https://example.com/photo.jpg');
|
||||||
|
|
||||||
|
$post = Post::latest('id')->first();
|
||||||
|
|
||||||
|
$this->patchJson("/api/posts/{$post->id}", ['image_url' => 'https://example.com/new-photo.jpg'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.image_url', 'https://example.com/new-photo.jpg');
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ use Illuminate\Support\Facades\Process;
|
|||||||
|
|
||||||
test('it publishes and updates the post on success', function () {
|
test('it publishes and updates the post on success', function () {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||||
|
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
|
||||||
'*/wp-json/wp/v2/posts' => Http::response(['link' => 'https://wp.example.com/post'], 201),
|
'*/wp-json/wp/v2/posts' => Http::response(['link' => 'https://wp.example.com/post'], 201),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$site = Site::factory()->wordpress()->create();
|
$site = Site::factory()->wordpress()->create();
|
||||||
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'publishing']);
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://images.example.com/photo.jpg', 'status' => 'publishing']);
|
||||||
|
|
||||||
(new PublishPostJob($post->id))->handle(app(PublisherManager::class));
|
(new PublishPostJob($post->id))->handle(app(PublisherManager::class));
|
||||||
|
|
||||||
@@ -26,11 +28,13 @@ test('it publishes and updates the post on success', function () {
|
|||||||
|
|
||||||
test('it marks the post failed when the publish attempt fails', function () {
|
test('it marks the post failed when the publish attempt fails', function () {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||||
|
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
|
||||||
'*/wp-json/wp/v2/posts' => Http::response(['message' => 'nope'], 401),
|
'*/wp-json/wp/v2/posts' => Http::response(['message' => 'nope'], 401),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$site = Site::factory()->wordpress()->create();
|
$site = Site::factory()->wordpress()->create();
|
||||||
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'publishing']);
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://images.example.com/photo.jpg', 'status' => 'publishing']);
|
||||||
|
|
||||||
(new PublishPostJob($post->id))->handle(app(PublisherManager::class));
|
(new PublishPostJob($post->id))->handle(app(PublisherManager::class));
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,20 @@ test('it fails when the post has no title', function () {
|
|||||||
->assertExitCode(1);
|
->assertExitCode(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it fails when the post has no image', function () {
|
||||||
|
$site = Site::factory()->wordpress()->create();
|
||||||
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => null]);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('no image')
|
||||||
|
->assertExitCode(1);
|
||||||
|
|
||||||
|
expect($post->fresh()->status)->toBe($post->status);
|
||||||
|
});
|
||||||
|
|
||||||
test('it fails when the site is not configured as WordPress', function () {
|
test('it fails when the site is not configured as WordPress', function () {
|
||||||
$site = Site::factory()->create(['cms_type' => 'custom']);
|
$site = Site::factory()->create(['cms_type' => 'custom']);
|
||||||
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://example.com/image.jpg']);
|
||||||
|
|
||||||
$this->artisan('posts:publish', ['post' => $post->id])
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
->expectsOutputToContain('not configured as WordPress')
|
->expectsOutputToContain('not configured as WordPress')
|
||||||
@@ -40,15 +51,17 @@ test('it fails when the site is not configured as WordPress', function () {
|
|||||||
|
|
||||||
test('it fails when the WordPress site is missing credentials', function () {
|
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]);
|
$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']);
|
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://example.com/image.jpg']);
|
||||||
|
|
||||||
$this->artisan('posts:publish', ['post' => $post->id])
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
->expectsOutputToContain('missing WordPress credentials')
|
->expectsOutputToContain('missing WordPress credentials')
|
||||||
->assertExitCode(1);
|
->assertExitCode(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it publishes successfully and updates the post', function () {
|
test('it publishes successfully, uploading the image as the featured media', function () {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||||
|
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
|
||||||
'*/wp-json/wp/v2/posts' => Http::response([
|
'*/wp-json/wp/v2/posts' => Http::response([
|
||||||
'id' => 42,
|
'id' => 42,
|
||||||
'link' => 'https://wp.example.com/2026/07/hello-world/',
|
'link' => 'https://wp.example.com/2026/07/hello-world/',
|
||||||
@@ -61,6 +74,7 @@ test('it publishes successfully and updates the post', function () {
|
|||||||
'type' => 'blog',
|
'type' => 'blog',
|
||||||
'title' => 'Hello World',
|
'title' => 'Hello World',
|
||||||
'body' => 'Post content',
|
'body' => 'Post content',
|
||||||
|
'image_url' => 'https://images.example.com/photo.jpg',
|
||||||
'status' => 'scheduled',
|
'status' => 'scheduled',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -72,16 +86,69 @@ test('it publishes successfully and updates the post', function () {
|
|||||||
->status->toBe('published')
|
->status->toBe('published')
|
||||||
->published_url->toBe('https://wp.example.com/2026/07/hello-world/');
|
->published_url->toBe('https://wp.example.com/2026/07/hello-world/');
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/media'
|
||||||
|
&& $request->hasHeader('Content-Disposition', 'attachment; filename="photo.jpg"')
|
||||||
|
&& $request->body() === 'fake-image-bytes';
|
||||||
|
});
|
||||||
|
|
||||||
Http::assertSent(function ($request) use ($site) {
|
Http::assertSent(function ($request) use ($site) {
|
||||||
return $request->url() === "{$site->url}/wp-json/wp/v2/posts"
|
return $request->url() === "{$site->url}/wp-json/wp/v2/posts"
|
||||||
&& $request['title'] === 'Hello World'
|
&& $request['title'] === 'Hello World'
|
||||||
&& $request['content'] === 'Post content'
|
&& $request['content'] === 'Post content'
|
||||||
&& $request['status'] === 'publish';
|
&& $request['status'] === 'publish'
|
||||||
|
&& $request['featured_media'] === 99;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it marks the post as failed when the image cannot be downloaded', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://images.example.com/*' => Http::response('not found', 404),
|
||||||
|
'*/wp-json/*' => Http::response([], 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$site = Site::factory()->wordpress()->create();
|
||||||
|
$post = Post::factory()->create([
|
||||||
|
'site_id' => $site->id,
|
||||||
|
'type' => 'blog',
|
||||||
|
'title' => 'Hi',
|
||||||
|
'image_url' => 'https://images.example.com/missing.jpg',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('could not download image')
|
||||||
|
->assertExitCode(1);
|
||||||
|
|
||||||
|
expect($post->fresh()->status)->toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it marks the post as failed when WordPress rejects the image upload', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||||
|
'*/wp-json/wp/v2/media' => Http::response(['message' => 'Sorry, you are not allowed to upload files.'], 401),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$site = Site::factory()->wordpress()->create();
|
||||||
|
$post = Post::factory()->create([
|
||||||
|
'site_id' => $site->id,
|
||||||
|
'type' => 'blog',
|
||||||
|
'title' => 'Hi',
|
||||||
|
'image_url' => 'https://images.example.com/photo.jpg',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
|
->expectsOutputToContain('not allowed to upload files')
|
||||||
|
->assertExitCode(1);
|
||||||
|
|
||||||
|
expect($post->fresh()->status)->toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
test('it marks the post as failed when WordPress returns an error', function () {
|
test('it marks the post as failed when WordPress returns an error', function () {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||||
|
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
|
||||||
'*/wp-json/wp/v2/posts' => Http::response([
|
'*/wp-json/wp/v2/posts' => Http::response([
|
||||||
'code' => 'rest_cannot_create',
|
'code' => 'rest_cannot_create',
|
||||||
'message' => 'Sorry, you are not allowed to create posts.',
|
'message' => 'Sorry, you are not allowed to create posts.',
|
||||||
@@ -89,7 +156,13 @@ test('it marks the post as failed when WordPress returns an error', function ()
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$site = Site::factory()->wordpress()->create();
|
$site = Site::factory()->wordpress()->create();
|
||||||
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);
|
$post = Post::factory()->create([
|
||||||
|
'site_id' => $site->id,
|
||||||
|
'type' => 'blog',
|
||||||
|
'title' => 'Hi',
|
||||||
|
'image_url' => 'https://images.example.com/photo.jpg',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->artisan('posts:publish', ['post' => $post->id])
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
->expectsOutputToContain('not allowed to create posts')
|
->expectsOutputToContain('not allowed to create posts')
|
||||||
@@ -99,12 +172,21 @@ test('it marks the post as failed when WordPress returns an error', function ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it marks the post as failed when the site is unreachable', function () {
|
test('it marks the post as failed when the site is unreachable', function () {
|
||||||
Http::fake(function () {
|
Http::fake([
|
||||||
throw new \Illuminate\Http\Client\ConnectionException('Could not connect');
|
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||||
});
|
'*/wp-json/*' => function () {
|
||||||
|
throw new \Illuminate\Http\Client\ConnectionException('Could not connect');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
$site = Site::factory()->wordpress()->create();
|
$site = Site::factory()->wordpress()->create();
|
||||||
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);
|
$post = Post::factory()->create([
|
||||||
|
'site_id' => $site->id,
|
||||||
|
'type' => 'blog',
|
||||||
|
'title' => 'Hi',
|
||||||
|
'image_url' => 'https://images.example.com/photo.jpg',
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->artisan('posts:publish', ['post' => $post->id])
|
$this->artisan('posts:publish', ['post' => $post->id])
|
||||||
->expectsOutputToContain('could not connect')
|
->expectsOutputToContain('could not connect')
|
||||||
@@ -115,6 +197,8 @@ test('it marks the post as failed when the site is unreachable', function () {
|
|||||||
|
|
||||||
test('it correctly builds the WordPress endpoint URL when the site URL has a trailing slash', function () {
|
test('it correctly builds the WordPress endpoint URL when the site URL has a trailing slash', function () {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||||
|
'*/wp-json/wp/v2/media' => Http::response(['id' => 99], 201),
|
||||||
'*/wp-json/wp/v2/posts' => Http::response([
|
'*/wp-json/wp/v2/posts' => Http::response([
|
||||||
'id' => 42,
|
'id' => 42,
|
||||||
'link' => 'https://wp.example.com/2026/07/hello-world/',
|
'link' => 'https://wp.example.com/2026/07/hello-world/',
|
||||||
@@ -127,6 +211,7 @@ test('it correctly builds the WordPress endpoint URL when the site URL has a tra
|
|||||||
'type' => 'blog',
|
'type' => 'blog',
|
||||||
'title' => 'Hello World',
|
'title' => 'Hello World',
|
||||||
'body' => 'Post content',
|
'body' => 'Post content',
|
||||||
|
'image_url' => 'https://images.example.com/photo.jpg',
|
||||||
'status' => 'draft',
|
'status' => 'draft',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -136,4 +221,8 @@ test('it correctly builds the WordPress endpoint URL when the site URL has a tra
|
|||||||
Http::assertSent(function ($request) {
|
Http::assertSent(function ($request) {
|
||||||
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/posts';
|
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/posts';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/media';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user