From b3b250aaca150863cadbef654fbd0d68e571c4b8 Mon Sep 17 00:00:00 2001 From: m105 Date: Wed, 22 Jul 2026 14:03:43 +0300 Subject: [PATCH] Add free-text api_instructions field to Sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For custom sites we'll post to via their own API — there's no wired-in publisher for these, so this is where the site owner documents how to actually post there (auth, endpoints, payload shape). Shown in the UI for non-WordPress sites, exposed via the JSON API, and pointed to from AI_API.md's "any other network/CMS" fallback section so an agent checks it before improvising. Co-Authored-By: Claude Sonnet 5 --- AI_API.md | 20 +++++++++---- app/Http/Resources/SiteResource.php | 1 + app/Livewire/Sites/Index.php | 6 ++++ app/Models/Site.php | 1 + ...06_add_api_instructions_to_sites_table.php | 28 +++++++++++++++++++ .../views/livewire/sites/index.blade.php | 9 ++++++ tests/Feature/Api/SitesApiTest.php | 12 ++++++++ tests/Feature/Livewire/SitesTest.php | 21 ++++++++++++++ tests/Feature/SiteModelTest.php | 17 +++++++++++ 9 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_07_22_140106_add_api_instructions_to_sites_table.php diff --git a/AI_API.md b/AI_API.md index d5e9295..7458b16 100644 --- a/AI_API.md +++ b/AI_API.md @@ -82,12 +82,21 @@ Response shape: "cms_type": "wordpress", "wp_username": "admin", "wp_app_password": "abcd 1234 efgh 5678", + "api_instructions": null, "created_at": "2026-07-19T12:00:00.000000Z", "updated_at": "2026-07-19T12:00:00.000000Z" } } ``` +`api_instructions` is a free-text field (nullable) for `cms_type=custom`/`other` +sites — there's no built-in publisher for these yet, so this is where the +user documents how to actually post there: auth method, endpoints, payload +shape, whatever's needed. **Read it before improvising** when you're about +to publish to a site with no wired-in publisher (see "Any other +network/CMS" below) — it's the site owner telling you directly how their +API works, rather than you having to guess or ask. + `cms_type` is one of `wordpress`, `custom`, `other`. `wp_username`/ `wp_app_password` are only meaningful (non-null) when `cms_type` is `wordpress` — they're a WordPress **Application Password** pair (see @@ -269,11 +278,12 @@ integration yet) There is no built-in publishing path. Do it however is appropriate for that platform (its own API, its own automation), using the credentials -from `GET /api/sites` / `GET /api/accounts`, then report back via -`PATCH /api/posts/{id}` exactly as described above. This is the general -fallback contract: **whatever publishes a post, always ends with either -the `posts:publish` command's automatic bookkeeping, or a manual `PATCH` -setting `status` and, if applicable, `published_url`.** +from `GET /api/sites` / `GET /api/accounts` — check the site's +`api_instructions` first, that's exactly what it's there for — then report +back via `PATCH /api/posts/{id}` exactly as described above. This is the +general fallback contract: **whatever publishes a post, always ends with +either the `posts:publish` command's automatic bookkeeping, or a manual +`PATCH` setting `status` and, if applicable, `published_url`.** ## Quick reference diff --git a/app/Http/Resources/SiteResource.php b/app/Http/Resources/SiteResource.php index 049823e..cce5877 100644 --- a/app/Http/Resources/SiteResource.php +++ b/app/Http/Resources/SiteResource.php @@ -17,6 +17,7 @@ class SiteResource extends JsonResource 'cms_type' => $this->cms_type, 'wp_username' => $this->wp_username, 'wp_app_password' => $this->wp_app_password, + 'api_instructions' => $this->api_instructions, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Livewire/Sites/Index.php b/app/Livewire/Sites/Index.php index b29b8e8..80dcaf5 100644 --- a/app/Livewire/Sites/Index.php +++ b/app/Livewire/Sites/Index.php @@ -27,6 +27,8 @@ class Index extends Component public string $wpAppPassword = ''; + public ?string $apiInstructions = null; + public function render() { return view('livewire.sites.index', [ @@ -51,6 +53,7 @@ class Index extends Component $this->cmsType = $site->cms_type; $this->wpUsername = $site->wp_username ?? ''; $this->wpAppPassword = $site->wp_app_password ?? ''; + $this->apiInstructions = $site->api_instructions; $this->showForm = true; } @@ -63,6 +66,7 @@ class Index extends Component 'cmsType' => ['required', 'string', 'in:' . implode(',', Site::CMS_TYPES)], 'wpUsername' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'], 'wpAppPassword' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'], + 'apiInstructions' => ['nullable', 'string'], ]); Site::updateOrCreate(['id' => $this->editingId], [ @@ -72,6 +76,7 @@ class Index extends Component 'cms_type' => $validated['cmsType'], 'wp_username' => $validated['cmsType'] === 'wordpress' ? $validated['wpUsername'] : null, 'wp_app_password' => $validated['cmsType'] === 'wordpress' ? $validated['wpAppPassword'] : null, + 'api_instructions' => $validated['apiInstructions'], ]); $this->resetForm(); @@ -97,6 +102,7 @@ class Index extends Component $this->cmsType = 'custom'; $this->wpUsername = ''; $this->wpAppPassword = ''; + $this->apiInstructions = null; $this->resetValidation(); } } diff --git a/app/Models/Site.php b/app/Models/Site.php index c26a0c6..a852374 100644 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -18,5 +18,6 @@ class Site extends Model 'cms_type', 'wp_username', 'wp_app_password', + 'api_instructions', ]; } diff --git a/database/migrations/2026_07_22_140106_add_api_instructions_to_sites_table.php b/database/migrations/2026_07_22_140106_add_api_instructions_to_sites_table.php new file mode 100644 index 0000000..f9e5ec3 --- /dev/null +++ b/database/migrations/2026_07_22_140106_add_api_instructions_to_sites_table.php @@ -0,0 +1,28 @@ +text('api_instructions')->nullable()->after('wp_app_password'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sites', function (Blueprint $table) { + $table->dropColumn('api_instructions'); + }); + } +}; diff --git a/resources/views/livewire/sites/index.blade.php b/resources/views/livewire/sites/index.blade.php index c21f917..3d07897 100644 --- a/resources/views/livewire/sites/index.blade.php +++ b/resources/views/livewire/sites/index.blade.php @@ -34,6 +34,8 @@ {{ $revealedId === $site->id ? 'Hide' : 'Show' }} + @elseif ($site->api_instructions) +
Has API instructions
@endif @@ -89,6 +91,13 @@ @error('wpAppPassword') {{ $message }} @enderror + @else +
+ +

Notes for posting here via this site's own API — auth method, endpoints, payload shape, etc. Read by whoever (agent or you) ends up publishing to it.

+ + @error('apiInstructions') {{ $message }} @enderror +
@endif
diff --git a/tests/Feature/Api/SitesApiTest.php b/tests/Feature/Api/SitesApiTest.php index e434686..bba7cba 100644 --- a/tests/Feature/Api/SitesApiTest.php +++ b/tests/Feature/Api/SitesApiTest.php @@ -46,3 +46,15 @@ test('it includes WordPress fields in the site response', function () { ->assertJsonPath('data.wp_username', 'admin') ->assertJsonPath('data.wp_app_password', 'abcd 1234 efgh 5678'); }); + +test('it includes api_instructions in the site response', function () { + $site = Site::factory()->create([ + 'cms_type' => 'custom', + 'api_instructions' => 'POST to /api/publish with a bearer token from .env', + ]); + + $response = $this->getJson("/api/sites/{$site->id}"); + + $response->assertOk() + ->assertJsonPath('data.api_instructions', 'POST to /api/publish with a bearer token from .env'); +}); diff --git a/tests/Feature/Livewire/SitesTest.php b/tests/Feature/Livewire/SitesTest.php index 186394b..269722c 100644 --- a/tests/Feature/Livewire/SitesTest.php +++ b/tests/Feature/Livewire/SitesTest.php @@ -121,3 +121,24 @@ test('switching a WordPress site to a non-WordPress cms_type clears its credenti ->wp_username->toBeNull() ->wp_app_password->toBeNull(); }); + +test('a custom site can be created with api_instructions', function () { + Livewire::test(Index::class) + ->set('name', 'Custom Site') + ->set('url', 'https://custom.example.com') + ->set('cmsType', 'custom') + ->set('apiInstructions', 'POST to /api/publish with a bearer token from .env') + ->call('save'); + + $site = Site::where('name', 'Custom Site')->first(); + + expect($site->api_instructions)->toBe('POST to /api/publish with a bearer token from .env'); +}); + +test('editing a site loads its api_instructions', function () { + $site = Site::factory()->create(['cms_type' => 'custom', 'api_instructions' => 'Use the /publish endpoint']); + + Livewire::test(Index::class) + ->call('edit', $site->id) + ->assertSet('apiInstructions', 'Use the /publish endpoint'); +}); diff --git a/tests/Feature/SiteModelTest.php b/tests/Feature/SiteModelTest.php index 6c9b7ff..ac925a1 100644 --- a/tests/Feature/SiteModelTest.php +++ b/tests/Feature/SiteModelTest.php @@ -50,3 +50,20 @@ test('a site can be created as WordPress with credentials', function () { ->wp_username->toBe('admin') ->wp_app_password->toBe('abcd 1234 efgh 5678'); }); + +test('a custom site can store free-text api_instructions', function () { + $site = Site::create([ + 'name' => 'Custom Site', + 'url' => 'https://custom.example.com', + 'cms_type' => 'custom', + 'api_instructions' => 'POST to /api/publish with a bearer token from .env', + ]); + + expect($site->fresh()->api_instructions)->toBe('POST to /api/publish with a bearer token from .env'); +}); + +test('api_instructions defaults to null', function () { + $site = Site::create(['name' => 'My Blog', 'url' => 'https://example.com']); + + expect($site->fresh()->api_instructions)->toBeNull(); +});