Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/Actions/Post/CreatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ public static function execute(Workspace $workspace, User $user, array $data): P
$updates['content_type'] = $contentType;
}

if (($meta = data_get($platformData, 'meta')) !== null) {
$updates['meta'] = $meta;
}

$post->postPlatforms()
->where('social_account_id', $accountId)
->update($updates);
Expand Down
27 changes: 27 additions & 0 deletions app/Enums/PostPlatform/AspectRatio.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Enums\PostPlatform;

enum AspectRatio: string
{
case Square = '1:1';
case Portrait = '4:5';
case Landscape = '16:9';
case Original = 'original';

/**
* Width-to-height ratio used to center-crop an image. `Original` means "no
* crop", so it resolves to a square (1.0) only as a safe fallback — callers
* should bypass cropping entirely for `Original`.
*/
public function toFloat(): float
{
return match ($this) {
self::Portrait => 4 / 5,
self::Landscape => 16 / 9,
self::Square, self::Original => 1.0,
};
}
}
5 changes: 4 additions & 1 deletion app/Http/Controllers/Api/PostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
);
}

return new PostResource(data_get($result, 'post'));
$updated = data_get($result, 'post');
$updated->load(['postPlatforms.socialAccount']);

return new PostResource($updated);
}

public function destroy(Request $request, Post $post): JsonResponse
Expand Down
3 changes: 3 additions & 0 deletions app/Http/Requests/Api/Post/StorePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Http\Requests\Api\Post;

use App\Enums\PostPlatform\AspectRatio;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Models\SocialAccount;
Expand Down Expand Up @@ -49,6 +50,8 @@ public function rules(): array
Rule::in(array_column(ContentType::cases(), 'value')),
new ContentTypeMatchesPlatform,
],
'platforms.*.meta' => ['sometimes', 'nullable', 'array'],
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
'scheduled_at' => ['nullable', 'date', 'after:now'],
'label_ids' => ['sometimes', 'array'],
'label_ids.*' => [
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Requests/Api/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Http\Requests\Api\Post;

use App\Enums\Post\Status;
use App\Enums\PostPlatform\AspectRatio;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
Expand Down Expand Up @@ -51,6 +52,7 @@ public function rules(): array
new ContentTypeMatchesPostPlatform,
],
'platforms.*.meta' => ['nullable', 'array'],
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
'scheduled_at' => [
'nullable',
'date',
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Requests/App/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Enums\Media\Source;
use App\Enums\Post\Status;
use App\Enums\PostPlatform\AspectRatio;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Rules\ContentFitsPlatformLimits;
Expand Down Expand Up @@ -70,7 +71,7 @@ public function rules(): array
Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia]),
],
'platforms.*.meta' => ['nullable', 'array'],
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::in(['1:1', '4:5', '16:9', 'original'])],
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])],
'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'],
'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'],
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/Api/PostPlatformResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public function toArray(Request $request): array
'id' => $this->id,
'platform' => $this->platform?->value,
'content_type' => $this->content_type?->value,
'meta' => $this->meta,
'status' => $this->status?->value,
'enabled' => $this->enabled,
'platform_url' => $this->platform_url,
Expand Down
4 changes: 4 additions & 0 deletions app/Mcp/Tools/Post/CreatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Mcp\Tools\Post;

use App\Actions\Post\CreatePost;
use App\Enums\PostPlatform\AspectRatio;
use App\Enums\PostPlatform\ContentType;
use App\Http\Resources\Api\PostResource;
use App\Rules\ContentTypeMatchesPlatform;
Expand Down Expand Up @@ -37,6 +38,8 @@ public function handle(Request $request): ResponseFactory
->where('is_active', true),
],
'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform],
'platforms.*.meta' => ['sometimes', 'array'],
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
]);

$post = CreatePost::execute($workspace, $request->user(), $validated);
Expand All @@ -58,6 +61,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 (e.g. aspect_ratio: 1:1|4:5|16:9|original for Instagram/Facebook feed images).'),
]))
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
];
Expand Down
2 changes: 2 additions & 0 deletions app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Actions\Post\UpdatePost;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\Status;
use App\Enums\PostPlatform\AspectRatio;
use App\Enums\PostPlatform\ContentType;
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
Expand Down Expand Up @@ -49,6 +50,7 @@ public function handle(Request $request): Response|ResponseFactory
],
'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform],
'platforms.*.meta' => ['sometimes', 'array'],
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
]);

$payload = collect($validated)->except('post_id')->all();
Expand Down
7 changes: 2 additions & 5 deletions app/Services/Social/Concerns/CropsImageForAspectRatio.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Services\Social\Concerns;

use App\Enums\PostPlatform\AspectRatio;
use App\Exceptions\Social\SocialPublishException;
use App\Services\Media\MediaOptimizer;
use Illuminate\Support\Facades\Http;
Expand Down Expand Up @@ -51,11 +52,7 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati

protected function aspectRatioToFloat(string $ratio): float
{
return match ($ratio) {
'4:5' => 4 / 5,
'16:9' => 16 / 9,
default => 1.0,
};
return AspectRatio::tryFrom($ratio)?->toFloat() ?? 1.0;
}

/**
Expand Down
86 changes: 86 additions & 0 deletions tests/Feature/Api/PostApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -577,3 +577,89 @@
->assertOk()
->assertJsonStructure(['id', 'status', 'scheduled_at', 'published_at']);
});

it('creates a post with platform meta (aspect_ratio) and returns it', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '4:5']],
],
])
->assertCreated()
->assertJsonPath('platforms.0.meta.aspect_ratio', '4:5');

$platform = Post::where('workspace_id', $this->workspace->id)->first()
->postPlatforms()->where('social_account_id', $this->socialAccount->id)->first();
expect($platform->meta['aspect_ratio'])->toBe('4:5');
});

it('rejects creating a post with an invalid aspect_ratio', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '3:2']],
],
])
->assertJsonValidationErrors(['platforms.0.meta.aspect_ratio']);
});

it('rejects updating a post with an invalid aspect_ratio', 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' => 'linkedin_post', 'meta' => ['aspect_ratio' => '3:2']],
],
])
->assertJsonValidationErrors(['platforms.0.meta.aspect_ratio']);
});

it('accepts a valid aspect_ratio on update and persists it', 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' => 'linkedin_post', 'meta' => ['aspect_ratio' => '16:9']],
],
])
->assertOk()
->assertJsonPath('platforms.0.meta.aspect_ratio', '16:9');

expect($postPlatform->fresh()->meta['aspect_ratio'])->toBe('16:9');
});

it('accepts the original aspect_ratio (no crop) on create', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => 'original']],
],
])
->assertCreated();

$platform = Post::where('workspace_id', $this->workspace->id)->first()
->postPlatforms()->where('social_account_id', $this->socialAccount->id)->first();
expect($platform->meta['aspect_ratio'])->toBe('original');
});
80 changes: 80 additions & 0 deletions tests/Feature/Mcp/PostToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,83 @@

$response->assertHasErrors();
});

test('create post persists platform meta (aspect_ratio)', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '4:5']],
],
]);

$response->assertOk();

$platform = Post::where('workspace_id', $this->workspace->id)->first()
->postPlatforms()->where('social_account_id', $this->socialAccount->id)->first();
expect($platform->meta['aspect_ratio'])->toBe('4:5');
});

test('create post rejects an invalid aspect_ratio', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '3:2']],
],
]);

$response->assertHasErrors();
});

test('update post rejects an invalid aspect_ratio', 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, 'meta' => ['aspect_ratio' => '3:2']],
],
]);

$response->assertHasErrors();
});

test('create post returns the platform meta in the response (read-back)', function () {
TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '4:5']],
],
])
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('platforms.0.meta.aspect_ratio', '4:5')->etc());
});

test('update post accepts a valid aspect_ratio and persists it', 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,
]);

TryPostServer::actingAs($this->user)
->tool(UpdatePostTool::class, [
'post_id' => $post->id,
'platforms' => [
['id' => $platform->id, 'meta' => ['aspect_ratio' => '16:9']],
],
])
->assertOk();

expect($platform->fresh()->meta['aspect_ratio'])->toBe('16:9');
});
19 changes: 19 additions & 0 deletions tests/Unit/Enums/AspectRatioTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

use App\Enums\PostPlatform\AspectRatio;

test('aspect ratio has the expected cases and values', function () {
expect(array_column(AspectRatio::cases(), 'value'))
->toEqualCanonicalizing(['1:1', '4:5', '16:9', 'original']);
});

test('aspect ratio maps to the correct crop float', function (string $value, float $expected) {
expect(AspectRatio::from($value)->toFloat())->toBe($expected);
})->with([
'1:1' => ['1:1', 1.0],
'4:5' => ['4:5', 4 / 5],
'16:9' => ['16:9', 16 / 9],
'original' => ['original', 1.0],
]);
Loading