Stop persisting instagram_carousel as a content type
Instagram carousels were stored as content_type=instagram_carousel, which the publisher's match() did not handle — publishing failed with "Unsupported Instagram content type: instagram_carousel" for any post created via API, MCP, or template (the AI flow worked only thanks to an inline band-aid that rewrote carousel to feed before saving). A carousel is just an Instagram feed post with multiple images: the editor, preview, and publisher already treat a multi-image feed as a carousel. So instagram_carousel is a generation format, not a stored content type. Remove it from the ContentType enum entirely; it now lives only as an AI generation-format string (wizard card + slide structure + carousel templates are untouched), and posts always persist as instagram_feed. - ContentType: drop the InstagramCarousel case; InstagramFeed maxMediaCount 1 -> 10 - StreamPostCreation: resolvedContentType() maps carousel -> feed; band-aid removed - StartPostCreationRequest: accept instagram_carousel as a generation format - Frontend: carousel becomes a wizard-local AiFormat; UI/UX unchanged - API/MCP now reject instagram_carousel as a content_type (Rule::in no longer lists it)
This commit is contained in:
parent
afae1a57c0
commit
cca7893e0f
11 changed files with 239 additions and 27 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
export const ContentType = {
|
||||
InstagramFeed: 'instagram_feed',
|
||||
InstagramCarousel: 'instagram_carousel',
|
||||
InstagramStory: 'instagram_story',
|
||||
InstagramReel: 'instagram_reel',
|
||||
LinkedInPost: 'linkedin_post',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
91
tests/Feature/Ai/StreamPostCreationTest.php
Normal file
91
tests/Feature/Ai/StreamPostCreationTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue