# 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": ""}` 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": ""}` 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).