diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index e97f45e7..0d56e74c 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -361,6 +361,49 @@ public function mediaRules(): array ]; } + /** + * Content-type row for REST / MCP listings (agents + API clients). + * Keep in sync with mediaRules() capability fields — do not omit mins. + * + * @return array{ + * value: string, + * label: string, + * description: string, + * max_media_count: int, + * min_media_count: int, + * requires_media: bool, + * accept_images: bool, + * accept_videos: bool, + * accept_documents: bool, + * accepts_gif: bool, + * forbids_mixed_media: bool, + * max_video_duration_sec: int|null, + * max_image_bytes: int|null, + * max_video_bytes: int|null, + * max_document_bytes: int|null + * } + */ + public function toListingArray(): array + { + return [ + 'value' => $this->value, + 'label' => $this->label(), + 'description' => $this->description(), + 'max_media_count' => $this->maxMediaCount(), + 'min_media_count' => $this->minMediaCount(), + 'requires_media' => $this->requiresMedia(), + 'accept_images' => $this->supportsImage(), + 'accept_videos' => $this->supportsVideo(), + 'accept_documents' => $this->supportsDocument(), + 'accepts_gif' => $this->acceptsGif(), + 'forbids_mixed_media' => ! $this->supportsMixedMedia(), + 'max_video_duration_sec' => $this->maxVideoDurationSec(), + 'max_image_bytes' => $this->maxImageBytes(), + 'max_video_bytes' => $this->maxVideoBytes(), + 'max_document_bytes' => $this->maxDocumentBytes(), + ]; + } + /** * @return array> */ diff --git a/app/Http/Resources/Api/PlatformContentTypesResource.php b/app/Http/Resources/Api/PlatformContentTypesResource.php index 428d72e8..bc902a4c 100644 --- a/app/Http/Resources/Api/PlatformContentTypesResource.php +++ b/app/Http/Resources/Api/PlatformContentTypesResource.php @@ -36,17 +36,7 @@ public function toArray(Request $request): array ), 'default_content_type' => ContentType::defaultFor($platform)->value, 'content_types' => array_map( - fn (ContentType $type) => [ - 'value' => $type->value, - 'label' => $type->label(), - 'description' => $type->description(), - 'max_media_count' => $type->maxMediaCount(), - 'requires_media' => $type->requiresMedia(), - 'max_video_duration_sec' => $type->maxVideoDurationSec(), - 'max_image_bytes' => $type->maxImageBytes(), - 'max_video_bytes' => $type->maxVideoBytes(), - 'max_document_bytes' => $type->maxDocumentBytes(), - ], + fn (ContentType $type) => $type->toListingArray(), array_values(ContentType::forPlatform($platform)), ), ]; diff --git a/app/Mcp/Tools/Platform/ListContentTypesTool.php b/app/Mcp/Tools/Platform/ListContentTypesTool.php index 315bc550..fb054066 100644 --- a/app/Mcp/Tools/Platform/ListContentTypesTool.php +++ b/app/Mcp/Tools/Platform/ListContentTypesTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, per-type max image/video/document bytes, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] +#[Description('List the valid content_types per social platform plus their constraints (max/min media count, whether media is required, accept_images/videos/documents/gif, forbids_mixed_media, max video duration in seconds, per-type max image/video/document bytes, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] class ListContentTypesTool extends Tool { public function handle(Request $request): ResponseFactory @@ -23,17 +23,7 @@ public function handle(Request $request): ResponseFactory foreach (Platform::cases() as $platform) { $contentTypes = array_map( - fn (ContentType $type) => [ - 'value' => $type->value, - 'label' => $type->label(), - 'description' => $type->description(), - 'max_media_count' => $type->maxMediaCount(), - 'requires_media' => $type->requiresMedia(), - 'max_video_duration_sec' => $type->maxVideoDurationSec(), - 'max_image_bytes' => $type->maxImageBytes(), - 'max_video_bytes' => $type->maxVideoBytes(), - 'max_document_bytes' => $type->maxDocumentBytes(), - ], + fn (ContentType $type) => $type->toListingArray(), array_values(ContentType::forPlatform($platform)), ); diff --git a/app/Services/Automation/GenerateNodeValidator.php b/app/Services/Automation/GenerateNodeValidator.php index 633527a0..0ca56ed3 100644 --- a/app/Services/Automation/GenerateNodeValidator.php +++ b/app/Services/Automation/GenerateNodeValidator.php @@ -60,6 +60,12 @@ private function issueForAccount(ContentType $contentType, int $imageCount): ?st return __('automations.errors.generate_image_format_required'); } + $min = $contentType->minMediaCount(); + + if ($min > 0 && $imageCount < $min) { + return __('posts.edit.compliance.too_few_files', ['min' => (string) $min]); + } + if ($contentType->requiresMedia() && $imageCount === 0) { return __('posts.edit.compliance.requires_media'); } diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index c860736a..0f1abd6a 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -232,13 +232,21 @@ const imageCountCap = computed(() => ), ); -// Floor at 1 when any selected account requires media (Pinterest pin, IG feed, -// etc.) — otherwise the empty-accounts clamp to 0 sticks after selecting them -// and surfaces a false "add an image" compliance error. +// Floor at requiresMedia (1) or content-type minFiles (e.g. Pinterest carousel = 2) +// — otherwise the empty-accounts clamp to 0 sticks after selecting them and +// surfaces a false "add an image" compliance error / under-min carousel. const minImageCount = computed(() => - local.value.accounts.some((a) => getMediaRulesForContentType(a.content_type).requiresMedia) - ? 1 - : 0, + local.value.accounts.reduce((floor, a) => { + const rules = getMediaRulesForContentType(a.content_type); + let accountMin = 0; + if (rules.requiresMedia) { + accountMin = Math.max(accountMin, 1); + } + if (rules.minFiles) { + accountMin = Math.max(accountMin, rules.minFiles); + } + return Math.max(floor, accountMin); + }, 0), ); // Single picker: 0 = no image (text-only), 1 = single image, 2+ = carousel. diff --git a/tests/Feature/Api/PlatformApiTest.php b/tests/Feature/Api/PlatformApiTest.php index 21435178..16d43b50 100644 --- a/tests/Feature/Api/PlatformApiTest.php +++ b/tests/Feature/Api/PlatformApiTest.php @@ -28,7 +28,13 @@ 'label', 'description', 'max_media_count', + 'min_media_count', 'requires_media', + 'accept_images', + 'accept_videos', + 'accept_documents', + 'accepts_gif', + 'forbids_mixed_media', 'max_video_duration_sec', 'max_image_bytes', 'max_video_bytes', @@ -42,10 +48,14 @@ $platforms = collect($response->json('platforms')); $instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']); $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); + $pinterestTypes = collect($platforms->firstWhere('platform', 'pinterest')['content_types']); expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024); + expect($instagramTypes->firstWhere('value', 'instagram_reel')['accept_images'])->toBeFalse(); expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); + expect($pinterestTypes->firstWhere('value', 'pinterest_carousel')['min_media_count'])->toBe(2); + expect($pinterestTypes->firstWhere('value', 'pinterest_pin')['min_media_count'])->toBe(0); }); it('rejects content-types without auth', function () { diff --git a/tests/Feature/Automation/GenerateNodeValidationTest.php b/tests/Feature/Automation/GenerateNodeValidationTest.php index d173ec4c..295bee90 100644 --- a/tests/Feature/Automation/GenerateNodeValidationTest.php +++ b/tests/Feature/Automation/GenerateNodeValidationTest.php @@ -37,6 +37,25 @@ ->assertJsonValidationErrors(['nodes.1.data.accounts']); }); +it('rejects a generate node that targets Pinterest carousel below the minimum slide count', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->putJson(route('app.automations.update', $automation->id), [ + 'nodes' => [ + ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], + ['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [ + 'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel', 'meta' => ['board_id' => 'board-1']]], + 'prompt_template' => 'hi', + 'target_slide_count' => 1, + ]], + ], + 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['nodes.1.data.accounts']); +}); + it('allows saving a generate node within the content type limit', function () { $automation = Automation::factory()->for($this->workspace)->create(); diff --git a/tests/Feature/Mcp/PlatformToolTest.php b/tests/Feature/Mcp/PlatformToolTest.php index 684c5b24..d5f3a795 100644 --- a/tests/Feature/Mcp/PlatformToolTest.php +++ b/tests/Feature/Mcp/PlatformToolTest.php @@ -40,7 +40,13 @@ 'label', 'description', 'max_media_count', + 'min_media_count', 'requires_media', + 'accept_images', + 'accept_videos', + 'accept_documents', + 'accepts_gif', + 'forbids_mixed_media', 'max_video_duration_sec', 'max_image_bytes', 'max_video_bytes', @@ -72,9 +78,11 @@ $platforms = collect($json->toArray()['platforms']); $instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']); $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); + $pinterestTypes = collect($platforms->firstWhere('platform', 'pinterest')['content_types']); expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024); expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); + expect($pinterestTypes->firstWhere('value', 'pinterest_carousel')['min_media_count'])->toBe(2); }); }); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index b47483af..aa87067a 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -61,6 +61,21 @@ expect($rules['telegram_post']['accepts_gif'])->toBeTrue(); }); +test('listing array mirrors media capability fields for api and mcp', function () { + $listing = ContentType::PinterestCarousel->toListingArray(); + + expect($listing)->toMatchArray([ + 'value' => 'pinterest_carousel', + 'max_media_count' => 5, + 'min_media_count' => 2, + 'requires_media' => true, + 'accept_images' => true, + 'accept_videos' => false, + ]); + + expect(ContentType::InstagramReel->toListingArray()['accept_images'])->toBeFalse(); +}); + test('media rules reuse enum capability helpers', function () { $rules = ContentType::InstagramStory->mediaRules();