Add a Publisher abstraction and queued job to publish from the UI

Introduce Publisher/PublisherManager (WordPress + Reddit implementations)
so publishing logic is shared between the CLI, the queue, and future
network integrations. Add a "Publish" button on the Posts page that
dispatches PublishPostJob instead of blocking the request — Reddit's
publisher shells out to the existing Playwright sidecar and can take
tens of seconds, so it can't run synchronously in a web request.

Adds a `target` field (e.g. subreddit) and `publishing` status to Post,
plus a `publish_error` field so failures surface in the UI. Refactors
posts:publish to delegate to WordPressPublisher without changing its
CLI output contract (existing tests pass unchanged).

Also fixes two bugs found along the way: UpdatePostRequest was silently
dropping `published_url` from PATCH requests (never in its validation
rules, despite AI_API.md documenting it), and three Livewire index-page
tests were broken by the earlier auth change (never re-run after adding
the `auth` middleware) since they hit routes via HTTP without a logged-in
user.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
m105
2026-07-21 14:04:45 +03:00
co-authored by Claude Sonnet 5
parent 0cf72b44b7
commit 9278d2e8a0
26 changed files with 735 additions and 76 deletions
+41 -9
View File
@@ -36,8 +36,11 @@ 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"
└─ status: draft -> scheduled -> published | failed
└─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit)
└─ 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
publish attempt is rejected back to draft — see below)
```
The typical agent loop is:
@@ -45,7 +48,14 @@ The typical agent loop is:
1. Read Sites/Social Accounts to know what you're working with (`GET /api/sites`, `GET /api/accounts`).
2. Create a Post as a draft (`POST /api/posts`), or find one that's already `due` (`GET /api/posts?due=1`).
3. Publish it externally, by whatever mechanism fits its `type`/site (see "How to actually publish" below).
4. Report the outcome back with `PATCH /api/posts/{id}` — set `status` to `published` or `failed`, and `published_url` if you have one, **unless** you used the `posts:publish` command, which already does this step for you.
4. Report the outcome back with `PATCH /api/posts/{id}` — set `status` to `published` or `failed`, and `published_url` if you have one, **unless** you used the `posts:publish` command or the app's own Publish button/job, which already does this step for you.
`publishing` is a transient status set automatically while a background publish
job (triggered by the app's own "Publish" button in the UI) is running — you
won't normally set it yourself. If the underlying publisher rejects the post
before attempting anything (missing title, no target, credentials not set),
the job puts it back to `draft` rather than `failed`, since nothing was
actually attempted; check `publish_error` for why.
## Sites
@@ -117,7 +127,7 @@ Full CRUD — this is the resource the agent actively drives.
`GET /api/posts`
Query params (all optional, combine freely):
- `?status=` — exact match: `draft` | `scheduled` | `published` | `failed`
- `?status=` — exact match: `draft` | `scheduled` | `publishing` | `published` | `failed`
- `?site_id=` — exact match
- `?social_account_id=` — exact match
- `?due=1` — the important one for automation: returns posts where
@@ -134,6 +144,7 @@ Body:
"type": "blog",
"title": "Optional title",
"body": "Required content",
"target": null,
"status": "draft",
"scheduled_at": null
}
@@ -141,8 +152,9 @@ Body:
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. `status` defaults to `draft` if omitted.
`scheduled_at` nullable datetime.
`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.
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
@@ -151,7 +163,7 @@ it's what later determines *how* the post gets published (see below).
`PATCH /api/posts/{id}` — partial update. This is how you **report a
publish result** back for anything you didn't publish via the
`posts:publish` command:
`posts:publish` command or the app's own Publish button:
```json
{ "status": "published", "published_url": "https://example.com/2026/07/my-post" }
```
@@ -164,9 +176,28 @@ or on failure:
## How to actually publish a post
This app is a data store and scheduler — **it does not publish content by
itself except via one specific command below.** Publishing mechanism
depends on the post's target:
Publishing happens through a shared `Publisher` abstraction
(`app/Support/Publishing/`) — one implementation per network family
(`WordPressPublisher`, `RedditPublisher`, more to come), resolved by `Post.type`
via `PublisherManager`. There are three ways to trigger it, depending on
who/what is driving:
- **From the UI**: the Posts page has a "Publish" button (for any post whose
type currently has a publisher). Clicking it sets `status=publishing` and
dispatches `PublishPostJob` on the queue — a worker must be running
(`php artisan queue:work`) for anything to actually happen. The UI polls
and updates itself once the job finishes.
- **From the CLI**, for WordPress specifically: `php artisan posts:publish
{post_id}` (unchanged, see below) — synchronous, no queue needed.
- **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
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.
Publishing mechanism depends on the post's target:
### WordPress sites (`type=blog`, site has `cms_type=wordpress`)
@@ -244,6 +275,7 @@ setting `status` and, if applicable, `published_url`.**
| Task | How |
|---|---|
| Find work to publish | `GET /api/posts?due=1` |
| Publish from the UI | click "Publish" on the Posts page (queues `PublishPostJob`; needs a queue worker running) |
| Publish to a WordPress site | `php artisan posts:publish {post_id}` (handles status/published_url itself) |
| Publish to Reddit | `sidecars/reddit-post/run.sh ...`, then `PATCH /api/posts/{id}` yourself |
| Publish anywhere else | do it yourself, then `PATCH /api/posts/{id}` yourself |