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.
This commit is contained in:
Paulo Castellano 2026-06-24 16:32:27 -03:00
parent f1822ca77c
commit 7388313f5c
39 changed files with 971 additions and 26 deletions

View file

@ -43,6 +43,15 @@ public function isImage(): bool
return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']);
}
public function isDocument(): bool
{
if ($this->mime_type) {
return $this->mime_type === 'application/pdf';
}
return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf';
}
/**
* @param array<string, mixed> $data
*/
@ -63,6 +72,7 @@ public static function fromArray(array $data): self
'webp' => 'image/webp',
'mp4' => 'video/mp4',
'mov' => 'video/quicktime',
'pdf' => 'application/pdf',
default => null,
};
}

View file

@ -8,12 +8,14 @@ enum Type: string
{
case Image = 'image';
case Video = 'video';
case Document = 'document';
public function label(): string
{
return match ($this) {
self::Image => 'Imagem',
self::Video => 'Vídeo',
self::Document => 'Documento',
};
}
@ -31,6 +33,11 @@ public function label(): string
* server-side transcoding, accepting WebM would just produce
* platform-specific publish failures down the line.
*
* Document accepts PDF only — the swipeable LinkedIn document
* (carousel) format. PPTX/DOCX are also valid LinkedIn documents
* but are converted server-side by LinkedIn and lose fonts, so we
* keep the surface to PDF.
*
* @return array<int, string>
*/
public function allowedMimeTypes(): array
@ -38,6 +45,7 @@ public function allowedMimeTypes(): array
return match ($this) {
self::Image => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
self::Video => ['video/mp4', 'video/quicktime'],
self::Document => ['application/pdf'],
};
}
@ -52,6 +60,7 @@ public function extensions(): array
return match ($this) {
self::Image => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
self::Video => ['mp4', 'mov'],
self::Document => ['pdf'],
};
}

View file

@ -16,10 +16,12 @@ enum ContentType: string
// LinkedIn
case LinkedInPost = 'linkedin_post';
case LinkedInCarousel = 'linkedin_carousel';
case LinkedInDocument = 'linkedin_document';
// LinkedIn Page
case LinkedInPagePost = 'linkedin_page_post';
case LinkedInPageCarousel = 'linkedin_page_carousel';
case LinkedInPageDocument = 'linkedin_page_document';
// Facebook
case FacebookPost = 'facebook_post';
@ -70,6 +72,7 @@ public function label(): string
self::InstagramStory => 'Story',
self::LinkedInPost, self::LinkedInPagePost => 'Post',
self::LinkedInCarousel, self::LinkedInPageCarousel => 'Carousel',
self::LinkedInDocument, self::LinkedInPageDocument => 'Document',
self::FacebookPost => 'Post',
self::FacebookReel => 'Reel',
self::FacebookStory => 'Story',
@ -97,8 +100,8 @@ public function platform(): SocialPlatform
{
return match ($this) {
self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram,
self::LinkedInPost, self::LinkedInCarousel => SocialPlatform::LinkedIn,
self::LinkedInPagePost, self::LinkedInPageCarousel => SocialPlatform::LinkedInPage,
self::LinkedInPost, self::LinkedInCarousel, self::LinkedInDocument => SocialPlatform::LinkedIn,
self::LinkedInPagePost, self::LinkedInPageCarousel, self::LinkedInPageDocument => SocialPlatform::LinkedInPage,
self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook,
self::TikTokVideo, self::TikTokPhoto => SocialPlatform::TikTok,
self::YouTubeShort => SocialPlatform::YouTube,
@ -166,6 +169,7 @@ public function maxMediaCount(): int
self::InstagramReel, self::InstagramStory => 1,
self::LinkedInPost, self::LinkedInPagePost => 1,
self::LinkedInCarousel, self::LinkedInPageCarousel => 20,
self::LinkedInDocument, self::LinkedInPageDocument => 1,
self::FacebookPost => 10,
self::FacebookReel, self::FacebookStory => 1,
self::TikTokVideo => 1,
@ -188,6 +192,7 @@ public function supportsVideo(): bool
self::InstagramFeed, self::InstagramReel, self::InstagramStory => true,
self::LinkedInPost, self::LinkedInPagePost => true,
self::LinkedInCarousel, self::LinkedInPageCarousel => false,
self::LinkedInDocument, self::LinkedInPageDocument => false,
self::FacebookPost, self::FacebookReel, self::FacebookStory => true,
self::TikTokVideo => true,
self::TikTokPhoto => false,
@ -212,10 +217,24 @@ public function supportsImage(): bool
self::TikTokPhoto => true,
self::YouTubeShort => false,
self::PinterestVideoPin => false,
self::LinkedInDocument, self::LinkedInPageDocument => false,
default => true,
};
}
/**
* Whether this content type carries a document (PDF) attachment instead of
* images/videos — currently the LinkedIn swipeable document ("carousel").
* A document post is PDF-only: it never combines with images or videos.
*/
public function supportsDocument(): bool
{
return match ($this) {
self::LinkedInDocument, self::LinkedInPageDocument => true,
default => false,
};
}
/**
* Whether a single post may carry images and a video together. Most
* targets accept only one or the other; this is conservative and lists

View file

@ -94,7 +94,7 @@ public function color(): string
public function allowedMediaTypes(): array
{
return match ($this) {
self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video],
self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video, MediaType::Document],
self::X => [MediaType::Image, MediaType::Video],
self::TikTok => [MediaType::Video],
self::YouTube => [MediaType::Video],

View file

@ -22,6 +22,7 @@ public function rules(): array
$allowedMimes = [
...MediaType::Image->allowedMimeTypes(),
...MediaType::Video->allowedMimeTypes(),
...MediaType::Document->allowedMimeTypes(),
];
return [

View file

@ -22,6 +22,7 @@ public function rules(): array
$allowed = implode(',', array_merge(
MediaType::Image->allowedMimeTypes(),
MediaType::Video->allowedMimeTypes(),
MediaType::Document->allowedMimeTypes(),
));
$maxKb = (int) config('ai.mcp.upload.max_size_mb') * 1024;

View file

@ -16,7 +16,7 @@ public function authorize(): bool
public function rules(): array
{
$allowedMimes = [...MediaType::Image->allowedMimeTypes(), ...MediaType::Video->allowedMimeTypes()];
$allowedMimes = [...MediaType::Image->allowedMimeTypes(), ...MediaType::Video->allowedMimeTypes(), ...MediaType::Document->allowedMimeTypes()];
return [
// Use the largest per-type cap as the upper bound; per-type

View file

@ -39,7 +39,7 @@ protected function prepareForValidation(): void
*/
public function rules(): array
{
$allowedSuffixes = collect([MediaType::Image, MediaType::Video])
$allowedSuffixes = collect([MediaType::Image, MediaType::Video, MediaType::Document])
->flatMap(fn (MediaType $type) => $type->extensions())
->map(fn (string $ext) => '.'.$ext)
->all();

View file

@ -14,7 +14,7 @@
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Download images or videos from public URLs and attach them to a post. Each URL is fetched, stored, and registered as a Media record on the workspace. Allowed types are intersected with the platforms enabled on the post (e.g. nothing accepted if no platform supports the media type).')]
#[Description('Download images, videos, or PDF documents from public URLs and attach them to a post. Each URL is fetched, stored, and registered as a Media record on the workspace. Allowed types are intersected with the platforms enabled on the post (e.g. nothing accepted if no platform supports the media type).')]
class AttachMediaFromUrlTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
@ -53,7 +53,7 @@ public function schema(JsonSchema $schema): array
'urls' => $schema->array()
->items($schema->string())
->required()
->description('Public HTTP/HTTPS URLs of images or videos. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, video/webm.'),
->description('Public HTTP/HTTPS URLs of images, videos, or PDFs. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
];
}
}

View file

@ -14,7 +14,7 @@
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image or video, up to the workspace upload cap — 50 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')]
#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document, up to the workspace upload cap — 50 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')]
class RequestMediaUploadTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory

View file

@ -81,6 +81,15 @@ public function isImage(): bool
return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']);
}
public function isDocument(): bool
{
if ($this->mime_type) {
return $this->mime_type === 'application/pdf';
}
return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf';
}
public function getTemporaryUrl(int $expirationMinutes = 60): string
{
return Storage::temporaryUrl(

View file

@ -164,6 +164,10 @@ private function getMediaType(string $mimeType): string
return 'video';
}
if ($mimeType === 'application/pdf') {
return 'document';
}
throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}");
}

View file

@ -53,6 +53,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$hasImage = collect($media)->contains(fn ($item) => $this->isImage((array) $item));
$hasVideo = collect($media)->contains(fn ($item) => $this->isVideo((array) $item));
$hasDocument = collect($media)->contains(fn ($item) => $this->isDocument((array) $item));
if ($hasImage && ! $contentType->supportsImage()) {
$fail("{$contentType->label()} does not support images.");
@ -62,6 +63,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$fail("{$contentType->label()} does not support videos.");
}
if ($hasDocument && ! $contentType->supportsDocument()) {
$fail("{$contentType->label()} does not support PDF documents.");
}
if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) {
$fail("{$contentType->label()} can't combine an image and a video in the same post.");
}
@ -90,4 +95,16 @@ private function isVideo(array $item): bool
return str_starts_with((string) data_get($item, 'mime_type', ''), 'video/');
}
/**
* @param array<string, mixed> $item
*/
private function isDocument(array $item): bool
{
if (data_get($item, 'type') === MediaType::Document->value) {
return true;
}
return data_get($item, 'mime_type') === 'application/pdf';
}
}

View file

@ -63,6 +63,7 @@ public function publish(PostPlatform $postPlatform): array
try {
return match ($contentType) {
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account),
ContentType::LinkedInPageDocument => $this->publishDocument($organizationUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform), $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account),
default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"),
};
@ -89,6 +90,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
return match ($contentType) {
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account),
ContentType::LinkedInPageDocument => $this->publishDocument($organizationUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform), $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account),
default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"),
};
@ -219,6 +221,77 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med
];
}
/**
* The title shown on a LinkedIn document (PDF carousel) post. Falls back
* to the uploaded file name, then a generic label, so it's never empty.
*/
private function resolveDocumentTitle(PostPlatform $postPlatform): string
{
$title = data_get($postPlatform->meta, 'document_title');
if (filled($title)) {
return (string) $title;
}
return $postPlatform->post->mediaItems->first()?->original_filename ?? 'Document';
}
private function publishDocument(string $organizationUrn, ?string $content, $mediaCollection, string $title, $account): array
{
$document = $mediaCollection->first(fn ($media) => $media->isDocument());
if (! $document) {
throw new \Exception('No PDF document for LinkedIn Page document post');
}
$documentUrn = $this->uploadDocument($document, $organizationUrn);
if (! $documentUrn) {
throw new \Exception('LinkedIn Page document upload failed');
}
$payload = [
'author' => $organizationUrn,
'commentary' => $content ?? '',
'visibility' => 'PUBLIC',
'distribution' => [
'feedDistribution' => 'MAIN_FEED',
'targetEntities' => [],
'thirdPartyDistributionChannels' => [],
],
'lifecycleState' => 'PUBLISHED',
'content' => [
'media' => [
'id' => $documentUrn,
'title' => $title,
],
],
];
$response = $this->getHttpClient()
->post("{$this->baseUrl}/rest/posts", $payload);
if ($response->failed()) {
Log::error('LinkedIn Page document post creation failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
$postId = $response->header('x-restli-id');
$username = $account->username;
$postUrl = $username
? "https://www.linkedin.com/company/{$username}/posts/"
: "https://www.linkedin.com/feed/update/{$postId}";
return [
'id' => $postId ?? 'unknown',
'url' => $postId ? $postUrl : null,
];
}
private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken)
@ -450,6 +523,98 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
Log::warning('LinkedIn Page video processing timeout, proceeding anyway');
}
private function uploadDocument($mediaItem, string $ownerUrn): ?string
{
// Step 1: Initialize upload
$initResponse = $this->getHttpClient()
->post("{$this->baseUrl}/rest/documents?action=initializeUpload", [
'initializeUploadRequest' => [
'owner' => $ownerUrn,
],
]);
if ($initResponse->failed()) {
Log::error('LinkedIn Page document init failed', ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
$initData = $initResponse->json();
$uploadUrl = $initData['value']['uploadUrl'] ?? null;
$documentUrn = $initData['value']['document'] ?? null;
if (! $uploadUrl || ! $documentUrn) {
throw new \Exception('LinkedIn Page document upload init missing uploadUrl or document URN');
}
// Step 2: Upload the PDF in a single request (Documents API is not chunked)
$tempFile = tempnam(sys_get_temp_dir(), 'lip_document_');
try {
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
if ($downloadResponse->failed()) {
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
}
$stream = fopen($tempFile, 'r');
$uploadResponse = Http::withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/pdf',
])
->timeout(600)
->withBody($stream, 'application/pdf')
->put($uploadUrl);
if (is_resource($stream)) {
fclose($stream);
}
if ($uploadResponse->failed()) {
Log::error('LinkedIn Page document upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]);
$this->handleApiError($uploadResponse);
}
} finally {
@unlink($tempFile);
}
// Step 3: Wait for processing (SYNCHRONOUS_UPLOAD is not supported)
$this->waitForDocumentProcessing($documentUrn);
return $documentUrn;
}
private function waitForDocumentProcessing(string $documentUrn, int $maxAttempts = 30): void
{
$encodedUrn = urlencode($documentUrn);
for ($i = 0; $i < $maxAttempts; $i++) {
$response = $this->getHttpClient()
->get("{$this->baseUrl}/rest/documents/{$encodedUrn}");
if ($response->failed()) {
Log::warning('LinkedIn Page document status check failed', ['attempt' => $i]);
sleep(5);
continue;
}
$status = data_get($response->json(), 'status', 'UNKNOWN');
if ($status === 'AVAILABLE') {
return;
}
if ($status === 'PROCESSING_FAILED') {
throw new \Exception('LinkedIn Page document processing failed');
}
sleep(5);
}
Log::warning('LinkedIn Page document processing timeout, proceeding anyway');
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);

View file

@ -57,6 +57,7 @@ public function publish(PostPlatform $postPlatform): array
try {
return match ($contentType) {
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->post->mediaItems),
ContentType::LinkedInDocument => $this->publishDocument($personUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform)),
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->post->mediaItems),
default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"),
};
@ -82,6 +83,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
return match ($contentType) {
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->post->mediaItems),
ContentType::LinkedInDocument => $this->publishDocument($personUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform)),
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->post->mediaItems),
default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"),
};
@ -200,6 +202,73 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl
];
}
/**
* The title shown on a LinkedIn document (PDF carousel) post. Falls back
* to the uploaded file name, then a generic label, so it's never empty —
* LinkedIn renders the title beneath the document.
*/
private function resolveDocumentTitle(PostPlatform $postPlatform): string
{
$title = data_get($postPlatform->meta, 'document_title');
if (filled($title)) {
return (string) $title;
}
return $postPlatform->post->mediaItems->first()?->original_filename ?? 'Document';
}
private function publishDocument(string $personUrn, ?string $content, $mediaCollection, string $title): array
{
$document = $mediaCollection->first(fn ($media) => $media->isDocument());
if (! $document) {
throw new \Exception('No PDF document for LinkedIn document post');
}
$documentUrn = $this->uploadDocument($document, $personUrn);
if (! $documentUrn) {
throw new \Exception('LinkedIn document upload failed');
}
$payload = [
'author' => $personUrn,
'commentary' => $content ?? '',
'visibility' => 'PUBLIC',
'distribution' => [
'feedDistribution' => 'MAIN_FEED',
'targetEntities' => [],
'thirdPartyDistributionChannels' => [],
],
'lifecycleState' => 'PUBLISHED',
'content' => [
'media' => [
'id' => $documentUrn,
'title' => $title,
],
],
];
$response = $this->getHttpClient()
->post("{$this->baseUrl}/rest/posts", $payload);
if ($response->failed()) {
Log::error('LinkedIn document post creation failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
$postId = $response->header('x-restli-id');
return [
'id' => $postId ?? 'unknown',
'url' => $postId ? "https://www.linkedin.com/feed/update/{$postId}" : null,
];
}
private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken)
@ -431,6 +500,98 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
Log::warning('LinkedIn video processing timeout, proceeding anyway');
}
private function uploadDocument($mediaItem, string $ownerUrn): ?string
{
// Step 1: Initialize upload
$initResponse = $this->getHttpClient()
->post("{$this->baseUrl}/rest/documents?action=initializeUpload", [
'initializeUploadRequest' => [
'owner' => $ownerUrn,
],
]);
if ($initResponse->failed()) {
Log::error('LinkedIn document init failed', ['body' => $this->redactResponseBody($initResponse->body())]);
$this->handleApiError($initResponse);
}
$initData = $initResponse->json();
$uploadUrl = $initData['value']['uploadUrl'] ?? null;
$documentUrn = $initData['value']['document'] ?? null;
if (! $uploadUrl || ! $documentUrn) {
throw new \Exception('LinkedIn document upload init missing uploadUrl or document URN');
}
// Step 2: Upload the PDF in a single request (Documents API is not chunked)
$tempFile = tempnam(sys_get_temp_dir(), 'li_document_');
try {
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
if ($downloadResponse->failed()) {
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
}
$stream = fopen($tempFile, 'r');
$uploadResponse = Http::withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/pdf',
])
->timeout(600)
->withBody($stream, 'application/pdf')
->put($uploadUrl);
if (is_resource($stream)) {
fclose($stream);
}
if ($uploadResponse->failed()) {
Log::error('LinkedIn document upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]);
$this->handleApiError($uploadResponse);
}
} finally {
@unlink($tempFile);
}
// Step 3: Wait for processing (SYNCHRONOUS_UPLOAD is not supported)
$this->waitForDocumentProcessing($documentUrn);
return $documentUrn;
}
private function waitForDocumentProcessing(string $documentUrn, int $maxAttempts = 30): void
{
$encodedUrn = urlencode($documentUrn);
for ($i = 0; $i < $maxAttempts; $i++) {
$response = $this->getHttpClient()
->get("{$this->baseUrl}/rest/documents/{$encodedUrn}");
if ($response->failed()) {
Log::warning('LinkedIn document status check failed', ['attempt' => $i]);
sleep(5);
continue;
}
$status = data_get($response->json(), 'status', 'UNKNOWN');
if ($status === 'AVAILABLE') {
return;
}
if ($status === 'PROCESSING_FAILED') {
throw new \Exception('LinkedIn document processing failed');
}
sleep(5);
}
Log::warning('LinkedIn document processing timeout, proceeding anyway');
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);

View file

@ -45,6 +45,9 @@ public static function rules(): 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'],

View file

@ -46,6 +46,8 @@
'max_size_mb' => [
'image' => (int) env('MEDIA_IMAGE_MAX_SIZE_MB', 10),
'video' => (int) env('MEDIA_VIDEO_MAX_SIZE_MB', 1024),
// LinkedIn caps document (PDF carousel) uploads at 100MB.
'document' => (int) env('MEDIA_DOCUMENT_MAX_SIZE_MB', 100),
],
],

View file

@ -143,6 +143,7 @@
'variant' => [
'post' => 'Post',
'carousel' => 'Carousel',
'document' => 'Document',
],
],
'pinterest' => [
@ -188,9 +189,11 @@
'min_files_required' => 'This post type requires at least :min media files (you have :current).',
'no_video_allowed' => 'This post type does not accept videos.',
'no_image_allowed' => 'This post type accepts only videos.',
'no_document_allowed' => 'This post type does not accept PDF documents.',
'gif_not_allowed' => 'This platform does not accept GIF. Remove the GIF or choose a different network.',
'image_too_large' => 'Image exceeds the :max limit for this post type (yours is :current).',
'video_too_large' => 'Video exceeds the :max limit for this post type (yours is :current).',
'document_too_large' => 'PDF exceeds the :max limit for this post type (yours is :current).',
'video_too_long' => 'Video is :current long, but this post type allows up to :max.',
'aspect_ratio_too_narrow' => 'Aspect ratio :current is too tall for this post type (min :min).',
'aspect_ratio_too_wide' => 'Aspect ratio :current is too wide for this post type (max :max).',
@ -472,6 +475,14 @@
'label' => 'Carousel',
'description' => 'Swipeable images',
],
'linkedin_document' => [
'label' => 'Document',
'description' => 'Swipeable PDF carousel',
],
'linkedin_page_document' => [
'label' => 'Document',
'description' => 'Swipeable PDF carousel',
],
'facebook_post' => [
'label' => 'Post',
'description' => 'Standard post on your page',

View file

@ -143,6 +143,7 @@
'variant' => [
'post' => 'Publicación',
'carousel' => 'Carrusel',
'document' => 'Documento',
],
],
'pinterest' => [
@ -188,9 +189,11 @@
'min_files_required' => 'Este tipo requiere al menos :min archivos (tienes :current).',
'no_video_allowed' => 'Este tipo no acepta videos.',
'no_image_allowed' => 'Este tipo acepta solo videos.',
'no_document_allowed' => 'Este tipo no acepta documentos PDF.',
'gif_not_allowed' => 'Esta red no acepta GIF. Elimínalo o selecciona otra red.',
'image_too_large' => 'La imagen supera el límite de :max (la tuya es :current).',
'video_too_large' => 'El video supera el límite de :max (el tuyo es :current).',
'document_too_large' => 'El PDF supera el límite de :max (el tuyo es :current).',
'video_too_long' => 'El video dura :current, pero este tipo permite hasta :max.',
'aspect_ratio_too_narrow' => 'La proporción :current es demasiado alta (mínimo :min).',
'aspect_ratio_too_wide' => 'La proporción :current es demasiado ancha (máximo :max).',
@ -472,6 +475,14 @@
'label' => 'Carrusel',
'description' => 'Imágenes deslizables',
],
'linkedin_document' => [
'label' => 'Documento',
'description' => 'Carrusel PDF deslizable',
],
'linkedin_page_document' => [
'label' => 'Documento',
'description' => 'Carrusel PDF deslizable',
],
'facebook_post' => [
'label' => 'Post',
'description' => 'Post estándar en tu página',

View file

@ -143,6 +143,7 @@
'variant' => [
'post' => 'Post',
'carousel' => 'Carrossel',
'document' => 'Documento',
],
],
'pinterest' => [
@ -188,9 +189,11 @@
'min_files_required' => 'Este tipo exige pelo menos :min arquivos (você tem :current).',
'no_video_allowed' => 'Este tipo não aceita vídeos.',
'no_image_allowed' => 'Este tipo aceita apenas vídeos.',
'no_document_allowed' => 'Este tipo não aceita documentos PDF.',
'gif_not_allowed' => 'Esta rede não aceita GIF. Remova o GIF ou escolha outra rede.',
'image_too_large' => 'A imagem passa do limite de :max (a sua tem :current).',
'video_too_large' => 'O vídeo passa do limite de :max (o seu tem :current).',
'document_too_large' => 'O PDF passa do limite de :max (o seu tem :current).',
'video_too_long' => 'O vídeo dura :current, mas este tipo permite no máximo :max.',
'aspect_ratio_too_narrow' => 'A proporção :current está muito alta (mínimo :min).',
'aspect_ratio_too_wide' => 'A proporção :current está muito larga (máximo :max).',
@ -472,6 +475,14 @@
'label' => 'Carrossel',
'description' => 'Imagens deslizáveis',
],
'linkedin_document' => [
'label' => 'Documento',
'description' => 'Carrossel PDF deslizável',
],
'linkedin_page_document' => [
'label' => 'Documento',
'description' => 'Carrossel PDF deslizável',
],
'facebook_post' => [
'label' => 'Post',
'description' => 'Post padrão na sua página',

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router, useHttp } from '@inertiajs/vue3';
import { IconCloudUpload, IconLoader2, IconPencilPlus, IconPhoto, IconPlus, IconSearch, IconTrash } from '@tabler/icons-vue';
import { IconCloudUpload, IconFileTypePdf, IconLoader2, IconPencilPlus, IconPhoto, IconPlus, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import { toast } from 'vue-sonner';
@ -91,11 +91,17 @@ const handleAssetClick = (asset: AssetMedia) => {
toggleSelect(asset);
return;
}
const items = uploads.value.map((a) => ({
// PDFs can't render in the image lightbox — open them in a new tab.
if (asset.type === 'document' || asset.mime_type === 'application/pdf') {
window.open(asset.url, '_blank', 'noopener');
return;
}
const previewable = uploads.value.filter((a) => a.type !== 'document' && a.mime_type !== 'application/pdf');
const items = previewable.map((a) => ({
url: a.url,
type: a.type === 'video' ? ('video' as const) : ('image' as const),
}));
const idx = uploads.value.findIndex((a) => a.id === asset.id);
const idx = previewable.findIndex((a) => a.id === asset.id);
lightbox.value?.openCollection(items, idx);
};
@ -617,7 +623,7 @@ onUnmounted(() => {
type="file"
class="hidden"
multiple
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4"
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,application/pdf"
@change="handleFileSelect"
/>
<div v-if="uploading" class="absolute inset-0 flex items-center justify-center rounded-2xl bg-card/85">
@ -668,6 +674,13 @@ onUnmounted(() => {
muted
preload="metadata"
/>
<div
v-else-if="asset.type === 'document' || asset.mime_type === 'application/pdf'"
class="flex size-full flex-col items-center justify-center gap-1.5 bg-rose-50 p-3 text-center"
>
<IconFileTypePdf class="size-9 text-rose-600" />
<span class="line-clamp-2 break-all text-[11px] font-medium text-foreground/70">{{ asset.original_filename }}</span>
</div>
<img
v-else
:src="asset.url"

View file

@ -43,10 +43,12 @@ const variants = computed(() =>
? [
{ value: ContentType.LinkedInPagePost, labelKey: 'posts.form.linkedin.variant.post' },
{ value: ContentType.LinkedInPageCarousel, labelKey: 'posts.form.linkedin.variant.carousel' },
{ value: ContentType.LinkedInPageDocument, labelKey: 'posts.form.linkedin.variant.document' },
]
: [
{ value: ContentType.LinkedInPost, labelKey: 'posts.form.linkedin.variant.post' },
{ value: ContentType.LinkedInCarousel, labelKey: 'posts.form.linkedin.variant.carousel' },
{ value: ContentType.LinkedInDocument, labelKey: 'posts.form.linkedin.variant.document' },
],
);

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import {
IconAlertTriangle,
IconFileTypePdf,
IconGripVertical,
IconHash,
IconLibraryPhoto,
@ -73,10 +74,17 @@ const mediaThumbRefs = ref<HTMLElement[]>([]);
const lightbox = ref<InstanceType<typeof ImagePreviewDialog> | null>(null);
const openPreview = (item: MediaItem) => {
const idx = media.value.findIndex((m) => m.id === item.id);
// PDFs can't render in the image lightbox — open them in a new tab.
if (isDocument(item)) {
window.open(item.url, '_blank', 'noopener');
return;
}
const previewable = media.value.filter((m) => !isDocument(m));
const idx = previewable.findIndex((m) => m.id === item.id);
if (idx < 0) return;
lightbox.value?.openCollection(
media.value.map((m) => ({
previewable.map((m) => ({
url: m.url,
type: isVideo(m) ? 'video' as const : 'image' as const,
})),
@ -87,6 +95,9 @@ const openPreview = (item: MediaItem) => {
const isVideo = (item: MediaItem): boolean =>
item.type === 'video' || Boolean(item.mime_type?.startsWith('video/'));
const isDocument = (item: MediaItem): boolean =>
item.type === 'document' || item.mime_type === 'application/pdf';
const formatDuration = (seconds: number): string => {
const total = Math.round(seconds);
const m = Math.floor(total / 60);
@ -245,6 +256,13 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
class="h-full w-full object-cover"
muted
/>
<div
v-else-if="isDocument(item)"
class="flex h-full w-full flex-col items-center justify-center gap-1 bg-rose-50 p-2 text-center"
>
<IconFileTypePdf class="size-7 text-rose-600" />
<span class="line-clamp-2 break-all text-[10px] font-medium text-foreground/70">{{ item.original_filename || 'PDF' }}</span>
</div>
<img
v-else
:src="item.url"

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia } from '@/composables/useMedia';
import { isDocumentMedia, isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
@ -77,7 +77,12 @@ defineProps<Props>();
'row-span-2': media.length === 3 && index === 0,
'aspect-square': media.length > 1,
}">
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
<div v-if="isDocumentMedia(item)" class="aspect-[4/5] w-full bg-[#f4f2ee] dark:bg-[#38434f]">
<iframe :src="`${item.url}#toolbar=0&navpanes=0&view=FitH`"
:title="item.original_filename || 'PDF document'"
class="h-full w-full border-0" loading="lazy" />
</div>
<img v-else-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
class="w-full h-full object-cover" />
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
<div v-if="media.length > 4 && index === 3"

View file

@ -38,7 +38,8 @@ export const getMediaValidationWarning = (
const rules = getMediaRulesForContentType(contentType);
const videos = media.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const images = media.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const documents = media.filter((m) => m.type === 'document' || m.mime_type === 'application/pdf');
const images = media.filter((m) => m.type === 'image' || (m.mime_type?.startsWith('image/') ?? false));
const gifs = media.filter((m) => m.mime_type === 'image/gif');
const total = media.length;
@ -57,17 +58,31 @@ export const getMediaValidationWarning = (
if (! rules.acceptImages && images.length > 0) {
return { key: 'no_image_allowed', params: {} };
}
if (! rules.acceptDocuments && documents.length > 0) {
return { key: 'no_document_allowed', params: {} };
}
if (! rules.acceptsGif && gifs.length > 0) {
return { key: 'gif_not_allowed', params: {} };
}
for (const m of media) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const isDocument = m.type === 'document' || m.mime_type === 'application/pdf';
const size = m.size ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
const duration = m.meta?.duration ?? 0;
if (isDocument) {
if (rules.maxDocumentBytes && size > rules.maxDocumentBytes) {
return {
key: 'document_too_large',
params: { max: formatBytes(rules.maxDocumentBytes), current: formatBytes(size) },
};
}
continue;
}
if (isVideo) {
if (rules.maxVideoBytes && size > rules.maxVideoBytes) {
return {
@ -119,8 +134,16 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string
const rules = getMediaRulesForContentType(contentType);
const isVideo = item.type === 'video' || Boolean(item.mime_type?.startsWith('video/'));
const isDocument = item.type === 'document' || item.mime_type === 'application/pdf';
const isGif = item.mime_type === 'image/gif';
if (isDocument) {
if (! rules.acceptDocuments) return 'no_document_allowed';
const docSize = item.size ?? 0;
if (rules.maxDocumentBytes && docSize > rules.maxDocumentBytes) return 'document_too_large';
return null;
}
if (isVideo && ! rules.acceptVideos) return 'no_video_allowed';
if (! isVideo && ! rules.acceptImages) return 'no_image_allowed';
if (isGif && ! rules.acceptsGif) return 'gif_not_allowed';
@ -150,8 +173,14 @@ export const isVideoMedia = (item: MediaItem | null | undefined): boolean => {
return item.type === 'video' || Boolean(item.mime_type?.startsWith('video/'));
};
export const isDocumentMedia = (item: MediaItem | null | undefined): boolean => {
if (! item) return false;
return item.type === 'document' || item.mime_type === 'application/pdf';
};
export const isImageMedia = (item: MediaItem | null | undefined): boolean => {
if (! item) return false;
if (isVideoMedia(item)) return false;
if (isDocumentMedia(item)) return false;
return true;
};

View file

@ -5,11 +5,13 @@ export interface MediaRules {
minFiles?: number;
acceptImages: boolean;
acceptVideos: boolean;
acceptDocuments?: boolean;
requiresMedia: boolean;
acceptsGif: boolean;
forbidsMixedMedia?: boolean;
maxImageBytes?: number;
maxVideoBytes?: number;
maxDocumentBytes?: number;
maxVideoDurationSec?: number;
aspectRatioMin?: number;
aspectRatioMax?: number;
@ -81,6 +83,17 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
maxImageBytes: 5 * MB,
aspectRatioMin: 0.5, aspectRatioMax: 1,
},
// Document (PDF) posts — the swipeable LinkedIn carousel. One PDF, up to 100MB.
linkedin_document: {
maxFiles: 1, acceptImages: false, acceptVideos: false, acceptDocuments: true, requiresMedia: true,
acceptsGif: false,
maxDocumentBytes: 100 * MB,
},
linkedin_page_document: {
maxFiles: 1, acceptImages: false, acceptVideos: false, acceptDocuments: true, requiresMedia: true,
acceptsGif: false,
maxDocumentBytes: 100 * MB,
},
// TikTok
tiktok_video: {
@ -169,6 +182,9 @@ export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {
if (rules.value.acceptVideos) {
types.push('video/*');
}
if (rules.value.acceptDocuments) {
types.push('application/pdf');
}
return types.join(',');
});
@ -180,6 +196,7 @@ export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {
return (file: File): boolean => {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isDocument = file.type === 'application/pdf';
if (isImage && !rules.value.acceptImages) {
return false;
@ -187,11 +204,17 @@ export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {
if (isVideo && !rules.value.acceptVideos) {
return false;
}
if (isDocument) {
return Boolean(rules.value.acceptDocuments);
}
return isImage || isVideo;
};
});
const getAcceptDescription = computed<string>(() => {
if (rules.value.acceptDocuments && !rules.value.acceptImages && !rules.value.acceptVideos) {
return 'PDF document';
}
if (rules.value.acceptImages && rules.value.acceptVideos) {
return 'Images or videos';
}

View file

@ -39,8 +39,8 @@ const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
'instagram_reel',
'instagram_story',
],
linkedin: ['linkedin_post', 'linkedin_carousel'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel'],
linkedin: ['linkedin_post', 'linkedin_carousel', 'linkedin_document'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel', 'linkedin_page_document'],
facebook: ['facebook_post', 'facebook_reel', 'facebook_story'],
tiktok: ['tiktok_video'],
youtube: ['youtube_short'],

View file

@ -24,8 +24,8 @@ export const PLATFORM_VARIANTS: Record<string, string[]> = {
[Platform.Facebook]: [ContentType.FacebookPost, ContentType.FacebookReel, ContentType.FacebookStory],
[Platform.Instagram]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory],
[Platform.InstagramFacebook]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory],
[Platform.LinkedIn]: [ContentType.LinkedInPost, ContentType.LinkedInCarousel],
[Platform.LinkedInPage]: [ContentType.LinkedInPagePost, ContentType.LinkedInPageCarousel],
[Platform.LinkedIn]: [ContentType.LinkedInPost, ContentType.LinkedInCarousel, ContentType.LinkedInDocument],
[Platform.LinkedInPage]: [ContentType.LinkedInPagePost, ContentType.LinkedInPageCarousel, ContentType.LinkedInPageDocument],
[Platform.TikTok]: [ContentType.TikTokVideo, ContentType.TikTokPhoto],
[Platform.Pinterest]: [ContentType.PinterestPin, ContentType.PinterestVideoPin, ContentType.PinterestCarousel],
};

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { IconArrowLeft, IconCalendar, IconExternalLink, IconLoader2 } from '@tabler/icons-vue';
import { IconArrowLeft, IconCalendar, IconExternalLink, IconFileTypePdf, IconLoader2 } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
@ -90,12 +90,24 @@ const lightbox = ref<InstanceType<typeof ImagePreviewDialog> | null>(null);
const isVideoItem = (item: MediaItem): boolean =>
item.type === 'video' || (item.mime_type?.startsWith('video/') ?? false);
const isDocumentItem = (item: MediaItem): boolean =>
item.type === 'document' || item.mime_type === 'application/pdf';
const openLightbox = (i: number) => {
const collection = props.post.media.map((m) => ({
const item = props.post.media[i];
// PDFs can't render in the image lightbox — open them in a new tab.
if (item && isDocumentItem(item)) {
window.open(item.url, '_blank', 'noopener');
return;
}
const previewable = props.post.media.filter((m) => !isDocumentItem(m));
const collection = previewable.map((m) => ({
url: m.url,
type: isVideoItem(m) ? ('video' as const) : ('image' as const),
}));
lightbox.value?.openCollection(collection, i);
const idx = previewable.findIndex((m) => m.id === item?.id);
lightbox.value?.openCollection(collection, idx < 0 ? 0 : idx);
};
usePostEcho(props.post.id, '.post.platform.status.updated', () => {
@ -171,6 +183,13 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
class="h-full w-full object-cover"
muted
/>
<div
v-else-if="isDocumentItem(item)"
class="flex h-full w-full flex-col items-center justify-center gap-1.5 bg-rose-50 p-2 text-center"
>
<IconFileTypePdf class="size-8 text-rose-600" />
<span class="line-clamp-2 break-all text-[10px] font-medium text-foreground/70">{{ item.original_filename || 'PDF' }}</span>
</div>
<img
v-else
:src="item.url"

View file

@ -4,8 +4,10 @@ export const ContentType = {
InstagramReel: 'instagram_reel',
LinkedInPost: 'linkedin_post',
LinkedInCarousel: 'linkedin_carousel',
LinkedInDocument: 'linkedin_document',
LinkedInPagePost: 'linkedin_page_post',
LinkedInPageCarousel: 'linkedin_page_carousel',
LinkedInPageDocument: 'linkedin_page_document',
FacebookPost: 'facebook_post',
FacebookReel: 'facebook_reel',
FacebookStory: 'facebook_story',

View file

@ -51,6 +51,23 @@
->and(data_get($meta, 'embeds.0.color'))->toBe('#5865F2');
});
it('persists the LinkedIn document_title meta on store', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$this->withHeaders($this->headers)
->postJson(route('api.posts.store'), [
'content' => 'Check our latest deck',
'platforms' => [[
'social_account_id' => $linkedin->id,
'content_type' => ContentType::LinkedInDocument->value,
'meta' => ['document_title' => 'Q2 Report'],
]],
])
->assertCreated();
expect(PostPlatform::where('social_account_id', $linkedin->id)->sole()->meta['document_title'])->toBe('Q2 Report');
});
it('persists per-platform meta across networks on store', function () {
$instagram = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram]);
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);

View file

@ -189,6 +189,29 @@
expect($this->workspace->getMedia('assets')->count())->toBe(1);
});
test('chunked upload completes for a pdf document', function () {
// Real %PDF magic bytes so mime_content_type detects application/pdf.
$content = "%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n";
$size = strlen($content);
$response = $this->actingAs($this->user)->call(
'POST',
route('app.assets.store-chunked'),
[], [], [],
[
'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size,
'HTTP_X_FILE_NAME' => 'deck.pdf',
'HTTP_ACCEPT' => 'application/json',
'CONTENT_TYPE' => 'application/octet-stream',
],
$content,
);
$response->assertSuccessful();
$response->assertJson(['done' => true]);
expect($this->workspace->getMedia('assets')->first()->type->value)->toBe('document');
});
test('chunked upload reports progress on intermediate chunks', function () {
$response = $this->actingAs($this->user)->call(
'POST',

View file

@ -52,6 +52,24 @@
->and(data_get($meta, 'embeds.0.title'))->toBe('Release');
});
test('create post persists LinkedIn document_title meta', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
'content' => 'Check our latest deck',
'platforms' => [[
'social_account_id' => $linkedin->id,
'content_type' => ContentType::LinkedInDocument->value,
'meta' => ['document_title' => 'Q2 Report'],
]],
]);
$response->assertOk();
expect(PostPlatform::where('social_account_id', $linkedin->id)->sole()->meta['document_title'])->toBe('Q2 Report');
});
test('update post merges per-platform meta', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,

View file

@ -283,3 +283,66 @@
&& isset($request['content']['media']['id'])
);
});
test('linkedin page publisher can publish a document (pdf carousel) using organization urn', function () {
$this->postPlatform->update([
'content_type' => ContentType::LinkedInPageDocument,
'meta' => ['document_title' => 'Company Deck'],
]);
$this->post->update([
'media' => [
[
'id' => 'doc-media-1',
'path' => 'media/2026-01/company-deck.pdf',
'url' => 'https://example.com/media/2026-01/company-deck.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'company-deck.pdf',
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/org/0';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
return Http::response([
'value' => [
'uploadUrl' => $uploadUrl,
'document' => 'urn:li:document:OrgDocUrn',
],
], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/documents/')) {
return Http::response(['status' => 'AVAILABLE'], 200);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:orgdoc999']);
}
return Http::response('fake-pdf-bytes', 200);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:orgdoc999');
expect($result['url'])->toContain('linkedin.com/company/testcompany/posts/');
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload'));
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
return ($request['author'] ?? '') === 'urn:li:organization:123456'
&& data_get($request->data(), 'content.media.id') === 'urn:li:document:OrgDocUrn'
&& data_get($request->data(), 'content.media.title') === 'Company Deck';
});
});

View file

@ -380,3 +380,201 @@
Http::assertSent(fn ($request) => str_contains($request->url(), 'finalizeUpload'));
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher can publish a document (pdf carousel) with a title', function () {
$this->postPlatform->update([
'content_type' => ContentType::LinkedInDocument,
'meta' => ['document_title' => 'My Slides'],
]);
$this->post->update([
'media' => [
[
'id' => 'doc-media-1',
'path' => 'media/2026-01/deck.pdf',
'url' => 'https://example.com/media/2026-01/deck.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'deck.pdf',
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/0';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
return Http::response([
'value' => [
'uploadUrl' => $uploadUrl,
'document' => 'urn:li:document:FakeDocUrn',
],
], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/documents/')) {
return Http::response(['status' => 'AVAILABLE'], 200);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:doc999']);
}
return Http::response('fake-pdf-bytes', 200);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:doc999');
expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:doc999');
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload'));
Http::assertSent(function ($request) use ($uploadUrl) {
return $request->url() === $uploadUrl
&& $request->hasHeader('Content-Type', 'application/pdf');
});
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
return data_get($request->data(), 'content.media.id') === 'urn:li:document:FakeDocUrn'
&& data_get($request->data(), 'content.media.title') === 'My Slides';
});
});
test('linkedin publisher document title falls back to the file name', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->post->update([
'media' => [
[
'id' => 'doc-media-1',
'path' => 'media/2026-01/whitepaper.pdf',
'url' => 'https://example.com/media/2026-01/whitepaper.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'whitepaper.pdf',
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/1';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:Doc2']], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/documents/')) {
return Http::response(['status' => 'AVAILABLE'], 200);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:doc2']);
}
return Http::response('fake-pdf-bytes', 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
return data_get($request->data(), 'content.media.title') === 'whitepaper.pdf';
});
});
test('linkedin publisher waits for document processing before posting', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->post->update([
'media' => [
[
'id' => 'doc-media-1',
'path' => 'media/2026-01/deck.pdf',
'url' => 'https://example.com/media/2026-01/deck.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'deck.pdf',
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/2';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:Doc3']], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/documents/')) {
return Http::response(['status' => 'AVAILABLE'], 200);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:doc3']);
}
return Http::response('fake-pdf-bytes', 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/documents/urn'));
});
test('linkedin publisher throws and does not post when document processing fails', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->post->update([
'media' => [
[
'id' => 'doc-media-1',
'path' => 'media/2026-01/deck.pdf',
'url' => 'https://example.com/media/2026-01/deck.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'deck.pdf',
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/fail';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:Fail']], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/documents/')) {
return Http::response(['status' => 'PROCESSING_FAILED'], 200);
}
return Http::response('fake-pdf-bytes', 200);
});
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'LinkedIn document processing failed');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});

View file

@ -150,3 +150,29 @@
expect(ContentType::BlueskyPost->supportsVideo())->toBeTrue();
expect(ContentType::MastodonPost->supportsVideo())->toBeTrue();
});
test('linkedin document content types are pdf-only and single-file', function () {
foreach ([ContentType::LinkedInDocument, ContentType::LinkedInPageDocument] as $type) {
expect($type->label())->toBe('Document');
expect($type->supportsDocument())->toBeTrue();
expect($type->supportsImage())->toBeFalse();
expect($type->supportsVideo())->toBeFalse();
expect($type->requiresMedia())->toBeTrue();
expect($type->maxMediaCount())->toBe(1);
}
expect(ContentType::LinkedInDocument->platform())->toBe(Platform::LinkedIn);
expect(ContentType::LinkedInPageDocument->platform())->toBe(Platform::LinkedInPage);
});
test('only linkedin document content types support documents', function () {
expect(ContentType::LinkedInPost->supportsDocument())->toBeFalse();
expect(ContentType::LinkedInCarousel->supportsDocument())->toBeFalse();
expect(ContentType::InstagramFeed->supportsDocument())->toBeFalse();
expect(ContentType::XPost->supportsDocument())->toBeFalse();
});
test('linkedin document content types are exposed for their platform', function () {
expect(ContentType::forPlatform(Platform::LinkedIn))->toContain(ContentType::LinkedInDocument);
expect(ContentType::forPlatform(Platform::LinkedInPage))->toContain(ContentType::LinkedInPageDocument);
});

View file

@ -7,16 +7,23 @@
test('media type has correct values', function () {
expect(Type::Image->value)->toBe('image');
expect(Type::Video->value)->toBe('video');
expect(Type::Document->value)->toBe('document');
});
test('media type has labels', function () {
expect(Type::Image->label())->toBe('Imagem');
expect(Type::Video->label())->toBe('Vídeo');
expect(Type::Document->label())->toBe('Documento');
});
test('media type has allowed mime types', function () {
expect(Type::Image->allowedMimeTypes())->toContain('image/jpeg', 'image/png');
expect(Type::Video->allowedMimeTypes())->toContain('video/mp4', 'video/quicktime');
expect(Type::Document->allowedMimeTypes())->toBe(['application/pdf']);
});
test('media type document accepts only the pdf extension', function () {
expect(Type::Document->extensions())->toBe(['pdf']);
});
test('media type max size in mb is read from config', function () {
@ -37,6 +44,12 @@
test('media type resolves from mime', function () {
expect(Type::fromMime('image/jpeg'))->toBe(Type::Image);
expect(Type::fromMime('video/mp4'))->toBe(Type::Video);
expect(Type::fromMime('application/pdf'))->toBeNull();
expect(Type::fromMime('application/pdf'))->toBe(Type::Document);
expect(Type::fromMime('not-a-mime'))->toBeNull();
});
test('media type document max size in mb is read from config', function () {
config(['trypost.media.max_size_mb.document' => 100]);
expect(Type::Document->maxSizeInMb())->toBe(100);
});

View file

@ -136,6 +136,39 @@ function runMediaRule(string $contentType, array $media): array
expect(runMediaRule(ContentType::DiscordMessage->value, $media))->toBe([]);
});
test('linkedin document accepts a pdf', function () {
$media = [['type' => MediaType::Document->value, 'mime_type' => 'application/pdf']];
expect(runMediaRule(ContentType::LinkedInDocument->value, $media))->toBe([]);
expect(runMediaRule(ContentType::LinkedInPageDocument->value, $media))->toBe([]);
});
test('linkedin document detects a pdf from mime when type field is missing', function () {
$media = [['mime_type' => 'application/pdf']];
expect(runMediaRule(ContentType::LinkedInDocument->value, $media))->toBe([]);
});
test('linkedin document rejects an image', function () {
$media = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']];
$errors = runMediaRule(ContentType::LinkedInDocument->value, $media);
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('does not support images');
});
test('a pdf is rejected on content types that are not documents', function () {
$media = [['type' => MediaType::Document->value, 'mime_type' => 'application/pdf']];
$errors = runMediaRule(ContentType::LinkedInPost->value, $media);
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('does not support PDF documents');
expect(runMediaRule(ContentType::LinkedInCarousel->value, $media)[0])->toContain('does not support PDF documents');
});
test('does nothing for invalid content type values', function () {
expect(runMediaRule('not_a_real_content_type', []))->toBe([]);
});

View file

@ -166,9 +166,18 @@
expect($media->type->value)->toBe('video');
});
test('add media detects document type for a pdf', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->create('deck.pdf', 1000, 'application/pdf');
$media = $workspace->addMedia($file, 'logo');
expect($media->type->value)->toBe('document');
});
test('add media throws on unsupported MIME type', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->create('document.pdf', 1000, 'application/pdf');
$file = UploadedFile::fake()->create('archive.zip', 1000, 'application/zip');
expect(fn () => $workspace->addMedia($file, 'logo'))
->toThrow(InvalidArgumentException::class);