refactor(linkedin): infer post format from media + unify account connection

Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.

Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
This commit is contained in:
Paulo Castellano 2026-06-24 21:05:09 -03:00
parent 051c797cc1
commit d84666360a
50 changed files with 1438 additions and 1902 deletions

View file

@ -251,8 +251,8 @@ private function extractContent(array $structured, GeneratorFormat $format, Cont
*
* Carousel-capable content types:
* - instagram_feed (Instagram feed carousel = multi-image feed post)
* - linkedin_carousel (LinkedIn personal carousel PDF)
* - linkedin_page_carousel (LinkedIn page carousel PDF)
* - linkedin_post (LinkedIn multi-image post — 2+ images)
* - linkedin_page_post (LinkedIn page multi-image post)
* - pinterest_carousel (Pinterest carousel pin)
* - tiktok_photo (TikTok photo carousel)
*

View file

@ -13,15 +13,12 @@ enum ContentType: string
case InstagramReel = 'instagram_reel';
case InstagramStory = 'instagram_story';
// LinkedIn
// LinkedIn — one type per account kind; the publish format (single image,
// multi-image, video, or PDF document) is inferred from the attached media.
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';
@ -71,8 +68,6 @@ public function label(): string
self::InstagramReel => 'Reel',
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',
@ -100,8 +95,8 @@ public function platform(): SocialPlatform
{
return match ($this) {
self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram,
self::LinkedInPost, self::LinkedInCarousel, self::LinkedInDocument => SocialPlatform::LinkedIn,
self::LinkedInPagePost, self::LinkedInPageCarousel, self::LinkedInPageDocument => SocialPlatform::LinkedInPage,
self::LinkedInPost => SocialPlatform::LinkedIn,
self::LinkedInPagePost => SocialPlatform::LinkedInPage,
self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook,
self::TikTokVideo, self::TikTokPhoto => SocialPlatform::TikTok,
self::YouTubeShort => SocialPlatform::YouTube,
@ -167,9 +162,7 @@ public function maxMediaCount(): int
return match ($this) {
self::InstagramFeed => 10,
self::InstagramReel, self::InstagramStory => 1,
self::LinkedInPost, self::LinkedInPagePost => 1,
self::LinkedInCarousel, self::LinkedInPageCarousel => 20,
self::LinkedInDocument, self::LinkedInPageDocument => 1,
self::LinkedInPost, self::LinkedInPagePost => 20,
self::FacebookPost => 10,
self::FacebookReel, self::FacebookStory => 1,
self::TikTokVideo => 1,
@ -191,8 +184,6 @@ public function supportsVideo(): bool
return match ($this) {
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,
@ -217,20 +208,19 @@ 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.
* Whether this content type can carry a PDF document (the swipeable LinkedIn
* document/carousel). LinkedIn infers the document format from a PDF being
* attached; the document is always the only attachment (see the media rule).
*/
public function supportsDocument(): bool
{
return match ($this) {
self::LinkedInDocument, self::LinkedInPageDocument => true,
self::LinkedInPost, self::LinkedInPagePost => true,
default => false,
};
}
@ -245,6 +235,8 @@ public function supportsMixedMedia(): bool
{
return match ($this) {
self::BlueskyPost => false,
// LinkedIn publishes images XOR one video XOR one document — never mixed.
self::LinkedInPost, self::LinkedInPagePost => false,
default => true,
};
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums\SocialAccount;
/**
* Which LinkedIn identity a member posts as after the unified connect flow:
* their personal profile (creates a `linkedin` account) or a company page
* they administer (creates a `linkedin-page` account).
*/
enum LinkedInIdentityType: string
{
case Person = 'person';
case Organization = 'organization';
}

View file

@ -112,7 +112,7 @@ public function allowedMediaTypes(): array
public function maxImages(): int
{
return match ($this) {
self::LinkedIn, self::LinkedInPage => 1,
self::LinkedIn, self::LinkedInPage => 20,
self::X => 4,
self::TikTok => 0,
self::YouTube => 0,
@ -287,6 +287,22 @@ public function isEnabled(): bool
return config("trypost.platforms.{$this->value}.enabled", true);
}
/**
* Whether this platform gets its own "Connect" card in the accounts grid.
* LinkedIn company pages are reached through the unified LinkedIn connect
* flow's identity picker, never a standalone card. The single LinkedIn card
* stands for the whole network, so it shows whenever the profile OR the
* company-page capability is enabled (self-hosters may run with only one).
*/
public function isConnectable(): bool
{
return match ($this) {
self::LinkedInPage => false,
self::LinkedIn => self::LinkedIn->isEnabled() || self::LinkedInPage->isEnabled(),
default => $this->isEnabled(),
};
}
/**
* Static, platform-specific data exposed to the frontend (e.g. TikTok privacy options,
* compliance URLs). Returns an empty array for platforms with no extra config.

View file

@ -4,41 +4,64 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\LinkedInIdentityType;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\LinkedInTokenSynchronizer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Enum;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
class LinkedInController extends SocialController
{
protected string $driver = 'linkedin';
protected string $driver = 'linkedin-openid';
protected SocialPlatform $platform = SocialPlatform::LinkedIn;
public function connect(Request $request): Response|RedirectResponse
/**
* The LinkedIn card stands for both the personal profile and company-page
* capabilities, each independently toggleable so self-hosters can run with
* just one. The connect flow is available while either is enabled.
*/
protected function ensurePlatformEnabled(): void
{
if (! $this->personEnabled() && ! $this->organizationEnabled()) {
abort(Response::HTTP_FORBIDDEN, 'This platform is currently unavailable.');
}
}
public function connect(Request $request): Response|View
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
$this->authorize('manageAccounts', $workspace);
return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.linkedin.scopes'));
session(['social_connect_workspace' => $workspace->id]);
return Inertia::location(
Socialite::driver($this->driver)
->scopes($this->connectScopes())
->redirect()
->getTargetUrl()
);
}
public function callback(Request $request): View
public function callback(Request $request): View|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
@ -55,37 +78,24 @@ public function callback(Request $request): View
try {
$socialUser = Socialite::driver($this->driver)->user();
// Fetch vanityName from LinkedIn API (not available via OpenID)
$username = $this->fetchVanityName($socialUser->token);
$avatarPath = uploadFromUrl($socialUser->getAvatar());
$account = $workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
],
[
'username' => $username,
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
session([
'linkedin_pending' => [
'workspace_id' => $workspace->id,
'token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
// LinkedIn returns scope CSV-joined but Socialite splits on space, so re-split here.
'scopes' => explode(',', implode(',', $socialUser->approvedScopes)),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'expires_in' => $socialUser->expiresIn,
'approved_scopes' => $socialUser->approvedScopes ?? [],
'person' => [
'id' => $socialUser->getId(),
'name' => $socialUser->getName(),
'avatar' => $socialUser->getAvatar(),
'vanity_name' => $this->personEnabled() ? $this->fetchVanityName($socialUser->token) : null,
],
'organizations' => $this->organizationEnabled() ? $this->fetchOrganizations($socialUser->token) : [],
],
);
]);
// Sync tokens to LinkedIn Page if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException $e) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
return redirect()->route('app.social.linkedin.select-identity');
} catch (\Exception $e) {
Log::error('LinkedIn OAuth Error', [
'error' => $e->getMessage(),
@ -95,6 +105,209 @@ public function callback(Request $request): View
}
}
public function selectIdentity(Request $request): InertiaResponse|View
{
$pending = session('linkedin_pending');
if (! $pending) {
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($pending['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
return Inertia::render('accounts/LinkedInSelect', [
'person' => $this->personEnabled() ? $pending['person'] : null,
'organizations' => $pending['organizations'],
]);
}
public function select(Request $request): View
{
$validator = Validator::make($request->all(), [
'type' => ['required', new Enum(LinkedInIdentityType::class)],
'organization_id' => 'required_if:type,organization',
]);
if ($validator->fails()) {
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
$validated = $validator->validated();
$pending = session('linkedin_pending');
if (! $pending) {
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($pending['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
$type = LinkedInIdentityType::from(data_get($validated, 'type'));
if (($type === LinkedInIdentityType::Organization && ! $this->organizationEnabled())
|| ($type === LinkedInIdentityType::Person && ! $this->personEnabled())) {
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
try {
if ($type === LinkedInIdentityType::Organization) {
$organization = $this->resolveAdministeredOrganization($pending, data_get($validated, 'organization_id'));
if (! $organization) {
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
$this->connectOrganization($workspace, $pending, $organization);
} else {
$this->connectPerson($workspace, $pending);
}
session()->forget('linkedin_pending');
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn selection error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
/**
* The user's personal LinkedIn profile becomes a `linkedin` account.
*/
private function connectPerson(Workspace $workspace, array $pending): void
{
$person = $pending['person'];
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => SocialPlatform::LinkedIn->value,
'platform_user_id' => data_get($person, 'id'),
],
[
'username' => data_get($person, 'vanity_name'),
'display_name' => data_get($person, 'name'),
'avatar_url' => uploadFromUrl(data_get($person, 'avatar')),
'access_token' => $pending['token'],
'refresh_token' => $pending['refresh_token'],
'token_expires_at' => $pending['expires_in'] ? now()->addSeconds($pending['expires_in']) : null,
'scopes' => $this->normalizeScopes($pending['approved_scopes'] ?? []),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
],
);
}
/**
* Match the chosen organization id against the admin-verified list captured at
* callback, so a tampered POST cannot connect a company the member does not
* administer.
*
* @param array<string, mixed> $pending
* @return array<string, mixed>|null
*/
private function resolveAdministeredOrganization(array $pending, mixed $organizationId): ?array
{
return collect(data_get($pending, 'organizations', []))
->first(fn ($organization) => (string) data_get($organization, 'id') === (string) $organizationId);
}
/**
* A company the user administers becomes a `linkedin-page` account, with the
* acting member recorded in meta so the page publisher can post on its behalf.
* The organization data comes from the admin-verified session list, never the
* request body.
*
* @param array<string, mixed> $pending
* @param array<string, mixed> $organization
*/
private function connectOrganization(Workspace $workspace, array $pending, array $organization): void
{
$organizationId = data_get($organization, 'id');
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => SocialPlatform::LinkedInPage->value,
'platform_user_id' => $organizationId,
],
[
'username' => data_get($organization, 'vanity_name'),
'display_name' => data_get($organization, 'name'),
'avatar_url' => uploadFromUrl(data_get($organization, 'logo')),
'access_token' => $pending['token'],
'refresh_token' => $pending['refresh_token'],
'token_expires_at' => $pending['expires_in'] ? now()->addSeconds($pending['expires_in']) : null,
'scopes' => $this->normalizeScopes($pending['approved_scopes'] ?? []),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'organization_id' => $organizationId,
'admin_user_id' => data_get($pending, 'person.id'),
'admin_name' => data_get($pending, 'person.name'),
],
],
);
}
/**
* Union of the scopes for the enabled capabilities, so one consent screen
* grants exactly what the workspace can use — member posting, company-page
* administration, or both — and the user picks the identity afterwards.
*
* @return array<int, string>
*/
private function connectScopes(): array
{
$scopes = [];
if ($this->personEnabled()) {
$scopes = array_merge($scopes, config('trypost.platforms.linkedin.scopes'));
}
if ($this->organizationEnabled()) {
$scopes = array_merge($scopes, config('trypost.platforms.linkedin-page.scopes'));
}
return array_values(array_unique($scopes));
}
private function personEnabled(): bool
{
return SocialPlatform::LinkedIn->isEnabled();
}
private function organizationEnabled(): bool
{
return SocialPlatform::LinkedInPage->isEnabled();
}
/**
* LinkedIn returns approved scopes comma-joined, but Socialite splits OAuth
* scopes on space — so the whole CSV lands as a single array element. Re-split
* on commas to store individual scope tokens.
*
* @param array<int, string> $approvedScopes
* @return array<int, string>
*/
private function normalizeScopes(array $approvedScopes): array
{
return array_values(array_filter(explode(',', implode(',', $approvedScopes))));
}
private function fetchVanityName(string $accessToken): ?string
{
try {
@ -115,4 +328,45 @@ private function fetchVanityName(string $accessToken): ?string
return null;
}
/**
* Organizations the authenticated member administers, used to offer company
* pages as a posting identity alongside their personal profile.
*
* @return array<int, array{id: mixed, name: string, vanity_name: ?string, logo: ?string}>
*/
private function fetchOrganizations(string $accessToken): array
{
$response = Http::withToken($accessToken)
->get(config('trypost.platforms.linkedin.api').'/v2/organizationAcls', [
'q' => 'roleAssignee',
'role' => 'ADMINISTRATOR',
'projection' => '(elements*(organization~(id,localizedName,vanityName,logoV2(original~:playableStreams))))',
]);
if ($response->failed()) {
Log::error('LinkedIn Organizations fetch error', [
'error' => $response->body(),
]);
return [];
}
$organizations = [];
foreach (data_get($response->json(), 'elements', []) as $element) {
$org = data_get($element, 'organization~');
if ($org) {
$organizations[] = [
'id' => data_get($org, 'id'),
'name' => data_get($org, 'localizedName', 'Unknown'),
'vanity_name' => data_get($org, 'vanityName'),
'logo' => data_get($org, 'logoV2.original~.elements.0.identifiers.0.identifier'),
];
}
}
return $organizations;
}
}

View file

@ -1,273 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\LinkedInTokenSynchronizer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class LinkedInPageController extends SocialController
{
protected string $driver = 'linkedin-openid';
protected SocialPlatform $platform = SocialPlatform::LinkedInPage;
public function connect(Request $request): SymfonyResponse|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
session([
'social_connect_workspace' => $workspace->id,
'linkedin_page_reconnect_id' => null,
]);
return Inertia::location(
Socialite::driver($this->driver)
->scopes(config('trypost.platforms.linkedin-page.scopes'))
->with([
'redirect_uri' => config('services.linkedin-openid.redirect_page'),
])
->redirect()
->getTargetUrl()
);
}
public function callback(Request $request): View|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
$socialUser = Socialite::driver($this->driver)
->scopes(config('trypost.platforms.linkedin-page.scopes'))
->with([
'redirect_uri' => config('services.linkedin-openid.redirect_page'),
])
->user();
// Fetch organizations the user is admin of
$organizations = $this->fetchOrganizations($socialUser->token);
if (empty($organizations)) {
return $this->popupCallback(false, __('accounts.popup_callback.not_linkedin_admin'), $this->platform->value);
}
// Store data in session and redirect to selection page
session([
'linkedin_page_pending' => [
'workspace_id' => $workspace->id,
'user_id' => $socialUser->getId(),
'name' => $socialUser->getName(),
'avatar' => $socialUser->getAvatar(),
'token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'expires_in' => $socialUser->expiresIn,
'approved_scopes' => $socialUser->approvedScopes ?? [],
'organizations' => $organizations,
'reconnect_id' => session('linkedin_page_reconnect_id'),
],
]);
return redirect()->route('app.social.linkedin-page.select-page');
} catch (\Exception $e) {
Log::error('LinkedIn Page OAuth Error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
public function selectPage(Request $request): Response|RedirectResponse
{
$pendingData = session('linkedin_page_pending');
if (! $pendingData) {
session()->flash('flash.banner', __('accounts.flash.session_expired'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('app.accounts');
}
$workspace = Workspace::find($pendingData['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
session()->flash('flash.banner', __('accounts.flash.workspace_not_found'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('app.accounts');
}
return Inertia::render('accounts/LinkedInPageSelect', [
'workspace' => $workspace,
'organizations' => $pendingData['organizations'],
]);
}
public function select(Request $request): View
{
$request->validate([
'organization_id' => 'required',
'organization_name' => 'required|string',
'organization_vanity_name' => 'nullable|string',
'organization_logo' => 'nullable|string',
]);
$pendingData = session('linkedin_page_pending');
if (! $pendingData) {
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($pendingData['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
$avatarPath = uploadFromUrl($request->organization_logo);
$reconnectId = $pendingData['reconnect_id'] ?? null;
if ($reconnectId) {
// Reconnect existing account
$existingAccount = $workspace->socialAccounts()->find($reconnectId);
if ($existingAccount) {
$existingAccount->update([
'platform_user_id' => $request->organization_id,
'username' => $request->organization_vanity_name,
'display_name' => $request->organization_name,
'avatar_url' => $avatarPath,
'access_token' => $pendingData['token'],
'refresh_token' => $pendingData['refresh_token'],
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
// LinkedIn returns scope CSV-joined but Socialite splits on space, so re-split here.
'scopes' => explode(',', implode(',', $pendingData['approved_scopes'] ?? [])),
'meta' => [
'organization_id' => $request->organization_id,
'admin_user_id' => $pendingData['user_id'],
'admin_name' => $pendingData['name'],
],
]);
$existingAccount->markAsConnected();
// Sync tokens to LinkedIn personal if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($existingAccount);
session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value);
}
}
$account = $workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => $request->organization_id,
],
[
'username' => $request->organization_vanity_name,
'display_name' => $request->organization_name,
'avatar_url' => $avatarPath,
'access_token' => $pendingData['token'],
'refresh_token' => $pendingData['refresh_token'],
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
// LinkedIn returns scope CSV-joined but Socialite splits on space, so re-split here.
'scopes' => explode(',', implode(',', $pendingData['approved_scopes'] ?? [])),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'organization_id' => $request->organization_id,
'admin_user_id' => $pendingData['user_id'],
'admin_name' => $pendingData['name'],
],
],
);
// Sync tokens to LinkedIn personal if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn Page selection error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_page'), $this->platform->value);
}
}
private function fetchOrganizations(string $accessToken): array
{
$response = Http::withToken($accessToken)
->get(config('trypost.platforms.linkedin-page.api').'/v2/organizationAcls', [
'q' => 'roleAssignee',
'role' => 'ADMINISTRATOR',
'projection' => '(elements*(organization~(id,localizedName,vanityName,logoV2(original~:playableStreams))))',
]);
if ($response->failed()) {
Log::error('LinkedIn Organizations fetch error', [
'error' => $response->body(),
]);
return [];
}
$data = $response->json();
$organizations = [];
foreach (data_get($data, 'elements', []) as $element) {
$org = data_get($element, 'organization~', null);
if ($org) {
$logoUrl = null;
$logoUrl = data_get($org, 'logoV2.original~.elements.0.identifiers.0.identifier');
$organizations[] = [
'id' => data_get($org, 'id'),
'name' => data_get($org, 'localizedName', 'Unknown'),
'vanity_name' => data_get($org, 'vanityName', null),
'logo' => $logoUrl,
];
}
}
return $organizations;
}
}

View file

@ -43,12 +43,14 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('manageAccounts', $workspace);
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();
$platforms = collect(SocialPlatform::cases())
->filter(fn ($platform) => $platform->isConnectable())
->map(fn ($platform) => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();
return Inertia::render('accounts/Index', [
'workspace' => $workspace,

View file

@ -158,6 +158,11 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$fail("{$contentType->label()} does not support PDF documents.");
}
// A PDF document is always published on its own (LinkedIn document post).
if ($hasDocument && $count > 1) {
$fail('A PDF document must be the only attachment.');
}
if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) {
$fail("{$contentType->label()} can't combine an image and a video in the same post.");
}

View file

@ -135,9 +135,6 @@ private function refreshLinkedInToken(SocialAccount $account): void
]);
$account->refresh();
// Sync tokens between LinkedIn personal and LinkedIn Page
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
}
private function refreshXToken(SocialAccount $account): void

View file

@ -4,7 +4,6 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
@ -58,20 +57,34 @@ public function publish(PostPlatform $postPlatform): array
}
$organizationUrn = "urn:li:organization:{$organizationId}";
$contentType = $postPlatform->content_type;
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}"),
};
return $this->dispatchByMedia($organizationUrn, $content, $postPlatform);
} catch (TokenExpiredException $e) {
return $this->retryWithRefresh($postPlatform, $content, $e);
}
}
/**
* The page publish format follows the attached media — a PDF becomes a
* document post, 2+ images a multi-image post, and anything else a regular
* post.
*/
private function dispatchByMedia(string $organizationUrn, ?string $content, PostPlatform $postPlatform): array
{
$media = $postPlatform->post->mediaItems;
if ($media->contains(fn ($item) => $item->isDocument())) {
return $this->publishDocument($organizationUrn, $content, $media, $this->resolveDocumentTitle($postPlatform), $this->account);
}
if ($media->filter(fn ($item) => $item->isImage())->count() >= 2) {
return $this->publishCarousel($organizationUrn, $content, $media, $this->account);
}
return $this->publishPost($organizationUrn, $content, $media, $this->account);
}
private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array
{
if ($this->hasRetried) {
@ -86,14 +99,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$organizationId = $this->account->meta['organization_id'] ?? null;
$organizationUrn = "urn:li:organization:{$organizationId}";
$contentType = $postPlatform->content_type;
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}"),
};
return $this->dispatchByMedia($organizationUrn, $content, $postPlatform);
} catch (\Throwable $e) {
Log::error('LinkedIn Page refresh failed during retry', [
'account_id' => $this->account->id,
@ -175,10 +182,6 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med
}
}
if (empty($carouselItems)) {
throw new \Exception('No valid images for LinkedIn Page carousel');
}
$payload = [
'author' => $organizationUrn,
'commentary' => $content ?? '',

View file

@ -4,7 +4,6 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
@ -52,20 +51,34 @@ public function publish(PostPlatform $postPlatform): array
$this->accessToken = $this->account->access_token;
$personUrn = "urn:li:person:{$this->account->platform_user_id}";
$contentType = $postPlatform->content_type;
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}"),
};
return $this->dispatchByMedia($personUrn, $content, $postPlatform);
} catch (TokenExpiredException $e) {
return $this->retryWithRefresh($postPlatform, $content, $e);
}
}
/**
* LinkedIn's publish format follows the attached media — a PDF becomes a
* document post, 2+ images a multi-image post, and anything else (a single
* image/video or text only) a regular post.
*/
private function dispatchByMedia(string $personUrn, ?string $content, PostPlatform $postPlatform): array
{
$media = $postPlatform->post->mediaItems;
if ($media->contains(fn ($item) => $item->isDocument())) {
return $this->publishDocument($personUrn, $content, $media, $this->resolveDocumentTitle($postPlatform));
}
if ($media->filter(fn ($item) => $item->isImage())->count() >= 2) {
return $this->publishCarousel($personUrn, $content, $media);
}
return $this->publishPost($personUrn, $content, $media);
}
private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array
{
if ($this->hasRetried) {
@ -79,14 +92,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$this->accessToken = $this->account->access_token;
$personUrn = "urn:li:person:{$this->account->platform_user_id}";
$contentType = $postPlatform->content_type;
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}"),
};
return $this->dispatchByMedia($personUrn, $content, $postPlatform);
} catch (\Throwable $e) {
Log::error('LinkedIn refresh failed during retry', [
'account_id' => $this->account->id,
@ -162,10 +169,6 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl
}
}
if (empty($carouselItems)) {
throw new \Exception('No valid images for LinkedIn carousel');
}
$payload = [
'author' => $personUrn,
'commentary' => $content ?? '',

View file

@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Collection;
class LinkedInTokenSynchronizer
{
/**
* Sync tokens from LinkedIn personal to LinkedIn Page (or vice versa).
* When one account gets a new token, update every linked account in the
* same workspace so multiple pages sharing the same admin keep working.
*/
public function syncTokens(SocialAccount $sourceAccount): void
{
$workspace = $sourceAccount->workspace;
$linkedUserId = $this->getLinkedInUserId($sourceAccount);
if (! $linkedUserId) {
return;
}
$targets = $this->findLinkedAccounts($workspace, $sourceAccount, $linkedUserId);
foreach ($targets as $target) {
$target->update([
'access_token' => $sourceAccount->access_token,
'refresh_token' => $sourceAccount->refresh_token,
'token_expires_at' => $sourceAccount->token_expires_at,
]);
if ($target->isDisconnected()) {
$target->markAsConnected();
}
}
}
/**
* Get the LinkedIn user ID from an account.
* For personal accounts, it's the platform_user_id.
* For page accounts, it's stored in meta.admin_user_id.
*/
private function getLinkedInUserId(SocialAccount $account): ?string
{
if ($account->platform === Platform::LinkedIn) {
return $account->platform_user_id;
}
if ($account->platform === Platform::LinkedInPage) {
return $account->meta['admin_user_id'] ?? null;
}
return null;
}
/**
* Find every linked LinkedIn account (personal <-> page) in the same workspace.
*
* @return Collection<int, SocialAccount>
*/
private function findLinkedAccounts(Workspace $workspace, SocialAccount $sourceAccount, string $linkedUserId): Collection
{
if ($sourceAccount->platform === Platform::LinkedIn) {
// Source is personal: every page admin'd by this LinkedIn user.
return $workspace->socialAccounts()
->where('platform', Platform::LinkedInPage->value)
->whereJsonContains('meta->admin_user_id', $linkedUserId)
->get();
}
if ($sourceAccount->platform === Platform::LinkedInPage) {
// Source is page: the personal LinkedIn account belonging to its admin.
return $workspace->socialAccounts()
->where('platform', Platform::LinkedIn->value)
->where('platform_user_id', $linkedUserId)
->get();
}
return new Collection;
}
}

View file

@ -47,7 +47,6 @@
'client_id' => env('LINKEDIN_CLIENT_ID'),
'client_secret' => env('LINKEDIN_CLIENT_SECRET'),
'redirect' => env('LINKEDIN_CLIENT_REDIRECT'),
'redirect_page' => env('LINKEDIN_PAGE_CLIENT_REDIRECT'),
],
'x' => [

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* LinkedIn no longer has per-format content types — the publish format is
* inferred from the attached media. Collapse the old carousel/document
* variants back onto the single post type for each account kind.
*/
public function up(): void
{
DB::table('post_platforms')
->whereIn('content_type', ['linkedin_carousel', 'linkedin_document'])
->update(['content_type' => 'linkedin_post']);
DB::table('post_platforms')
->whereIn('content_type', ['linkedin_page_carousel', 'linkedin_page_document'])
->update(['content_type' => 'linkedin_page_post']);
}
public function down(): void
{
// Irreversible: the per-format variants were merged into the single post
// type and the original distinction is no longer recoverable.
}
};

View file

@ -15,7 +15,7 @@
'disconnect' => 'Disconnect',
'descriptions' => [
'linkedin' => 'Connect your LinkedIn personal profile',
'linkedin' => 'Connect your LinkedIn profile or company page',
'linkedin-page' => 'Connect a LinkedIn company page',
'x' => 'Connect your X (Twitter) account',
'tiktok' => 'Connect your TikTok account',
@ -98,6 +98,12 @@
'no_pages' => 'No pages found',
'no_pages_description' => 'You are not an administrator of any LinkedIn page.',
'page_label' => 'LinkedIn Page',
'select_title' => 'Where do you want to post?',
'select_subtitle' => 'Post as yourself or choose a company page you manage.',
'person_tag' => 'Person',
'organization_tag' => 'Organization',
'view' => 'View',
'choose' => 'Choose',
],
'flash' => [

View file

@ -139,12 +139,8 @@
'settings' => 'LinkedIn Settings',
'settings_page' => 'LinkedIn Page Settings',
'posting_to' => 'Posting to',
'variant_label' => 'Post type',
'variant' => [
'post' => 'Post',
'carousel' => 'Carousel',
'document' => 'Document',
],
'document_title' => 'Document title',
'document_title_placeholder' => 'Shown on your PDF document post',
],
'pinterest' => [
'settings' => 'Pinterest Settings',
@ -190,6 +186,8 @@
'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.',
'no_mixed_media' => "Images and a video can't be combined in the same post.",
'document_not_alone' => 'A PDF must be the only attachment.',
'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).',
@ -348,6 +346,7 @@
'no_mixed_media' => "Images and videos can't be combined in the same post.",
'no_gifs' => 'GIFs are not supported here.',
'no_documents' => 'PDF documents are not supported by this format.',
'document_not_alone' => 'A PDF must be the only attachment.',
'video_too_large' => 'Video exceeds the size limit for this platform.',
'video_too_long' => 'Video must be under :seconds seconds for this format.',
'image_too_large' => 'Image exceeds the size limit for this platform.',
@ -463,27 +462,11 @@
],
'linkedin_post' => [
'label' => 'Post',
'description' => 'Standard post with text and media',
],
'linkedin_carousel' => [
'label' => 'Carousel',
'description' => 'Swipeable images',
'description' => 'Standard post — single image, multi-image, video, or PDF',
],
'linkedin_page_post' => [
'label' => 'Post',
'description' => 'Standard post with text and media',
],
'linkedin_page_carousel' => [
'label' => 'Carousel',
'description' => 'Swipeable images',
],
'linkedin_document' => [
'label' => 'Document',
'description' => 'Swipeable PDF carousel',
],
'linkedin_page_document' => [
'label' => 'Document',
'description' => 'Swipeable PDF carousel',
'description' => 'Standard post — single image, multi-image, video, or PDF',
],
'facebook_post' => [
'label' => 'Post',

View file

@ -15,7 +15,7 @@
'disconnect' => 'Desconectar',
'descriptions' => [
'linkedin' => 'Conecta tu perfil personal de LinkedIn',
'linkedin' => 'Conecta tu perfil de LinkedIn o página de empresa',
'linkedin-page' => 'Conecta una página de empresa de LinkedIn',
'x' => 'Conecta tu cuenta de X (Twitter)',
'tiktok' => 'Conecta tu cuenta de TikTok',
@ -98,6 +98,12 @@
'no_pages' => 'No se encontraron páginas',
'no_pages_description' => 'No eres administrador de ninguna página de LinkedIn.',
'page_label' => 'Página de LinkedIn',
'select_title' => '¿Dónde quieres publicar?',
'select_subtitle' => 'Publica como tú mismo o elige una página de empresa que administres.',
'person_tag' => 'Persona',
'organization_tag' => 'Empresa',
'view' => 'Ver',
'choose' => 'Elegir',
],
'flash' => [

View file

@ -139,12 +139,8 @@
'settings' => 'Configuración de LinkedIn',
'settings_page' => 'Configuración de la Página de LinkedIn',
'posting_to' => 'Publicando en',
'variant_label' => 'Tipo de publicación',
'variant' => [
'post' => 'Publicación',
'carousel' => 'Carrusel',
'document' => 'Documento',
],
'document_title' => 'Título del documento',
'document_title_placeholder' => 'Se muestra en tu publicación de documento PDF',
],
'pinterest' => [
'settings' => 'Configuración de Pinterest',
@ -190,6 +186,8 @@
'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.',
'no_mixed_media' => 'No se pueden combinar imágenes y un video en la misma publicación.',
'document_not_alone' => 'Un PDF debe ser el único archivo adjunto.',
'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).',
@ -348,6 +346,7 @@
'no_mixed_media' => 'No se pueden combinar imágenes y videos en la misma publicación.',
'no_gifs' => 'Los GIFs no son compatibles aquí.',
'no_documents' => 'Los documentos PDF no son compatibles con este formato.',
'document_not_alone' => 'Un PDF debe ser el único archivo adjunto.',
'video_too_large' => 'El video supera el límite de tamaño de esta plataforma.',
'video_too_long' => 'El video debe durar menos de :seconds segundos en este formato.',
'image_too_large' => 'La imagen supera el límite de tamaño de esta plataforma.',
@ -463,27 +462,11 @@
],
'linkedin_post' => [
'label' => 'Post',
'description' => 'Post estándar con texto y multimedia',
],
'linkedin_carousel' => [
'label' => 'Carrusel',
'description' => 'Imágenes deslizables',
'description' => 'Post estándar — imagen, varias imágenes, video o PDF',
],
'linkedin_page_post' => [
'label' => 'Post',
'description' => 'Post estándar con texto y multimedia',
],
'linkedin_page_carousel' => [
'label' => 'Carrusel',
'description' => 'Imágenes deslizables',
],
'linkedin_document' => [
'label' => 'Documento',
'description' => 'Carrusel PDF deslizable',
],
'linkedin_page_document' => [
'label' => 'Documento',
'description' => 'Carrusel PDF deslizable',
'description' => 'Post estándar — imagen, varias imágenes, video o PDF',
],
'facebook_post' => [
'label' => 'Post',

View file

@ -15,7 +15,7 @@
'disconnect' => 'Desconectar',
'descriptions' => [
'linkedin' => 'Conecte seu perfil pessoal do LinkedIn',
'linkedin' => 'Conecte seu perfil do LinkedIn ou página de empresa',
'linkedin-page' => 'Conecte uma página de empresa do LinkedIn',
'x' => 'Conecte sua conta do X (Twitter)',
'tiktok' => 'Conecte sua conta do TikTok',
@ -98,6 +98,12 @@
'no_pages' => 'Nenhuma página encontrada',
'no_pages_description' => 'Você não é administrador de nenhuma página do LinkedIn.',
'page_label' => 'Página do LinkedIn',
'select_title' => 'Onde você quer publicar?',
'select_subtitle' => 'Publique como você mesmo ou escolha uma página de empresa que você administra.',
'person_tag' => 'Pessoa',
'organization_tag' => 'Empresa',
'view' => 'Ver',
'choose' => 'Escolher',
],
'flash' => [

View file

@ -139,12 +139,8 @@
'settings' => 'Configurações do LinkedIn',
'settings_page' => 'Configurações da Página do LinkedIn',
'posting_to' => 'Publicando em',
'variant_label' => 'Tipo de publicação',
'variant' => [
'post' => 'Post',
'carousel' => 'Carrossel',
'document' => 'Documento',
],
'document_title' => 'Título do documento',
'document_title_placeholder' => 'Aparece no seu post de documento PDF',
],
'pinterest' => [
'settings' => 'Configurações do Pinterest',
@ -190,6 +186,8 @@
'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.',
'no_mixed_media' => 'Não dá pra combinar imagens e um vídeo no mesmo post.',
'document_not_alone' => 'Um PDF deve ser o único anexo.',
'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).',
@ -348,6 +346,7 @@
'no_mixed_media' => 'Não é possível combinar imagem e vídeo na mesma publicação.',
'no_gifs' => 'GIFs não são suportados aqui.',
'no_documents' => 'Documentos PDF não são suportados por este formato.',
'document_not_alone' => 'Um PDF deve ser o único anexo.',
'video_too_large' => 'Vídeo excede o limite de tamanho desta plataforma.',
'video_too_long' => 'Vídeo deve ter menos de :seconds segundos neste formato.',
'image_too_large' => 'Imagem excede o limite de tamanho desta plataforma.',
@ -463,27 +462,11 @@
],
'linkedin_post' => [
'label' => 'Post',
'description' => 'Post padrão com texto e mídia',
],
'linkedin_carousel' => [
'label' => 'Carrossel',
'description' => 'Imagens deslizáveis',
'description' => 'Post padrão — imagem, várias imagens, vídeo ou PDF',
],
'linkedin_page_post' => [
'label' => 'Post',
'description' => 'Post padrão com texto e mídia',
],
'linkedin_page_carousel' => [
'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',
'description' => 'Post padrão — imagem, várias imagens, vídeo ou PDF',
],
'facebook_post' => [
'label' => 'Post',

View file

@ -165,9 +165,10 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel
:platform="channel.platform"
:content-type="channel.contentType"
:media="media"
:meta="channel.meta"
:disabled="disabled"
:preview-only="previewOnly"
@update:content-type="emit('update:contentType', channel.id, $event)"
@update:meta="emit('update:meta', channel.id, $event)"
/>
<DiscordSettings
v-else-if="channel.platform === Platform.Discord"

View file

@ -44,8 +44,8 @@ const getPlatformDescription = (platform: string): string =>
// Mirrors `NetworksGrid.vue` from the marketing site — pastel tile bg
// + ink 2px border + slight rotation per platform, real PNG logo inside.
// `linkedin-page` / `instagram-facebook` fall back to the base brand
// image and same color since they're variants of the same network.
// `instagram-facebook` falls back to the base brand image and same color
// since it's a variant of the same network.
const platformTheme: Record<
string,
{ bg: string; rotate: string; image: string }
@ -70,11 +70,6 @@ const platformTheme: Record<
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
'linkedin-page': {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
x: {
bg: 'bg-amber-200',
rotate: 'rotate-2',
@ -177,6 +172,9 @@ const disconnectAccount = (account: ConnectedAccount) => {
const needsReconnect = (account: ConnectedAccount): boolean =>
account.status === 'disconnected' || account.status === 'token_expired';
const connectEntryFor = (platformValue: string): string =>
platformValue === Platform.LinkedInPage ? Platform.LinkedIn : platformValue;
const openConnect = (platformValue: string) => {
if (platformValue === Platform.Telegram) {
telegramOpen.value = true;
@ -195,7 +193,7 @@ const connectPlatform = (platformValue: string) => {
};
const reconnectAccount = (account: ConnectedAccount) => {
openConnect(account.platform);
openConnect(connectEntryFor(account.platform));
};
const CardState = {

View file

@ -213,7 +213,7 @@ const getBoards = (account: SocialAccount): PinterestBoard[] =>
// Image-capability is derived from the SAME media rules the post editor uses
// (per content type), never a hardcoded list — facebook_post, tiktok_photo,
// linkedin_carousel etc. all accept multiple images. We cap AI image generation
// linkedin_post etc. all accept multiple images. We cap AI image generation
// at MAX_GENERATED_IMAGES regardless of how many a platform technically allows.
const MAX_GENERATED_IMAGES = 10;

View file

@ -3,10 +3,11 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { Input } from '@/components/ui/input';
import { getMediaValidationWarning, isDocumentMedia } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
import { Platform } from '@/types/platform';
interface SocialAccount {
id: string;
@ -21,41 +22,33 @@ interface Props {
platform: string;
contentType: string;
media: MediaItem[];
meta?: Record<string, any>;
disabled?: boolean;
previewOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
meta: () => ({}),
disabled: false,
previewOnly: false,
});
const emit = defineEmits<{
'update:contentType': [value: string];
'update:meta': [value: Record<string, any>];
}>();
const open = ref(false);
const isPage = computed(() => props.platform === 'linkedin-page');
const isPage = computed(() => props.platform === Platform.LinkedInPage);
const variants = computed(() =>
isPage.value
? [
{ 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' },
],
);
// The publish format follows the media; the only thing to configure is the
// title shown on a document (PDF) post, so we only expose it when a PDF is set.
const hasPdf = computed(() => props.media.some((item) => isDocumentMedia(item)));
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
};
const documentTitle = computed({
get: () => (props.meta?.document_title as string | undefined) ?? '',
set: (value: string) => emit('update:meta', { ...props.meta, document_title: value || null }),
});
const warning = computed(() => getMediaValidationWarning(props.contentType, props.media));
</script>
@ -94,23 +87,14 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</div>
</div>
<div class="space-y-2">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
{{ $t(variant.labelKey) }}
</button>
</div>
<div v-if="hasPdf" class="space-y-2">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.document_title') }}</p>
<Input
v-model="documentTitle"
type="text"
:placeholder="$t('posts.form.linkedin.document_title_placeholder')"
:disabled="disabled || previewOnly"
/>
</div>
<p

View file

@ -61,6 +61,12 @@ export const getMediaValidationWarning = (
if (! rules.acceptDocuments && documents.length > 0) {
return { key: 'no_document_allowed', params: {} };
}
if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) {
return { key: 'no_mixed_media', params: {} };
}
if (rules.acceptDocuments && documents.length > 0 && total > 1) {
return { key: 'document_not_alone', params: {} };
}
if (! rules.acceptsGif && gifs.length > 0) {
return { key: 'gif_not_allowed', params: {} };
}

View file

@ -60,39 +60,18 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// LinkedIn
// LinkedIn — one type per account kind; the publish format (single image,
// multi-image, video, or PDF document) is inferred from the attached media.
// Images (up to 20) XOR one video XOR one PDF, never mixed.
linkedin_post: {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60,
},
linkedin_carousel: {
maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 5 * MB,
aspectRatioMin: 0.5, aspectRatioMax: 1,
maxFiles: 20, acceptImages: true, acceptVideos: true, acceptDocuments: true,
requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB,
},
linkedin_page_post: {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60,
},
linkedin_page_carousel: {
maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
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,
maxFiles: 20, acceptImages: true, acceptVideos: true, acceptDocuments: true,
requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB,
},
// TikTok

View file

@ -39,8 +39,8 @@ const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
'instagram_reel',
'instagram_story',
],
linkedin: ['linkedin_post', 'linkedin_carousel', 'linkedin_document'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel', 'linkedin_page_document'],
linkedin: ['linkedin_post'],
'linkedin-page': ['linkedin_page_post'],
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, ContentType.LinkedInDocument],
[Platform.LinkedInPage]: [ContentType.LinkedInPagePost, ContentType.LinkedInPageCarousel, ContentType.LinkedInPageDocument],
[Platform.LinkedIn]: [ContentType.LinkedInPost],
[Platform.LinkedInPage]: [ContentType.LinkedInPagePost],
[Platform.TikTok]: [ContentType.TikTokVideo, ContentType.TikTokPhoto],
[Platform.Pinterest]: [ContentType.PinterestPin, ContentType.PinterestVideoPin, ContentType.PinterestCarousel],
};
@ -101,6 +101,7 @@ export const getMediaIncompatibilityReason = (
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
if (!rules.acceptDocuments && documents.length > 0) return trans('posts.edit.compliance.no_documents');
if (rules.acceptDocuments && documents.length > 0 && total > 1) return trans('posts.edit.compliance.document_not_alone');
if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) return trans('posts.edit.compliance.no_mixed_media');
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });

View file

@ -1,111 +0,0 @@
<script setup lang="ts">
import { IconBuilding, IconCheck } from '@tabler/icons-vue';
import { ref } from 'vue';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { select as selectLinkedInPage } from '@/routes/app/social/linkedin-page';
interface Organization {
id: string;
name: string;
vanity_name: string | null;
logo: string | null;
}
interface Workspace {
id: string;
name: string;
}
interface Props {
workspace: Workspace;
organizations: Organization[];
error?: string;
}
defineProps<Props>();
const formRef = ref<HTMLFormElement | null>(null);
const selectedOrg = ref<Organization | null>(null);
const handleSelectPage = (org: Organization) => {
selectedOrg.value = org;
// Submit via regular form to get Blade response
setTimeout(() => formRef.value?.submit(), 0);
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
</script>
<template>
<PopupLayout :title="$t('accounts.linkedin.title')">
<!-- Hidden form for regular POST submission -->
<form ref="formRef" :action="selectLinkedInPage.url()" method="POST" class="hidden">
<input type="hidden" name="_token" :value="csrfToken" />
<input type="hidden" name="organization_id" :value="selectedOrg?.id" />
<input type="hidden" name="organization_name" :value="selectedOrg?.name" />
<input type="hidden" name="organization_vanity_name" :value="selectedOrg?.vanity_name ?? ''" />
<input type="hidden" name="organization_logo" :value="selectedOrg?.logo ?? ''" />
</form>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<img src="/images/accounts/linkedin.png" alt="LinkedIn" class="h-10 w-10" />
<div>
<h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.linkedin.title') }}</h1>
<p class="text-sm text-muted-foreground">{{ $t('accounts.linkedin.description') }}</p>
</div>
</div>
<Alert v-if="error" variant="destructive">
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<div v-if="organizations.length === 0 && !error" class="text-center py-12">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<IconBuilding class="h-7 w-7 text-muted-foreground" />
</div>
<h3 class="mt-4 text-lg font-semibold">{{ $t('accounts.linkedin.no_pages') }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('accounts.linkedin.no_pages_description') }}
</p>
</div>
<div v-else class="grid gap-3">
<button
v-for="org in organizations"
:key="org.id"
@click="handleSelectPage(org)"
class="group relative overflow-hidden rounded-lg border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
<div class="flex items-center gap-4">
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="org.logo" :src="org.logo" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<IconBuilding class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<h3 class="font-semibold truncate group-hover:text-primary transition-colors">
{{ org.name }}
</h3>
<p v-if="org.vanity_name" class="text-sm text-muted-foreground truncate">
linkedin.com/company/{{ org.vanity_name }}
</p>
<p v-else class="text-sm text-muted-foreground">{{ $t('accounts.linkedin.page_label') }}</p>
</div>
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<IconCheck class="h-4 w-4" />
</div>
</div>
</div>
</button>
</div>
</div>
</PopupLayout>
</template>

View file

@ -0,0 +1,186 @@
<script setup lang="ts">
import { IconBuilding, IconExternalLink, IconUser } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { select as selectLinkedIn } from '@/routes/app/social/linkedin';
interface Person {
id: string;
name: string;
avatar: string | null;
vanity_name: string | null;
}
interface Organization {
id: string;
name: string;
vanity_name: string | null;
logo: string | null;
}
const props = defineProps<{
person: Person | null;
organizations: Organization[];
}>();
const isEmpty = computed(
() => !props.person && props.organizations.length === 0,
);
const formRef = ref<HTMLFormElement | null>(null);
const selectedType = ref<'person' | 'organization'>('person');
const selectedOrg = ref<Organization | null>(null);
const csrfToken =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') ?? '';
const submit = () => {
setTimeout(() => formRef.value?.submit(), 0);
};
const choosePerson = () => {
selectedType.value = 'person';
selectedOrg.value = null;
submit();
};
const chooseOrganization = (org: Organization) => {
selectedType.value = 'organization';
selectedOrg.value = org;
submit();
};
const openExternal = (url: string | null) => {
if (url) {
window.open(url, '_blank', 'noopener');
}
};
const personUrl = (vanity: string | null): string | null =>
vanity ? `https://www.linkedin.com/in/${vanity}` : null;
const organizationUrl = (vanity: string | null): string | null =>
vanity ? `https://www.linkedin.com/company/${vanity}` : null;
</script>
<template>
<PopupLayout :title="$t('accounts.linkedin.select_title')">
<form ref="formRef" :action="selectLinkedIn.url()" method="POST" class="hidden">
<input type="hidden" name="_token" :value="csrfToken" />
<input type="hidden" name="type" :value="selectedType" />
<input type="hidden" name="organization_id" :value="selectedOrg?.id ?? ''" />
</form>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<img src="/images/accounts/linkedin.png" alt="LinkedIn" class="h-10 w-10" />
<div>
<h1 class="text-xl font-bold tracking-tight">
{{ $t('accounts.linkedin.select_title') }}
</h1>
<p class="text-sm text-muted-foreground">
{{ $t('accounts.linkedin.select_subtitle') }}
</p>
</div>
</div>
<div v-if="isEmpty" class="py-12 text-center" dusk="linkedin-identity-empty">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<IconBuilding class="h-7 w-7 text-muted-foreground" />
</div>
<h3 class="mt-4 text-lg font-semibold">{{ $t('accounts.linkedin.no_pages') }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('accounts.linkedin.no_pages_description') }}
</p>
</div>
<div v-else class="grid gap-3">
<div
v-if="person"
class="flex items-center gap-4 rounded-lg border bg-card p-4"
dusk="linkedin-identity-person"
>
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="person.avatar" :src="person.avatar" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<IconUser class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate font-semibold">{{ person.name }}</h3>
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"
>
{{ $t('accounts.linkedin.person_tag') }}
</span>
</div>
<p v-if="person.vanity_name" class="truncate text-sm text-muted-foreground">
linkedin.com/in/{{ person.vanity_name }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button
v-if="personUrl(person.vanity_name)"
variant="ghost"
size="sm"
@click="openExternal(personUrl(person.vanity_name))"
>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.linkedin.view') }}
</Button>
<Button size="sm" dusk="choose-person" @click="choosePerson">
{{ $t('accounts.linkedin.choose') }}
</Button>
</div>
</div>
<div
v-for="org in organizations"
:key="org.id"
class="flex items-center gap-4 rounded-lg border bg-card p-4"
dusk="linkedin-identity-organization"
>
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="org.logo" :src="org.logo" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<IconBuilding class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate font-semibold">{{ org.name }}</h3>
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"
>
{{ $t('accounts.linkedin.organization_tag') }}
</span>
</div>
<p v-if="org.vanity_name" class="truncate text-sm text-muted-foreground">
linkedin.com/company/{{ org.vanity_name }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button
v-if="organizationUrl(org.vanity_name)"
variant="ghost"
size="sm"
@click="openExternal(organizationUrl(org.vanity_name))"
>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.linkedin.view') }}
</Button>
<Button size="sm" dusk="choose-organization" @click="chooseOrganization(org)">
{{ $t('accounts.linkedin.choose') }}
</Button>
</div>
</div>
</div>
</div>
</PopupLayout>
</template>

View file

@ -3,11 +3,7 @@ export const ContentType = {
InstagramStory: 'instagram_story',
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

@ -36,7 +36,6 @@
use App\Http\Controllers\Auth\InstagramController;
use App\Http\Controllers\Auth\InstagramFacebookController;
use App\Http\Controllers\Auth\LinkedInController;
use App\Http\Controllers\Auth\LinkedInPageController;
use App\Http\Controllers\Auth\MastodonController;
use App\Http\Controllers\Auth\PinterestController;
use App\Http\Controllers\Auth\SocialController;
@ -80,11 +79,8 @@
Route::middleware(['auth'])->group(function () {
Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect');
Route::get('accounts/linkedin/callback', [LinkedInController::class, 'callback'])->name('app.social.linkedin.callback');
Route::get('connect/linkedin-page', [LinkedInPageController::class, 'connect'])->name('app.social.linkedin-page.connect');
Route::get('accounts/linkedin-page/callback', [LinkedInPageController::class, 'callback'])->name('app.social.linkedin-page.callback');
Route::get('accounts/linkedin-page/select', [LinkedInPageController::class, 'selectPage'])->name('app.social.linkedin-page.select-page');
Route::post('accounts/linkedin-page/select', [LinkedInPageController::class, 'select'])->name('app.social.linkedin-page.select');
Route::get('accounts/linkedin/select', [LinkedInController::class, 'selectIdentity'])->name('app.social.linkedin.select-identity');
Route::post('accounts/linkedin/select', [LinkedInController::class, 'select'])->name('app.social.linkedin.select');
Route::get('connect/x', [XController::class, 'connect'])->name('app.social.x.connect');
Route::get('accounts/x/callback', [XController::class, 'callback'])->name('app.social.x.callback');

View file

@ -59,7 +59,7 @@
'content' => 'Check our latest deck',
'platforms' => [[
'social_account_id' => $linkedin->id,
'content_type' => ContentType::LinkedInDocument->value,
'content_type' => ContentType::LinkedInPost->value,
'meta' => ['document_title' => 'Q2 Report'],
]],
])
@ -83,52 +83,28 @@
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$this->withHeaders($this->headers)
->putJson(route('api.posts.update', $post), [
'status' => PostStatus::Publishing->value,
'platforms' => [['id' => $platform->id, 'content_type' => ContentType::LinkedInDocument->value]],
'platforms' => [['id' => $platform->id, 'content_type' => ContentType::LinkedInPost->value]],
])
->assertOk();
Queue::assertPushed(PublishPost::class);
});
it('rejects publishing a LinkedIn document post whose media is an image, not a PDF', function () {
it('rejects publishing a LinkedIn post that mixes a PDF with an image', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'media' => [[
'id' => 'img-1', 'path' => 'medias/slide.jpg', 'url' => 'https://example.com/slide.jpg',
'type' => 'image', 'mime_type' => 'image/jpeg', 'original_filename' => 'slide.jpg',
]],
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
]);
$this->withHeaders($this->headers)
->putJson(route('api.posts.update', $post), [
'status' => PostStatus::Publishing->value,
'platforms' => [['id' => $platform->id, 'content_type' => ContentType::LinkedInDocument->value]],
])
->assertUnprocessable()
->assertJsonValidationErrors(['platforms.0.content_type']);
});
it('rejects publishing a regular LinkedIn post that has a PDF attached', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'media' => [[
'id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf',
'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
]],
'media' => [
['id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf', 'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf'],
['id' => 'img-1', 'path' => 'medias/slide.jpg', 'url' => 'https://example.com/slide.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'original_filename' => 'slide.jpg'],
],
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
@ -159,7 +135,7 @@
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$this->withHeaders($this->headers)
@ -172,31 +148,6 @@
Queue::assertPushed(PublishPost::class);
});
it('rejects publishing a misconfigured post even when content_type is not resubmitted', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'media' => [[
'id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf',
'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
]],
]);
// Stored as a regular post but carrying a PDF — the client publishes without resubmitting content_type.
$platform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$this->withHeaders($this->headers)
->putJson(route('api.posts.update', $post), [
'status' => PostStatus::Publishing->value,
'platforms' => [['id' => $platform->id]],
])
->assertUnprocessable()
->assertJsonValidationErrors(['platforms.0.content_type']);
});
it('rejects only the platform that cannot take a PDF in a multi-platform post', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$x = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::X]);
@ -212,7 +163,7 @@
]);
$linkedinPlatform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$xPlatform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $x->id,
@ -223,7 +174,7 @@
->putJson(route('api.posts.update', $post), [
'status' => PostStatus::Publishing->value,
'platforms' => [
['id' => $linkedinPlatform->id, 'content_type' => ContentType::LinkedInDocument->value],
['id' => $linkedinPlatform->id, 'content_type' => ContentType::LinkedInPost->value],
['id' => $xPlatform->id, 'content_type' => ContentType::XPost->value],
],
])

View file

@ -285,7 +285,7 @@
$action = app(RunGenerateNode::class);
$accountsConfig = [
['social_account_id' => '1', 'content_type' => ContentType::LinkedInPost->value, 'meta' => []],
['social_account_id' => '1', 'content_type' => ContentType::PinterestPin->value, 'meta' => []],
['social_account_id' => '2', 'content_type' => ContentType::InstagramReel->value, 'meta' => []],
];

View file

@ -5,6 +5,7 @@
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Enums\UserWorkspace\Role;
use App\Events\PostPlatformStatusUpdated;
@ -20,6 +21,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\LinkedInPagePublisher;
use App\Services\Social\LinkedInPublisher;
use Carbon\Carbon;
use Illuminate\Support\Facades\Bus;
@ -81,6 +83,37 @@
expect($this->postPlatform->platform_url)->toBe('https://linkedin.com/post/123');
});
test('publish job dispatches a linkedin-page post to the page publisher', function () {
Event::fake();
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$pageAccount = SocialAccount::factory()->linkedinPage()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'user_id' => $this->user->id,
]);
$pagePostPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $pageAccount->id,
'platform' => Platform::LinkedInPage,
'content_type' => ContentType::LinkedInPagePost,
'enabled' => true,
]);
$publisher = Mockery::mock(LinkedInPagePublisher::class);
$publisher->shouldReceive('publish')->once()->andReturn([
'id' => 'org-post-1',
'url' => 'https://linkedin.com/company/post/1',
]);
$this->app->instance(LinkedInPagePublisher::class, $publisher);
(new PublishToSocialPlatform($pagePostPlatform))->handle();
$pagePostPlatform->refresh();
expect($pagePostPlatform->status)->toBe(PlatformStatus::Published);
expect($pagePostPlatform->platform_post_id)->toBe('org-post-1');
});
test('publish job runs the real document flow end-to-end for a LinkedIn PDF post', function () {
Event::fake();
@ -89,7 +122,7 @@
'platform_user_id' => 'person-xyz',
'token_expires_at' => now()->addDays(60),
]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'content' => 'Our deck',
'media' => [[

View file

@ -106,7 +106,7 @@
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
PostPlatform::factory()->create([
'post_id' => $this->post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$response = TryPostServer::actingAs($this->user)

View file

@ -62,7 +62,7 @@
'content' => 'Check our latest deck',
'platforms' => [[
'social_account_id' => $linkedin->id,
'content_type' => ContentType::LinkedInDocument->value,
'content_type' => ContentType::LinkedInPost->value,
'meta' => ['document_title' => 'Q2 Report'],
]],
]);
@ -187,7 +187,7 @@
]);
PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$uploadToken = (string) Str::uuid();
@ -233,7 +233,7 @@
]);
PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$response = TryPostServer::actingAs($this->user)
@ -243,55 +243,27 @@
Queue::assertPushed(PublishPost::class);
});
test('publish post rejects a LinkedIn document whose media is an image, not a PDF', function () {
test('publish post rejects a LinkedIn post that mixes a PDF with an image', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'media' => [[
'id' => 'img-1', 'path' => 'medias/slide.jpg', 'url' => 'https://example.com/slide.jpg',
'type' => 'image', 'mime_type' => 'image/jpeg', 'original_filename' => 'slide.jpg',
]],
'media' => [
['id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf', 'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf'],
['id' => 'img-1', 'path' => 'medias/slide.jpg', 'url' => 'https://example.com/slide.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'original_filename' => 'slide.jpg'],
],
]);
PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
]);
$response = TryPostServer::actingAs($this->user)
->tool(PublishPostTool::class, ['post_id' => $post->id]);
$response->assertHasErrors(['Document does not support images.']);
});
test('update post rejects scheduling a misconfigured LinkedIn post without resubmitting content_type', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'media' => [[
'id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf',
'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
]],
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$response = TryPostServer::actingAs($this->user)
->tool(UpdatePostTool::class, [
'post_id' => $post->id,
'status' => PostStatus::Scheduled->value,
'scheduled_at' => now()->addDay()->toIso8601String(),
'platforms' => [['id' => $platform->id]],
]);
->tool(PublishPostTool::class, ['post_id' => $post->id]);
$response->assertHasErrors(['Post does not support PDF documents.']);
$response->assertHasErrors(['A PDF document must be the only attachment.']);
});
test('publish post succeeds for a Discord platform with a channel', function () {

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Models\PostPlatform;
use Illuminate\Support\Facades\DB;
/**
* Load the data migration as an instance so its up() can run against rows that
* still carry the retired per-format content types.
*/
function collapseLinkedInContentTypesMigration(): object
{
return require database_path('migrations/2026_06_24_220651_collapse_linkedin_content_types.php');
}
test('the migration collapses retired linkedin profile content types onto linkedin_post', function () {
$platform = PostPlatform::factory()->create(['content_type' => ContentType::LinkedInPost]);
foreach (['linkedin_carousel', 'linkedin_document'] as $legacy) {
DB::table('post_platforms')->where('id', $platform->id)->update(['content_type' => $legacy]);
collapseLinkedInContentTypesMigration()->up();
expect(DB::table('post_platforms')->where('id', $platform->id)->value('content_type'))
->toBe('linkedin_post');
}
});
test('the migration collapses retired linkedin page content types onto linkedin_page_post', function () {
$platform = PostPlatform::factory()->create(['content_type' => ContentType::LinkedInPagePost]);
foreach (['linkedin_page_carousel', 'linkedin_page_document'] as $legacy) {
DB::table('post_platforms')->where('id', $platform->id)->update(['content_type' => $legacy]);
collapseLinkedInContentTypesMigration()->up();
expect(DB::table('post_platforms')->where('id', $platform->id)->value('content_type'))
->toBe('linkedin_page_post');
}
});
test('the migration leaves other platforms content types untouched', function () {
$xPost = PostPlatform::factory()->create(['content_type' => ContentType::XPost]);
collapseLinkedInContentTypesMigration()->up();
expect(DB::table('post_platforms')->where('id', $xPost->id)->value('content_type'))
->toBe(ContentType::XPost->value);
});

View file

@ -1,219 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Social\LinkedInPublisher;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->workspace = Workspace::factory()->create();
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
'platform_user_id' => 'linkedin-123',
'access_token' => 'test-token',
'token_expires_at' => now()->addDays(30),
]);
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'content' => 'Test LinkedIn post']);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'content_type' => ContentType::LinkedInPost,
]);
});
test('linkedin publisher publishes text only post', function () {
Http::fake([
'*/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:123456']),
]);
$publisher = new LinkedInPublisher;
$result = $publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:123456');
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:123456');
});
test('linkedin publisher throws exception for carousel without images', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInCarousel]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'No valid images for LinkedIn carousel');
});
test('linkedin publisher throws token expired exception on oauth error', function () {
Http::fake([
'*' => Http::response([
'code' => 'REVOKED_ACCESS_TOKEN',
'message' => 'Token has been revoked',
], 401),
]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
});
test('linkedin publisher throws token expired exception on expired token', function () {
Http::fake([
'*' => Http::response([
'code' => 'EXPIRED_ACCESS_TOKEN',
'message' => 'Token has expired',
], 401),
]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
});
test('linkedin publisher throws token expired exception on invalid token', function () {
Http::fake([
'*' => Http::response([
'code' => 'INVALID_ACCESS_TOKEN',
'message' => 'Token is invalid',
], 401),
]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
});
test('linkedin publisher refreshes token when expired', function () {
$this->socialAccount->update([
'token_expires_at' => now()->subHour(),
'refresh_token' => 'refresh-token-123',
]);
Http::fake([
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 3600,
], 200),
'*/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:123456']),
]);
$publisher = new LinkedInPublisher;
$result = $publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:123456');
$this->socialAccount->refresh();
expect($this->socialAccount->access_token)->toBe('new-access-token');
});
test('linkedin publisher throws exception when no refresh token', function () {
$this->socialAccount->update([
'token_expires_at' => now()->subHour(),
'refresh_token' => null,
]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class, 'No refresh token available');
});
test('linkedin publisher throws exception on api error', function () {
Http::fake([
'*' => Http::response([
'code' => 'INVALID_REQUEST',
'message' => 'Invalid request parameters',
], 400),
]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(Exception::class);
});
test('linkedin publisher throws exception for unsupported content type', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramReel]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Unsupported LinkedIn content type');
});
test('linkedin publisher handles 401 status as token error', function () {
Http::fake([
'*' => Http::response([
'message' => 'Unauthorized',
], 401),
]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
});
test('linkedin publisher handles token refresh failure', function () {
$this->socialAccount->update([
'token_expires_at' => now()->subHour(),
'refresh_token' => 'refresh-token-123',
]);
Http::fake([
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
'code' => 'INVALID_REFRESH_TOKEN',
'message' => 'Invalid refresh token',
], 400),
]);
$publisher = new LinkedInPublisher;
expect(fn () => $publisher->publish($this->postPlatform))
->toThrow(Exception::class);
});
test('linkedin publisher refreshes token when expiring soon', function () {
$this->socialAccount->update([
'token_expires_at' => now()->addMinutes(5),
'refresh_token' => 'refresh-token-123',
]);
Http::fake([
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 3600,
], 200),
'*/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:123456']),
]);
$publisher = new LinkedInPublisher;
$result = $publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:123456');
$this->socialAccount->refresh();
expect($this->socialAccount->access_token)->toBe('new-access-token');
});
test('linkedin publisher handles empty post id header', function () {
Http::fake([
'*/rest/posts' => Http::response(null, 201),
]);
$publisher = new LinkedInPublisher;
$result = $publisher->publish($this->postPlatform);
// When header is empty, id will be empty string or 'unknown'
expect($result)->toHaveKey('id');
expect($result)->toHaveKey('url');
});

View file

@ -47,7 +47,7 @@
test('linkedin page publisher can publish text-only post', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -69,7 +69,7 @@
test('linkedin page publisher uses organization urn', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -90,7 +90,7 @@
test('linkedin page publisher uses correct headers', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -107,7 +107,7 @@
test('linkedin page publisher throws exception on api error', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response([
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response([
'message' => 'Invalid request',
'status' => 400,
], 400),
@ -119,11 +119,11 @@
test('linkedin page publisher throws token expired exception on auth error after retry', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response([
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response([
'code' => 'EXPIRED_ACCESS_TOKEN',
'message' => 'The token used in the request has expired',
], 401),
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'error' => 'invalid_grant',
'error_description' => 'The refresh token is invalid',
], 400),
@ -137,12 +137,12 @@
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
Http::fake([
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 5184000,
], 200),
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -171,7 +171,7 @@
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
Http::fake([
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'error' => 'invalid_grant',
'error_description' => 'The refresh token is invalid',
], 400),
@ -185,7 +185,7 @@
$this->post->update(['content' => '']);
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -199,16 +199,9 @@
});
});
test('linkedin page publisher throws exception for unsupported content type', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramFeed]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Unsupported LinkedIn Page content type');
});
test('linkedin page publisher builds correct company url when username present', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -222,7 +215,7 @@
$this->socialAccount->update(['username' => null]);
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -286,7 +279,7 @@
test('linkedin page publisher can publish a document (pdf carousel) using organization urn', function () {
$this->postPlatform->update([
'content_type' => ContentType::LinkedInPageDocument,
'content_type' => ContentType::LinkedInPagePost,
'meta' => ['document_title' => 'Company Deck'],
]);
$this->post->update([
@ -348,7 +341,7 @@
});
test('linkedin page publisher throws and does not post when document processing fails', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInPageDocument]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInPagePost]);
$this->post->update([
'media' => [[
'id' => 'doc-media-1', 'path' => 'media/2026-01/company-deck.pdf',

View file

@ -42,7 +42,7 @@
test('linkedin publisher can publish text-only post', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -64,7 +64,7 @@
test('linkedin publisher uses correct headers', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -81,7 +81,7 @@
test('linkedin publisher throws exception on api error', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response([
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response([
'message' => 'Invalid request',
'status' => 400,
], 400),
@ -93,11 +93,11 @@
test('linkedin publisher throws token expired exception on auth error after retry', function () {
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response([
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response([
'code' => 'EXPIRED_ACCESS_TOKEN',
'message' => 'The token used in the request has expired',
], 401),
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'error' => 'invalid_grant',
'error_description' => 'The refresh token is invalid',
], 400),
@ -111,12 +111,12 @@
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
Http::fake([
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 5184000,
], 200),
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -145,7 +145,7 @@
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
Http::fake([
'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'error' => 'invalid_grant',
'error_description' => 'The refresh token is invalid',
], 400),
@ -159,7 +159,7 @@
$this->post->update(['content' => '']);
Http::fake([
'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
@ -173,13 +173,6 @@
});
});
test('linkedin publisher throws exception for unsupported content type', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramFeed]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Unsupported LinkedIn content type');
});
test('linkedin publisher can publish post with image', function () {
$this->post->update([
'media' => [
@ -231,7 +224,7 @@
});
test('linkedin publisher can publish carousel with multiple images', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInCarousel]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'media' => [
[
@ -383,7 +376,7 @@
test('linkedin publisher can publish a document (pdf carousel) with a title', function () {
$this->postPlatform->update([
'content_type' => ContentType::LinkedInDocument,
'content_type' => ContentType::LinkedInPost,
'meta' => ['document_title' => 'My Slides'],
]);
$this->post->update([
@ -448,7 +441,7 @@
});
test('linkedin publisher document title falls back to the file name', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'media' => [
[
@ -497,7 +490,7 @@
});
test('linkedin publisher waits for document processing before posting', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'media' => [
[
@ -540,7 +533,7 @@
});
test('linkedin publisher throws and does not post when document processing fails', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'media' => [
[
@ -580,7 +573,7 @@
});
test('linkedin publisher throws and does not post when document init fails', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'media' => [[
'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf',
@ -603,7 +596,7 @@
});
test('linkedin publisher throws when document init response is missing the urn', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'media' => [[
'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf',
@ -625,3 +618,45 @@
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher treats a 401 response without an error code as a token error', function () {
Http::fake([
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(['message' => 'Unauthorized'], 401),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
});
test('linkedin publisher refreshes the token when it is expiring soon', function () {
$this->socialAccount->update([
'token_expires_at' => now()->addMinutes(5),
'refresh_token' => 'refresh-token-123',
]);
Http::fake([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 3600,
], 200),
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:soon']),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:soon');
$this->socialAccount->refresh();
expect($this->socialAccount->access_token)->toBe('new-access-token');
});
test('linkedin publisher falls back to an empty id and null url when the post id header is missing', function () {
Http::fake([
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('');
expect($result['url'])->toBeNull();
});

View file

@ -1,270 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Status;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\LinkedInTokenSynchronizer;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->synchronizer = new LinkedInTokenSynchronizer;
});
test('syncs tokens from linkedin personal to linkedin page', function () {
$linkedInUserId = 'linkedin-user-123';
$personalAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => $linkedInUserId,
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'token_expires_at' => now()->addDays(60),
]);
$pageAccount = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-123',
'access_token' => 'old-access-token',
'refresh_token' => 'old-refresh-token',
'token_expires_at' => now()->subDay(),
'meta' => [
'organization_id' => 'org-123',
'admin_user_id' => $linkedInUserId,
'admin_name' => 'Test User',
],
]);
$this->synchronizer->syncTokens($personalAccount);
$pageAccount->refresh();
expect($pageAccount->access_token)->toBe('new-access-token');
expect($pageAccount->refresh_token)->toBe('new-refresh-token');
expect($pageAccount->token_expires_at->toDateString())->toBe(now()->addDays(60)->toDateString());
});
test('syncs tokens from linkedin page to linkedin personal', function () {
$linkedInUserId = 'linkedin-user-456';
$personalAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => $linkedInUserId,
'access_token' => 'old-access-token',
'refresh_token' => 'old-refresh-token',
'token_expires_at' => now()->subDay(),
]);
$pageAccount = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-456',
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'token_expires_at' => now()->addDays(60),
'meta' => [
'organization_id' => 'org-456',
'admin_user_id' => $linkedInUserId,
'admin_name' => 'Test User',
],
]);
$this->synchronizer->syncTokens($pageAccount);
$personalAccount->refresh();
expect($personalAccount->access_token)->toBe('new-access-token');
expect($personalAccount->refresh_token)->toBe('new-refresh-token');
expect($personalAccount->token_expires_at->toDateString())->toBe(now()->addDays(60)->toDateString());
});
test('marks disconnected account as connected after sync', function () {
$linkedInUserId = 'linkedin-user-789';
$personalAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => $linkedInUserId,
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'token_expires_at' => now()->addDays(60),
]);
$pageAccount = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-789',
'access_token' => 'old-access-token',
'refresh_token' => 'old-refresh-token',
'status' => Status::Disconnected,
'meta' => [
'organization_id' => 'org-789',
'admin_user_id' => $linkedInUserId,
'admin_name' => 'Test User',
],
]);
$this->synchronizer->syncTokens($personalAccount);
$pageAccount->refresh();
expect($pageAccount->status)->toBe(Status::Connected);
});
test('does not sync when no linked account exists', function () {
$personalAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'linkedin-user-solo',
'access_token' => 'access-token',
'refresh_token' => 'refresh-token',
]);
// This should not throw any errors
$this->synchronizer->syncTokens($personalAccount);
// Just verify the account still has its original tokens
expect($personalAccount->access_token)->toBe('access-token');
});
test('syncs tokens to every linked page admin\'d by the same user', function () {
$linkedInUserId = 'linkedin-user-multi';
$personalAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => $linkedInUserId,
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'token_expires_at' => now()->addDays(60),
]);
$pageOne = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-one',
'access_token' => 'old-token-1',
'refresh_token' => 'old-refresh-1',
'meta' => [
'organization_id' => 'org-one',
'admin_user_id' => $linkedInUserId,
'admin_name' => 'Test User',
],
]);
$pageTwo = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-two',
'access_token' => 'old-token-2',
'refresh_token' => 'old-refresh-2',
'meta' => [
'organization_id' => 'org-two',
'admin_user_id' => $linkedInUserId,
'admin_name' => 'Test User',
],
]);
$unrelatedPage = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-other-admin',
'access_token' => 'untouched-token',
'refresh_token' => 'untouched-refresh',
'meta' => [
'organization_id' => 'org-other-admin',
'admin_user_id' => 'someone-else',
'admin_name' => 'Other User',
],
]);
$this->synchronizer->syncTokens($personalAccount);
$pageOne->refresh();
$pageTwo->refresh();
$unrelatedPage->refresh();
expect($pageOne->access_token)->toBe('new-access-token');
expect($pageOne->refresh_token)->toBe('new-refresh-token');
expect($pageTwo->access_token)->toBe('new-access-token');
expect($pageTwo->refresh_token)->toBe('new-refresh-token');
expect($unrelatedPage->access_token)->toBe('untouched-token');
});
test('does not sync across different workspaces', function () {
$linkedInUserId = 'linkedin-user-cross';
$otherWorkspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$personalAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => $linkedInUserId,
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
]);
$pageAccountOtherWorkspace = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $otherWorkspace->id,
'platform_user_id' => 'org-other',
'access_token' => 'old-access-token',
'refresh_token' => 'old-refresh-token',
'meta' => [
'organization_id' => 'org-other',
'admin_user_id' => $linkedInUserId,
'admin_name' => 'Test User',
],
]);
$this->synchronizer->syncTokens($personalAccount);
$pageAccountOtherWorkspace->refresh();
// Should NOT have been synced because it's in a different workspace
expect($pageAccountOtherWorkspace->access_token)->toBe('old-access-token');
});
test('does not sync across different LinkedIn admins inside the same workspace', function () {
// Scenario: multi-user workspace. Owner connects their own LinkedIn
// (personal + page they admin); a teammate later connects their
// company's LinkedIn Page that they admin. Two distinct LinkedIn
// user identities coexist in the same workspace and must NOT share
// tokens — each token belongs to whoever authorized it at LinkedIn.
$ownerLinkedInId = 'linkedin-user-owner';
$clientLinkedInId = 'linkedin-user-client';
$ownerPersonal = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => $ownerLinkedInId,
'access_token' => 'owner-new-access-token',
'refresh_token' => 'owner-new-refresh-token',
'token_expires_at' => now()->addDays(60),
]);
$ownerPage = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-owner',
'access_token' => 'owner-old-access-token',
'refresh_token' => 'owner-old-refresh-token',
'meta' => [
'organization_id' => 'org-owner',
'admin_user_id' => $ownerLinkedInId,
'admin_name' => 'Owner',
],
]);
$clientPage = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-client',
'access_token' => 'client-untouched-access-token',
'refresh_token' => 'client-untouched-refresh-token',
'meta' => [
'organization_id' => 'org-client',
'admin_user_id' => $clientLinkedInId,
'admin_name' => 'Client',
],
]);
$this->synchronizer->syncTokens($ownerPersonal);
// Owner's own page got the new token.
$ownerPage->refresh();
expect($ownerPage->access_token)->toBe('owner-new-access-token');
expect($ownerPage->refresh_token)->toBe('owner-new-refresh-token');
// Client's page (different admin_user_id) was NOT touched even
// though it lives in the same workspace.
$clientPage->refresh();
expect($clientPage->access_token)->toBe('client-untouched-access-token');
expect($clientPage->refresh_token)->toBe('client-untouched-refresh-token');
});

View file

@ -9,6 +9,8 @@
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -19,7 +21,24 @@
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
});
test('linkedin connect redirects to oauth provider', function () {
/**
* Build a Socialite user for the LinkedIn person behind the OAuth grant.
*/
function linkedInSocialiteUser(string $id = 'person-123'): SocialiteUser
{
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn($id);
$socialiteUser->shouldReceive('getName')->andReturn('John Doe');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'test-access-token';
$socialiteUser->refreshToken = 'test-refresh-token';
$socialiteUser->expiresIn = 5184000; // 60 days
$socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social'];
return $socialiteUser;
}
test('linkedin connect redirects to oauth provider via the openid driver', function () {
$driverMock = Mockery::mock();
$driverMock->shouldReceive('scopes')->andReturnSelf();
$driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([
@ -27,7 +46,7 @@
]));
Socialite::shouldReceive('driver')
->with('linkedin')
->with('linkedin-openid')
->andReturn($driverMock);
$response = $this->actingAs($this->user)
@ -40,12 +59,12 @@
});
/**
* Mock the LinkedIn Socialite driver, hit /connect, and return the scopes
* the controller requested.
* Mock the openid driver, hit connect, and return the scopes the controller asked for.
*
* @return array<int, string>
*/
$captureConnectScopes = function (object $test): array {
function captureLinkedInConnectScopes(object $test): array
{
$captured = [];
$driverMock = Mockery::mock();
@ -60,68 +79,250 @@
'getTargetUrl' => 'https://www.linkedin.com/oauth/v2/authorization?test=1',
]));
Socialite::shouldReceive('driver')
->with('linkedin')
->andReturn($driverMock);
Socialite::shouldReceive('driver')->with('linkedin-openid')->andReturn($driverMock);
$test->actingAs($test->user)
->withHeader('X-Inertia', 'true')
->get(route('app.social.linkedin.connect'));
return $captured;
};
}
test('linkedin connect requests the default scope set', function () use ($captureConnectScopes) {
test('linkedin connect requests the union of personal and organization scopes', function () {
config(['trypost.platforms.linkedin.scopes' => ['openid', 'profile', 'email', 'w_member_social']]);
config(['trypost.platforms.linkedin-page.scopes' => ['openid', 'profile', 'email', 'w_organization_social', 'r_organization_social', 'rw_organization_admin', 'w_member_social']]);
expect(captureLinkedInConnectScopes($this))->toEqualCanonicalizing([
'openid', 'profile', 'email', 'w_member_social',
'w_organization_social', 'r_organization_social', 'rw_organization_admin',
]);
});
test('connect requests only personal scopes when company pages are disabled', function () {
config(['trypost.platforms.linkedin.enabled' => true]);
config(['trypost.platforms.linkedin-page.enabled' => false]);
config(['trypost.platforms.linkedin.scopes' => ['openid', 'profile', 'email', 'w_member_social']]);
expect($captureConnectScopes($this))->toEqualCanonicalizing([
expect(captureLinkedInConnectScopes($this))->toEqualCanonicalizing([
'openid', 'profile', 'email', 'w_member_social',
]);
});
test('linkedin connect requests the scopes configured via LINKEDIN_SCOPES', function () use ($captureConnectScopes) {
// Operators whose LinkedIn app has legacy/enterprise products approved
// override the default set via LINKEDIN_SCOPES (exploded into the config
// array), e.g. re-adding r_basicprofile to restore the vanityName lookup.
config(['trypost.platforms.linkedin.scopes' => ['openid', 'profile', 'email', 'w_member_social', 'r_basicprofile']]);
test('connect requests only organization scopes when the personal profile is disabled', function () {
config(['trypost.platforms.linkedin.enabled' => false]);
config(['trypost.platforms.linkedin-page.enabled' => true]);
config(['trypost.platforms.linkedin-page.scopes' => ['openid', 'w_organization_social', 'r_organization_social', 'rw_organization_admin']]);
expect($captureConnectScopes($this))->toEqualCanonicalizing([
'openid', 'profile', 'email', 'w_member_social', 'r_basicprofile',
expect(captureLinkedInConnectScopes($this))->toEqualCanonicalizing([
'openid', 'w_organization_social', 'r_organization_social', 'rw_organization_admin',
]);
});
test('linkedin oauth callback creates account', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
test('connect is forbidden when both linkedin capabilities are disabled', function () {
config(['trypost.platforms.linkedin.enabled' => false]);
config(['trypost.platforms.linkedin-page.enabled' => false]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('abc123xyz');
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
$socialiteUser->shouldReceive('getName')->andReturn('John Doe');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'test-access-token';
$socialiteUser->refreshToken = 'test-refresh-token';
$socialiteUser->expiresIn = 5184000; // 60 days
$socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social'];
$this->actingAs($this->user)
->get(route('app.social.linkedin.connect'))
->assertForbidden();
});
test('connect returns the popup callback when the user has no workspace', function () {
// Runs inside the OAuth popup, so it must not redirect away and strand it.
$this->user->update(['current_workspace_id' => null]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.connect'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
});
test('linkedin callback stores the person and organizations then redirects to the selector', function () {
session(['social_connect_workspace' => $this->workspace->id]);
Socialite::shouldReceive('driver')
->with('linkedin')
->andReturn(Mockery::mock([
'user' => $socialiteUser,
]));
->with('linkedin-openid')
->andReturn(Mockery::mock(['user' => linkedInSocialiteUser()]));
Http::fake([
'https://api.linkedin.com/v2/me*' => Http::response([
'id' => 'abc123xyz',
'vanityName' => 'johndoe',
'localizedFirstName' => 'John',
'localizedLastName' => 'Doe',
config('trypost.platforms.linkedin.api').'/v2/me*' => Http::response(['id' => 'person-123', 'vanityName' => 'johndoe'], 200),
config('trypost.platforms.linkedin.api').'/v2/organizationAcls*' => Http::response([
'elements' => [
['organization~' => ['id' => 123456, 'localizedName' => 'Test Company', 'vanityName' => 'testcompany']],
],
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertRedirect(route('app.social.linkedin.select-identity'));
expect(session('linkedin_pending.person.id'))->toBe('person-123');
expect(session('linkedin_pending.person.vanity_name'))->toBe('johndoe');
expect(session('linkedin_pending.organizations'))->toHaveCount(1);
});
test('linkedin callback still redirects to the selector when the member administers no organizations', function () {
session(['social_connect_workspace' => $this->workspace->id]);
Socialite::shouldReceive('driver')
->with('linkedin-openid')
->andReturn(Mockery::mock(['user' => linkedInSocialiteUser()]));
Http::fake([
config('trypost.platforms.linkedin.api').'/v2/me*' => Http::response(['vanityName' => 'johndoe'], 200),
config('trypost.platforms.linkedin.api').'/v2/organizationAcls*' => Http::response(['elements' => []], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertRedirect(route('app.social.linkedin.select-identity'));
expect(session('linkedin_pending.organizations'))->toBe([]);
});
test('linkedin callback fails with expired session', function () {
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
});
test('linkedin callback handles oauth errors gracefully', function () {
session(['social_connect_workspace' => $this->workspace->id]);
$mock = Mockery::mock();
$mock->shouldReceive('user')->andThrow(new Exception('OAuth error'));
Socialite::shouldReceive('driver')->with('linkedin-openid')->andReturn($mock);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
});
test('select-identity screen renders the person and organizations', function () {
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [
['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null],
],
]]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('accounts/LinkedInSelect')
->where('person.name', 'John Doe')
->has('organizations', 1)
);
});
test('select-identity hides the personal profile when that capability is disabled', function () {
config(['trypost.platforms.linkedin.enabled' => false]);
config(['trypost.platforms.linkedin-page.enabled' => true]);
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'w_organization_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => null],
'organizations' => [
['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null],
],
]]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('accounts/LinkedInSelect')
->where('person', null)
->has('organizations', 1)
);
});
test('selecting the person is rejected when the personal profile capability is disabled', function () {
config(['trypost.platforms.linkedin.enabled' => false]);
config(['trypost.platforms.linkedin-page.enabled' => true]);
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'w_organization_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => null],
'organizations' => [],
]]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'person',
]);
$response->assertViewHas('success', false);
$this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 'person-123']);
});
test('selecting an organization is rejected when company pages are disabled', function () {
config(['trypost.platforms.linkedin.enabled' => true]);
config(['trypost.platforms.linkedin-page.enabled' => false]);
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [],
]]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'organization',
'organization_id' => 123456,
]);
$response->assertViewHas('success', false);
$this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 123456]);
});
test('select-identity returns the popup callback when the session expired', function () {
// Rendered inside the OAuth popup, so a redirect would strand it — it must
// answer with the self-closing callback view instead.
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
});
test('selecting the person creates a linkedin account', function () {
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [],
]]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'person',
]);
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
@ -129,159 +330,212 @@
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn->value,
'platform_user_id' => 'abc123xyz',
'platform_user_id' => 'person-123',
'username' => 'johndoe',
'display_name' => 'John Doe',
'status' => Status::Connected->value,
]);
expect(session('linkedin_pending'))->toBeNull();
});
test('selecting the person downloads and stores the avatar', function () {
Storage::fake();
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
'person' => ['id' => 'person-avatar', 'name' => 'John Doe', 'avatar' => 'https://media.example.com/avatar.jpg', 'vanity_name' => 'johndoe'],
'organizations' => [],
]]);
Http::fake([
'https://media.example.com/avatar.jpg' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
]);
$this->actingAs($this->user)->post(route('app.social.linkedin.select'), ['type' => 'person']);
// The avatar download (uploadFromUrl) ran and a stored path was persisted.
Http::assertSent(fn ($request) => $request->url() === 'https://media.example.com/avatar.jpg');
$account = SocialAccount::where('platform_user_id', 'person-avatar')->first();
expect($account->getRawOriginal('avatar_url'))->not->toBeNull();
});
test('selecting an organization creates a linkedin-page account with the admin recorded in meta', function () {
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'profile', 'email', 'w_organization_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [
['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null],
],
]]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'organization',
'organization_id' => 123456,
]);
$response->assertOk();
$response->assertViewHas('success', true);
$account = SocialAccount::where('platform', Platform::LinkedInPage->value)
->where('platform_user_id', 123456)
->first();
expect($account)->not->toBeNull();
expect($account->display_name)->toBe('Test Company');
expect($account->username)->toBe('testcompany');
expect($account->meta['admin_user_id'])->toBe('person-123');
expect($account->meta['admin_name'])->toBe('John Doe');
});
test('selecting an organization the member does not administer is rejected', function () {
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'w_organization_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [
['id' => 111, 'name' => 'My Company', 'vanity_name' => 'myco', 'logo' => null],
],
]]);
// 999 is not in the admin-verified list — a tampered POST must not connect it.
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'organization',
'organization_id' => 999,
]);
$response->assertViewHas('success', false);
$this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 999]);
});
test('selecting an organization splits comma-separated approvedScopes before saving', function () {
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
// Socialite returns LinkedIn scopes CSV-joined into a single element.
'approved_scopes' => ['email,openid,profile,w_organization_social,r_organization_social,rw_organization_admin,w_member_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [
['id' => 999888, 'name' => 'Scope Company', 'vanity_name' => 'scopeco', 'logo' => null],
],
]]);
$this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'organization',
'organization_id' => 999888,
]);
$account = SocialAccount::where('platform_user_id', 999888)->first();
expect($account->scopes)->toEqualCanonicalizing([
'email', 'openid', 'profile',
'w_organization_social', 'r_organization_social',
'rw_organization_admin', 'w_member_social',
]);
});
test('linkedin callback shows network_taken when the linkedin network is already connected', function () {
config()->set('trypost.self_hosted', false);
// A LinkedIn Page occupies the shared "linkedin" network for this workspace.
SocialAccount::factory()->linkedinPage()->create([
test('select rejects an invalid identity type without stranding the popup', function () {
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'existing-linkedin-page',
]);
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => [],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [],
]]);
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('new-profile-id');
$socialiteUser->shouldReceive('getName')->andReturn('John Doe');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'test-access-token';
$socialiteUser->refreshToken = 'test-refresh-token';
$socialiteUser->expiresIn = 5184000;
$socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social'];
Socialite::shouldReceive('driver')
->with('linkedin')
->andReturn(Mockery::mock(['user' => $socialiteUser]));
Http::fake([
'https://api.linkedin.com/v2/me*' => Http::response([
'id' => 'new-profile-id',
'vanityName' => 'johndoe',
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), ['type' => 'bogus']);
// A redirect-back would strand the popup; it must answer with the self-closing callback view.
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
$this->assertDatabaseMissing('social_accounts', [
'platform' => Platform::LinkedIn->value,
'platform_user_id' => 'new-profile-id',
]);
$this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 'person-123']);
});
test('linkedin oauth callback splits comma-separated approvedScopes before saving', function () {
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('abc123xyz');
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
$socialiteUser->shouldReceive('getName')->andReturn('John Doe');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'test-access-token';
$socialiteUser->refreshToken = 'test-refresh-token';
$socialiteUser->expiresIn = 5184000;
// LinkedIn returns scopes comma-separated; Socialite's LinkedInProvider
// splits on space (the OAuth 2.0 default), so approvedScopes lands as
// a single-element array with the whole CSV inside. The save path
// must normalize back to individual tokens.
$socialiteUser->approvedScopes = ['email,openid,profile,w_member_social'];
Socialite::shouldReceive('driver')
->with('linkedin')
->andReturn(Mockery::mock(['user' => $socialiteUser]));
Http::fake([
'https://api.linkedin.com/v2/me*' => Http::response([
'id' => 'abc123xyz',
'vanityName' => 'johndoe',
], 200),
test('select fails with expired session', function () {
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'person',
]);
$this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$account = SocialAccount::where('platform_user_id', 'abc123xyz')->first();
expect($account->scopes)->toEqualCanonicalizing([
'email', 'openid', 'profile', 'w_member_social',
]);
});
test('linkedin callback fails with expired session', function () {
// No session data - simulating expired session
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
});
test('user can connect multiple linkedin accounts in self-hosted mode', function () {
test('selecting the person shows network_taken when a linkedin page already occupies the network', function () {
config()->set('trypost.self_hosted', false);
SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'existing-linkedin-page',
]);
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
'person' => ['id' => 'new-person', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [],
]]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'person',
]);
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
$this->assertDatabaseMissing('social_accounts', [
'platform' => Platform::LinkedIn->value,
'platform_user_id' => 'new-person',
]);
});
test('user can connect multiple linkedin organizations in self-hosted mode', function () {
config()->set('trypost.self_hosted', true);
SocialAccount::factory()->linkedin()->create([
SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'abc123xyz',
'platform_user_id' => '123456',
]);
session([
'social_connect_workspace' => $this->workspace->id,
session(['linkedin_pending' => [
'workspace_id' => $this->workspace->id,
'token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'profile', 'email', 'w_organization_social'],
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
'organizations' => [
['id' => 789012, 'name' => 'Another Company', 'vanity_name' => 'anothercompany', 'logo' => null],
],
]]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [
'type' => 'organization',
'organization_id' => 789012,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('xyz789abc');
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
$socialiteUser->shouldReceive('getName')->andReturn('Jane Doe');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'new-access-token';
$socialiteUser->refreshToken = 'new-refresh-token';
$socialiteUser->expiresIn = 5184000;
$socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social'];
Socialite::shouldReceive('driver')
->with('linkedin')
->andReturn(Mockery::mock([
'user' => $socialiteUser,
]));
Http::fake([
'https://api.linkedin.com/v2/me*' => Http::response([
'vanityName' => 'janedoe',
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedIn)->count())->toBe(2);
});
test('linkedin callback handles oauth errors gracefully', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$mock = Mockery::mock();
$mock->shouldReceive('user')->andThrow(new Exception('OAuth error'));
Socialite::shouldReceive('driver')
->with('linkedin')
->andReturn($mock);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedInPage)->count())->toBe(2);
});

View file

@ -1,311 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
});
test('linkedin page connect redirects to oauth provider', function () {
$driverMock = Mockery::mock();
$driverMock->shouldReceive('scopes')->andReturnSelf();
$driverMock->shouldReceive('with')->andReturnSelf();
$driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([
'getTargetUrl' => 'https://www.linkedin.com/oauth/v2/authorization?test=1',
]));
Socialite::shouldReceive('driver')
->with('linkedin-openid')
->andReturn($driverMock);
$response = $this->actingAs($this->user)
->withHeader('X-Inertia', 'true')
->get(route('app.social.linkedin-page.connect'));
$response->assertStatus(409); // Inertia::location returns 409 with X-Inertia header
expect(session('social_connect_workspace'))->toBe($this->workspace->id);
});
test('linkedin page oauth callback fetches organizations', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('user123');
$socialiteUser->shouldReceive('getName')->andReturn('John Doe');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'test-access-token';
$socialiteUser->refreshToken = 'test-refresh-token';
$socialiteUser->expiresIn = 5184000;
$socialiteMock = Mockery::mock();
$socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock);
$socialiteMock->shouldReceive('with')->andReturn($socialiteMock);
$socialiteMock->shouldReceive('user')->andReturn($socialiteUser);
Socialite::shouldReceive('driver')
->with('linkedin-openid')
->andReturn($socialiteMock);
Http::fake([
'https://api.linkedin.com/v2/organizationAcls*' => Http::response([
'elements' => [
[
'organization~' => [
'id' => 123456,
'localizedName' => 'Test Company',
'vanityName' => 'testcompany',
],
],
],
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.callback'));
$response->assertRedirect(route('app.social.linkedin-page.select-page'));
expect(session('linkedin_page_pending'))->not->toBeNull();
expect(session('linkedin_page_pending.organizations'))->toHaveCount(1);
});
test('linkedin page callback fails when user has no organizations', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('user123');
$socialiteUser->shouldReceive('getName')->andReturn('John Doe');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'test-access-token';
$socialiteUser->refreshToken = 'test-refresh-token';
$socialiteUser->expiresIn = 5184000;
$socialiteMock = Mockery::mock();
$socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock);
$socialiteMock->shouldReceive('with')->andReturn($socialiteMock);
$socialiteMock->shouldReceive('user')->andReturn($socialiteUser);
Socialite::shouldReceive('driver')
->with('linkedin-openid')
->andReturn($socialiteMock);
Http::fake([
'https://api.linkedin.com/v2/organizationAcls*' => Http::response([
'elements' => [],
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'You are not an administrator of any LinkedIn page.');
});
test('linkedin page callback fails with expired session', function () {
// No session data - simulating expired session
$response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
});
test('linkedin page select creates account', function () {
session([
'linkedin_page_pending' => [
'workspace_id' => $this->workspace->id,
'user_id' => 'user123',
'name' => 'John Doe',
'avatar' => null,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'organizations' => [
['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null],
],
],
]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [
'organization_id' => 123456,
'organization_name' => 'Test Company',
'organization_vanity_name' => 'testcompany',
'organization_logo' => null,
]);
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedInPage->value,
'platform_user_id' => 123456,
'username' => 'testcompany',
'display_name' => 'Test Company',
'status' => Status::Connected->value,
]);
});
test('linkedin page select splits comma-separated approvedScopes before saving', function () {
session([
'linkedin_page_pending' => [
'workspace_id' => $this->workspace->id,
'user_id' => 'user123',
'name' => 'John Doe',
'avatar' => null,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
// Simulates what Socialite returns for LinkedIn (CSV-joined into
// a single array element because the provider splits on space).
'approved_scopes' => ['email,openid,profile,w_organization_social,r_organization_social,rw_organization_admin,w_member_social'],
'organizations' => [
['id' => 999888, 'name' => 'Scope Company', 'vanity_name' => 'scopeco', 'logo' => null],
],
],
]);
$this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [
'organization_id' => 999888,
'organization_name' => 'Scope Company',
'organization_vanity_name' => 'scopeco',
'organization_logo' => null,
]);
$account = SocialAccount::where('platform_user_id', 999888)->first();
expect($account->scopes)->toEqualCanonicalizing([
'email', 'openid', 'profile',
'w_organization_social', 'r_organization_social',
'rw_organization_admin', 'w_member_social',
]);
});
test('linkedin page select fails with expired session', function () {
// No session data
$response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [
'organization_id' => 123456,
'organization_name' => 'Test Company',
'organization_vanity_name' => 'testcompany',
]);
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
});
test('user can connect multiple linkedin pages in self-hosted mode', function () {
config()->set('trypost.self_hosted', true);
SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '123456',
]);
session([
'linkedin_page_pending' => [
'workspace_id' => $this->workspace->id,
'user_id' => 'user123',
'name' => 'John Doe',
'avatar' => null,
'token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 5184000,
'organizations' => [
['id' => 789012, 'name' => 'Another Company', 'vanity_name' => 'anothercompany', 'logo' => null],
],
],
]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [
'organization_id' => 789012,
'organization_name' => 'Another Company',
'organization_vanity_name' => 'anothercompany',
'organization_logo' => null,
]);
$response->assertOk();
$response->assertViewHas('success', true);
expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedInPage)->count())->toBe(2);
});
test('linkedin page callback handles oauth errors gracefully', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteMock = Mockery::mock();
$socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock);
$socialiteMock->shouldReceive('with')->andReturn($socialiteMock);
$socialiteMock->shouldReceive('user')->andThrow(new Exception('OAuth error'));
Socialite::shouldReceive('driver')
->with('linkedin-openid')
->andReturn($socialiteMock);
$response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
});
test('linkedin page select shows network_taken when a linkedin account is already connected', function () {
config()->set('trypost.self_hosted', false);
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
'platform_user_id' => 'existing-linkedin',
]);
session([
'linkedin_page_pending' => [
'workspace_id' => $this->workspace->id,
'user_id' => 'user123',
'name' => 'John Doe',
'avatar' => null,
'token' => 'test-access-token',
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'organizations' => [
['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null],
],
],
]);
$response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [
'organization_id' => 123456,
'organization_name' => 'Test Company',
'organization_vanity_name' => 'testcompany',
'organization_logo' => null,
]);
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::LinkedIn->value, Platform::LinkedInPage->value])->count())->toBe(1);
});

View file

@ -43,6 +43,56 @@
);
});
test('accounts index offers a single linkedin card and no standalone linkedin page card', function () {
$response = $this->actingAs($this->user)->get(route('app.accounts'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('accounts/Index', false)
->where('platforms', fn ($platforms) => collect($platforms)->contains('value', Platform::LinkedIn->value)
&& ! collect($platforms)->contains('value', Platform::LinkedInPage->value)
)
);
});
test('the linkedin card still shows when only company pages are enabled', function () {
config(['trypost.platforms.linkedin.enabled' => false]);
config(['trypost.platforms.linkedin-page.enabled' => true]);
$response = $this->actingAs($this->user)->get(route('app.accounts'));
$response->assertInertia(fn ($page) => $page
->component('accounts/Index', false)
->where('platforms', fn ($platforms) => collect($platforms)->contains('value', Platform::LinkedIn->value))
);
});
test('the linkedin card disappears only when both capabilities are disabled', function () {
config(['trypost.platforms.linkedin.enabled' => false]);
config(['trypost.platforms.linkedin-page.enabled' => false]);
$response = $this->actingAs($this->user)->get(route('app.accounts'));
$response->assertInertia(fn ($page) => $page
->component('accounts/Index', false)
->where('platforms', fn ($platforms) => ! collect($platforms)->contains('value', Platform::LinkedIn->value))
);
});
test('a connected linkedin page account is still returned so it surfaces under the linkedin card', function () {
SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
]);
$response = $this->actingAs($this->user)->get(route('app.accounts'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('accounts/Index', false)
->where('connectedAccounts.0.platform', Platform::LinkedInPage->value)
);
});
test('an unsubscribed account can disconnect during onboarding (no active subscription required)', function () {
config(['trypost.self_hosted' => false]);

View file

@ -10,7 +10,6 @@
expect(ContentType::InstagramReel->label())->toBe('Reel');
expect(ContentType::InstagramStory->label())->toBe('Story');
expect(ContentType::LinkedInPost->label())->toBe('Post');
expect(ContentType::LinkedInCarousel->label())->toBe('Carousel');
expect(ContentType::YouTubeShort->label())->toBe('Short');
expect(ContentType::XPost->label())->toBe('Post');
expect(ContentType::TikTokVideo->label())->toBe('Video');
@ -60,7 +59,7 @@
test('content type has correct max media count', function () {
expect(ContentType::InstagramFeed->maxMediaCount())->toBe(10);
expect(ContentType::InstagramReel->maxMediaCount())->toBe(1);
expect(ContentType::LinkedInCarousel->maxMediaCount())->toBe(20);
expect(ContentType::LinkedInPost->maxMediaCount())->toBe(20);
expect(ContentType::XPost->maxMediaCount())->toBe(4);
expect(ContentType::PinterestCarousel->maxMediaCount())->toBe(5);
expect(ContentType::BlueskyPost->maxMediaCount())->toBe(4);
@ -71,7 +70,7 @@
expect(ContentType::InstagramReel->supportsVideo())->toBeTrue();
expect(ContentType::TikTokVideo->supportsVideo())->toBeTrue();
expect(ContentType::YouTubeShort->supportsVideo())->toBeTrue();
expect(ContentType::LinkedInCarousel->supportsVideo())->toBeFalse();
expect(ContentType::LinkedInPost->supportsVideo())->toBeTrue();
expect(ContentType::PinterestPin->supportsVideo())->toBeFalse();
});
@ -151,28 +150,25 @@
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');
test('linkedin post content types accept images, videos and documents but never mixed', function () {
foreach ([ContentType::LinkedInPost, ContentType::LinkedInPagePost] as $type) {
expect($type->supportsImage())->toBeTrue();
expect($type->supportsVideo())->toBeTrue();
expect($type->supportsDocument())->toBeTrue();
expect($type->supportsImage())->toBeFalse();
expect($type->supportsVideo())->toBeFalse();
expect($type->requiresMedia())->toBeTrue();
expect($type->maxMediaCount())->toBe(1);
expect($type->supportsMixedMedia())->toBeFalse();
expect($type->requiresMedia())->toBeFalse();
expect($type->maxMediaCount())->toBe(20);
}
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();
test('only linkedin post content types support documents', function () {
expect(ContentType::LinkedInPost->supportsDocument())->toBeTrue();
expect(ContentType::LinkedInPagePost->supportsDocument())->toBeTrue();
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);
test('linkedin exposes a single post content type per account kind', function () {
expect(ContentType::forPlatform(Platform::LinkedIn))->toHaveCount(1)->toContain(ContentType::LinkedInPost);
expect(ContentType::forPlatform(Platform::LinkedInPage))->toHaveCount(1)->toContain(ContentType::LinkedInPagePost);
});

View file

@ -45,7 +45,7 @@
});
test('platform has correct max images', function () {
expect(Platform::LinkedIn->maxImages())->toBe(1);
expect(Platform::LinkedIn->maxImages())->toBe(20);
expect(Platform::X->maxImages())->toBe(4);
expect(Platform::TikTok->maxImages())->toBe(0);
expect(Platform::YouTube->maxImages())->toBe(0);
@ -110,3 +110,25 @@
expect($enabled)->not->toContain(Platform::LinkedIn);
});
test('only linkedin pages are not directly connectable', function () {
expect(Platform::LinkedInPage->isConnectable())->toBeFalse();
expect(Platform::LinkedIn->isConnectable())->toBeTrue();
expect(Platform::InstagramFacebook->isConnectable())->toBeTrue();
expect(Platform::Instagram->isConnectable())->toBeTrue();
});
test('the linkedin card is connectable while either capability is enabled', function () {
config(['trypost.platforms.linkedin.enabled' => true, 'trypost.platforms.linkedin-page.enabled' => false]);
expect(Platform::LinkedIn->isConnectable())->toBeTrue();
config(['trypost.platforms.linkedin.enabled' => false, 'trypost.platforms.linkedin-page.enabled' => true]);
expect(Platform::LinkedIn->isConnectable())->toBeTrue();
config(['trypost.platforms.linkedin.enabled' => false, 'trypost.platforms.linkedin-page.enabled' => false]);
expect(Platform::LinkedIn->isConnectable())->toBeFalse();
// A company page never gets its own card regardless of toggles.
config(['trypost.platforms.linkedin-page.enabled' => true]);
expect(Platform::LinkedInPage->isConnectable())->toBeFalse();
});

View file

@ -136,71 +136,90 @@ function runMediaRule(string $contentType, array $media): array
expect(runMediaRule(ContentType::DiscordMessage->value, $media))->toBe([]);
});
test('linkedin document accepts a pdf', function () {
test('linkedin post accepts a pdf on its own', function () {
$media = [['type' => MediaType::Document->value, 'mime_type' => 'application/pdf']];
expect(runMediaRule(ContentType::LinkedInDocument->value, $media))->toBe([]);
expect(runMediaRule(ContentType::LinkedInPageDocument->value, $media))->toBe([]);
expect(runMediaRule(ContentType::LinkedInPost->value, $media))->toBe([]);
expect(runMediaRule(ContentType::LinkedInPagePost->value, $media))->toBe([]);
});
test('linkedin document detects a pdf from mime when type field is missing', function () {
test('linkedin post detects a pdf from mime when type field is missing', function () {
$media = [['mime_type' => 'application/pdf']];
expect(runMediaRule(ContentType::LinkedInDocument->value, $media))->toBe([]);
expect(runMediaRule(ContentType::LinkedInPost->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']];
test('a pdf must be the only attachment on linkedin', function () {
$media = [
['type' => MediaType::Document->value, 'mime_type' => 'application/pdf'],
['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'],
];
$errors = runMediaRule(ContentType::LinkedInPost->value, $media);
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('does not support PDF documents');
expect($errors[0])->toContain('must be the only attachment');
});
expect(runMediaRule(ContentType::LinkedInCarousel->value, $media)[0])->toContain('does not support PDF documents');
test('linkedin rejects mixing an image and a video', function () {
$media = [
['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'],
['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'],
];
$errors = runMediaRule(ContentType::LinkedInPost->value, $media);
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain("can't combine an image and a video");
});
test('a pdf is rejected on content types that do not support documents', function () {
$media = [['type' => MediaType::Document->value, 'mime_type' => 'application/pdf']];
$errors = runMediaRule(ContentType::XPost->value, $media);
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('does not support PDF documents');
});
test('falls back to stored media when the request omits the media key', function () {
// A PDF fallback on a document-capable type passes.
$errors = [];
(new ContentTypeCompatibleWithMedia([['type' => 'document', 'mime_type' => 'application/pdf']]))
->setData([]) // no 'media' key in the request -> use the fallback
->validate('platforms.0.content_type', ContentType::LinkedInDocument->value, function (string $message) use (&$errors): void {
->validate('platforms.0.content_type', ContentType::LinkedInPost->value, function (string $message) use (&$errors): void {
$errors[] = $message;
});
expect($errors)->toBe([]);
$imageErrors = [];
(new ContentTypeCompatibleWithMedia([['type' => 'image', 'mime_type' => 'image/jpeg']]))
// The same PDF fallback on X (no document support) is rejected — proving the fallback is used.
$xErrors = [];
(new ContentTypeCompatibleWithMedia([['type' => 'document', 'mime_type' => 'application/pdf']]))
->setData([])
->validate('platforms.0.content_type', ContentType::LinkedInDocument->value, function (string $message) use (&$imageErrors): void {
$imageErrors[] = $message;
->validate('platforms.0.content_type', ContentType::XPost->value, function (string $message) use (&$xErrors): void {
$xErrors[] = $message;
});
expect($imageErrors)->toHaveCount(1);
expect($imageErrors[0])->toContain('does not support images');
expect($xErrors)->toHaveCount(1);
expect($xErrors[0])->toContain('does not support PDF documents');
});
test('request media takes precedence over the stored fallback', function () {
// Fallback is a PDF, but the request explicitly carries an image instead.
// Fallback is a lone PDF (would pass), but the request carries a PDF + image,
// which must be rejected — proving the request media is used over the fallback.
$errors = [];
(new ContentTypeCompatibleWithMedia([['type' => 'document', 'mime_type' => 'application/pdf']]))
->setData(['media' => [['type' => 'image', 'mime_type' => 'image/jpeg']]])
->validate('platforms.0.content_type', ContentType::LinkedInDocument->value, function (string $message) use (&$errors): void {
->setData(['media' => [
['type' => 'document', 'mime_type' => 'application/pdf'],
['type' => 'image', 'mime_type' => 'image/jpeg'],
]])
->validate('platforms.0.content_type', ContentType::LinkedInPost->value, function (string $message) use (&$errors): void {
$errors[] = $message;
});
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('does not support images');
expect($errors[0])->toContain('must be the only attachment');
});
test('does nothing for invalid content type values', function () {

View file

@ -35,7 +35,6 @@ function runMatchesPlatformRule(string $contentType, ?string $accountId, array $
]);
expect(runMatchesPlatformRule(ContentType::LinkedInPost->value, $linkedin->id))->toBe([]);
expect(runMatchesPlatformRule(ContentType::LinkedInCarousel->value, $linkedin->id))->toBe([]);
});
test('fails when content_type belongs to a different platform', function () {

View file

@ -42,7 +42,6 @@ function runMatchesPostPlatformRule(string $contentType, ?string $postPlatformId
]);
expect(runMatchesPostPlatformRule(ContentType::LinkedInPost->value, $postPlatform->id))->toBe([]);
expect(runMatchesPostPlatformRule(ContentType::LinkedInCarousel->value, $postPlatform->id))->toBe([]);
});
test('fails when content_type belongs to a different platform than the post_platform', function () {