Merge pull request #80 from trypostit/fix/instagram-carousel-content-type

Stop persisting instagram_carousel as a content type
This commit is contained in:
Paulo Castellano 2026-06-04 20:10:59 -03:00 committed by GitHub
commit a06fe78d62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 239 additions and 27 deletions

View file

@ -10,7 +10,6 @@ enum ContentType: string
{
// Instagram
case InstagramFeed = 'instagram_feed';
case InstagramCarousel = 'instagram_carousel';
case InstagramReel = 'instagram_reel';
case InstagramStory = 'instagram_story';
@ -51,11 +50,16 @@ enum ContentType: string
// Mastodon
case MastodonPost = 'mastodon_post';
/**
* AI generation format for an Instagram carousel. Not a content type
* carousel posts are persisted as InstagramFeed.
*/
public const CAROUSEL_FORMAT = 'instagram_carousel';
public function label(): string
{
return match ($this) {
self::InstagramFeed => 'Feed Post',
self::InstagramCarousel => 'Carousel',
self::InstagramReel => 'Reel',
self::InstagramStory => 'Story',
self::LinkedInPost, self::LinkedInPagePost => 'Post',
@ -84,7 +88,7 @@ public function description(): string
public function platform(): SocialPlatform
{
return match ($this) {
self::InstagramFeed, self::InstagramCarousel, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram,
self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram,
self::LinkedInPost, self::LinkedInCarousel => SocialPlatform::LinkedIn,
self::LinkedInPagePost, self::LinkedInPageCarousel => SocialPlatform::LinkedInPage,
self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook,
@ -109,7 +113,6 @@ public function aiImageDimensions(): array
return match ($this) {
// Vertical 4:5 (Instagram preferred portrait, Threads mirrors it)
self::InstagramFeed,
self::InstagramCarousel,
self::ThreadsPost => ['width' => 1080, 'height' => 1350],
// Square 1:1 (LinkedIn, X, Facebook, Bluesky, Mastodon)
@ -135,7 +138,7 @@ public function aiImageDimensions(): array
public function aspectRatio(): ?string
{
return match ($this) {
self::InstagramFeed, self::InstagramCarousel => '4:5',
self::InstagramFeed => '4:5',
self::InstagramReel, self::InstagramStory => '9:16',
self::FacebookReel, self::FacebookStory => '9:16',
self::TikTokVideo, self::YouTubeShort => '9:16',
@ -149,8 +152,7 @@ public function aspectRatio(): ?string
public function maxMediaCount(): int
{
return match ($this) {
self::InstagramFeed => 1,
self::InstagramCarousel => 10,
self::InstagramFeed => 10,
self::InstagramReel, self::InstagramStory => 1,
self::LinkedInPost, self::LinkedInPagePost => 1,
self::LinkedInCarousel, self::LinkedInPageCarousel => 20,
@ -172,7 +174,6 @@ public function supportsVideo(): bool
{
return match ($this) {
self::InstagramFeed, self::InstagramReel, self::InstagramStory => true,
self::InstagramCarousel => false,
self::LinkedInPost, self::LinkedInPagePost => true,
self::LinkedInCarousel, self::LinkedInPageCarousel => false,
self::FacebookPost, self::FacebookReel, self::FacebookStory => true,
@ -237,7 +238,6 @@ public static function aiSupported(): array
{
return [
self::InstagramFeed,
self::InstagramCarousel,
self::InstagramStory,
self::LinkedInPost,
self::LinkedInPagePost,

View file

@ -20,11 +20,14 @@ public function authorize(): bool
*/
public function rules(): array
{
$allowedFormats = array_map(fn (ContentType $t) => $t->value, ContentType::aiSupported());
$allowedFormats[] = ContentType::CAROUSEL_FORMAT;
return [
'format' => [
'required',
'string',
Rule::in(array_map(fn (ContentType $t) => $t->value, ContentType::aiSupported())),
Rule::in($allowedFormats),
],
'social_account_id' => ['nullable', 'uuid'],
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],

View file

@ -52,7 +52,7 @@ public function handle(): void
$workspace = Workspace::findOrFail($this->workspaceId);
$socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null;
$isCarousel = $this->format === 'instagram_carousel';
$isCarousel = $this->format === ContentType::CAROUSEL_FORMAT;
$agentFormat = $isCarousel ? 'carousel' : 'single';
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
@ -120,13 +120,26 @@ public function handle(): void
*/
private function dimensionsForFormat(): array
{
$type = ContentType::tryFrom($this->format);
$type = $this->resolvedContentType();
return $type
? $type->aiImageDimensions()
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
}
/**
* The stored content type for the requested generation format. The carousel
* generation format is persisted as an Instagram feed post.
*/
private function resolvedContentType(): ?ContentType
{
if ($this->format === ContentType::CAROUSEL_FORMAT) {
return ContentType::InstagramFeed;
}
return ContentType::tryFrom($this->format);
}
private function humanize(Workspace $workspace, array $structured, string $format): array
{
try {
@ -277,23 +290,20 @@ private function createPost(Workspace $workspace, string $content, array $media,
'date' => $this->date,
]);
$contentType = ContentType::tryFrom($this->format);
$contentType = $this->resolvedContentType();
if ($contentType && $socialAccount) {
$aspectRatio = $this->aspectRatioFor($contentType);
$platformContentType = $contentType === ContentType::InstagramCarousel
? ContentType::InstagramFeed
: $contentType;
$post->postPlatforms()
->where('social_account_id', $socialAccount->id)
->each(function ($platform) use ($aspectRatio, $platformContentType): void {
->each(function ($platform) use ($aspectRatio, $contentType): void {
$meta = $platform->meta ?? [];
if ($aspectRatio !== null) {
$meta['aspect_ratio'] = $aspectRatio;
}
$platform->meta = $meta;
$platform->content_type = $platformContentType->value;
$platform->content_type = $contentType->value;
$platform->enabled = true;
$platform->save();
});

View file

@ -42,8 +42,11 @@ const emit = defineEmits<{
cancel: [];
}>();
const CAROUSEL_FORMAT = 'instagram_carousel' as const;
type AiFormat = ContentTypeValue | typeof CAROUSEL_FORMAT;
// Selections
const selectedFormat = ref<ContentTypeValue | null>(null);
const selectedFormat = ref<AiFormat | null>(null);
const selectedAccountId = ref<string | null>(null);
const includeImages = ref(true);
const imageCount = ref(2);
@ -59,9 +62,9 @@ const httpStart = useHttp<{
date: string | null;
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null });
const AI_FORMATS: Array<{ value: ContentTypeValue; platforms: string[] }> = [
const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
{ value: ContentType.InstagramCarousel, platforms: ['instagram', 'instagram-facebook'] },
{ value: CAROUSEL_FORMAT, platforms: ['instagram', 'instagram-facebook'] },
{ value: ContentType.InstagramStory, platforms: ['instagram', 'instagram-facebook'] },
{ value: ContentType.LinkedInPost, platforms: ['linkedin'] },
{ value: ContentType.LinkedInPagePost, platforms: ['linkedin-page'] },
@ -95,7 +98,7 @@ const accountsForFormat = computed(() => {
return props.socialAccounts.filter((a) => format.platforms.includes(a.platform));
});
const isCarousel = computed(() => selectedFormat.value === ContentType.InstagramCarousel);
const isCarousel = computed(() => selectedFormat.value === CAROUSEL_FORMAT);
const requiresImage = computed(() =>
selectedFormat.value === ContentType.FacebookPost ||
selectedFormat.value === ContentType.PinterestPin ||
@ -140,11 +143,11 @@ watch(accountsForFormat, (accounts) => {
}
});
const selectFormat = (format: ContentTypeValue) => {
const selectFormat = (format: AiFormat) => {
selectedFormat.value = format;
// Sensible default per format. Picking a format always pre-selects an
// image option so the user sees a chip highlighted on arrival.
if (format === ContentType.InstagramCarousel) {
if (format === CAROUSEL_FORMAT) {
imageCount.value = 5;
} else if (format === ContentType.InstagramFeed) {
imageCount.value = 1;

View file

@ -1,6 +1,5 @@
export const ContentType = {
InstagramFeed: 'instagram_feed',
InstagramCarousel: 'instagram_carousel',
InstagramStory: 'instagram_story',
InstagramReel: 'instagram_reel',
LinkedInPost: 'linkedin_post',

View file

@ -50,6 +50,26 @@
->assertJsonValidationErrors(['format']);
});
test('start accepts instagram_carousel as a generation format', function () {
Bus::fake();
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
]);
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'prompt' => 'Five tips about productivity',
'format' => 'instagram_carousel',
'social_account_id' => $account->id,
'image_count' => 5,
])
->assertStatus(Response::HTTP_ACCEPTED);
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->format === 'instagram_carousel');
});
test('start rejects social_account_id from another workspace', function () {
Bus::fake();

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\PostPlatform\ContentType;
use App\Enums\UserWorkspace\Role;
use App\Jobs\Ai\StreamPostCreation;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Ai\Image;
beforeEach(function () {
Bus::fake();
Storage::fake();
Image::fake();
$this->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]);
$this->account = SocialAccount::factory()->instagram()->create([
'workspace_id' => $this->workspace->id,
]);
});
function runStreamPostCreation(string $format, SocialAccount $account, int $imageCount): void
{
(new StreamPostCreation(
userId: $account->workspace->user_id,
creationId: (string) Str::uuid(),
workspaceId: $account->workspace_id,
format: $format,
socialAccountId: $account->id,
imageCount: $imageCount,
prompt: 'Five tips about productivity',
))->handle();
}
test('AI carousel generation stores the post as an instagram feed, never instagram_carousel', function () {
// Empty image_keywords make TemplateImageGenerator::render() return null, so
// the storage decision is exercised without touching the image pipeline.
PostContentGenerator::fake([[
'caption' => 'Swipe to see the tips',
'slides' => [
['title' => 'Tip 1', 'body' => 'First tip', 'image_keywords' => []],
['title' => 'Tip 2', 'body' => 'Second tip', 'image_keywords' => []],
],
]]);
PostContentHumanizer::fake([[
'caption' => 'Swipe to see the tips',
'slides' => [
['title' => 'Tip 1', 'body' => 'First tip'],
['title' => 'Tip 2', 'body' => 'Second tip'],
],
]]);
runStreamPostCreation('instagram_carousel', $this->account, 2);
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
expect($platform->content_type)->toBe(ContentType::InstagramFeed);
expect($platform->meta['aspect_ratio'] ?? null)->toBe('4:5');
});
test('AI single feed generation stores the post as an instagram feed', function () {
PostContentGenerator::fake([[
'content' => 'A single productivity tip',
'image_title' => 'Tip',
'image_body' => 'Do less',
'image_keywords' => [],
]]);
PostContentHumanizer::fake([[
'content' => 'A single productivity tip',
'image_title' => 'Tip',
'image_body' => 'Do less',
]]);
runStreamPostCreation('instagram_feed', $this->account, 0);
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
expect($platform->content_type)->toBe(ContentType::InstagramFeed);
});

View file

@ -167,6 +167,39 @@
->assertOk();
});
it('rejects creating a post with instagram_carousel — carousel is not a stored content_type', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'instagram_carousel'],
],
])
->assertJsonValidationErrors(['platforms.0.content_type']);
});
it('rejects updating a post with instagram_carousel — carousel is not a stored content_type', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
]);
$postPlatform = PostPlatform::factory()->linkedin()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'enabled' => true,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'draft',
'platforms' => [
['id' => $postPlatform->id, 'content_type' => 'instagram_carousel'],
],
])
->assertJsonValidationErrors(['platforms.0.content_type']);
});
it('cannot update post from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$otherSocialAccount = SocialAccount::factory()->create([

View file

@ -9,7 +9,9 @@
use App\Mcp\Tools\Post\DeletePostTool;
use App\Mcp\Tools\Post\GetPostTool;
use App\Mcp\Tools\Post\ListPostsTool;
use App\Mcp\Tools\Post\UpdatePostTool;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@ -177,6 +179,38 @@
$response->assertHasErrors();
});
test('create post rejects instagram_carousel — carousel is not a stored content_type', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'instagram_carousel'],
],
]);
$response->assertHasErrors();
});
test('update post rejects instagram_carousel — carousel is not a stored content_type', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = TryPostServer::actingAs($this->user)
->tool(UpdatePostTool::class, [
'post_id' => $post->id,
'platforms' => [
['id' => $platform->id, 'content_type' => 'instagram_carousel'],
],
]);
$response->assertHasErrors();
});
test('create post rejects a content_type that does not match the social account platform', function () {
// x_post on a LinkedIn account — ContentTypeMatchesPlatform should reject.
$response = TryPostServer::actingAs($this->user)

View file

@ -525,3 +525,19 @@
expect(data_get($this->post->media, '0.source'))->toBe('ai');
expect(data_get($this->post->media, '0.source_meta.title'))->toBe('Fix ECP typo');
});
test('instagram_carousel is rejected as a content_type — carousel is a feed post with multiple images', function () {
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Draft->value,
'platforms' => [
[
'id' => $this->postPlatform->id,
'content_type' => 'instagram_carousel',
'meta' => [],
],
],
]);
$response->assertSessionHasErrors('platforms.0.content_type');
});

View file

@ -53,9 +53,12 @@
expect(ContentType::XPost->aspectRatio())->toBeNull();
});
test('instagram_carousel is not a content type — it is an AI generation format only', function () {
expect(ContentType::tryFrom('instagram_carousel'))->toBeNull();
});
test('content type has correct max media count', function () {
expect(ContentType::InstagramFeed->maxMediaCount())->toBe(1);
expect(ContentType::InstagramCarousel->maxMediaCount())->toBe(10);
expect(ContentType::InstagramFeed->maxMediaCount())->toBe(10);
expect(ContentType::InstagramReel->maxMediaCount())->toBe(1);
expect(ContentType::LinkedInCarousel->maxMediaCount())->toBe(20);
expect(ContentType::XPost->maxMediaCount())->toBe(4);