diff --git a/app/Actions/SocialAccount/ListPinterestBoards.php b/app/Actions/SocialAccount/ListPinterestBoards.php new file mode 100644 index 00000000..0785486e --- /dev/null +++ b/app/Actions/SocialAccount/ListPinterestBoards.php @@ -0,0 +1,29 @@ + + */ + public static function execute(SocialAccount $account): array + { + $boards = app(PinterestPublisher::class)->getBoards($account); + + return Collection::make($boards) + ->map(fn (mixed $board): array => [ + 'id' => (string) data_get($board, 'id'), + 'name' => (string) data_get($board, 'name'), + ]) + ->filter(fn (array $board): bool => $board['id'] !== '') + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Api/SocialAccountController.php b/app/Http/Controllers/Api/SocialAccountController.php index b6827dc5..bc4cf4c5 100644 --- a/app/Http/Controllers/Api/SocialAccountController.php +++ b/app/Http/Controllers/Api/SocialAccountController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api; +use App\Actions\SocialAccount\ListPinterestBoards; use App\Actions\SocialAccount\ToggleSocialAccount; +use App\Enums\SocialAccount\Platform; use App\Http\Resources\Api\SocialAccountResource; use App\Models\SocialAccount; use Illuminate\Http\JsonResponse; @@ -34,4 +36,25 @@ public function toggle(Request $request, SocialAccount $account): SocialAccountR return new SocialAccountResource($account); } + + public function boards(Request $request, SocialAccount $account): JsonResponse + { + if ($account->workspace_id !== $request->user()->currentWorkspace->id) { + return response()->json( + ['message' => 'Account not found.'], + Response::HTTP_NOT_FOUND, + ); + } + + if ($account->platform !== Platform::Pinterest) { + return response()->json( + ['message' => 'Boards are only available for Pinterest accounts.'], + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + return response()->json([ + 'boards' => ListPinterestBoards::execute($account), + ]); + } } diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index cd2d1850..5f661dfb 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -27,6 +27,7 @@ use App\Mcp\Tools\Signature\DeleteSignatureTool; use App\Mcp\Tools\Signature\ListSignaturesTool; use App\Mcp\Tools\Signature\UpdateSignatureTool; +use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool; use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool; use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool; use App\Mcp\Tools\Workspace\GetWorkspaceTool; @@ -73,6 +74,7 @@ class TryPostServer extends Server // Social Accounts ListSocialAccountsTool::class, + ListPinterestBoardsTool::class, ToggleSocialAccountTool::class, // Workspace diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 50c1ce08..c2c61324 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -63,7 +63,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 18dea6c5..bf2c9cb8 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -105,7 +105,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'), 'content_type' => $p->string()->description('New content_type for this platform.'), - 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'), ])) ->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'), ]; diff --git a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php new file mode 100644 index 00000000..d8dae901 --- /dev/null +++ b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php @@ -0,0 +1,48 @@ +validate([ + 'account_id' => ['required', 'string', 'uuid'], + ]); + + $account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id) + ->find(data_get($validated, 'account_id')); + + if (! $account) { + return Response::error('Social account not found.'); + } + + if ($account->platform !== Platform::Pinterest) { + return Response::error('This tool only works with Pinterest social accounts.'); + } + + return Response::structured([ + 'boards' => ListPinterestBoards::execute($account), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'account_id' => $schema->string()->required()->description('The UUID of the connected Pinterest social account.'), + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 9c603e79..85a86b44 100644 --- a/routes/api.php +++ b/routes/api.php @@ -50,6 +50,9 @@ // Social Accounts Route::get('/social-accounts', [SocialAccountController::class, 'index'])->name('api.social-accounts.index'); Route::put('/social-accounts/{account}/toggle', [SocialAccountController::class, 'toggle'])->name('api.social-accounts.toggle'); + Route::get('/social-accounts/{account}/boards', [SocialAccountController::class, 'boards']) + ->middleware('throttle:60,1') + ->name('api.social-accounts.boards'); // API Keys Route::get('/api-keys', [ApiKeyController::class, 'index'])->name('api.api-keys.index'); diff --git a/tests/Feature/Api/PinterestBoardsApiTest.php b/tests/Feature/Api/PinterestBoardsApiTest.php new file mode 100644 index 00000000..01967708 --- /dev/null +++ b/tests/Feature/Api/PinterestBoardsApiTest.php @@ -0,0 +1,79 @@ +user = $result['user']; + $this->workspace = $result['workspace']; + $this->plainToken = $result['plain_token']; +}); + +it('lists pinterest boards for a connected account', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'items' => [ + ['id' => 'board_1', 'name' => 'Ideas', 'privacy' => 'PUBLIC'], + ['id' => 'board_2', 'name' => 'Product', 'privacy' => 'PUBLIC'], + ], + ], 200), + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'boards' => [ + ['id' => 'board_1', 'name' => 'Ideas'], + ['id' => 'board_2', 'name' => 'Product'], + ], + ]); +}); + +it('rejects boards listing for non-pinterest accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertUnprocessable(); + $response->assertJsonPath('message', 'Boards are only available for Pinterest accounts.'); +}); + +it('cannot list boards for an account from another workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertNotFound(); +}); + +it('requires authentication to list boards', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->getJson(route('api.social-accounts.boards', $account)) + ->assertUnauthorized(); +}); diff --git a/tests/Feature/Mcp/ListPinterestBoardsToolTest.php b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php new file mode 100644 index 00000000..6dec26b5 --- /dev/null +++ b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php @@ -0,0 +1,79 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('lists pinterest boards as id and name', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'items' => [ + ['id' => 'board_1', 'name' => 'Ideas', 'privacy' => 'PUBLIC'], + ['id' => 'board_2', 'name' => 'Product'], + ], + ], 200), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('boards', 2) + ->where('boards.0.id', 'board_1') + ->where('boards.0.name', 'Ideas') + ->where('boards.1.id', 'board_2') + ->where('boards.1.name', 'Product'); + }); +}); + +test('rejects non-pinterest accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['This tool only works with Pinterest social accounts.']); +}); + +test('cannot list boards for another workspace account', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Social account not found.']); +}); + +test('validates account_id is required', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, []); + + $response->assertHasErrors(); +});