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:
m105
2026-07-21 20:03:25 +03:00
co-authored by Claude Sonnet 5
parent fd185ab813
commit 42abc658f2
14 changed files with 259 additions and 28 deletions
+21 -13
View File
@@ -37,6 +37,8 @@ SocialAccount (login for a social network)
Post (one piece of content, belongs to a Site, optionally a SocialAccount)
└─ type: same vocabulary as SocialAccount.network, plus "blog"
└─ 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
└─ published_url: set once it's actually live somewhere
└─ publish_error: set to the failure reason when status=failed (or when a
@@ -145,6 +147,7 @@ Body:
"title": "Optional title",
"body": "Required content",
"target": null,
"image_url": null,
"status": "draft",
"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`,
`facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`.
`body` required. `title` nullable. `target` nullable — a network-specific
destination, e.g. the subreddit name for `type=reddit`. `status` defaults to
`draft` if omitted. `scheduled_at` nullable datetime.
destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable
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**
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
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
Playwright, tens of seconds) — which is exactly why the UI path is
queue-backed rather than a direct synchronous call.
@@ -212,17 +217,20 @@ php artisan posts:publish {post_id}
What it does:
1. Loads the Post and its Site.
2. Validates: `post.type` must be `blog`, `post.title` must be set,
`site.cms_type` must be `wordpress`, and the site must have
`wp_username`/`wp_app_password` set. Any failure here 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
the site's WordPress Application Password, `status: publish` (goes live
immediately, not a WP draft).
4. On success: sets `Post.status = published`, `Post.published_url` to the
`post.image_url` must be set, `site.cms_type` must be `wordpress`, and
the site must have `wp_username`/`wp_app_password` set. Any failure here
exits 1 with a descriptive message and **does not touch** `Post.status`.
3. Downloads `post.image_url`, then POSTs it to
`{site.url}/wp-json/wp/v2/media` (HTTP Basic Auth with the site's
WordPress Application Password) to upload it as media and get back a
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.
5. On failure (bad credentials, unreachable site, WordPress error): sets
`Post.status = failed`, prints `{"success": false, "error": "..."}`,
exits 1.
6. On failure at either HTTP step (bad credentials, unreachable site,
unreachable/rejected image, WordPress error): sets `Post.status = failed`,
prints `{"success": false, "error": "..."}`, exits 1.
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`).