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:
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user