trypost/app/Support/PostPlatformMetaRules.php
Paulo Castellano 7388313f5c feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.

- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip

LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 16:32:27 -03:00

143 lines
6.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\PostPlatform\AspectRatio;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
/**
* Single source of truth for per-platform `PostPlatform.meta` validation, shared
* by the web, public REST API, and MCP post create/update flows so every entry
* point accepts the same per-platform settings and enforces the same
* required-on-publish rules.
*/
class PostPlatformMetaRules
{
/**
* TikTok content visibility options.
*
* @var array<int, string>
*/
public const TIKTOK_PRIVACY_LEVELS = [
'PUBLIC_TO_EVERYONE',
'MUTUAL_FOLLOW_FRIENDS',
'FOLLOWER_OF_CREATOR',
'SELF_ONLY',
];
/**
* Validation rules for `platforms.*.meta` and all its per-platform sub-keys.
* Spread into a FormRequest/MCP tool rule set as the complete meta contract.
*
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'platforms.*.meta' => ['sometimes', 'nullable', 'array'],
// Instagram / Facebook
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
// LinkedIn — title shown on a document (PDF carousel) post
'platforms.*.meta.document_title' => ['sometimes', 'nullable', 'string', 'max:300'],
// TikTok
'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(self::TIKTOK_PRIVACY_LEVELS)],
'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'],
'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'],
'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'],
'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'],
'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'],
'platforms.*.meta.disclose' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],
// Pinterest
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
// Discord
'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.mentions' => ['sometimes', 'nullable', 'array'],
'platforms.*.meta.mentions.*.token' => ['required', 'string'],
'platforms.*.meta.mentions.*.label' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.embeds' => ['sometimes', 'nullable', 'array', 'max:10'],
'platforms.*.meta.embeds.*.title' => ['sometimes', 'nullable', 'string', 'max:256'],
'platforms.*.meta.embeds.*.description' => ['sometimes', 'nullable', 'string', 'max:4096'],
'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'],
'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'],
'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'],
];
}
/**
* Adds validation errors for per-platform meta that becomes mandatory once a
* post is published or scheduled (TikTok privacy, Pinterest board, Discord
* channel), based on the submitted request platforms. The caller resolves each
* platform row to its Platform enum, since that lookup differs between create
* (by social account) and update (by post platform).
*
* @param array<int, mixed> $platforms
* @param callable(mixed, int): ?Platform $resolvePlatform
*/
public static function addRequiredOnPublishErrors(Validator $validator, array $platforms, callable $resolvePlatform): void
{
foreach ($platforms as $index => $platform) {
$violation = self::requiredMetaViolation($resolvePlatform($platform, $index), data_get($platform, 'meta'));
if ($violation !== null) {
[$field, $message] = $violation;
$validator->errors()->add("platforms.{$index}.meta.{$field}", $message);
}
}
}
/**
* Asserts that every ENABLED platform already stored on a post has the meta it
* needs to publish. Used by entry points that publish a post's stored state
* without resubmitting platforms (e.g. the MCP publish tool), so a misconfigured
* post fails fast with a clear message instead of only at publish time.
*
* @throws ValidationException
*/
public static function assertStoredPostPublishable(Post $post): void
{
$errors = [];
foreach ($post->postPlatforms()->where('enabled', true)->get()->values() as $index => $postPlatform) {
$violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta);
if ($violation !== null) {
[$field, $message] = $violation;
$errors["platforms.{$index}.meta.{$field}"] = $message;
}
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
/**
* The missing required meta field for a platform about to publish, or null when
* nothing is missing. Single source of "what each platform requires to publish".
*
* @return array{0: string, 1: string}|null [field, message]
*/
private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array
{
return match (true) {
$platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')],
$platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')],
$platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')],
default => null,
};
}
}