fix(posts): block scheduling when content exceeds any platform's char limit

Threads posts over 500 chars were saved + scheduled successfully and only
failed inside the publish job. The frontend already showed the 537|500 badge
but `canSchedule` ignored content length, so Schedule and Post Now stayed
enabled. Backend `UpdatePostRequest` only capped at 63206 (Facebook's max),
not per-platform.

- Add `Platform::contentOverflow()` as the single source of truth and reuse it
  from `HasSocialHttpClient::validateContentLength` (publish-time).
- New `ContentFitsPlatformLimits` rule applied to the `content` field on
  `App\\UpdatePostRequest`, `Api\\UpdatePostRequest`, and `Api\\StorePostRequest`
  via `Rule::when(...)` so drafts are not blocked.
- Rule dedupes per platform (two Threads accounts -> one error) and reports
  the platform label, hard cap, and overage via i18n.
- Edit.vue feeds `contentLengthOverflows` into `canSchedule` and lists each
  offending platform in `postActionTooltip` using the existing
  `getPlatformLabel` resolver.
This commit is contained in:
Paulo Castellano 2026-05-11 19:39:41 -03:00
parent 479011b0b3
commit 953be22b5b
13 changed files with 475 additions and 12 deletions

View file

@ -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

View file

@ -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<int|string, Platform>
*/
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');
}
}

View file

@ -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<int|string, Platform>
*/
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');
}
}

View file

@ -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<int|string, Platform>
*/
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');
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Enums\SocialAccount\Platform;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Collection;
use Illuminate\Translation\PotentiallyTranslatedString;
/**
* Fails the content field once per platform whose hard `maxContentLength()` is
* exceeded by the submitted text. Pre-resolve the platforms the post is bound
* to (App: from `post_platforms.id`; API store: from `social_accounts.id`) and
* pass them in keeps the rule decoupled from the FormRequest payload shape.
*/
class ContentFitsPlatformLimits implements ValidationRule
{
/**
* @param Collection<int|string, Platform> $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,
]));
}
}
}

View file

@ -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

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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.

View file

@ -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])

View file

@ -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');
});

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Rules\ContentFitsPlatformLimits;
function runFitsRule(string $content, array $platforms): array
{
$errors = [];
$rule = new ContentFitsPlatformLimits(collect($platforms));
$rule->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([]);
});