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:
parent
f1822ca77c
commit
7388313f5c
39 changed files with 971 additions and 26 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public function rules(): array
|
|||
$allowedMimes = [
|
||||
...MediaType::Image->allowedMimeTypes(),
|
||||
...MediaType::Video->allowedMimeTypes(),
|
||||
...MediaType::Document->allowedMimeTypes(),
|
||||
];
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue