Skip to content

Commit a06fe78

Browse files
Merge pull request #80 from trypostit/fix/instagram-carousel-content-type
Stop persisting instagram_carousel as a content type
2 parents afae1a5 + cca7893 commit a06fe78

11 files changed

Lines changed: 239 additions & 27 deletions

File tree

app/Enums/PostPlatform/ContentType.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ enum ContentType: string
1010
{
1111
// Instagram
1212
case InstagramFeed = 'instagram_feed';
13-
case InstagramCarousel = 'instagram_carousel';
1413
case InstagramReel = 'instagram_reel';
1514
case InstagramStory = 'instagram_story';
1615

@@ -51,11 +50,16 @@ enum ContentType: string
5150
// Mastodon
5251
case MastodonPost = 'mastodon_post';
5352

53+
/**
54+
* AI generation format for an Instagram carousel. Not a content type —
55+
* carousel posts are persisted as InstagramFeed.
56+
*/
57+
public const CAROUSEL_FORMAT = 'instagram_carousel';
58+
5459
public function label(): string
5560
{
5661
return match ($this) {
5762
self::InstagramFeed => 'Feed Post',
58-
self::InstagramCarousel => 'Carousel',
5963
self::InstagramReel => 'Reel',
6064
self::InstagramStory => 'Story',
6165
self::LinkedInPost, self::LinkedInPagePost => 'Post',
@@ -84,7 +88,7 @@ public function description(): string
8488
public function platform(): SocialPlatform
8589
{
8690
return match ($this) {
87-
self::InstagramFeed, self::InstagramCarousel, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram,
91+
self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram,
8892
self::LinkedInPost, self::LinkedInCarousel => SocialPlatform::LinkedIn,
8993
self::LinkedInPagePost, self::LinkedInPageCarousel => SocialPlatform::LinkedInPage,
9094
self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook,
@@ -109,7 +113,6 @@ public function aiImageDimensions(): array
109113
return match ($this) {
110114
// Vertical 4:5 (Instagram preferred portrait, Threads mirrors it)
111115
self::InstagramFeed,
112-
self::InstagramCarousel,
113116
self::ThreadsPost => ['width' => 1080, 'height' => 1350],
114117

115118
// Square 1:1 (LinkedIn, X, Facebook, Bluesky, Mastodon)
@@ -135,7 +138,7 @@ public function aiImageDimensions(): array
135138
public function aspectRatio(): ?string
136139
{
137140
return match ($this) {
138-
self::InstagramFeed, self::InstagramCarousel => '4:5',
141+
self::InstagramFeed => '4:5',
139142
self::InstagramReel, self::InstagramStory => '9:16',
140143
self::FacebookReel, self::FacebookStory => '9:16',
141144
self::TikTokVideo, self::YouTubeShort => '9:16',
@@ -149,8 +152,7 @@ public function aspectRatio(): ?string
149152
public function maxMediaCount(): int
150153
{
151154
return match ($this) {
152-
self::InstagramFeed => 1,
153-
self::InstagramCarousel => 10,
155+
self::InstagramFeed => 10,
154156
self::InstagramReel, self::InstagramStory => 1,
155157
self::LinkedInPost, self::LinkedInPagePost => 1,
156158
self::LinkedInCarousel, self::LinkedInPageCarousel => 20,
@@ -172,7 +174,6 @@ public function supportsVideo(): bool
172174
{
173175
return match ($this) {
174176
self::InstagramFeed, self::InstagramReel, self::InstagramStory => true,
175-
self::InstagramCarousel => false,
176177
self::LinkedInPost, self::LinkedInPagePost => true,
177178
self::LinkedInCarousel, self::LinkedInPageCarousel => false,
178179
self::FacebookPost, self::FacebookReel, self::FacebookStory => true,
@@ -237,7 +238,6 @@ public static function aiSupported(): array
237238
{
238239
return [
239240
self::InstagramFeed,
240-
self::InstagramCarousel,
241241
self::InstagramStory,
242242
self::LinkedInPost,
243243
self::LinkedInPagePost,

app/Http/Requests/App/Ai/StartPostCreationRequest.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ public function authorize(): bool
2020
*/
2121
public function rules(): array
2222
{
23+
$allowedFormats = array_map(fn (ContentType $t) => $t->value, ContentType::aiSupported());
24+
$allowedFormats[] = ContentType::CAROUSEL_FORMAT;
25+
2326
return [
2427
'format' => [
2528
'required',
2629
'string',
27-
Rule::in(array_map(fn (ContentType $t) => $t->value, ContentType::aiSupported())),
30+
Rule::in($allowedFormats),
2831
],
2932
'social_account_id' => ['nullable', 'uuid'],
3033
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],

app/Jobs/Ai/StreamPostCreation.php

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public function handle(): void
5252
$workspace = Workspace::findOrFail($this->workspaceId);
5353
$socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null;
5454

55-
$isCarousel = $this->format === 'instagram_carousel';
55+
$isCarousel = $this->format === ContentType::CAROUSEL_FORMAT;
5656
$agentFormat = $isCarousel ? 'carousel' : 'single';
5757

5858
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
@@ -120,13 +120,26 @@ public function handle(): void
120120
*/
121121
private function dimensionsForFormat(): array
122122
{
123-
$type = ContentType::tryFrom($this->format);
123+
$type = $this->resolvedContentType();
124124

125125
return $type
126126
? $type->aiImageDimensions()
127127
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
128128
}
129129

130+
/**
131+
* The stored content type for the requested generation format. The carousel
132+
* generation format is persisted as an Instagram feed post.
133+
*/
134+
private function resolvedContentType(): ?ContentType
135+
{
136+
if ($this->format === ContentType::CAROUSEL_FORMAT) {
137+
return ContentType::InstagramFeed;
138+
}
139+
140+
return ContentType::tryFrom($this->format);
141+
}
142+
130143
private function humanize(Workspace $workspace, array $structured, string $format): array
131144
{
132145
try {
@@ -277,23 +290,20 @@ private function createPost(Workspace $workspace, string $content, array $media,
277290
'date' => $this->date,
278291
]);
279292

280-
$contentType = ContentType::tryFrom($this->format);
293+
$contentType = $this->resolvedContentType();
281294

282295
if ($contentType && $socialAccount) {
283296
$aspectRatio = $this->aspectRatioFor($contentType);
284-
$platformContentType = $contentType === ContentType::InstagramCarousel
285-
? ContentType::InstagramFeed
286-
: $contentType;
287297

288298
$post->postPlatforms()
289299
->where('social_account_id', $socialAccount->id)
290-
->each(function ($platform) use ($aspectRatio, $platformContentType): void {
300+
->each(function ($platform) use ($aspectRatio, $contentType): void {
291301
$meta = $platform->meta ?? [];
292302
if ($aspectRatio !== null) {
293303
$meta['aspect_ratio'] = $aspectRatio;
294304
}
295305
$platform->meta = $meta;
296-
$platform->content_type = $platformContentType->value;
306+
$platform->content_type = $contentType->value;
297307
$platform->enabled = true;
298308
$platform->save();
299309
});

resources/js/components/posts/create/AiPostWizard.vue

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ const emit = defineEmits<{
4242
cancel: [];
4343
}>();
4444
45+
const CAROUSEL_FORMAT = 'instagram_carousel' as const;
46+
type AiFormat = ContentTypeValue | typeof CAROUSEL_FORMAT;
47+
4548
// Selections
46-
const selectedFormat = ref<ContentTypeValue | null>(null);
49+
const selectedFormat = ref<AiFormat | null>(null);
4750
const selectedAccountId = ref<string | null>(null);
4851
const includeImages = ref(true);
4952
const imageCount = ref(2);
@@ -59,9 +62,9 @@ const httpStart = useHttp<{
5962
date: string | null;
6063
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null });
6164
62-
const AI_FORMATS: Array<{ value: ContentTypeValue; platforms: string[] }> = [
65+
const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [
6366
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
64-
{ value: ContentType.InstagramCarousel, platforms: ['instagram', 'instagram-facebook'] },
67+
{ value: CAROUSEL_FORMAT, platforms: ['instagram', 'instagram-facebook'] },
6568
{ value: ContentType.InstagramStory, platforms: ['instagram', 'instagram-facebook'] },
6669
{ value: ContentType.LinkedInPost, platforms: ['linkedin'] },
6770
{ value: ContentType.LinkedInPagePost, platforms: ['linkedin-page'] },
@@ -95,7 +98,7 @@ const accountsForFormat = computed(() => {
9598
return props.socialAccounts.filter((a) => format.platforms.includes(a.platform));
9699
});
97100
98-
const isCarousel = computed(() => selectedFormat.value === ContentType.InstagramCarousel);
101+
const isCarousel = computed(() => selectedFormat.value === CAROUSEL_FORMAT);
99102
const requiresImage = computed(() =>
100103
selectedFormat.value === ContentType.FacebookPost ||
101104
selectedFormat.value === ContentType.PinterestPin ||
@@ -140,11 +143,11 @@ watch(accountsForFormat, (accounts) => {
140143
}
141144
});
142145
143-
const selectFormat = (format: ContentTypeValue) => {
146+
const selectFormat = (format: AiFormat) => {
144147
selectedFormat.value = format;
145148
// Sensible default per format. Picking a format always pre-selects an
146149
// image option so the user sees a chip highlighted on arrival.
147-
if (format === ContentType.InstagramCarousel) {
150+
if (format === CAROUSEL_FORMAT) {
148151
imageCount.value = 5;
149152
} else if (format === ContentType.InstagramFeed) {
150153
imageCount.value = 1;

resources/js/enums/content-type.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
export const ContentType = {
22
InstagramFeed: 'instagram_feed',
3-
InstagramCarousel: 'instagram_carousel',
43
InstagramStory: 'instagram_story',
54
InstagramReel: 'instagram_reel',
65
LinkedInPost: 'linkedin_post',

tests/Feature/Ai/PostAiCreateTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,26 @@
5050
->assertJsonValidationErrors(['format']);
5151
});
5252

53+
test('start accepts instagram_carousel as a generation format', function () {
54+
Bus::fake();
55+
56+
$account = SocialAccount::factory()->create([
57+
'workspace_id' => $this->workspace->id,
58+
'platform' => Platform::Instagram,
59+
]);
60+
61+
$this->actingAs($this->user)
62+
->postJson(route('app.posts.ai.create'), [
63+
'prompt' => 'Five tips about productivity',
64+
'format' => 'instagram_carousel',
65+
'social_account_id' => $account->id,
66+
'image_count' => 5,
67+
])
68+
->assertStatus(Response::HTTP_ACCEPTED);
69+
70+
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->format === 'instagram_carousel');
71+
});
72+
5373
test('start rejects social_account_id from another workspace', function () {
5474
Bus::fake();
5575

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use App\Ai\Agents\PostContentGenerator;
6+
use App\Ai\Agents\PostContentHumanizer;
7+
use App\Enums\PostPlatform\ContentType;
8+
use App\Enums\UserWorkspace\Role;
9+
use App\Jobs\Ai\StreamPostCreation;
10+
use App\Models\PostPlatform;
11+
use App\Models\SocialAccount;
12+
use App\Models\User;
13+
use App\Models\Workspace;
14+
use Illuminate\Support\Facades\Bus;
15+
use Illuminate\Support\Facades\Storage;
16+
use Illuminate\Support\Str;
17+
use Laravel\Ai\Image;
18+
19+
beforeEach(function () {
20+
Bus::fake();
21+
Storage::fake();
22+
Image::fake();
23+
24+
$this->user = User::factory()->create();
25+
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
26+
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
27+
$this->user->update(['current_workspace_id' => $this->workspace->id]);
28+
29+
$this->account = SocialAccount::factory()->instagram()->create([
30+
'workspace_id' => $this->workspace->id,
31+
]);
32+
});
33+
34+
function runStreamPostCreation(string $format, SocialAccount $account, int $imageCount): void
35+
{
36+
(new StreamPostCreation(
37+
userId: $account->workspace->user_id,
38+
creationId: (string) Str::uuid(),
39+
workspaceId: $account->workspace_id,
40+
format: $format,
41+
socialAccountId: $account->id,
42+
imageCount: $imageCount,
43+
prompt: 'Five tips about productivity',
44+
))->handle();
45+
}
46+
47+
test('AI carousel generation stores the post as an instagram feed, never instagram_carousel', function () {
48+
// Empty image_keywords make TemplateImageGenerator::render() return null, so
49+
// the storage decision is exercised without touching the image pipeline.
50+
PostContentGenerator::fake([[
51+
'caption' => 'Swipe to see the tips',
52+
'slides' => [
53+
['title' => 'Tip 1', 'body' => 'First tip', 'image_keywords' => []],
54+
['title' => 'Tip 2', 'body' => 'Second tip', 'image_keywords' => []],
55+
],
56+
]]);
57+
PostContentHumanizer::fake([[
58+
'caption' => 'Swipe to see the tips',
59+
'slides' => [
60+
['title' => 'Tip 1', 'body' => 'First tip'],
61+
['title' => 'Tip 2', 'body' => 'Second tip'],
62+
],
63+
]]);
64+
65+
runStreamPostCreation('instagram_carousel', $this->account, 2);
66+
67+
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
68+
69+
expect($platform->content_type)->toBe(ContentType::InstagramFeed);
70+
expect($platform->meta['aspect_ratio'] ?? null)->toBe('4:5');
71+
});
72+
73+
test('AI single feed generation stores the post as an instagram feed', function () {
74+
PostContentGenerator::fake([[
75+
'content' => 'A single productivity tip',
76+
'image_title' => 'Tip',
77+
'image_body' => 'Do less',
78+
'image_keywords' => [],
79+
]]);
80+
PostContentHumanizer::fake([[
81+
'content' => 'A single productivity tip',
82+
'image_title' => 'Tip',
83+
'image_body' => 'Do less',
84+
]]);
85+
86+
runStreamPostCreation('instagram_feed', $this->account, 0);
87+
88+
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
89+
90+
expect($platform->content_type)->toBe(ContentType::InstagramFeed);
91+
});

tests/Feature/Api/PostApiTest.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,39 @@
167167
->assertOk();
168168
});
169169

170+
it('rejects creating a post with instagram_carousel — carousel is not a stored content_type', function () {
171+
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
172+
->postJson(route('api.posts.store'), [
173+
'platforms' => [
174+
['social_account_id' => $this->socialAccount->id, 'content_type' => 'instagram_carousel'],
175+
],
176+
])
177+
->assertJsonValidationErrors(['platforms.0.content_type']);
178+
});
179+
180+
it('rejects updating a post with instagram_carousel — carousel is not a stored content_type', function () {
181+
$post = Post::factory()->create([
182+
'workspace_id' => $this->workspace->id,
183+
'user_id' => $this->user->id,
184+
'status' => PostStatus::Draft,
185+
]);
186+
187+
$postPlatform = PostPlatform::factory()->linkedin()->create([
188+
'post_id' => $post->id,
189+
'social_account_id' => $this->socialAccount->id,
190+
'enabled' => true,
191+
]);
192+
193+
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
194+
->putJson(route('api.posts.update', $post), [
195+
'status' => 'draft',
196+
'platforms' => [
197+
['id' => $postPlatform->id, 'content_type' => 'instagram_carousel'],
198+
],
199+
])
200+
->assertJsonValidationErrors(['platforms.0.content_type']);
201+
});
202+
170203
it('cannot update post from another workspace', function () {
171204
$otherWorkspace = Workspace::factory()->create();
172205
$otherSocialAccount = SocialAccount::factory()->create([

0 commit comments

Comments
 (0)