2026-01-15 01:13:44 +00:00
|
|
|
<?php
|
|
|
|
|
|
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-15 01:13:44 +00:00
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
|
|
2026-06-25 00:05:09 +00:00
|
|
|
use App\Enums\SocialAccount\LinkedInIdentityType;
|
2026-01-17 17:44:37 +00:00
|
|
|
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
|
|
|
|
use App\Enums\SocialAccount\Status;
|
2026-06-22 15:53:13 +00:00
|
|
|
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
2026-01-15 01:13:44 +00:00
|
|
|
use App\Models\Workspace;
|
|
|
|
|
use Illuminate\Http\RedirectResponse;
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-06-25 00:05:09 +00:00
|
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
|
use Illuminate\Validation\Rules\Enum;
|
|
|
|
|
use Inertia\Inertia;
|
|
|
|
|
use Inertia\Response as InertiaResponse;
|
2026-01-15 01:13:44 +00:00
|
|
|
use Laravel\Socialite\Facades\Socialite;
|
|
|
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
|
|
|
|
|
|
class LinkedInController extends SocialController
|
|
|
|
|
{
|
2026-06-25 00:05:09 +00:00
|
|
|
protected string $driver = 'linkedin-openid';
|
2026-01-15 01:13:44 +00:00
|
|
|
|
|
|
|
|
protected SocialPlatform $platform = SocialPlatform::LinkedIn;
|
|
|
|
|
|
2026-06-25 00:05:09 +00:00
|
|
|
/**
|
|
|
|
|
* 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.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 16:54:16 +00:00
|
|
|
public function connect(Request $request): Response|InertiaResponse
|
2026-01-15 01:13:44 +00:00
|
|
|
{
|
2026-01-16 15:36:52 +00:00
|
|
|
$this->ensurePlatformEnabled();
|
2026-01-17 02:46:30 +00:00
|
|
|
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
2026-06-25 00:05:09 +00:00
|
|
|
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
|
2026-01-17 02:46:30 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-15 17:24:39 +00:00
|
|
|
$this->authorize('manageAccounts', $workspace);
|
2026-01-15 01:13:44 +00:00
|
|
|
|
2026-06-25 00:05:09 +00:00
|
|
|
session(['social_connect_workspace' => $workspace->id]);
|
|
|
|
|
|
|
|
|
|
return Inertia::location(
|
|
|
|
|
Socialite::driver($this->driver)
|
|
|
|
|
->scopes($this->connectScopes())
|
|
|
|
|
->redirect()
|
|
|
|
|
->getTargetUrl()
|
|
|
|
|
);
|
2026-01-15 01:13:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 16:54:16 +00:00
|
|
|
public function callback(Request $request): InertiaResponse|RedirectResponse
|
2026-01-15 01:13:44 +00:00
|
|
|
{
|
|
|
|
|
$workspaceId = session('social_connect_workspace');
|
|
|
|
|
|
|
|
|
|
if (! $workspaceId) {
|
feat: localize OAuth popup callback messages across 12 controllers
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.
Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
/ 'Account reconnected!' line — the popup already shows a checkmark
and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
(page not found, no Facebook pages, no YouTube channels, etc.).
Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
2026-05-07 17:03:52 +00:00
|
|
|
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
|
2026-01-15 01:13:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$workspace = Workspace::find($workspaceId);
|
|
|
|
|
|
2026-01-15 17:24:39 +00:00
|
|
|
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
feat: localize OAuth popup callback messages across 12 controllers
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.
Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
/ 'Account reconnected!' line — the popup already shows a checkmark
and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
(page not found, no Facebook pages, no YouTube channels, etc.).
Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
2026-05-07 17:03:52 +00:00
|
|
|
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
|
2026-01-15 01:13:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$socialUser = Socialite::driver($this->driver)->user();
|
|
|
|
|
|
2026-06-25 00:05:09 +00:00
|
|
|
session([
|
|
|
|
|
'linkedin_pending' => [
|
|
|
|
|
'workspace_id' => $workspace->id,
|
|
|
|
|
'token' => $socialUser->token,
|
2026-04-15 12:46:18 +00:00
|
|
|
'refresh_token' => $socialUser->refreshToken,
|
2026-06-25 00:05:09 +00:00
|
|
|
'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) : [],
|
2026-04-15 12:46:18 +00:00
|
|
|
],
|
2026-06-25 00:05:09 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return redirect()->route('app.social.linkedin.select-identity');
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::error('LinkedIn OAuth Error', [
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 16:54:16 +00:00
|
|
|
public function selectIdentity(Request $request): InertiaResponse
|
2026-06-25 00:05:09 +00:00
|
|
|
{
|
|
|
|
|
$pending = session('linkedin_pending');
|
|
|
|
|
|
|
|
|
|
if (! $pending) {
|
|
|
|
|
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$workspace = Workspace::find($pending['workspace_id']);
|
2026-01-15 01:13:44 +00:00
|
|
|
|
2026-06-25 00:05:09 +00:00
|
|
|
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'],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 16:54:16 +00:00
|
|
|
public function select(Request $request): InertiaResponse
|
2026-06-25 00:05:09 +00:00
|
|
|
{
|
|
|
|
|
$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');
|
2026-01-26 14:15:29 +00:00
|
|
|
|
feat: localize OAuth popup callback messages across 12 controllers
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.
Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
/ 'Account reconnected!' line — the popup already shows a checkmark
and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
(page not found, no Facebook pages, no YouTube channels, etc.).
Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
2026-05-07 17:03:52 +00:00
|
|
|
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
|
2026-06-25 00:05:09 +00:00
|
|
|
} catch (NetworkAlreadyConnectedException) {
|
2026-06-22 15:53:13 +00:00
|
|
|
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
|
2026-01-15 01:13:44 +00:00
|
|
|
} catch (\Exception $e) {
|
2026-06-25 00:05:09 +00:00
|
|
|
Log::error('LinkedIn selection error', [
|
2026-01-15 01:13:44 +00:00
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
|
feat: localize OAuth popup callback messages across 12 controllers
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.
Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
/ 'Account reconnected!' line — the popup already shows a checkmark
and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
(page not found, no Facebook pages, no YouTube channels, etc.).
Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
2026-05-07 17:03:52 +00:00
|
|
|
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
|
2026-01-15 01:13:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 00:05:09 +00:00
|
|
|
/**
|
|
|
|
|
* 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))));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 01:13:44 +00:00
|
|
|
private function fetchVanityName(string $accessToken): ?string
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
$response = Http::withToken($accessToken)
|
|
|
|
|
->withHeaders(['X-RestLi-Protocol-Version' => '2.0.0'])
|
2026-05-02 16:49:20 +00:00
|
|
|
->get(config('trypost.platforms.linkedin.api').'/v2/me', [
|
2026-01-15 01:13:44 +00:00
|
|
|
'projection' => '(id,vanityName,localizedFirstName,localizedLastName)',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($response->successful()) {
|
|
|
|
|
return $response->json('vanityName');
|
|
|
|
|
}
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::warning('Failed to fetch LinkedIn vanityName', [
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-06-25 00:05:09 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
2026-01-15 01:13:44 +00:00
|
|
|
}
|