From 88aebe298cd9576f236399a88fce06c9fe4007d2 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 19 Jul 2026 16:21:46 +0300 Subject: [PATCH] docs: add design spec for WordPress publishing command Extends Sites with cms_type + WP Application Password credentials and adds a posts:publish Artisan command to push a Post to a WordPress site's REST API, mirroring the reddit-post sidecar's role for a native, non-browser-automation CMS target. --- .../2026-07-19-wordpress-publish-design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-wordpress-publish-design.md diff --git a/docs/superpowers/specs/2026-07-19-wordpress-publish-design.md b/docs/superpowers/specs/2026-07-19-wordpress-publish-design.md new file mode 100644 index 0000000..2b4067f --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-wordpress-publish-design.md @@ -0,0 +1,94 @@ +# 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). \ No newline at end of file