Files
muninn/docs/superpowers/specs/2026-07-19-wordpress-publish-design.md
T

94 lines
7.1 KiB
Markdown
Raw Normal View History

# WordPress Publishing — Design
## Purpose
Extends the existing Sites & Posts data model so a Site can be marked as a WordPress-powered CMS with its own REST API credentials, and adds an Artisan command that publishes an existing draft/scheduled `Post` to that site via the WordPress REST API. This is the WordPress equivalent of the `reddit-post` sidecar, but implemented natively in Laravel rather than as an external Playwright process, since WordPress's REST API is a direct HTTP call and the Post/Site data already lives in this app's database.
## Scope
- Distinguish a Site's CMS by a `cms_type` field: `wordpress`, `custom`, or `other`.
- Store WordPress Application Password credentials (`wp_username` + `wp_app_password`) on a Site.
- Add `php artisan posts:publish {post}` — loads a `Post` by ID, publishes it to its Site's WordPress REST API, and updates the Post's `status` and `published_url`.
- Update the Sites Livewire UI to manage `cms_type` and the WordPress credential fields.
- Expose the new Site fields via the existing read-only Sites API, and the new Post field via the existing Posts API.
Out of scope:
- Any CMS integration beyond WordPress — `custom` and `other` are labels only, with no publish path.
- Updating/editing an already-published WP post, deleting a WP post, or any WP endpoint beyond creating a post.
- Media/image uploads, categories, tags, excerpts, or any WP post field beyond title/content/status.
- Scheduled publishing via WP's own `future` post status — this command always publishes immediately (`status: publish`) when invoked; scheduling is this app's own `Post.scheduled_at` + AI-driven polling, unchanged from the existing design.
- Authentication/authorization on the command, UI, or API (consistent with the rest of the app — local network only).
- Any plugin-based WP auth scheme (JWT, API-key plugins) — Application Passwords (built into WordPress core 5.6+) only.
## Data Model
### `sites` table — new columns
| Column | Type | Constraints |
|---|---|---|
| cms_type | string (enum) | required, default `custom` — one of: `wordpress`, `custom`, `other` |
| wp_username | string | nullable |
| wp_app_password | string | nullable, plain text (no hashing/encryption — same precedent as `social_accounts.password`) |
`wp_username`/`wp_app_password` are only meaningful when `cms_type = wordpress`; no DB-level constraint enforces this pairing (validated at the UI/command level instead).
### `posts` table — new column
| Column | Type | Constraints |
|---|---|---|
| published_url | string | nullable — set on successful publish; generic across future publish targets, not WordPress-specific |
## Command: `php artisan posts:publish {post}`
**Arguments:** one required argument, the `Post` id.
**Flow:**
1. Load the `Post` by id (404/"post not found" error and exit 1 if missing). Eager-load its `site` relation.
2. Validate, in order, failing fast with a descriptive message and exit code 1 on the first failure:
- `post.type === 'blog'` — else `"Post {id} has type '{type}', expected 'blog' to publish to a CMS."`
- `post.site.cms_type === 'wordpress'` — else `"Site '{name}' is not configured as WordPress (cms_type={cms_type})."`
- `post.site.wp_username` and `post.site.wp_app_password` are both present — else `"Site '{name}' is missing WordPress credentials."`
- These three pre-flight checks happen before any DB write to the Post — a mismatched post/site pairing is a usage error, not a publish failure, so the Post's `status` is left untouched in these cases.
3. Send `POST {site.url}/wp-json/wp/v2/posts` via Guzzle, HTTP Basic Auth (`wp_username` / `wp_app_password`), JSON body:
```json
{ "title": post.title, "content": post.body, "status": "publish" }
```
4. **On success** (2xx response with a `link` field in the JSON body):
- Update the Post: `status = 'published'`, `published_url = response.link`.
- Print `{"success": true, "url": "<published_url>"}` to stdout, exit 0.
5. **On failure** (network/connection error, non-2xx response, or malformed response missing `link`):
- Update the Post: `status = 'failed'`.
- Print `{"success": false, "error": "<description>"}` to stdout, exit 1.
- Error description includes the WP error message when the response body has one (WP REST errors return `{code, message, data}`), otherwise a generic description of the failure (e.g. "could not connect to site", "unexpected response from WordPress").
**Title handling:** `Post.title` is nullable in the existing schema, but WordPress requires a title. If `post.title` is null/empty at step 3, this is a pre-flight validation failure (same category as step 2): `"Post {id} has no title, required to publish to WordPress."` — exit 1, Post status untouched.
## UI (Livewire)
Sites create/edit form gains:
- **CMS Type** — dropdown: WordPress / Custom / Other (default Custom).
- When **WordPress** is selected, two additional fields appear:
- **WP Username** — text input.
- **WP Application Password** — masked input with reveal toggle (same convention as the Social Accounts password field). Not required at the DB level, but the form validates both are present together when `cms_type = wordpress` is submitted (client-side/Livewire validation — DB stays nullable to avoid migration headaches if a site is later switched away from WordPress).
Sites table view gains a **CMS Type** column. No changes to the Posts UI (the new `published_url` field is visible as a link in the Posts table's existing "actions" area or a new column — small addition, decided at plan time).
## API
No new endpoints.
- `GET /api/sites` / `GET /api/sites/{id}` — `SiteResource` now also returns `cms_type`, `wp_username`, `wp_app_password` (plain text), consistent with the existing precedent that Social Accounts' API already returns plain-text passwords, since the AI agent needs credentials to act directly.
- `GET /api/posts` / `GET /api/posts/{id}` — response shape gains `published_url`.
## Testing / Edge Cases
- Feature test: `posts:publish` on a post with `type != 'blog'` fails with the expected message, exit 1, Post status unchanged.
- Feature test: `posts:publish` on a post whose site has `cms_type != wordpress` fails accordingly, Post status unchanged.
- Feature test: `posts:publish` on a WordPress site missing credentials fails accordingly.
- Feature test: `posts:publish` on a post with no title fails accordingly.
- Feature test: successful publish (mocked HTTP via Guzzle's mock handler / Laravel's `Http::fake` equivalent) — asserts Post updated to `status=published` with correct `published_url`, and the printed JSON/exit code are correct.
- Feature test: WP API returns an error response (e.g. 401 invalid credentials, 500) — asserts Post updated to `status=failed`, correct error message surfaced, exit 1.
- Feature test: WP API unreachable (connection exception) — asserts graceful failure, `status=failed`, exit 1.
- Livewire test: Sites form shows/hides WP fields based on `cms_type` selection, and validates the paired-fields rule.
- Migration test/manual check: existing sites default to `cms_type=custom` with null WP fields (no backfill needed beyond the column defaults).