diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 264db101..162bb181 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -122,6 +122,19 @@ public function maxContentLength(): int }; } + /** + * Number of characters the given content exceeds this platform's hard cap, + * or null when it fits. Single source of truth for content-length checks — + * used both at schedule/publish-validation time and at publish time itself + * so the two paths can never drift apart. + */ + public function contentOverflow(string $content): ?int + { + $over = mb_strlen($content) - $this->maxContentLength(); + + return $over > 0 ? $over : null; + } + /** * Recommended target length (in characters) for AI-generated posts. This * is the engagement sweet spot — much shorter than the platform's hard diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index be7a023a..f575ab5f 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -5,8 +5,12 @@ namespace App\Http\Requests\Api\Post; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; +use App\Models\SocialAccount; +use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeMatchesPlatform; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Collection; use Illuminate\Validation\Rule; class StorePostRequest extends FormRequest @@ -21,7 +25,15 @@ public function rules(): array $workspaceId = $this->user()->currentWorkspace->id; return [ - 'content' => ['nullable', 'string', 'max:63206'], + 'content' => [ + 'nullable', + 'string', + 'max:63206', + Rule::when( + $this->filled('scheduled_at'), + [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms($workspaceId))] + ), + ], 'media' => ['sometimes', 'array'], 'platforms' => ['required', 'array', 'min:1'], 'platforms.*.social_account_id' => [ @@ -45,4 +57,20 @@ public function rules(): array ], ]; } + + /** + * @return Collection + */ + private function resolveSelectedPlatforms(string $workspaceId): Collection + { + $accountIds = collect($this->input('platforms', []))->pluck('social_account_id')->filter()->all(); + if (empty($accountIds)) { + return collect(); + } + + return SocialAccount::query() + ->where('workspace_id', $workspaceId) + ->whereIn('id', $accountIds) + ->pluck('platform', 'id'); + } } diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 29fa072a..7d20f39a 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -6,9 +6,13 @@ use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Models\Post; +use App\Models\PostPlatform; +use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeMatchesPostPlatform; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Collection; use Illuminate\Validation\Rule; class UpdatePostRequest extends FormRequest @@ -20,9 +24,23 @@ public function authorize(): bool public function rules(): array { + $enforcesPlatformLimits = in_array( + $this->input('status'), + [Status::Scheduled->value, Status::Publishing->value], + true, + ); + return [ 'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])], - 'content' => ['nullable', 'string', 'max:63206'], + 'content' => [ + 'nullable', + 'string', + 'max:63206', + Rule::when( + $enforcesPlatformLimits, + [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())] + ), + ], 'media' => ['sometimes', 'array'], 'platforms' => ['sometimes', 'array'], 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))], @@ -45,4 +63,23 @@ public function rules(): array 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], ]; } + + /** + * @return Collection + */ + private function resolveSelectedPlatforms(): Collection + { + $ids = collect($this->input('platforms', []))->pluck('id')->filter()->all(); + if (empty($ids)) { + return collect(); + } + + $post = $this->route('post'); + $postId = $post instanceof Post ? $post->id : $post; + + return PostPlatform::query() + ->where('post_id', $postId) + ->whereIn('id', $ids) + ->pluck('platform', 'id'); + } } diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index d0de7640..6e8b7e30 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -7,8 +7,10 @@ use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeCompatibleWithMedia; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Collection; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; @@ -29,7 +31,15 @@ public function rules(): array return [ 'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])], - 'content' => ['nullable', 'string', 'max:63206'], + 'content' => [ + 'nullable', + 'string', + 'max:63206', + Rule::when( + $enforcesMediaCompatibility, + [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())] + ), + ], 'media' => ['sometimes', 'array'], 'media.*.id' => ['required', 'string'], 'media.*.path' => ['required', 'string', 'max:500'], @@ -108,4 +118,20 @@ private function isPublishingOrScheduling(): bool true, ); } + + /** + * @return Collection + */ + private function resolveSelectedPlatforms(): Collection + { + $ids = collect($this->input('platforms', []))->pluck('id')->filter()->all(); + if (empty($ids)) { + return collect(); + } + + return $this->route('post') + ->postPlatforms() + ->whereIn('id', $ids) + ->pluck('platform', 'id'); + } } diff --git a/app/Rules/ContentFitsPlatformLimits.php b/app/Rules/ContentFitsPlatformLimits.php new file mode 100644 index 00000000..8fffe3c4 --- /dev/null +++ b/app/Rules/ContentFitsPlatformLimits.php @@ -0,0 +1,56 @@ + $platforms + */ + public function __construct(private Collection $platforms) {} + + /** + * @param Closure(string, ?string=): PotentiallyTranslatedString $fail + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $content = (string) $value; + $reported = []; + + foreach ($this->platforms as $platform) { + if (! $platform instanceof Platform) { + continue; + } + + if (isset($reported[$platform->value])) { + continue; + } + + $over = $platform->contentOverflow($content); + if ($over === null) { + continue; + } + + $reported[$platform->value] = true; + $fail(trans('posts.form.content_exceeds_platform', [ + 'platform' => $platform->label(), + 'limit' => $platform->maxContentLength(), + 'over' => $over, + ])); + } + } +} diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index c119073f..95d24bc4 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -14,14 +14,18 @@ trait HasSocialHttpClient { protected function validateContentLength(PostPlatform $postPlatform): void { - $maxLength = $postPlatform->platform->maxContentLength(); - $contentLength = mb_strlen($postPlatform->post->content ?? ''); + $content = $postPlatform->post->content ?? ''; - if ($contentLength > $maxLength) { - throw new \Exception( - "Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)." - ); + if ($postPlatform->platform->contentOverflow($content) === null) { + return; } + + $maxLength = $postPlatform->platform->maxContentLength(); + $contentLength = mb_strlen($content); + + throw new \Exception( + "Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)." + ); } protected function refreshTokenWithLock(SocialAccount $account, callable $refreshFn): void diff --git a/lang/en/posts.php b/lang/en/posts.php index 1f7c4cc5..835e4c6e 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -47,6 +47,7 @@ 'drag_to_reorder' => 'Drag to reorder', 'caption' => 'Caption', 'write_caption' => 'Write your caption...', + 'content_exceeds_platform' => ':platform: too long by :over chars (max :limit).', 'tiktok' => [ 'settings' => 'TikTok Settings', 'variant_label' => 'Post type', diff --git a/lang/es/posts.php b/lang/es/posts.php index f0c37f10..b3e8efd8 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -47,6 +47,7 @@ 'drag_to_reorder' => 'Arrastra para reordenar', 'caption' => 'Descripción', 'write_caption' => 'Escribe tu descripción...', + 'content_exceeds_platform' => ':platform: demasiado largo por :over caracteres (máx :limit).', 'tiktok' => [ 'settings' => 'Configuración de TikTok', 'variant_label' => 'Tipo de publicación', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index ee030523..ffb20853 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -47,6 +47,7 @@ 'drag_to_reorder' => 'Arraste para reordenar', 'caption' => 'Legenda', 'write_caption' => 'Escreva sua legenda...', + 'content_exceeds_platform' => ':platform: longo demais por :over caracteres (máx :limit).', 'tiktok' => [ 'settings' => 'Configurações do TikTok', 'variant_label' => 'Tipo de publicação', diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index 9e30322b..95a26401 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { usePostEcho } from '@/composables/echo/usePostEcho'; import { getMediaItemIssue } from '@/composables/useMedia'; import { getMediaRulesForContentType } from '@/composables/useMediaRules'; +import { getPlatformLabel } from '@/composables/usePlatformLogo'; import dayjs from '@/dayjs'; import debounce from '@/debounce'; import { Platform } from '@/enums/platform'; @@ -279,18 +280,34 @@ const tiktokComplianceValid = computed(() => { }); }); +const contentLengthOverflows = computed(() => { + const len = content.value.length; + return platformLimits.value + .filter((p) => len > p.maxLength) + .map((p) => ({ platform: p.platform, limit: p.maxLength, over: len - p.maxLength })); +}); + const canSchedule = computed( - () => mediaCompliancePerPlatformValid.value && tiktokComplianceValid.value, + () => mediaCompliancePerPlatformValid.value + && tiktokComplianceValid.value + && contentLengthOverflows.value.length === 0, ); const postActionTooltip = computed(() => { if (canSchedule.value) return ''; - // Collect platform-specific media compatibility issues. - const reasons = post.value.post_platforms + const mediaReasons = post.value.post_platforms .filter((pp) => selectedPlatformIds.value.includes(pp.id) && platformIssues.value[pp.id]) .map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`); + const lengthReasons = contentLengthOverflows.value.map((overflow) => trans('posts.form.content_exceeds_platform', { + platform: getPlatformLabel(overflow.platform), + limit: String(overflow.limit), + over: String(overflow.over), + })); + + const reasons = [...mediaReasons, ...lengthReasons]; + if (reasons.length > 0) return reasons.join('\n'); // No media issues — the only remaining blocker is TikTok compliance. diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 27897358..25043996 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -307,6 +307,96 @@ ->assertJsonValidationErrors(['platforms.0.content_type']); }); +it('rejects scheduling an over-limit threads post via the api store', function () { + $threadsAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + ]); + + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => str_repeat('a', 537), + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'platforms' => [ + ['social_account_id' => $threadsAccount->id, 'content_type' => ContentType::ThreadsPost->value], + ], + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors(['content']); + expect($response->json('errors.content.0')) + ->toContain('Threads') + ->toContain('500') + ->toContain('37'); +}); + +it('accepts creating an over-limit draft (no scheduled_at) via the api store', function () { + $threadsAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => str_repeat('a', 1000), + 'platforms' => [ + ['social_account_id' => $threadsAccount->id, 'content_type' => ContentType::ThreadsPost->value], + ], + ]) + ->assertCreated(); +}); + +it('rejects scheduling an over-limit threads post via the api update', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $threadsAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + ]); + $threadsPlatform = PostPlatform::factory()->threads()->create([ + 'post_id' => $post->id, + 'social_account_id' => $threadsAccount->id, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Scheduled->value, + 'content' => str_repeat('a', 600), + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'platforms' => [ + ['id' => $threadsPlatform->id, 'content_type' => ContentType::ThreadsPost->value], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['content']); +}); + +it('saving an over-limit threads post as draft via api skips the content-length check', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $threadsAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + ]); + $threadsPlatform = PostPlatform::factory()->threads()->create([ + 'post_id' => $post->id, + 'social_account_id' => $threadsAccount->id, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Draft->value, + 'content' => str_repeat('a', 1000), + 'platforms' => [ + ['id' => $threadsPlatform->id, 'content_type' => ContentType::ThreadsPost->value], + ], + ]) + ->assertSuccessful(); +}); + it('rejects creating a post when content_type does not match the social account platform', function () { // x_post on a LinkedIn account — ContentTypeMatchesPlatform should reject. $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) diff --git a/tests/Feature/UpdatePostRequestTest.php b/tests/Feature/UpdatePostRequestTest.php index 6adcdaf7..258db207 100644 --- a/tests/Feature/UpdatePostRequestTest.php +++ b/tests/Feature/UpdatePostRequestTest.php @@ -95,3 +95,130 @@ $response->assertSessionDoesntHaveErrors(['platforms.0.meta.privacy_level']); }); + +test('scheduling a threads post over 500 chars is rejected with the platform name', function () { + $threadsAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + ]); + $threadsPlatform = PostPlatform::factory()->threads()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $threadsAccount->id, + ]); + + $response = $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Scheduled->value, + 'content' => str_repeat('a', 537), + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'platforms' => [ + [ + 'id' => $threadsPlatform->id, + 'content_type' => ContentType::ThreadsPost->value, + 'meta' => [], + ], + ], + ]); + + $response->assertSessionHasErrors('content'); + expect(session('errors')->get('content')[0]) + ->toContain('Threads') + ->toContain('500') + ->toContain('37'); // over by 37 +}); + +test('scheduling a threads post within 500 chars passes content-length validation', function () { + $threadsAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + ]); + $threadsPlatform = PostPlatform::factory()->threads()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $threadsAccount->id, + ]); + + $response = $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Scheduled->value, + 'content' => str_repeat('a', 500), + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'platforms' => [ + [ + 'id' => $threadsPlatform->id, + 'content_type' => ContentType::ThreadsPost->value, + 'meta' => [], + ], + ], + ]); + + $response->assertSessionDoesntHaveErrors('content'); +}); + +test('saving an over-limit threads post as draft skips the content-length rule', function () { + $threadsAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + ]); + $threadsPlatform = PostPlatform::factory()->threads()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $threadsAccount->id, + ]); + + $response = $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Draft->value, + 'content' => str_repeat('a', 1000), + 'platforms' => [ + [ + 'id' => $threadsPlatform->id, + 'content_type' => ContentType::ThreadsPost->value, + 'meta' => [], + ], + ], + ]); + + $response->assertSessionDoesntHaveErrors('content'); +}); + +test('scheduling across multiple platforms enforces the strictest content-length cap', function () { + $facebookAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Facebook, + ]); + $facebookPlatform = PostPlatform::factory()->facebook()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $facebookAccount->id, + ]); + + $threadsAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + ]); + $threadsPlatform = PostPlatform::factory()->threads()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $threadsAccount->id, + ]); + + // 600 chars: fine for Facebook (63206 cap), over for Threads (500 cap). + $response = $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Scheduled->value, + 'content' => str_repeat('a', 600), + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'platforms' => [ + [ + 'id' => $facebookPlatform->id, + 'content_type' => ContentType::FacebookPost->value, + 'meta' => [], + ], + [ + 'id' => $threadsPlatform->id, + 'content_type' => ContentType::ThreadsPost->value, + 'meta' => [], + ], + ], + ]); + + $response->assertSessionHasErrors('content'); + expect(session('errors')->get('content')[0])->toContain('Threads'); +}); diff --git a/tests/Unit/Rules/ContentFitsPlatformLimitsTest.php b/tests/Unit/Rules/ContentFitsPlatformLimitsTest.php new file mode 100644 index 00000000..f841553d --- /dev/null +++ b/tests/Unit/Rules/ContentFitsPlatformLimitsTest.php @@ -0,0 +1,62 @@ +validate('content', $content, function (string $message) use (&$errors): void { + $errors[] = $message; + }); + + return $errors; +} + +test('passes when content fits every platform cap', function () { + $errors = runFitsRule(str_repeat('a', 280), [Platform::X, Platform::Threads, Platform::Facebook]); + + expect($errors)->toBe([]); +}); + +test('fails with the platform label, limit and overage when content exceeds a single platform', function () { + $errors = runFitsRule(str_repeat('a', 537), [Platform::Threads]); + + expect($errors)->toHaveCount(1); + expect($errors[0]) + ->toContain('Threads') + ->toContain('500') + ->toContain('37'); +}); + +test('emits one error per overflowing platform in a multi-platform set', function () { + // 320 chars: fine for Threads (500), over for X (280) and Bluesky (300). + $errors = runFitsRule(str_repeat('a', 320), [Platform::X, Platform::Bluesky, Platform::Threads]); + + expect($errors)->toHaveCount(2); + expect($errors[0])->toContain('X'); + expect($errors[1])->toContain('Bluesky'); +}); + +test('deduplicates errors when the same platform appears twice in the collection', function () { + // Two Threads accounts selected, content 600 chars — should still produce ONE error. + $errors = runFitsRule(str_repeat('a', 600), [Platform::Threads, Platform::Threads]); + + expect($errors)->toHaveCount(1); +}); + +test('passes for an empty platforms collection', function () { + $errors = runFitsRule(str_repeat('a', 10_000), []); + + expect($errors)->toBe([]); +}); + +test('treats null content as an empty string and passes', function () { + $errors = runFitsRule('', [Platform::Threads, Platform::X]); + + expect($errors)->toBe([]); +});