diff --git a/.gitignore b/.gitignore index 55044034..b23d783d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ yarn-error.log /.nova /.vscode /.zed +/docs/ diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 7e3f996a..b22c9b5b 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -205,7 +205,7 @@ public static function forPlatform(SocialPlatform $platform): array public static function defaultFor(SocialPlatform $platform): self { return match ($platform) { - SocialPlatform::Instagram => self::InstagramFeed, + SocialPlatform::Instagram, SocialPlatform::InstagramFacebook => self::InstagramFeed, SocialPlatform::LinkedIn => self::LinkedInPost, SocialPlatform::LinkedInPage => self::LinkedInPagePost, SocialPlatform::Facebook => self::FacebookPost, diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 09c6de93..f06603bc 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -15,6 +15,7 @@ enum Platform: string case YouTube = 'youtube'; case Facebook = 'facebook'; case Instagram = 'instagram'; + case InstagramFacebook = 'instagram-facebook'; case Threads = 'threads'; case Pinterest = 'pinterest'; case Bluesky = 'bluesky'; @@ -29,7 +30,8 @@ public function label(): string self::TikTok => 'TikTok', self::YouTube => 'YouTube Shorts', self::Facebook => 'Facebook Page', - self::Instagram => 'Instagram', + self::Instagram => 'Instagram (Standalone)', + self::InstagramFacebook => 'Instagram (Facebook Business)', self::Threads => 'Threads', self::Pinterest => 'Pinterest', self::Bluesky => 'Bluesky', @@ -46,6 +48,7 @@ public function color(): string self::YouTube => '#FF0000', self::Facebook => '#1877F2', self::Instagram => '#E4405F', + self::InstagramFacebook => '#E4405F', self::Threads => '#000000', self::Pinterest => '#E60023', self::Bluesky => '#0085FF', @@ -61,7 +64,7 @@ public function allowedMediaTypes(): array self::TikTok => [MediaType::Video], self::YouTube => [MediaType::Video], self::Facebook => [MediaType::Image, MediaType::Video], - self::Instagram => [MediaType::Image, MediaType::Video], + self::Instagram, self::InstagramFacebook => [MediaType::Image, MediaType::Video], self::Threads => [MediaType::Image, MediaType::Video], self::Pinterest => [MediaType::Image, MediaType::Video], self::Bluesky => [MediaType::Image, MediaType::Video], @@ -77,7 +80,7 @@ public function maxImages(): int self::TikTok => 0, self::YouTube => 0, self::Facebook => 10, - self::Instagram => 10, + self::Instagram, self::InstagramFacebook => 10, self::Threads => 10, self::Pinterest => 5, self::Bluesky => 4, @@ -93,7 +96,7 @@ public function maxContentLength(): int self::TikTok => 2200, self::YouTube => 5000, self::Facebook => 63206, - self::Instagram => 2200, + self::Instagram, self::InstagramFacebook => 2200, self::Threads => 500, self::Pinterest => 800, self::Bluesky => 300, @@ -108,6 +111,7 @@ public function requiredPublishScopes(): array { return match ($this) { self::Instagram => ['instagram_business_content_publish'], + self::InstagramFacebook => ['instagram_content_publish'], self::Facebook => ['pages_manage_posts'], self::TikTok => ['video.publish'], self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'], @@ -129,7 +133,7 @@ public function supportsTextOnly(): bool self::TikTok => false, self::YouTube => false, self::Facebook => true, - self::Instagram => false, + self::Instagram, self::InstagramFacebook => false, self::Threads => true, self::Pinterest => false, self::Bluesky => true, @@ -158,6 +162,15 @@ public static function allQueues(): array return array_map(fn (self $platform) => $platform->queue(), self::cases()); } + public function instagramGraphBaseUrl(): string + { + return match ($this) { + self::InstagramFacebook => 'https://graph.facebook.com/v20.0', + self::Instagram => 'https://graph.instagram.com/v24.0', + default => 'https://graph.instagram.com/v24.0', + }; + } + public function isEnabled(): bool { return config("trypost.platforms.{$this->value}.enabled", true); diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php new file mode 100644 index 00000000..e3205698 --- /dev/null +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -0,0 +1,85 @@ +user()->currentWorkspace; + + $accounts = $workspace->socialAccounts() + ->where('is_active', true) + ->whereIn('platform', self::SUPPORTED_PLATFORMS) + ->get() + ->map(fn (SocialAccount $account) => [ + 'id' => $account->id, + 'platform' => $account->platform->value, + 'display_name' => $account->display_name, + 'username' => $account->username, + 'avatar_url' => $account->avatar_url, + ]); + + return Inertia::render('analytics/Index', [ + 'accounts' => $accounts, + ]); + } + + public function show(Request $request, SocialAccount $account): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + + if ($account->workspace_id !== $workspace->id) { + abort(HttpResponse::HTTP_FORBIDDEN); + } + + $since = $request->has('since') ? Carbon::parse($request->input('since')) : null; + $until = $request->has('until') ? Carbon::parse($request->input('until')) : null; + + $metrics = match ($account->platform) { + Platform::TikTok => app(TikTokAnalytics::class)->getMetrics($account), + Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->getMetrics($account, $since, $until), + Platform::Threads => app(ThreadsAnalytics::class)->getMetrics($account, $since, $until), + Platform::Facebook => app(FacebookAnalytics::class)->getMetrics($account, $since, $until), + Platform::X => app(XAnalytics::class)->getMetrics($account, $since, $until), + Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until), + Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), + Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), + default => [], + }; + + return response()->json(['metrics' => $metrics]); + } +} diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 9847afad..2ec4e96d 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -28,6 +28,7 @@ class FacebookController extends SocialController 'pages_show_list', 'pages_read_engagement', 'pages_manage_posts', + 'read_insights', ]; public function connect(Request $request): Response|RedirectResponse @@ -61,6 +62,7 @@ public function connect(Request $request): Response|RedirectResponse return Inertia::location( Socialite::driver($this->driver) + ->usingGraphVersion('v25.0') ->setScopes($this->scopes) ->redirect() ->getTargetUrl() @@ -90,7 +92,14 @@ public function callback(Request $request): View|RedirectResponse } try { - $socialUser = Socialite::driver($this->driver)->user(); + $socialUser = Socialite::driver($this->driver)->usingGraphVersion('v25.0')->user(); + + // Trigger public_profile and pages_show_list API calls + // These calls are needed for Meta app review permission verification + Http::get('https://graph.facebook.com/v25.0/me', [ + 'fields' => 'id,name', + 'access_token' => $socialUser->token, + ]); // Fetch pages the user manages $pages = $this->fetchPages($socialUser->token); diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index dd8ebb14..d88b71a3 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -24,6 +24,7 @@ class InstagramController extends SocialController protected array $scopes = [ 'instagram_business_basic', 'instagram_business_content_publish', + 'instagram_business_manage_insights', ]; public function connect(Request $request): Response|RedirectResponse diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php new file mode 100644 index 00000000..67ad3a6d --- /dev/null +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -0,0 +1,288 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => $existingAccount?->id, + 'social_connect_onboarding' => $request->boolean('onboarding'), + ]); + + $url = Socialite::driver($this->driver) + ->usingGraphVersion('v20.0') + ->setScopes($this->scopes) + ->redirectUrl(route('app.social.instagram-facebook.callback')) + ->redirect() + ->getTargetUrl(); + + return Inertia::location($url); + } + + public function callback(Request $request): View|RedirectResponse + { + $workspaceId = session('social_connect_workspace'); + + if (! $workspaceId) { + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); + } + + $reconnectId = session('social_reconnect_id'); + $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + + try { + $socialUser = Socialite::driver($this->driver) + ->usingGraphVersion('v20.0') + ->redirectUrl(route('app.social.instagram-facebook.callback')) + ->user(); + + // Trigger public_profile API call for Meta app review verification + Http::get('https://graph.facebook.com/v20.0/me', [ + 'fields' => 'id,name', + 'access_token' => $socialUser->token, + ]); + + $pages = $this->fetchPagesWithInstagram($socialUser->token); + + if (empty($pages)) { + return $this->popupCallback(false, 'No Facebook Pages with linked Instagram accounts found.', $this->platform->value); + } + + if (count($pages) === 1) { + return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount); + } + + // Multiple pages — show selection + session([ + 'instagram_facebook_oauth' => [ + 'user_token' => $socialUser->token, + 'pages' => $pages, + 'reconnect_id' => $reconnectId, + ], + ]); + + return redirect()->route('app.social.instagram-facebook.select-page'); + } catch (\Exception $e) { + Log::error('Instagram via Facebook OAuth Error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); + } + } + + public function selectPage(Request $request) + { + $oauthData = session('instagram_facebook_oauth'); + $workspaceId = session('social_connect_workspace'); + + if (! $oauthData || ! $workspaceId) { + session()->flash('flash.banner', 'Session expired. Please try again.'); + session()->flash('flash.bannerStyle', 'danger'); + + return redirect()->route('app.accounts'); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace) { + return redirect()->route('app.accounts'); + } + + $pages = collect(data_get($oauthData, 'pages')) + ->map(fn ($page) => Arr::except($page, ['page_access_token'])) + ->toArray(); + + return Inertia::render('accounts/InstagramFacebookPageSelect', [ + 'workspace' => $workspace, + 'pages' => $pages, + ]); + } + + public function select(Request $request): View + { + $request->validate([ + 'page_id' => 'required|string', + ]); + + $oauthData = session('instagram_facebook_oauth'); + $workspaceId = session('social_connect_workspace'); + + if (! $oauthData || ! $workspaceId) { + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); + } + + $reconnectId = data_get($oauthData, 'reconnect_id'); + $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + + try { + $selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('page_id', $request->page_id); + + if (! $selectedPage) { + return $this->popupCallback(false, 'Page not found.', $this->platform->value); + } + + $result = $this->connectInstagramAccount($workspace, $selectedPage, $existingAccount); + + session()->forget(['instagram_facebook_oauth', 'social_reconnect_id']); + + return $result; + } catch (\Exception $e) { + Log::error('Instagram via Facebook page selection error', ['error' => $e->getMessage()]); + + return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value); + } + } + + private function connectInstagramAccount(Workspace $workspace, array $pageData, $existingAccount): View + { + $avatarPath = data_get($pageData, 'ig_picture') ? uploadFromUrl(data_get($pageData, 'ig_picture')) : null; + + $accountData = [ + 'platform_user_id' => data_get($pageData, 'ig_id'), + 'username' => data_get($pageData, 'ig_username'), + 'display_name' => data_get($pageData, 'ig_name', data_get($pageData, 'ig_username')), + 'avatar_url' => $avatarPath, + 'access_token' => data_get($pageData, 'page_access_token'), + 'refresh_token' => null, + 'token_expires_at' => null, + 'scopes' => $this->scopes, + 'meta' => [ + 'page_id' => data_get($pageData, 'page_id'), + 'page_name' => data_get($pageData, 'page_name'), + ], + ]; + + if ($existingAccount) { + $existingAccount->update($accountData); + $existingAccount->markAsConnected(); + + session()->forget('social_reconnect_id'); + + return $this->popupCallback(true, 'Instagram account reconnected!', $this->platform->value); + } + + $account = $workspace->socialAccounts()->create(array_merge($accountData, [ + 'platform' => $this->platform->value, + 'status' => Status::Connected, + ])); + + $isOnboarding = session('social_connect_onboarding', false); + + return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value, $isOnboarding); + } + + private function fetchPagesWithInstagram(string $userToken): array + { + try { + $response = Http::get('https://graph.facebook.com/v20.0/me/accounts', [ + 'access_token' => $userToken, + 'fields' => 'id,name,username,picture{url},access_token,instagram_business_account', + ]); + + if ($response->failed()) { + Log::error('Instagram via Facebook pages fetch failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return []; + } + + $pages = data_get($response->json(), 'data', []); + $results = []; + + foreach ($pages as $page) { + $igAccountId = data_get($page, 'instagram_business_account.id'); + + if (! $igAccountId) { + continue; + } + + // Fetch IG account details + $igResponse = Http::get("https://graph.facebook.com/v20.0/{$igAccountId}", [ + 'access_token' => data_get($page, 'access_token'), + 'fields' => 'username,name,profile_picture_url', + ]); + + $igData = $igResponse->successful() ? $igResponse->json() : []; + + $results[] = [ + 'page_id' => data_get($page, 'id'), + 'page_name' => data_get($page, 'name'), + 'page_picture' => data_get($page, 'picture.data.url'), + 'page_access_token' => data_get($page, 'access_token'), + 'ig_id' => $igAccountId, + 'ig_username' => data_get($igData, 'username'), + 'ig_name' => data_get($igData, 'name'), + 'ig_picture' => data_get($igData, 'profile_picture_url'), + ]; + } + + return $results; + } catch (\Exception $e) { + Log::error('Instagram via Facebook pages fetch error', ['error' => $e->getMessage()]); + + return []; + } + } +} diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 20f63c06..b465229a 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -22,6 +22,7 @@ class ThreadsController extends SocialController protected array $scopes = [ 'threads_basic', 'threads_content_publish', + 'threads_manage_insights', ]; public function connect(Request $request): Response|RedirectResponse diff --git a/app/Http/Controllers/Auth/TikTokController.php b/app/Http/Controllers/Auth/TikTokController.php index 3be174a7..a283f7c9 100644 --- a/app/Http/Controllers/Auth/TikTokController.php +++ b/app/Http/Controllers/Auth/TikTokController.php @@ -23,7 +23,10 @@ class TikTokController extends SocialController protected array $scopes = [ 'user.info.basic', 'user.info.profile', + 'user.info.stats', 'video.publish', + 'video.upload', + 'video.list', ]; public function connect(Request $request): Response|RedirectResponse diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index 6ec81df4..4786d536 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -26,6 +26,7 @@ class YouTubeController extends SocialController 'https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube.readonly', 'https://www.googleapis.com/auth/youtube.force-ssl', + 'https://www.googleapis.com/auth/yt-analytics.readonly', ]; public function connect(Request $request): Response|RedirectResponse diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index b440cb40..a6acd103 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -35,6 +35,14 @@ public function rules(): array 'platforms.*.content' => ['nullable', 'string', 'max:63206'], 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], 'platforms.*.meta' => ['nullable', 'array'], + 'platforms.*.meta.privacy_level' => ['sometimes', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])], + 'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'], + 'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'], + 'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'], + 'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], ]; diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 69ee3f47..6e7079c9 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -184,7 +184,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::TikTok => app(TikTokPublisher::class), SocialPlatform::YouTube => app(YouTubePublisher::class), SocialPlatform::Facebook => app(FacebookPublisher::class), - SocialPlatform::Instagram => app(InstagramPublisher::class), + SocialPlatform::Instagram, SocialPlatform::InstagramFacebook => app(InstagramPublisher::class), SocialPlatform::Threads => app(ThreadsPublisher::class), SocialPlatform::Pinterest => app(PinterestPublisher::class), SocialPlatform::Bluesky => app(BlueskyPublisher::class), diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index c5f0d1c3..0dd8b4b2 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -84,7 +84,7 @@ public function optimizeImage(string $filePath, Platform $platform): string private function getImageConfig(Platform $platform): array { return match ($platform) { - Platform::Instagram, Platform::Threads => [ + Platform::Instagram, Platform::InstagramFacebook, Platform::Threads => [ 'max_width' => 1440, 'max_size' => 8 * 1024 * 1024, 'format' => 'image/jpeg', diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 50cbca7b..46861867 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -29,7 +29,7 @@ public function verify(SocialAccount $account): bool Platform::LinkedIn => $this->verifyLinkedIn($account), Platform::LinkedInPage => $this->verifyLinkedInPage($account), Platform::X => $this->verifyX($account), - Platform::Instagram => $this->verifyInstagram($account), + Platform::Instagram, Platform::InstagramFacebook => $this->verifyInstagram($account), Platform::Facebook => $this->verifyFacebook($account), Platform::Threads => $this->verifyThreads($account), Platform::TikTok => $this->verifyTikTok($account), @@ -66,7 +66,8 @@ private function refreshTokenIfNeeded(SocialAccount $account): void Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), Platform::Instagram => $this->refreshInstagramToken($account), - // Facebook uses page tokens that don't expire + // InstagramFacebook uses page tokens that don't expire (like Facebook) + // Mastodon tokens don't expire // Mastodon tokens don't expire default => null, }; diff --git a/app/Services/Social/FacebookAnalytics.php b/app/Services/Social/FacebookAnalytics.php new file mode 100644 index 00000000..5f2de38a --- /dev/null +++ b/app/Services/Social/FacebookAnalytics.php @@ -0,0 +1,88 @@ +subDays(7); + $until ??= now(); + + $cacheKey = "analytics:facebook:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) { + return $this->fetchMetricsFromApi($account, $since, $until); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + $this->accessToken = $account->access_token; + + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/{$account->platform_user_id}/insights", [ + 'metric' => 'page_impressions_unique,page_posts_impressions_unique,page_post_engagements,page_daily_follows,page_video_views', + 'period' => 'day', + 'since' => $since->startOfDay()->unix(), + 'until' => $until->endOfDay()->unix(), + 'access_token' => $this->accessToken, + ]); + + if ($response->failed()) { + Log::warning('Facebook page insights fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $data = data_get($response->json(), 'data', []); + $metrics = []; + + foreach ($data as $metric) { + $name = data_get($metric, 'name'); + $values = data_get($metric, 'values', []); + + if (empty($values)) { + continue; + } + + $total = collect($values)->sum('value'); + + $label = match ($name) { + 'page_impressions_unique' => 'Page Impressions', + 'page_posts_impressions_unique' => 'Posts Impressions', + 'page_post_engagements' => 'Posts Engagement', + 'page_daily_follows' => 'Page Followers', + 'page_video_views' => 'Video Views', + default => ucfirst(str_replace('_', ' ', $name)), + }; + + $metrics[] = ['label' => $label, 'value' => $total]; + } + + return $metrics; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp(); + } +} diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php new file mode 100644 index 00000000..5c2ce996 --- /dev/null +++ b/app/Services/Social/InstagramAnalytics.php @@ -0,0 +1,174 @@ +subDays(7); + $until ??= now(); + + $cacheKey = "analytics:instagram:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) { + return $this->fetchMetricsFromApi($account, $since, $until); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + $this->baseUrl = preg_replace('#/v[\d.]+$#', '', $account->platform->instagramGraphBaseUrl()); + + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + + $this->accessToken = $account->access_token; + + $metrics = []; + + $timeSeriesMetrics = $this->fetchTimeSeriesMetrics($account, $since, $until); + $metrics = array_merge($metrics, $timeSeriesMetrics); + + $totalValueMetrics = $this->fetchTotalValueMetrics($account, $since, $until); + $metrics = array_merge($metrics, $totalValueMetrics); + + return $metrics; + } + + private function fetchTimeSeriesMetrics(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/v21.0/{$account->platform_user_id}/insights", [ + 'metric' => 'reach,follower_count', + 'period' => 'day', + 'since' => $since->startOfDay()->unix(), + 'until' => $until->endOfDay()->unix(), + 'access_token' => $this->accessToken, + ]); + + if ($response->failed()) { + Log::warning('Instagram insights (time series) fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $data = data_get($response->json(), 'data', []); + $metrics = []; + + foreach ($data as $metric) { + $name = data_get($metric, 'name'); + $values = data_get($metric, 'values', []); + + if (empty($values)) { + continue; + } + + $total = collect($values)->sum('value'); + + $label = match ($name) { + 'reach' => 'Reach', + 'follower_count' => 'Followers', + default => ucfirst(str_replace('_', ' ', $name)), + }; + + $metrics[] = ['label' => $label, 'value' => $total]; + } + + return $metrics; + } + + private function fetchTotalValueMetrics(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/v21.0/{$account->platform_user_id}/insights", [ + 'metric' => 'likes,comments,shares,saves,views,total_interactions', + 'metric_type' => 'total_value', + 'period' => 'day', + 'since' => $since->startOfDay()->unix(), + 'until' => $until->endOfDay()->unix(), + 'access_token' => $this->accessToken, + ]); + + if ($response->failed()) { + Log::warning('Instagram insights (total value) fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $data = data_get($response->json(), 'data', []); + $metrics = []; + + foreach ($data as $metric) { + $name = data_get($metric, 'name'); + $value = data_get($metric, 'total_value.value', 0); + + $label = match ($name) { + 'total_interactions' => 'Interactions', + default => ucfirst(str_replace('_', ' ', $name)), + }; + + $metrics[] = ['label' => $label, 'value' => $value]; + } + + return $metrics; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp(); + } + + private function refreshToken(SocialAccount $account): void + { + if ($account->platform === Platform::InstagramFacebook) { + return; + } + + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for Instagram account'); + } + + $response = Http::get('https://graph.instagram.com/refresh_access_token', [ + 'grant_type' => 'ig_refresh_token', + 'access_token' => $account->access_token, + ]); + + if ($response->failed()) { + Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + throw new TokenExpiredException('Instagram token refresh failed'); + } + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + } +} diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 06cc4004..dcc00200 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -5,6 +5,7 @@ namespace App\Services\Social; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\InstagramPublishException; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; @@ -18,13 +19,14 @@ class InstagramPublisher { use HasSocialHttpClient; - private string $baseUrl = 'https://graph.instagram.com/v24.0'; + private string $baseUrl; public function publish(PostPlatform $postPlatform): array { $this->validateContentLength($postPlatform); $account = $postPlatform->socialAccount; + $this->baseUrl = $account->platform->instagramGraphBaseUrl(); if ($account->is_token_expired || $account->is_token_expiring_soon) { $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); @@ -313,6 +315,11 @@ private function waitForMediaProcessing(string $containerId, string $accessToken private function refreshToken(SocialAccount $account): void { + // Instagram via Facebook uses page tokens that don't expire + if ($account->platform === Platform::InstagramFacebook) { + return; + } + $response = Http::get('https://graph.instagram.com/refresh_access_token', [ 'grant_type' => 'ig_refresh_token', 'access_token' => $account->access_token, diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php new file mode 100644 index 00000000..bc087059 --- /dev/null +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -0,0 +1,223 @@ +subDays(7); + $until ??= now(); + + $cacheKey = "analytics:linkedin-page:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) { + return $this->fetchMetricsFromApi($account, $since, $until); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + + $this->accessToken = $account->access_token; + + $orgUrn = urlencode("urn:li:organization:{$account->platform_user_id}"); + $startMs = $since->startOfDay()->getTimestampMs(); + $endMs = $until->endOfDay()->getTimestampMs(); + $timeInterval = "(timeRange:(start:{$startMs},end:{$endMs}),timeGranularityType:DAY)"; + + $metrics = []; + + // Page statistics (page views) + $pageStats = $this->fetchPageStatistics($orgUrn, $timeInterval); + $metrics = array_merge($metrics, $pageStats); + + // Follower statistics + $followerStats = $this->fetchFollowerStatistics($orgUrn, $timeInterval); + $metrics = array_merge($metrics, $followerStats); + + // Share statistics (engagement) + $shareStats = $this->fetchShareStatistics($orgUrn, $timeInterval); + $metrics = array_merge($metrics, $shareStats); + + return $metrics; + } + + private function fetchPageStatistics(string $orgUrn, string $timeInterval): array + { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/organizationPageStatistics", [ + 'q' => 'organization', + 'organization' => urldecode($orgUrn), + 'timeIntervals' => $timeInterval, + ]); + + if ($response->failed()) { + Log::warning('LinkedIn page statistics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $elements = data_get($response->json(), 'elements', []); + $totalPageViews = 0; + + foreach ($elements as $element) { + $totalPageViews += data_get($element, 'totalPageStatistics.views.allPageViews.pageViews', 0); + } + + return $totalPageViews > 0 ? [['label' => 'Page Views', 'value' => $totalPageViews]] : []; + } + + private function fetchFollowerStatistics(string $orgUrn, string $timeInterval): array + { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/organizationalEntityFollowerStatistics", [ + 'q' => 'organizationalEntity', + 'organizationalEntity' => urldecode($orgUrn), + 'timeIntervals' => $timeInterval, + ]); + + if ($response->failed()) { + Log::warning('LinkedIn follower statistics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $elements = data_get($response->json(), 'elements', []); + $organicFollowers = 0; + $paidFollowers = 0; + + foreach ($elements as $element) { + $organicFollowers += data_get($element, 'followerGains.organicFollowerGain', 0); + $paidFollowers += data_get($element, 'followerGains.paidFollowerGain', 0); + } + + $metrics = []; + + if ($organicFollowers > 0) { + $metrics[] = ['label' => 'Organic Followers', 'value' => $organicFollowers]; + } + + if ($paidFollowers > 0) { + $metrics[] = ['label' => 'Paid Followers', 'value' => $paidFollowers]; + } + + return $metrics; + } + + private function fetchShareStatistics(string $orgUrn, string $timeInterval): array + { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/organizationalEntityShareStatistics", [ + 'q' => 'organizationalEntity', + 'organizationalEntity' => urldecode($orgUrn), + 'timeIntervals' => $timeInterval, + ]); + + if ($response->failed()) { + Log::warning('LinkedIn share statistics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $elements = data_get($response->json(), 'elements', []); + $totalShares = 0; + $totalClicks = 0; + $totalLikes = 0; + $totalComments = 0; + $totalImpressions = 0; + + foreach ($elements as $element) { + $stats = data_get($element, 'totalShareStatistics', []); + $totalShares += data_get($stats, 'shareCount', 0); + $totalClicks += data_get($stats, 'clickCount', 0); + $totalLikes += data_get($stats, 'likeCount', 0); + $totalComments += data_get($stats, 'commentCount', 0); + $totalImpressions += data_get($stats, 'impressionCount', 0); + } + + $metrics = []; + + if ($totalImpressions > 0) { + $metrics[] = ['label' => 'Impressions', 'value' => $totalImpressions]; + } + if ($totalClicks > 0) { + $metrics[] = ['label' => 'Clicks', 'value' => $totalClicks]; + } + if ($totalLikes > 0) { + $metrics[] = ['label' => 'Likes', 'value' => $totalLikes]; + } + if ($totalComments > 0) { + $metrics[] = ['label' => 'Comments', 'value' => $totalComments]; + } + if ($totalShares > 0) { + $metrics[] = ['label' => 'Shares', 'value' => $totalShares]; + } + + return $metrics; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp()->withToken($this->accessToken) + ->withHeaders([ + 'Linkedin-Version' => '202601', + 'X-Restli-Protocol-Version' => '2.0.0', + ]); + } + + private function refreshToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); + } + + $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin-openid.client_id'), + 'client_secret' => config('services.linkedin-openid.client_secret'), + ]); + + if ($response->failed()) { + Log::error('LinkedIn token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + throw new TokenExpiredException('LinkedIn token refresh failed'); + } + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + } +} diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php new file mode 100644 index 00000000..195cb3f9 --- /dev/null +++ b/app/Services/Social/PinterestAnalytics.php @@ -0,0 +1,134 @@ +subDays(7); + $until ??= now(); + + $cacheKey = "analytics:pinterest:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) { + return $this->fetchMetricsFromApi($account, $since, $until); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + + $this->accessToken = $account->access_token; + + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/user_account/analytics", [ + 'start_date' => $since->format('Y-m-d'), + 'end_date' => $until->format('Y-m-d'), + ]); + + if ($response->failed()) { + Log::warning('Pinterest analytics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $dailyMetrics = data_get($response->json(), 'all.daily_metrics', []); + + if (empty($dailyMetrics)) { + return []; + } + + $totals = [ + 'PIN_CLICK_RATE' => 0, + 'IMPRESSION' => 0, + 'PIN_CLICK' => 0, + 'ENGAGEMENT' => 0, + 'SAVE' => 0, + ]; + + $count = 0; + + foreach ($dailyMetrics as $day) { + $metrics = data_get($day, 'metrics', []); + + if (! isset($metrics['PIN_CLICK_RATE'])) { + continue; + } + + $count++; + $totals['PIN_CLICK_RATE'] += $metrics['PIN_CLICK_RATE']; + $totals['IMPRESSION'] += $metrics['IMPRESSION'] ?? 0; + $totals['PIN_CLICK'] += $metrics['PIN_CLICK'] ?? 0; + $totals['ENGAGEMENT'] += $metrics['ENGAGEMENT'] ?? 0; + $totals['SAVE'] += $metrics['SAVE'] ?? 0; + } + + $avgClickRate = $count > 0 ? round($totals['PIN_CLICK_RATE'] / $count, 4) : 0; + + return [ + ['label' => 'Impressions', 'value' => $totals['IMPRESSION']], + ['label' => 'Pin Clicks', 'value' => $totals['PIN_CLICK']], + ['label' => 'Engagement', 'value' => $totals['ENGAGEMENT']], + ['label' => 'Saves', 'value' => $totals['SAVE']], + ['label' => 'Pin Click Rate', 'value' => $avgClickRate], + ]; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp()->withToken($this->accessToken); + } + + private function refreshToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for Pinterest account'); + } + + $response = Http::withBasicAuth( + config('services.pinterest.client_id'), + config('services.pinterest.client_secret'), + )->asForm()->post('https://api.pinterest.com/v5/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ]); + + if ($response->failed()) { + Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + throw new TokenExpiredException('Pinterest token refresh failed'); + } + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + } +} diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php new file mode 100644 index 00000000..f949871d --- /dev/null +++ b/app/Services/Social/ThreadsAnalytics.php @@ -0,0 +1,110 @@ +subDays(7); + $until ??= now(); + + $cacheKey = "analytics:threads:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) { + return $this->fetchMetricsFromApi($account, $since, $until); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + + $this->accessToken = $account->access_token; + + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/{$account->platform_user_id}/threads_insights", [ + 'metric' => 'views,likes,replies,reposts,quotes', + 'period' => 'day', + 'since' => $since->startOfDay()->unix(), + 'until' => $until->endOfDay()->unix(), + 'access_token' => $this->accessToken, + ]); + + if ($response->failed()) { + Log::warning('Threads insights fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $data = data_get($response->json(), 'data', []); + $metrics = []; + + foreach ($data as $metric) { + $name = data_get($metric, 'name'); + + // Some metrics return total_value, others return values array + $totalValue = data_get($metric, 'total_value.value'); + if ($totalValue !== null) { + $value = $totalValue; + } else { + $values = data_get($metric, 'values', []); + $value = collect($values)->sum('value'); + } + + $label = ucfirst(str_replace('_', ' ', $name)); + + $metrics[] = ['label' => $label, 'value' => $value]; + } + + return $metrics; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp(); + } + + private function refreshToken(SocialAccount $account): void + { + $response = Http::get('https://graph.threads.net/refresh_access_token', [ + 'grant_type' => 'th_refresh_token', + 'access_token' => $account->access_token, + ]); + + if ($response->failed()) { + Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + throw new TokenExpiredException('Threads token refresh failed'); + } + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + } +} diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php new file mode 100644 index 00000000..bed0e830 --- /dev/null +++ b/app/Services/Social/TikTokAnalytics.php @@ -0,0 +1,184 @@ +id}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account) { + return $this->fetchMetricsFromApi($account); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account): array + { + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + + $this->accessToken = $account->access_token; + + $metrics = []; + + $userStats = $this->fetchUserStats(); + $metrics = array_merge($metrics, $userStats); + + $videoMetrics = $this->fetchVideoMetrics(); + $metrics = array_merge($metrics, $videoMetrics); + + return $metrics; + } + + private function fetchUserStats(): array + { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/user/info/", [ + 'fields' => 'follower_count,following_count,likes_count,video_count', + ]); + + if ($response->failed()) { + Log::warning('TikTok user stats fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $user = data_get($response->json(), 'data.user', []); + + $metrics = []; + + if (($value = data_get($user, 'follower_count')) !== null) { + $metrics[] = ['label' => 'Followers', 'value' => $value]; + } + + if (($value = data_get($user, 'following_count')) !== null) { + $metrics[] = ['label' => 'Following', 'value' => $value]; + } + + if (($value = data_get($user, 'likes_count')) !== null) { + $metrics[] = ['label' => 'Total Likes', 'value' => $value]; + } + + if (($value = data_get($user, 'video_count')) !== null) { + $metrics[] = ['label' => 'Videos', 'value' => $value]; + } + + return $metrics; + } + + private function fetchVideoMetrics(): array + { + $videoListResponse = $this->getHttpClient() + ->post("{$this->baseUrl}/video/list/?fields=id", [ + 'max_count' => 20, + ]); + + if ($videoListResponse->failed()) { + Log::warning('TikTok video list fetch failed', [ + 'body' => $this->redactResponseBody($videoListResponse->body()), + ]); + + return []; + } + + $videos = data_get($videoListResponse->json(), 'data.videos', []); + + if (empty($videos)) { + return []; + } + + $videoIds = array_map(fn ($v) => $v['id'], $videos); + + $queryResponse = $this->getHttpClient() + ->post("{$this->baseUrl}/video/query/?fields=id,like_count,comment_count,share_count,view_count", [ + 'filters' => ['video_ids' => $videoIds], + ]); + + if ($queryResponse->failed()) { + Log::warning('TikTok video query failed', [ + 'body' => $this->redactResponseBody($queryResponse->body()), + ]); + + return []; + } + + $videoDetails = data_get($queryResponse->json(), 'data.videos', []); + + if (empty($videoDetails)) { + return []; + } + + $totalViews = 0; + $totalLikes = 0; + $totalComments = 0; + $totalShares = 0; + + foreach ($videoDetails as $video) { + $totalViews += data_get($video, 'view_count', 0); + $totalLikes += data_get($video, 'like_count', 0); + $totalComments += data_get($video, 'comment_count', 0); + $totalShares += data_get($video, 'share_count', 0); + } + + return [ + ['label' => 'Views', 'value' => $totalViews], + ['label' => 'Recent Likes', 'value' => $totalLikes], + ['label' => 'Recent Comments', 'value' => $totalComments], + ['label' => 'Recent Shares', 'value' => $totalShares], + ]; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp()->asJson()->withToken($this->accessToken); + } + + private function refreshToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for TikTok account'); + } + + $response = Http::asForm()->post('https://open.tiktokapis.com/v2/oauth/token/', [ + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ]); + + if ($response->failed()) { + Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + throw new TokenExpiredException('TikTok token refresh failed'); + } + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + } +} diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 3de19220..0f7ec730 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -60,16 +60,13 @@ public function publish(PostPlatform $postPlatform): array private function getHttpClient(): PendingRequest { - return $this->socialHttp()->withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/json; charset=UTF-8', - ]); + return $this->socialHttp()->asJson()->withToken($this->accessToken); } private function queryCreatorInfo(): array { $response = $this->getHttpClient() - ->post("{$this->baseUrl}/post/publish/creator_info/query/"); + ->post("{$this->baseUrl}/post/publish/creator_info/query/", []); if ($response->failed()) { Log::warning('TikTok creator_info query failed', ['body' => $this->redactResponseBody($response->body())]); @@ -97,19 +94,43 @@ private function queryCreatorInfo(): array ]; } + private function buildPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array + { + $meta = $postPlatform->meta ?? []; + + $privacyLevel = data_get($meta, 'privacy_level') + ?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY'); + + $postInfo = [ + 'title' => $content ?? '', + 'privacy_level' => $privacyLevel, + 'disable_duet' => ! data_get($meta, 'allow_duet', false), + 'disable_comment' => ! data_get($meta, 'allow_comments', true), + 'disable_stitch' => ! data_get($meta, 'allow_stitch', false), + ]; + + if (data_get($meta, 'is_aigc', false)) { + $postInfo['is_aigc'] = true; + } + + if (data_get($meta, 'brand_content_toggle', false)) { + $postInfo['brand_content_toggle'] = true; + } + + if (data_get($meta, 'brand_organic_toggle', false)) { + $postInfo['brand_organic_toggle'] = true; + } + + return $postInfo; + } + private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array { $creatorInfo = $this->queryCreatorInfo(); $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ - 'post_info' => [ - 'title' => $content ?? '', - 'privacy_level' => data_get($creatorInfo, 'privacy_level'), - 'disable_duet' => false, - 'disable_comment' => false, - 'disable_stitch' => false, - ], + 'post_info' => $this->buildPostInfo($postPlatform, $content, $creatorInfo), 'source_info' => [ 'source' => 'PULL_FROM_URL', 'video_url' => $media->url, @@ -133,11 +154,12 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte } // Wait for processing and get final status - $this->waitForPublishStatus($publishId); + $statusData = $this->waitForPublishStatus($publishId); + $postId = data_get($statusData, 'publicaly_available_post_id.0'); return [ - 'id' => $publishId, - 'url' => $this->buildTikTokUrl($postPlatform->socialAccount), + 'id' => $postId ?? $publishId, + 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), ]; } @@ -155,13 +177,19 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st $creatorInfo = $this->queryCreatorInfo(); + $postInfo = $this->buildPostInfo($postPlatform, $content, $creatorInfo); + // Photos don't support duet/stitch/is_aigc + unset($postInfo['disable_duet'], $postInfo['disable_stitch'], $postInfo['is_aigc']); + + // Auto add music is only for photos + $meta = $postPlatform->meta ?? []; + if (data_get($meta, 'auto_add_music', false)) { + $postInfo['auto_add_music'] = true; + } + $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/content/init/", [ - 'post_info' => [ - 'title' => $content ?? '', - 'privacy_level' => data_get($creatorInfo, 'privacy_level'), - 'disable_comment' => false, - ], + 'post_info' => $postInfo, 'source_info' => [ 'source' => 'PULL_FROM_URL', 'photo_cover_index' => 0, @@ -188,11 +216,12 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st } // Wait for processing and get final status - $this->waitForPublishStatus($publishId); + $statusData = $this->waitForPublishStatus($publishId); + $postId = data_get($statusData, 'publicaly_available_post_id.0'); return [ - 'id' => $publishId, - 'url' => $this->buildTikTokUrl($postPlatform->socialAccount), + 'id' => $postId ?? $publishId, + 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), ]; } @@ -235,10 +264,14 @@ private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): return ['publish_id' => $publishId]; } - private function buildTikTokUrl(SocialAccount $account): ?string + private function buildTikTokUrl(SocialAccount $account, ?string $postId = null): ?string { $username = $account->username; + if ($username && $postId) { + return "https://www.tiktok.com/@{$username}/video/{$postId}"; + } + if ($username) { return "https://www.tiktok.com/@{$username}"; } diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php new file mode 100644 index 00000000..d5b0860f --- /dev/null +++ b/app/Services/Social/XAnalytics.php @@ -0,0 +1,182 @@ +subDays(7); + $until ??= now(); + + // X API max lookback is 100 days + $daysDiff = $since->diffInDays($until); + if ($daysDiff > 100) { + $since = now()->subDays(100); + } + + $cacheKey = "analytics:x:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) { + return $this->fetchMetricsFromApi($account, $since, $until); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + + $this->accessToken = $account->access_token; + + // Fetch recent tweets in the period + $tweetIds = $this->fetchTweetIds($account, $since, $until); + + if (empty($tweetIds)) { + return []; + } + + // Fetch public_metrics for those tweets + return $this->fetchTweetMetrics($tweetIds); + } + + private function fetchTweetIds(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + $ids = []; + $paginationToken = null; + + for ($i = 0; $i < 5; $i++) { + $params = [ + 'start_time' => $since->toIso8601ZuluString(), + 'end_time' => $until->toIso8601ZuluString(), + 'max_results' => 100, + ]; + + if ($paginationToken) { + $params['pagination_token'] = $paginationToken; + } + + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/users/{$account->platform_user_id}/tweets", $params); + + if ($response->failed()) { + Log::warning('X tweets list fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + break; + } + + $data = $response->json(); + $tweets = data_get($data, 'data', []); + + foreach ($tweets as $tweet) { + $ids[] = data_get($tweet, 'id'); + } + + $paginationToken = data_get($data, 'meta.next_token'); + + if (! $paginationToken) { + break; + } + } + + return $ids; + } + + private function fetchTweetMetrics(array $tweetIds): array + { + $totals = [ + 'impression_count' => 0, + 'like_count' => 0, + 'retweet_count' => 0, + 'reply_count' => 0, + 'quote_count' => 0, + 'bookmark_count' => 0, + ]; + + // X API allows max 100 IDs per request + foreach (array_chunk($tweetIds, 100) as $chunk) { + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/tweets", [ + 'ids' => implode(',', $chunk), + 'tweet.fields' => 'public_metrics', + ]); + + if ($response->failed()) { + Log::warning('X tweets metrics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + continue; + } + + $tweets = data_get($response->json(), 'data', []); + + foreach ($tweets as $tweet) { + $metrics = data_get($tweet, 'public_metrics', []); + foreach ($totals as $key => &$total) { + $total += data_get($metrics, $key, 0); + } + } + } + + return [ + ['label' => 'Impressions', 'value' => $totals['impression_count']], + ['label' => 'Likes', 'value' => $totals['like_count']], + ['label' => 'Retweets', 'value' => $totals['retweet_count']], + ['label' => 'Replies', 'value' => $totals['reply_count']], + ['label' => 'Quotes', 'value' => $totals['quote_count']], + ['label' => 'Bookmarks', 'value' => $totals['bookmark_count']], + ]; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp()->withToken($this->accessToken); + } + + private function refreshToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for X account'); + } + + $response = $this->socialHttp()->asForm()->post('https://api.x.com/2/oauth2/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.x.client_id'), + ]); + + if ($response->failed()) { + Log::error('X token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + throw new TokenExpiredException('X token refresh failed'); + } + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + } +} diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php new file mode 100644 index 00000000..043cd6de --- /dev/null +++ b/app/Services/Social/YouTubeAnalytics.php @@ -0,0 +1,128 @@ +subDays(7); + $until ??= now(); + + $cacheKey = "analytics:youtube:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) { + return $this->fetchMetricsFromApi($account, $since, $until); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + + $this->accessToken = $account->access_token; + + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/reports", [ + 'ids' => 'channel==MINE', + 'startDate' => $since->format('Y-m-d'), + 'endDate' => $until->format('Y-m-d'), + 'metrics' => 'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,subscribersLost,likes', + ]); + + if ($response->failed()) { + Log::warning('YouTube Analytics fetch failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $json = $response->json(); + $rows = data_get($json, 'rows', []); + + if (empty($rows)) { + return []; + } + + $columnHeaders = data_get($json, 'columnHeaders', []); + $metricNames = collect($columnHeaders)->pluck('name')->toArray(); + $values = data_get($rows, '0', []); + + $metrics = []; + + foreach ($metricNames as $index => $name) { + $value = data_get($values, $index, 0); + + $label = match ($name) { + 'views' => 'Views', + 'estimatedMinutesWatched' => 'Minutes Watched', + 'averageViewDuration' => 'Avg. View Duration (s)', + 'averageViewPercentage' => 'Avg. View Percentage', + 'subscribersGained' => 'Subscribers Gained', + 'subscribersLost' => 'Subscribers Lost', + 'likes' => 'Likes', + default => ucfirst(str_replace('_', ' ', $name)), + }; + + $metrics[] = ['label' => $label, 'value' => round((float) $value, 1)]; + } + + return $metrics; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp()->withToken($this->accessToken); + } + + private function refreshToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for YouTube account'); + } + + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ]); + + if ($response->failed()) { + Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + + throw new TokenExpiredException('Failed to refresh YouTube token'); + } + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + } +} diff --git a/config/trypost.php b/config/trypost.php index c3ba10f0..5b17a19b 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -61,6 +61,9 @@ 'instagram' => [ 'enabled' => env('INSTAGRAM_ENABLED', true), ], + 'instagram-facebook' => [ + 'enabled' => env('TRYPOST_INSTAGRAM_FACEBOOK_ENABLED', true), + ], 'threads' => [ 'enabled' => env('THREADS_ENABLED', true), ], diff --git a/docs/superpowers/plans/2026-03-31-image-resize.md b/docs/superpowers/plans/2026-03-31-image-resize.md deleted file mode 100644 index a553aef3..00000000 --- a/docs/superpowers/plans/2026-03-31-image-resize.md +++ /dev/null @@ -1,281 +0,0 @@ -# Image Resize Per Platform Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Automatically optimize images before publishing to each social platform — resize, convert format, and reduce quality to meet each platform's limits. - -**Architecture:** A `MediaOptimizer` service with per-platform config (max width, max size, format, quality). Each publisher calls `optimizeImage()` before uploading. Uses a quality reduction loop to guarantee file size compliance. - -**Tech Stack:** Laravel 13, PHP 8.4, Intervention Image v4, Pest 4 - -**Spec:** `docs/superpowers/specs/2026-03-31-image-resize-design.md` - ---- - -### Task 1: Install Intervention Image - -**Files:** -- Modify: `composer.json` - -- [ ] **Step 1: Install the package** - -```bash -composer require intervention/image -``` - -- [ ] **Step 2: Verify installation** - -```bash -php artisan tinker --execute "echo Intervention\Image\ImageManager::class;" -``` - -Expected: `Intervention\Image\ImageManager` - -- [ ] **Step 3: Commit** - -```bash -git add composer.json composer.lock -git commit -m "chore: install intervention/image v4" -``` - ---- - -### Task 2: Create MediaOptimizer service with tests - -**Files:** -- Create: `app/Services/Media/MediaOptimizer.php` -- Test: `tests/Unit/Services/Media/MediaOptimizerTest.php` - -- [ ] **Step 1: Write failing tests** - -Create `tests/Unit/Services/Media/MediaOptimizerTest.php` with tests: - -1. `it optimizes image for instagram (converts to jpeg, max 1440px width)` - - Create a 2000px wide PNG test image using Intervention - - Optimize for Instagram - - Assert output is JPEG, width <= 1440, file size <= 8MB - -2. `it optimizes image for bluesky (under 1MB)` - - Create a large JPEG test image - - Optimize for Bluesky - - Assert output file size < 1MB (976KB) - -3. `it reduces quality to meet size limit` - - Create a high-quality large image - - Optimize for Bluesky (976KB limit) - - Assert output fits within limit - -4. `it does not upscale small images` - - Create a 500px wide image - - Optimize for Instagram (max 1440px) - - Assert width stays 500px (not upscaled) - -5. `it returns original if already within limits` - - Create a small JPEG under all limits - - Optimize for Facebook - - Assert output exists and is valid JPEG - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -php artisan test --compact --filter=MediaOptimizer -``` - -- [ ] **Step 3: Implement MediaOptimizer** - -Create `app/Services/Media/MediaOptimizer.php` with: -- `optimizeImage(string $filePath, Platform $platform): string` — returns path to optimized temp file -- `getImageConfig(Platform $platform): array` — returns config per platform from spec -- Quality reduction loop: if file exceeds max_size, reduce quality by 10 until it fits or quality reaches 30 - -Use the code from the spec. Use `ImageManager::gd()` as the driver. - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -php artisan test --compact --filter=MediaOptimizer -``` - -- [ ] **Step 5: Run Pint and commit** - -```bash -vendor/bin/pint --dirty --format agent -git add app/Services/Media/MediaOptimizer.php tests/Unit/Services/Media/MediaOptimizerTest.php -git commit -m "feat: add MediaOptimizer service with per-platform image optimization" -``` - ---- - -### Task 3: Integrate MediaOptimizer into BlueskyPublisher - -**Files:** -- Modify: `app/Services/Social/BlueskyPublisher.php` - -Bluesky is the most critical — hard 1MB limit. - -- [ ] **Step 1: Update uploadBlob method** - -In `BlueskyPublisher::uploadBlob()`, after downloading to temp file and before uploading: - -```php -// If it's an image, optimize for Bluesky -if (str_starts_with($mimeType, 'image/')) { - $optimizer = app(MediaOptimizer::class); - $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky); - @unlink($tempFile); - $tempFile = $optimizedPath; - $mimeType = 'image/jpeg'; // MediaOptimizer converts to JPEG -} -``` - -Remove the existing "Bluesky has 1MB limit" warning log — the optimizer handles it now. - -- [ ] **Step 2: Run tests** - -```bash -php artisan test --compact --filter=Bluesky -``` - -- [ ] **Step 3: Commit** - -```bash -git commit -m "feat: BlueskyPublisher uses MediaOptimizer for 1MB image limit" -``` - ---- - -### Task 4: Integrate MediaOptimizer into X, LinkedIn, LinkedInPage publishers - -**Files:** -- Modify: `app/Services/Social/XPublisher.php` -- Modify: `app/Services/Social/LinkedInPublisher.php` -- Modify: `app/Services/Social/LinkedInPagePublisher.php` - -These publishers upload images directly (not via URL pull). - -- [ ] **Step 1: Update XPublisher::uploadMedia** - -In the `uploadMedia` method, after downloading to temp file and before upload, optimize images: - -```php -if (str_starts_with($mimeType, 'image/') && !str_starts_with($mimeType, 'image/gif')) { - $optimizer = app(MediaOptimizer::class); - $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X); - @unlink($tempFile); - $tempFile = $optimizedPath; - $mimeType = 'image/jpeg'; - $fileSize = filesize($tempFile); -} -``` - -Note: Skip GIFs — they need special handling (animated). - -- [ ] **Step 2: Update LinkedInPublisher::uploadImage** - -In the `uploadImage` method, optimize before uploading: - -```php -$optimizer = app(MediaOptimizer::class); -$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::LinkedIn); -``` - -- [ ] **Step 3: Update LinkedInPagePublisher::uploadImage** - -Same as LinkedIn but with `Platform::LinkedInPage`. - -- [ ] **Step 4: Run tests** - -```bash -php artisan test --compact -``` - -- [ ] **Step 5: Commit** - -```bash -git commit -m "feat: X, LinkedIn, LinkedInPage publishers use MediaOptimizer for images" -``` - ---- - -### Task 5: Integrate MediaOptimizer into Mastodon and Pinterest publishers - -**Files:** -- Modify: `app/Services/Social/MastodonPublisher.php` -- Modify: `app/Services/Social/PinterestPublisher.php` - -- [ ] **Step 1: Update MastodonPublisher::uploadMedia** - -After downloading to temp file, optimize images before upload: - -```php -if (str_starts_with($mimeType, 'image/') && !str_starts_with($mimeType, 'image/gif')) { - $optimizer = app(MediaOptimizer::class); - $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Mastodon); - @unlink($tempFile); - $tempFile = $optimizedPath; -} -``` - -Note: Mastodon `uploadMedia` currently doesn't detect mime type from the media model. Need to pass it through or detect from temp file. - -- [ ] **Step 2: Update PinterestPublisher** - -Pinterest image pins upload images. Optimize before the multipart upload. - -- [ ] **Step 3: Run tests** - -```bash -php artisan test --compact -``` - -- [ ] **Step 4: Commit** - -```bash -git commit -m "feat: Mastodon, Pinterest publishers use MediaOptimizer for images" -``` - ---- - -### Task 6: Skip optimization for URL-pull platforms - -**Files:** None (verification only) - -Instagram, Facebook, Threads, and TikTok use URL pull — their APIs download media from our CDN. These platforms handle resize on their side. No changes needed. - -- [ ] **Step 1: Verify URL-pull platforms don't need optimization** - -Verify that these publishers pass `$media->url` directly to the API (not uploading binary): -- `InstagramPublisher` — uses `image_url` / `video_url` params -- `FacebookPublisher` — uses `url` / `file_url` params -- `ThreadsPublisher` — uses `image_url` / `video_url` params -- `TikTokPublisher` — uses `PULL_FROM_URL` source - -No code changes needed. Just verify and document. - -- [ ] **Step 2: Commit verification note** - -No commit needed — just verification. - ---- - -### Task 7: Final verification - -- [ ] **Step 1: Run full test suite** - -```bash -php artisan test --compact -``` - -All tests must pass. - -- [ ] **Step 2: Run Pint** - -```bash -vendor/bin/pint --dirty --format agent -``` - -- [ ] **Step 3: Final commit and push** - -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-03-31-social-error-mapping.md b/docs/superpowers/plans/2026-03-31-social-error-mapping.md deleted file mode 100644 index 82192a74..00000000 --- a/docs/superpowers/plans/2026-03-31-social-error-mapping.md +++ /dev/null @@ -1,368 +0,0 @@ -# Social Error Mapping Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace generic exceptions in all social publishers with platform-specific exceptions that give users clear error messages and provide structured context for Nightwatch. - -**Architecture:** Abstract base `SocialPublishException` with Laravel's native `context()` method, per-platform subclasses that parse API responses via `fromApiResponse()`, and an `ErrorCategory` enum. Token errors stay as `TokenExpiredException`. The `PublishToSocialPlatform` job catches `SocialPublishException` separately. - -**Tech Stack:** Laravel 13, PHP 8.4, Pest 4 - -**Spec:** `docs/superpowers/specs/2026-03-31-social-error-mapping-design.md` - ---- - -### Task 1: Create ErrorCategory enum and SocialPublishException base class - -**Files:** -- Create: `app/Exceptions/Social/ErrorCategory.php` -- Create: `app/Exceptions/Social/SocialPublishException.php` -- Test: `tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php` - -- [ ] **Step 1: Create ErrorCategory enum** - -Create `app/Exceptions/Social/ErrorCategory.php` with cases: MediaFormat, RateLimit, Permission, ContentPolicy, ServerError, Unknown. - -- [ ] **Step 2: Create SocialPublishException base class** - -Create `app/Exceptions/Social/SocialPublishException.php` with constructor taking `$userMessage`, `$category`, `$platformErrorCode`, `$rawResponse`. Implement `context()` method. Declare abstract `fromApiResponse()` and `platform()`. - -- [ ] **Step 3: Write test for base class context()** - -Create test verifying `context()` returns correct array with platform, category, error code, message, and raw response. - -- [ ] **Step 4: Run tests** - -Run: `php artisan test --compact --filter=SocialPublishException` - -- [ ] **Step 5: Run Pint and commit** - -```bash -vendor/bin/pint --dirty --format agent -git add app/Exceptions/Social/ tests/Unit/Exceptions/Social/ -git commit -m "feat: add ErrorCategory enum and SocialPublishException base class" -``` - ---- - -### Task 2: Create InstagramPublishException - -**Files:** -- Create: `app/Exceptions/Social/InstagramPublishException.php` -- Test: `tests/Unit/Exceptions/Social/InstagramPublishExceptionTest.php` - -- [ ] **Step 1: Write failing tests** - -Test that: -- Subcode 2207026 maps to "Unsupported video format" with MediaFormat category -- Subcode 2207042 maps to RateLimit category -- Subcode 2207050 maps to Permission category -- OAuthException type throws TokenExpiredException -- Unknown subcode falls through to error_user_msg -- Unknown subcode without error_user_msg falls through to error.message - -- [ ] **Step 2: Run tests to verify they fail** - -- [ ] **Step 3: Implement InstagramPublishException** - -Use the code from the spec — match on `error_subcode` (int), handle all 25 Instagram error codes. Check token errors first. Fallback to `error_user_msg` then `error.message`. - -- [ ] **Step 4: Run tests to verify they pass** - -- [ ] **Step 5: Run Pint and commit** - -```bash -vendor/bin/pint --dirty --format agent -git add app/Exceptions/Social/InstagramPublishException.php tests/Unit/Exceptions/Social/InstagramPublishExceptionTest.php -git commit -m "feat: add InstagramPublishException with 25 error codes" -``` - ---- - -### Task 3: Create TikTokPublishException - -**Files:** -- Create: `app/Exceptions/Social/TikTokPublishException.php` -- Test: `tests/Unit/Exceptions/Social/TikTokPublishExceptionTest.php` - -- [ ] **Step 1: Write failing tests** - -Test HTTP errors (access_token_invalid → TokenExpiredException, rate_limit_exceeded → RateLimit, file_format_check_failed → MediaFormat) and fail_reason errors (spam_risk_too_many_posts → RateLimit, video_pull_failed → ServerError). - -- [ ] **Step 2: Run tests to verify they fail** - -- [ ] **Step 3: Implement TikTokPublishException** - -Two parsing paths: HTTP response errors match on `error.code` string, publish status errors match on `fail_reason` string. Add static method `fromFailReason(string $failReason, ?string $rawResponse)` for publish status failures. - -- [ ] **Step 4: Run tests to verify they pass** - -- [ ] **Step 5: Run Pint and commit** - -```bash -git commit -m "feat: add TikTokPublishException with HTTP and fail_reason errors" -``` - ---- - -### Task 4: Create YouTubePublishException - -**Files:** -- Create: `app/Exceptions/Social/YouTubePublishException.php` -- Test: `tests/Unit/Exceptions/Social/YouTubePublishExceptionTest.php` - -- [ ] **Step 1: Write failing tests** - -Test: invalidTitle → ContentPolicy, uploadLimitExceeded → RateLimit, forbidden → Permission, HTTP 401 → TokenExpiredException. - -- [ ] **Step 2: Run tests to verify they fail** - -- [ ] **Step 3: Implement YouTubePublishException** - -Parse `Google\Service\Exception` — match on `getErrors()[0]['reason']` string. Handle HTTP 401 as TokenExpiredException. - -- [ ] **Step 4: Run tests to verify they pass** - -- [ ] **Step 5: Run Pint and commit** - -```bash -git commit -m "feat: add YouTubePublishException with 15 error reasons" -``` - ---- - -### Task 5: Create FacebookPublishException - -**Files:** -- Create: `app/Exceptions/Social/FacebookPublishException.php` -- Test: `tests/Unit/Exceptions/Social/FacebookPublishExceptionTest.php` - -- [ ] **Step 1: Write failing tests** - -Test: code 1363031 → MediaFormat, code 190 → TokenExpiredException, code 4 → RateLimit, code 1363042 → Permission. - -- [ ] **Step 2: Implement and test** - -Match on `error.code` (int). Token errors checked first by OAuthException type or code 190 + subcodes 458-467. Map all 30 error codes from spec. - -- [ ] **Step 3: Run Pint and commit** - -```bash -git commit -m "feat: add FacebookPublishException with 30 error codes" -``` - ---- - -### Task 6: Create remaining 6 platform exceptions - -**Files:** -- Create: `app/Exceptions/Social/LinkedInPublishException.php` -- Create: `app/Exceptions/Social/XPublishException.php` -- Create: `app/Exceptions/Social/ThreadsPublishException.php` -- Create: `app/Exceptions/Social/PinterestPublishException.php` -- Create: `app/Exceptions/Social/BlueskyPublishException.php` -- Create: `app/Exceptions/Social/MastodonPublishException.php` -- Test: `tests/Unit/Exceptions/Social/` (one test file per exception) - -- [ ] **Step 1: Create LinkedInPublishException with tests** - -Match on HTTP status + body text. 5 error mappings from spec. - -- [ ] **Step 2: Create XPublishException with tests** - -Match on Problem `type` suffix + HTTP status. 10 error mappings from spec. - -- [ ] **Step 3: Create ThreadsPublishException with tests** - -Same Graph API format as Instagram. Match on error.type + error.code. - -- [ ] **Step 4: Create PinterestPublishException with tests** - -Match on HTTP status + processing status. 6 error mappings. - -- [ ] **Step 5: Create BlueskyPublishException with tests** - -Match on AT Protocol error strings. 6 error mappings. - -- [ ] **Step 6: Create MastodonPublishException with tests** - -Match on HTTP status + error message text. 7 error mappings. - -- [ ] **Step 7: Run full test suite and commit** - -```bash -php artisan test --compact -git commit -m "feat: add error mapping for LinkedIn, X, Threads, Pinterest, Bluesky, Mastodon" -``` - ---- - -### Task 7: Update PublishToSocialPlatform job - -**Files:** -- Modify: `app/Jobs/PublishToSocialPlatform.php` -- Test: `tests/Feature/Jobs/PublishToSocialPlatformTest.php` - -- [ ] **Step 1: Write failing test** - -Test that when a publisher throws `SocialPublishException`, the job saves `$e->userMessage` to `error_message` (not the raw API response). - -- [ ] **Step 2: Add SocialPublishException catch block** - -Between the `TokenExpiredException` catch and the `\Throwable` catch, add: - -```php -} catch (SocialPublishException $e) { - Log::error('Social publish failed: ' . $e->userMessage); - $this->postPlatform->markAsFailed($e->userMessage); -} -``` - -- [ ] **Step 3: Run tests to verify pass** - -- [ ] **Step 4: Run Pint and commit** - -```bash -git commit -m "feat: PublishToSocialPlatform catches SocialPublishException" -``` - ---- - -### Task 8: Replace handleApiError in InstagramPublisher - -**Files:** -- Modify: `app/Services/Social/InstagramPublisher.php` -- Test: `tests/Feature/Jobs/PublishToSocialPlatformTest.php` (existing Instagram tests) - -- [ ] **Step 1: Replace handleApiError method** - -Replace the existing `handleApiError` with: - -```php -private function handleApiError(Response $response): never -{ - throw InstagramPublishException::fromApiResponse($response); -} -``` - -Remove the `TOKEN_ERROR_CODES` and `TOKEN_ERROR_SUBCODES` constants (now handled inside the exception). - -- [ ] **Step 2: Run tests** - -Run: `php artisan test --compact --filter=PublishToSocialPlatform` - -- [ ] **Step 3: Commit** - -```bash -git commit -m "refactor: InstagramPublisher uses InstagramPublishException" -``` - ---- - -### Task 9: Replace handleApiError in TikTok, YouTube, Facebook publishers - -**Files:** -- Modify: `app/Services/Social/TikTokPublisher.php` -- Modify: `app/Services/Social/YouTubePublisher.php` -- Modify: `app/Services/Social/FacebookPublisher.php` - -- [ ] **Step 1: Update TikTokPublisher** - -Replace `handleApiError` with `TikTokPublishException::fromApiResponse()`. Also update the `waitForPublishStatus` method to use `TikTokPublishException::fromFailReason()` when status is FAILED. - -- [ ] **Step 2: Update YouTubePublisher** - -Replace `handleGoogleError` with `YouTubePublishException::fromGoogleException()`. This takes a `Google\Service\Exception` instead of an HTTP response. - -- [ ] **Step 3: Update FacebookPublisher** - -Replace `handleApiError` with `FacebookPublishException::fromApiResponse()`. Remove TOKEN_ERROR constants. - -- [ ] **Step 4: Run tests** - -Run: `php artisan test --compact` - -- [ ] **Step 5: Commit** - -```bash -git commit -m "refactor: TikTok, YouTube, Facebook publishers use platform exceptions" -``` - ---- - -### Task 10: Replace handleApiError in remaining 6 publishers - -**Files:** -- Modify: `app/Services/Social/LinkedInPublisher.php` -- Modify: `app/Services/Social/LinkedInPagePublisher.php` -- Modify: `app/Services/Social/XPublisher.php` -- Modify: `app/Services/Social/ThreadsPublisher.php` -- Modify: `app/Services/Social/PinterestPublisher.php` -- Modify: `app/Services/Social/BlueskyPublisher.php` -- Modify: `app/Services/Social/MastodonPublisher.php` - -- [ ] **Step 1: Update LinkedInPublisher and LinkedInPagePublisher** - -Both share the same error format. Replace `handleApiError` with `LinkedInPublishException::fromApiResponse()`. - -- [ ] **Step 2: Update XPublisher** - -Replace `handleApiError` with `XPublishException::fromApiResponse()`. - -- [ ] **Step 3: Update ThreadsPublisher** - -Replace `handleApiError` with `ThreadsPublishException::fromApiResponse()`. Remove TOKEN constants. - -- [ ] **Step 4: Update PinterestPublisher** - -Replace `handleApiError` with `PinterestPublishException::fromApiResponse()`. - -- [ ] **Step 5: Update BlueskyPublisher** - -Bluesky error handling is scattered (inline checks). Consolidate into `BlueskyPublishException::fromApiResponse()`. - -- [ ] **Step 6: Update MastodonPublisher** - -Replace `handleApiError` with `MastodonPublishException::fromApiResponse()`. - -- [ ] **Step 7: Run full test suite** - -Run: `php artisan test --compact` - -- [ ] **Step 8: Commit** - -```bash -git commit -m "refactor: all publishers use platform-specific exceptions" -``` - ---- - -### Task 11: Final verification - -- [ ] **Step 1: Run full test suite** - -```bash -php artisan test --compact -``` - -All 917+ tests must pass. - -- [ ] **Step 2: Verify no remaining generic exceptions in publishers** - -```bash -grep -rn "throw new \\\\Exception" app/Services/Social/ | grep -v "TokenExpiredException\|SocialPublishException" -``` - -Should return only legitimate non-API exceptions (e.g., "requires media", "only supports video"). - -- [ ] **Step 3: Run Pint** - -```bash -vendor/bin/pint --dirty --format agent -``` - -- [ ] **Step 4: Final commit and push** - -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-04-01-publishing-engine-improvements.md b/docs/superpowers/plans/2026-04-01-publishing-engine-improvements.md deleted file mode 100644 index b8d12443..00000000 --- a/docs/superpowers/plans/2026-04-01-publishing-engine-improvements.md +++ /dev/null @@ -1,470 +0,0 @@ -# Publishing Engine Improvements Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Improve publishing reliability with rate limit retry, inline token refresh, per-platform concurrency control, and proactive token refresh. - -**Architecture:** A shared `HasSocialHttpClient` trait for rate limit retry, a `TokenRefresher` service for centralized refresh logic, Horizon per-platform queues for concurrency, and a scheduled command for proactive refresh. - -**Tech Stack:** Laravel 13, PHP 8.4, Horizon, Redis, Pest 4 - -**Spec:** `docs/superpowers/specs/2026-04-01-publishing-engine-improvements-design.md` - -**Scope:** Tasks 1-4 are for implementation now. Tasks 5-6 are documented for future sprints. - ---- - -## NOW — Implement - -### Task 1: Rate limit retry (429 handling) - -**Files:** -- Create: `app/Services/Social/Concerns/HasSocialHttpClient.php` -- Modify: All 11 publishers to use the trait -- Test: `tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php` - -- [ ] **Step 1: Write failing test for the trait** - -Create test that verifies: -- HTTP 429 response triggers automatic retry (up to 3 times) -- After 3 retries, the exception is thrown -- Non-429 errors are not retried -- Successful response after retry is returned normally - -```php -test('socialHttp retries on 429 responses', function () { - Http::fake([ - 'api.example.com/*' => Http::sequence() - ->push(['error' => 'rate_limit'], 429) - ->push(['data' => 'success'], 200), - ]); - - $client = new class { use HasSocialHttpClient; }; - $response = $client->socialHttp()->get('https://api.example.com/test'); - - expect($response->status())->toBe(200); - Http::assertSentCount(2); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -- [ ] **Step 3: Create HasSocialHttpClient trait** - -```php - $exception->response?->status() === 429, - throw: false, - )->timeout(120); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -- [ ] **Step 5: Integrate into all publishers** - -Replace `Http::withToken(...)` calls with `$this->socialHttp()->withToken(...)` in each publisher. The trait adds rate limit retry to all API calls automatically. - -For each publisher: -1. Add `use HasSocialHttpClient;` to the class -2. Replace direct `Http::` calls that hit platform APIs with `$this->socialHttp()->` -3. Keep `Http::withOptions(['sink' => ...])` for downloads (those don't need retry) - -Publishers to update: -- InstagramPublisher (uses `Http::post`, `Http::get`) -- FacebookPublisher (uses `Http::post`) -- TikTokPublisher (has `getHttpClient()` method — update it to use trait) -- YouTubePublisher (uses Google SDK — skip, SDK has its own retry) -- LinkedInPublisher (has `getHttpClient()` method — update it) -- LinkedInPagePublisher (has `getHttpClient()` method — update it) -- XPublisher (uses `Http::withToken`) -- ThreadsPublisher (uses `Http::post`, `Http::get`) -- PinterestPublisher (uses `Http::withToken`) -- BlueskyPublisher (uses `Http::withToken`) -- MastodonPublisher (uses `Http::withToken`) - -- [ ] **Step 6: Run all publisher tests** - -```bash -php artisan test --compact --filter="Unit.*Publisher" -``` - -- [ ] **Step 7: Commit** - -```bash -git commit -m "feat: add rate limit retry (429) to all publishers via HasSocialHttpClient trait" -``` - ---- - -### Task 2: Token refresh inline during publishing - -**Files:** -- Create: `app/Services/Social/TokenRefresher.php` -- Modify: `app/Jobs/PublishToSocialPlatform.php` -- Test: `tests/Unit/Services/Social/TokenRefresherTest.php` -- Test: `tests/Feature/Jobs/PublishToSocialPlatformTest.php` (add inline refresh test) - -- [ ] **Step 1: Create TokenRefresher service** - -Extract the refresh logic from `ConnectionVerifier::refreshTokenIfNeeded` into a standalone service that all publishers and the job can use: - -```php -platform) { - Platform::LinkedIn, Platform::LinkedInPage => $this->refreshLinkedIn($account), - Platform::X => $this->refreshX($account), - Platform::YouTube => $this->refreshYouTube($account), - Platform::TikTok => $this->refreshTikTok($account), - Platform::Pinterest => $this->refreshPinterest($account), - Platform::Threads => $this->refreshThreads($account), - Platform::Instagram => $this->refreshInstagram($account), - Platform::Bluesky => $this->refreshBluesky($account), - default => throw new TokenExpiredException('Token refresh not supported for ' . $account->platform->value), - }; - - $account->refresh(); - } - - // ... private methods extracted from ConnectionVerifier -} -``` - -- [ ] **Step 2: Write test for TokenRefresher** - -Test that each platform refresh works (mock HTTP calls). - -- [ ] **Step 3: Update PublishToSocialPlatform job with inline retry** - -Replace the current try/catch with a retry loop: - -```php -$maxAttempts = 2; - -for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { - try { - $publisher = $this->getPublisher(); - $result = $publisher->publish($this->postPlatform); - $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); - break; - } catch (TokenExpiredException $e) { - if ($attempt < $maxAttempts) { - try { - app(TokenRefresher::class)->refresh($this->postPlatform->socialAccount); - continue; - } catch (\Throwable $refreshError) { - // Refresh failed — fall through to disconnect - } - } - - Log::error('Token expired while publishing', [...]); - $this->postPlatform->markAsFailed($e->getMessage()); - $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); - break; - } catch (SocialPublishException $e) { - Log::error('Social publish failed: ' . $e->userMessage); - $this->postPlatform->markAsFailed($e->userMessage); - break; - } catch (\Throwable $e) { - Log::error('Unexpected publish error', [...]); - $this->postPlatform->markAsFailed($e->getMessage()); - break; - } -} -``` - -- [ ] **Step 4: Write test for inline token refresh** - -Test that when publish throws `TokenExpiredException`, the job refreshes the token and retries. On second failure, it disconnects. - -- [ ] **Step 5: Update ConnectionVerifier to use TokenRefresher** - -Replace duplicated refresh logic in `ConnectionVerifier` with calls to `TokenRefresher`. - -- [ ] **Step 6: Remove duplicated refresh methods from publishers** - -Each publisher currently has its own `refreshToken()` method. After `TokenRefresher` exists, publishers should delegate to it. However, this is a larger refactor — for now, keep the publisher refresh methods and just add the inline retry in the job. - -- [ ] **Step 7: Run tests and commit** - -```bash -php artisan test --compact --filter="PublishToSocialPlatform|TokenRefresher" -git commit -m "feat: inline token refresh retry during publishing" -``` - ---- - -### Task 3: Per-platform concurrency control via Horizon queues - -**Files:** -- Modify: `config/horizon.php` -- Modify: `app/Jobs/PublishToSocialPlatform.php` - -- [ ] **Step 1: Add per-platform queues to Horizon config** - -In `config/horizon.php`, add supervisor blocks for each platform: - -```php -'environments' => [ - 'production' => [ - 'social-default' => [ - 'connection' => 'redis', - 'queue' => [ - 'social-instagram', - 'social-facebook', - 'social-tiktok', - 'social-youtube', - 'social-linkedin', - 'social-linkedin-page', - 'social-x', - 'social-threads', - 'social-pinterest', - 'social-bluesky', - 'social-mastodon', - ], - 'balance' => 'auto', - 'autoScalingStrategy' => 'time', - 'minProcesses' => 1, - 'maxProcesses' => 3, - 'timeout' => 630, - 'maxTime' => 0, - 'maxJobs' => 0, - 'memory' => 256, - 'tries' => 1, - 'nice' => 0, - ], - ], - 'local' => [ - 'social-default' => [ - 'connection' => 'redis', - 'queue' => [ - 'social-instagram', - 'social-facebook', - 'social-tiktok', - 'social-youtube', - 'social-linkedin', - 'social-linkedin-page', - 'social-x', - 'social-threads', - 'social-pinterest', - 'social-bluesky', - 'social-mastodon', - ], - 'balance' => 'auto', - 'autoScalingStrategy' => 'time', - 'minProcesses' => 1, - 'maxProcesses' => 1, - 'timeout' => 630, - 'maxTime' => 0, - 'maxJobs' => 0, - 'memory' => 256, - 'tries' => 1, - 'nice' => 0, - ], - ], -], -``` - -- [ ] **Step 2: Update PublishToSocialPlatform to dispatch to platform queue** - -```php -public function __construct(public PostPlatform $postPlatform) -{ - $this->onQueue('social-' . $postPlatform->platform->value); -} -``` - -- [ ] **Step 3: Run tests and commit** - -```bash -php artisan test --compact --filter="PublishToSocialPlatform" -git commit -m "feat: per-platform Horizon queues for concurrency control" -``` - ---- - -### Task 4: Proactive token refresh - -**Files:** -- Create: `app/Console/Commands/RefreshExpiringTokens.php` -- Create: `app/Jobs/RefreshSocialToken.php` -- Modify: `routes/console.php` -- Test: `tests/Feature/Commands/RefreshExpiringTokensTest.php` - -- [ ] **Step 1: Create RefreshSocialToken job** - -```php -refresh($this->account); - } catch (\Throwable $e) { - Log::warning('Proactive token refresh failed', [ - 'account_id' => $this->account->id, - 'platform' => $this->account->platform->value, - 'error' => $e->getMessage(), - ]); - } - } -} -``` - -- [ ] **Step 2: Create RefreshExpiringTokens command** - -```php -where('status', Status::Connected) - ->whereNotNull('token_expires_at') - ->where('token_expires_at', '<=', now()->addHours(2)) - ->where('token_expires_at', '>', now()) - ->chunk(50, fn ($accounts) => $accounts->each( - fn ($account) => RefreshSocialToken::dispatch($account) - )); - } -} -``` - -- [ ] **Step 3: Schedule the command** - -In `routes/console.php`: -```php -Schedule::command(RefreshExpiringTokens::class)->hourly(); -``` - -- [ ] **Step 4: Write tests** - -Test that the command dispatches jobs for accounts with tokens expiring in 2 hours, and does NOT dispatch for tokens expiring in 5 hours or already expired. - -- [ ] **Step 5: Run tests and commit** - -```bash -php artisan test --compact --filter="RefreshExpiring" -git commit -m "feat: proactive token refresh for tokens expiring within 2 hours" -``` - ---- - -## FUTURE — Plan Only (Not Implementing Now) - -### Task 5: Webhooks post-publish - -**Scope:** Full webhook system for post lifecycle events. - -**Data model:** -- `webhooks` table: id, workspace_id, url, events (json array), secret (encrypted), is_active, created_at, updated_at -- Events: `post.published`, `post.failed`, `post.partially_published`, `account.disconnected` - -**Architecture:** -- `Webhook` model with `workspace` relationship -- `SendWebhook` job — dispatched after status change, signs payload with HMAC-SHA256, retries 3x with exponential backoff -- Webhook management CRUD (controller, form requests, Vue components) -- Webhook delivery logs table for debugging - -**Integration points:** -- `PublishToSocialPlatform` job — dispatch `SendWebhook` after markAsPublished/markAsFailed -- `SocialAccount::markAsDisconnected` — dispatch `SendWebhook` for account.disconnected - -**Estimated effort:** 2-3 days (backend + frontend + tests) - ---- - -### Task 6: Threads / Comments support - -**Scope:** Support posting a main post + sequential comments/replies as a thread. - -**Data model changes:** -- Add `parent_id` (nullable, self-referencing FK) to `post_platforms` -- Add `delay_seconds` (int, default 0) to `post_platforms` -- Add `thread_position` (int) to `post_platforms` - -**Publisher changes:** -- Add `comment(string $postId, string $content, ?array $media): array` method to each publisher that supports it: - - Instagram: `POST /{media-id}/comments` - - X/Twitter: `POST /2/tweets` with `reply.in_reply_to_tweet_id` - - Facebook: `POST /{post-id}/comments` - - LinkedIn: `POST /rest/socialActions/{post-urn}/comments` - - Threads: `POST /{user-id}/threads` with `reply_to_id` - -**Job changes:** -- `PublishToSocialPlatform` publishes main post first -- Then iterates over child posts (ordered by thread_position) -- Waits `delay_seconds` between each -- Each child calls `publisher->comment()` with the parent's platform_post_id - -**Frontend changes:** -- Thread builder UI in post editor -- Drag-to-reorder thread items -- Per-item content and media -- Delay configuration between items - -**Estimated effort:** 1-2 weeks (data model + backend + frontend + tests) diff --git a/docs/superpowers/specs/2026-03-31-image-resize-design.md b/docs/superpowers/specs/2026-03-31-image-resize-design.md deleted file mode 100644 index 66a8604c..00000000 --- a/docs/superpowers/specs/2026-03-31-image-resize-design.md +++ /dev/null @@ -1,196 +0,0 @@ -# Image & Media Resize Per Platform - -## Problem - -Each social platform has different limits for image size, resolution, format, and aspect ratio. Currently we upload media as-is — if it exceeds a platform's limits, the API rejects it. We need to automatically resize/convert images before publishing. - -## Solution - -Install `intervention/image` and create a `MediaOptimizer` service that processes images per platform before upload. Each platform has a configuration defining its limits, and the optimizer ensures media meets them. - -## Library - -[Intervention Image v4](https://image.intervention.io/v4) — PHP image handling library supporting GD and Imagick drivers. - -## Platform Media Specifications (from official docs) - -### Images - -| Platform | Max Size | Formats | Max Resolution | Aspect Ratio | Source | -|---|---|---|---|---|---| -| **Instagram** | 8 MB | JPEG only | 1440px width, min 320px | 4:5 to 1.91:1 | [Official](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | -| **Facebook** | 4 MB (PNG: 1 MB rec.) | JPEG, PNG, BMP, GIF, TIFF | Auto-resized | No limit | [Official](https://developers.facebook.com/docs/graph-api/reference/page/photos/) | -| **X/Twitter** | 5 MB | JPG, PNG, GIF, WEBP | No hard limit | No limit | [Official](https://docs.x.com/x-api/media/quickstart/best-practices) | -| **TikTok** | 20 MB | JPEG, WebP | 1080px max | No limit | [Official](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) | -| **YouTube** | N/A | N/A | N/A | N/A | Video only | -| **LinkedIn** | < 36M pixels | JPG, GIF, PNG | < 36,152,320 pixels total | No limit | [Official](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api) | -| **Threads** | 8 MB | JPEG only | Same as Instagram | Same as Instagram | Same API as Instagram | -| **Pinterest** | 20 MB (desktop), 32 MB (app) | PNG, JPEG | 1000x1500 recommended | 2:3 recommended | [Official](https://help.pinterest.com/en/business/article/pinterest-product-specs) | -| **Bluesky** | 1 MB | Any | No hard limit | No limit | [Official](https://docs.bsky.app/docs/advanced-guides/posts) | -| **Mastodon** | Instance-dependent (~10 MB) | JPG, PNG, GIF, WebP | No hard limit | No limit | [Official](https://docs.joinmastodon.org/methods/statuses/) | - -### Videos - -| Platform | Max Size | Formats | Codec | Max Resolution | Duration | Aspect Ratio | Source | -|---|---|---|---|---|---|---|---| -| **Instagram Feed** | 100 MB | MP4, MOV | H.264/HEVC | 1920px | 3s-60min | 4:5 to 1.91:1 | [Official](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | -| **Instagram Reel** | 300 MB | MP4, MOV | H.264/HEVC | 1920px | 3s-15min | 9:16 rec. | [Official](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | -| **Instagram Story** | 100 MB | MP4, MOV | H.264/HEVC | 1920px | 3-60s | 9:16 rec. | [Official](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | -| **Facebook** | 2 GB | MP4 | H.264 | No limit | 1s-40min | No limit | [Official](https://developers.facebook.com/docs/video-api/reference/error-codes/) | -| **X/Twitter** | 512 MB | MP4 | H.264 High | 1280x1024 | 0.5-140s | 1:3 to 3:1 | [Official](https://docs.x.com/x-api/media/quickstart/best-practices) | -| **TikTok** | 4 GB | MP4, WebM, MOV | H.264/H.265/VP8/VP9 | 4096px | Up to 10min | No limit | [Official](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) | -| **YouTube** | 128 GB | MP4, MOV, AVI, WebM+ | H.264 rec. | No limit | Up to 12h | No limit | [Official](https://developers.google.com/youtube/v3/docs/videos/insert) | -| **LinkedIn** | 500 MB | MP4 | H.264 | No limit | 3s-30min | No limit | [Official](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api) | -| **Pinterest** | 2 GB | MP4, MOV, M4V | H.264/H.265 | No limit | 4s-15min | 1:2 to 1.91:1 | [Official](https://help.pinterest.com/en/business/article/pinterest-product-specs) | -| **Bluesky** | 50 MB | MP4 | H.264 | 1920px | Up to 60s | No limit | [Official](https://docs.bsky.app/docs/advanced-guides/posts) | -| **Mastodon** | Instance-dependent (~40 MB) | MP4, WebM | H.264/VP9 | No limit | No limit | No limit | [Official](https://docs.joinmastodon.org/methods/statuses/) | - -## Architecture - -### MediaOptimizer Service - -```php -manager = ImageManager::gd(); // or ::imagick() - } - - /** - * Optimize an image for a specific platform. - * Returns path to optimized temp file (caller must clean up). - */ - public function optimizeImage(string $filePath, Platform $platform): string - { - $config = $this->getImageConfig($platform); - $image = $this->manager->read($filePath); - - // Resize if needed (maintain aspect ratio) - if ($config['max_width'] && $image->width() > $config['max_width']) { - $image->scaleDown(width: $config['max_width']); - } - - // Convert format if needed - $tempFile = tempnam(sys_get_temp_dir(), 'media_opt_'); - $encoded = $image->encodeByMediaType($config['format'], quality: $config['quality']); - file_put_contents($tempFile, $encoded); - - // Check file size, reduce quality if still too large - while (filesize($tempFile) > $config['max_size'] && $config['quality'] > 30) { - $config['quality'] -= 10; - $encoded = $image->encodeByMediaType($config['format'], quality: $config['quality']); - file_put_contents($tempFile, $encoded); - } - - return $tempFile; - } - - private function getImageConfig(Platform $platform): array - { - return match ($platform) { - Platform::Instagram, Platform::Threads => [ - 'max_width' => 1440, - 'max_size' => 8 * 1024 * 1024, // 8 MB - 'format' => 'image/jpeg', - 'quality' => 90, - ], - Platform::Facebook => [ - 'max_width' => 2048, - 'max_size' => 4 * 1024 * 1024, // 4 MB - 'format' => 'image/jpeg', - 'quality' => 90, - ], - Platform::X => [ - 'max_width' => 2048, - 'max_size' => 5 * 1024 * 1024, // 5 MB - 'format' => 'image/jpeg', - 'quality' => 90, - ], - Platform::TikTok => [ - 'max_width' => 1080, - 'max_size' => 20 * 1024 * 1024, // 20 MB - 'format' => 'image/jpeg', - 'quality' => 95, - ], - Platform::LinkedIn, Platform::LinkedInPage => [ - 'max_width' => 2048, - 'max_size' => 10 * 1024 * 1024, // 10 MB (practical limit) - 'format' => 'image/jpeg', - 'quality' => 90, - ], - Platform::Pinterest => [ - 'max_width' => 1000, - 'max_size' => 20 * 1024 * 1024, // 20 MB - 'format' => 'image/jpeg', - 'quality' => 90, - ], - Platform::Bluesky => [ - 'max_width' => 2048, - 'max_size' => 976 * 1024, // ~976 KB (under 1 MB with margin) - 'format' => 'image/jpeg', - 'quality' => 85, - ], - Platform::Mastodon => [ - 'max_width' => 2048, - 'max_size' => 10 * 1024 * 1024, // 10 MB - 'format' => 'image/jpeg', - 'quality' => 90, - ], - Platform::YouTube => [ - 'max_width' => 1920, - 'max_size' => 2 * 1024 * 1024, // 2 MB (thumbnails only) - 'format' => 'image/jpeg', - 'quality' => 90, - ], - }; - } -} -``` - -### Integration with Publishers - -Each publisher calls `MediaOptimizer::optimizeImage()` before uploading images: - -```php -// In publisher (e.g., BlueskyPublisher): -$optimizer = app(MediaOptimizer::class); -$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky); - -try { - // upload $optimizedPath -} finally { - @unlink($optimizedPath); -} -``` - -### What we DON'T do (video transcoding) - -Video transcoding (converting codecs, changing resolution) requires FFmpeg and is computationally expensive. For now: -- We validate video format/size before upload -- We let the platform API reject incompatible videos with clear error messages (from the error mapping spec) -- Video transcoding is a future feature if needed - -## Testing - -- Unit tests for `MediaOptimizer` with sample images of different sizes/formats -- Verify resize maintains aspect ratio -- Verify quality reduction loop stops at threshold -- Verify format conversion (PNG → JPEG) -- Verify Bluesky always produces < 1 MB output - -## Files Changed - -- Install: `intervention/image` v4 via composer -- Create: `app/Services/Media/MediaOptimizer.php` -- Modify: Publishers that upload images directly (Bluesky, X, LinkedIn, LinkedInPage, Mastodon, Pinterest) -- Create: `tests/Unit/Services/Media/MediaOptimizerTest.php` diff --git a/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md b/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md deleted file mode 100644 index 215ff9f4..00000000 --- a/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md +++ /dev/null @@ -1,553 +0,0 @@ -# Social Platform Error Mapping - -## Problem - -All publishers throw generic `\Exception` or `TokenExpiredException` with raw API error messages. Users see cryptic strings like `"Instagram API error: Only photo or video can be accepted as media type."` instead of actionable messages. Debugging requires reading raw logs. - -## Solution - -Create per-platform exception classes inside `app/Exceptions/Social/` that parse API responses and return clear user-facing messages, a categorized error type, and structured context for Nightwatch via Laravel's native `context()` method. - -## Architecture - -### Directory Structure - -``` -app/Exceptions/ - TokenExpiredException.php (existing, unchanged) - Social/ - SocialPublishException.php (abstract base) - ErrorCategory.php (enum) - InstagramPublishException.php - TikTokPublishException.php - YouTubePublishException.php - FacebookPublishException.php - LinkedInPublishException.php - XPublishException.php - ThreadsPublishException.php - PinterestPublishException.php - BlueskyPublishException.php - MastodonPublishException.php -``` - -### ErrorCategory Enum - -```php - - */ - public function context(): array - { - return [ - 'platform' => static::platform(), - 'category' => $this->category->value, - 'platform_error_code' => $this->platformErrorCode, - 'user_message' => $this->userMessage, - 'raw_response' => $this->rawResponse, - ]; - } - - /** - * Parse an API response and return a platform-specific exception. - */ - abstract public static function fromApiResponse(mixed $response): static; - - /** - * Platform identifier for logging. - */ - abstract protected static function platform(): string; -} -``` - -### Per-Platform Exception (Example: Instagram) - -`fromApiResponse` receives the Laravel HTTP response, checks for token errors first, then matches on `error_subcode` (the reliable identifier per Instagram's official error docs). When the API includes `error_user_msg`, we prefer that over our own message since it's localized by Meta. - -Reference: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/ - -```php -json() ?? []; - $error = data_get($json, 'error', []); - $errorCode = data_get($error, 'code'); - $errorSubcode = data_get($error, 'error_subcode'); - $errorType = data_get($error, 'type'); - $errorUserMsg = data_get($error, 'error_user_msg'); - - // Token errors throw TokenExpiredException - if ($errorType === 'OAuthException' || $errorCode === 190) { - throw new TokenExpiredException( - data_get($error, 'message', 'Instagram token expired'), - (string) $errorCode, - ); - } - - // Match on error_subcode (official Instagram error identifier) - [$message, $category] = match ($errorSubcode) { - // Media format errors - 2207026 => ['Unsupported video format. Please upload MP4 or MOV.', ErrorCategory::MediaFormat], - 2207005 => ['Unsupported image format.', ErrorCategory::MediaFormat], - 2207004 => ['Image is too large (max 8MB).', ErrorCategory::MediaFormat], - 2207009 => ['Aspect ratio not supported (must be between 4:5 and 1.91:1).', ErrorCategory::MediaFormat], - 2207057 => ['Thumbnail offset is outside the video duration.', ErrorCategory::MediaFormat], - 2207023 => ['Unknown media type.', ErrorCategory::MediaFormat], - - // Upload/processing errors - 2207003 => ['Media download timed out. Please try again.', ErrorCategory::ServerError], - 2207020 => ['Media has expired. Please upload again.', ErrorCategory::ServerError], - 2207032 => ['Failed to create media. Please try again.', ErrorCategory::ServerError], - 2207053 => ['Unknown upload error. Please try again.', ErrorCategory::ServerError], - 2207052 => ['Could not fetch media from URL. Please try again.', ErrorCategory::ServerError], - 2207006 => ['Media not found. Please upload again.', ErrorCategory::ServerError], - 2207008 => ['Media container expired. Please try again in a few minutes.', ErrorCategory::ServerError], - 2207027 => ['Media is not ready for publishing. Please wait and try again.', ErrorCategory::ServerError], - 2207001 => ['Instagram server error. Please try again.', ErrorCategory::ServerError], - - // Content validation - 2207010 => ['Caption is too long (max 2,200 characters, 30 hashtags, 20 @mentions).', ErrorCategory::ContentPolicy], - 2207028 => ['Carousel needs between 2 and 10 photos/videos.', ErrorCategory::ContentPolicy], - 2207051 => ['Instagram restricted this action to protect the community.', ErrorCategory::ContentPolicy], - - // Product tagging - 2207035 => ['Product tag positions are not supported for videos.', ErrorCategory::ContentPolicy], - 2207036 => ['Product tag positions are required for photos.', ErrorCategory::ContentPolicy], - 2207037 => ['Invalid product tag. The product may be deleted or not permitted.', ErrorCategory::ContentPolicy], - 2207040 => ['Too many tags (max 20).', ErrorCategory::ContentPolicy], - - // Rate limits - 2207042 => ['Daily publishing limit reached. Please try again tomorrow.', ErrorCategory::RateLimit], - - // Permissions - 2207050 => ['Instagram account is restricted or inactive. Please check the Instagram app.', ErrorCategory::Permission], - 2207081 => ["This account doesn't support Trial Reels.", ErrorCategory::Permission], - - // Fall through — use Instagram's own error_user_msg if available - default => [null, ErrorCategory::Unknown], - }; - - // Prefer Instagram's own user-facing message when we don't have a mapping - $message ??= $errorUserMsg ?? data_get($error, 'message', 'Instagram publishing failed'); - - return new static( - $message, - $category, - $errorSubcode ? (string) $errorSubcode : (string) $errorCode, - $response->body(), - ); - } -} -``` - -### Error Maps Per Platform - -Sources: Official API documentation + Postiz error mappings. - ---- - -#### Instagram (25 errors) - -Source: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/ - -Match on `error_subcode` (int). Use `error_user_msg` as fallback when available. - -| Subcode | Message | Category | -|---|---|---| -| 2207026 | Unsupported video format. Please upload MP4 or MOV. | MediaFormat | -| 2207005 | Unsupported image format. | MediaFormat | -| 2207004 | Image is too large (max 8MB). | MediaFormat | -| 2207009 | Aspect ratio not supported (must be between 4:5 and 1.91:1). | MediaFormat | -| 2207057 | Thumbnail offset is outside the video duration. | MediaFormat | -| 2207023 | Unknown media type. | MediaFormat | -| 2207003 | Media download timed out. Please try again. | ServerError | -| 2207020 | Media has expired. Please upload again. | ServerError | -| 2207032 | Failed to create media. Please try again. | ServerError | -| 2207053 | Unknown upload error. Please try again. | ServerError | -| 2207052 | Could not fetch media from URL. Please try again. | ServerError | -| 2207006 | Media not found. Please upload again. | ServerError | -| 2207008 | Media container expired. Please try again in a few minutes. | ServerError | -| 2207027 | Media is not ready for publishing. Please wait and try again. | ServerError | -| 2207001 | Instagram server error. Please try again. | ServerError | -| 2207010 | Caption is too long (max 2,200 characters, 30 hashtags, 20 @mentions). | ContentPolicy | -| 2207028 | Carousel needs between 2 and 10 photos/videos. | ContentPolicy | -| 2207051 | Instagram restricted this action to protect the community. | ContentPolicy | -| 2207035 | Product tag positions are not supported for videos. | ContentPolicy | -| 2207036 | Product tag positions are required for photos. | ContentPolicy | -| 2207037 | Invalid product tag. The product may be deleted or not permitted. | ContentPolicy | -| 2207040 | Too many tags (max 20). | ContentPolicy | -| 2207042 | Daily publishing limit reached. Please try again tomorrow. | RateLimit | -| 2207050 | Instagram account is restricted or inactive. Please check the Instagram app. | Permission | -| 2207081 | This account doesn't support Trial Reels. | Permission | - -Token errors: `OAuthException` type or code `190` → `TokenExpiredException`. - ---- - -#### TikTok (20 errors) - -Sources: https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status/ and https://developers.tiktok.com/doc/tiktok-api-v2-error-handling - -Two types of errors: HTTP response errors (match on `error` string) and publish status fail reasons (match on `fail_reason` string). - -**HTTP errors:** - -| Error Code | Message | Category | -|---|---|---| -| `access_token_invalid` | Access token is invalid or expired. Please reconnect. | TokenExpiredException | -| `scope_not_authorized` | Missing required permissions. Please reconnect with all scopes. | Permission | -| `scope_permission_missed` | Additional permissions required. Please reconnect. | Permission | -| `rate_limit_exceeded` | TikTok rate limit exceeded. Please try again later. | RateLimit | -| `invalid_file_upload` | File does not meet API specifications. | MediaFormat | -| `invalid_params` | Invalid request parameters. | MediaFormat | -| `internal_error` | TikTok server error. Please try again later. | ServerError | - -**Publish fail reasons:** - -| Fail Reason | Message | Category | -|---|---|---| -| `file_format_check_failed` | Unsupported media format. | MediaFormat | -| `duration_check_failed` | Video duration is not within allowed limits. | MediaFormat | -| `frame_rate_check_failed` | Video frame rate is not supported. | MediaFormat | -| `picture_size_check_failed` | Image dimensions exceed limits. | MediaFormat | -| `video_pull_failed` | Failed to download video from URL. | ServerError | -| `photo_pull_failed` | Failed to download photo from URL. | ServerError | -| `publish_cancelled` | Publishing was cancelled. | ContentPolicy | -| `auth_removed` | App access was revoked during processing. | Permission | -| `spam_risk_too_many_posts` | Daily posting limit reached. Try again tomorrow. | RateLimit | -| `spam_risk_user_banned_from_posting` | Account is banned from posting. | ContentPolicy | -| `spam_risk_text` | TikTok detected spam in the description. | ContentPolicy | -| `spam_risk` | Publishing request flagged as high-risk. | ContentPolicy | -| `internal` | TikTok server error. Please try again. | ServerError | - -Additional HTTP errors from Postiz: -- `reached_active_user_cap` → RateLimit: Daily active user quota reached. -- `unaudited_client_can_only_post_to_private_accounts` → Permission: App not approved for public posting. -- `url_ownership_unverified` → Permission: Domain ownership not verified. -- `privacy_level_option_mismatch` → Permission: Privacy level not available for this account. -- `app_version_check_failed` → Permission: TikTok app update required. - ---- - -#### YouTube (15 errors) - -Source: https://developers.google.com/youtube/v3/docs/videos/insert - -Match on `reason` field in `Google\Service\Exception::getErrors()[0]['reason']`. - -| Reason | Message | Category | -|---|---|---| -| `invalidTitle` | Video title is invalid or empty. | ContentPolicy | -| `invalidDescription` | Video description is invalid. | ContentPolicy | -| `invalidTags` | Video tags are invalid. | ContentPolicy | -| `invalidCategoryId` | Video category is invalid. | ContentPolicy | -| `invalidVideoMetadata` | Video metadata is invalid. Title and category are required. | ContentPolicy | -| `invalidPublishAt` | Scheduled publishing time is invalid. | ContentPolicy | -| `invalidFilename` | Video filename is invalid. | MediaFormat | -| `invalidRecordingDetails` | Recording details are invalid. | ContentPolicy | -| `invalidVideoGameRating` | Video game rating is invalid. | ContentPolicy | -| `mediaBodyRequired` | Video file is missing from the request. | MediaFormat | -| `uploadLimitExceeded` | Daily upload limit reached. Try again tomorrow. | RateLimit | -| `forbidden` | You don't have permission to upload to this channel. | Permission | -| `forbiddenLicenseSetting` | Invalid video license setting. | Permission | -| `forbiddenPrivacySetting` | Invalid video privacy setting. | Permission | -| `failedPrecondition` | Thumbnail too large or account not verified. | MediaFormat | - -Token errors: HTTP 401, `Unauthorized`, `UNAUTHENTICATED`, `invalid_grant` → `TokenExpiredException`. - ---- - -#### Facebook (30 errors) - -Sources: https://developers.facebook.com/docs/video-api/reference/error-codes/ and https://developers.facebook.com/docs/graph-api/guides/error-handling/ - -Match on `error.code` (int). Token errors checked first by `error.type === 'OAuthException'` or code `190`. - -**Token errors → TokenExpiredException:** -- Code `190` — Token expired -- Subcode `458` — App not installed -- Subcode `459` — User checkpointed -- Subcode `460` — Password changed -- Subcode `463` — Session expired -- Subcode `464` — Unconfirmed user -- Subcode `467` — Invalid token - -**Video upload errors (Session init):** - -| Code | Message | Category | -|---|---|---| -| 6000 | Problem with file. Try with another file. | MediaFormat | -| 1363042 | No permission to upload video here. | Permission | -| 1363023 | Video exceeds 2GB maximum size. | MediaFormat | -| 1363022 | Video below 1KB minimum size. | MediaFormat | - -**Video upload errors (Upload phase):** - -| Code | Message | Category | -|---|---|---| -| 1363030 | Upload timed out. Please try again. | ServerError | -| 1363019 | Problem uploading video. Please try again. | ServerError | -| 1363031 | Unsupported file format. | MediaFormat | -| 1363032 | File is not a valid video. | MediaFormat | -| 1363024 | Unsupported video format. | MediaFormat | -| 1363025 | Video is too short (minimum 1 second). | MediaFormat | -| 1363026 | Video is too long (maximum 40 minutes). | MediaFormat | -| 1363033 | Upload interrupted. Please try again. | ServerError | -| 1363037 | Invalid upload offset. | ServerError | -| 1363020 | No video file selected. | MediaFormat | -| 1363045 | Upload size mismatch. | ServerError | -| 1363041 | Upload session expired. Please try again. | ServerError | -| 1363021 | Problem during video upload. Please try again. | ServerError | -| 1363005 | No permission to edit this video. | Permission | - -**Reel/Story specific:** - -| Code | Message | Category | -|---|---|---| -| 1363047 | Reel encoding issue. Please try a different video. | MediaFormat | -| 1609008 | Video format not supported for Reels. | MediaFormat | -| 1609010 | Reel encoding requirements not met. | MediaFormat | -| 1366046 | Reels require a video. | ContentPolicy | -| 2061006 | Video is too short for this format. | MediaFormat | - -**General:** - -| Code | Message | Category | -|---|---|---| -| 1390008 | Caption is too long. | ContentPolicy | -| 1346003 | Thumbnail is incompatible. | ContentPolicy | -| 1349125 | Rate limit exceeded. Try again later. | RateLimit | -| 4 | Too many API calls. Please try again later. | RateLimit | -| 17 | User call limit reached. | RateLimit | -| 506 | Duplicate post detected. Please modify content. | ContentPolicy | - ---- - -#### X/Twitter (10 errors) - -Source: https://docs.x.com/x-api/fundamentals/response-codes-and-errors - -Match on Problem `type` suffix and HTTP status code. X uses RFC 7807 Problem Details. - -| Error Type / Status | Message | Category | -|---|---|---| -| `unsupported-authentication` / 401 | Authentication method not supported. Please reconnect. | TokenExpiredException | -| HTTP 401 | Access token is invalid or expired. | TokenExpiredException | -| `usage-capped` / 429 | Usage limit exceeded. Please try again later. | RateLimit | -| `rate-limit-exceeded` / 429 | Rate limit exceeded. Please try again later. | RateLimit | -| `invalid-request` / 400 | Invalid request. Check your post content. | ContentPolicy | -| `client-forbidden` / 403 | App not enrolled or lacks required access. | Permission | -| `not-authorized-for-resource` / 403 | Not authorized for this resource. | Permission | -| `resource-not-found` / 404 | Resource not found. | ContentPolicy | -| `The Tweet contains an invalid URL` | Post contains an invalid URL. | ContentPolicy | -| `video longer than 2 minutes` | Video exceeds the 2-minute limit for this account. | MediaFormat | -| HTTP 500/502/503/504 | X server error. Please try again later. | ServerError | - ---- - -#### LinkedIn (5 errors) - -Source: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api - -LinkedIn uses generic HTTP status codes. Match on HTTP status and response body text. - -| Status / Text | Message | Category | -|---|---|---| -| HTTP 401 | LinkedIn access token expired. Please reconnect. | TokenExpiredException | -| HTTP 403 | Not authorized to post to this account. | Permission | -| HTTP 422 | Invalid post data. Please check your content. | ContentPolicy | -| `Unable to obtain activity` | LinkedIn server error. Please try again. | ServerError | -| `resource is forbidden` | Access to this resource is forbidden. | Permission | - ---- - -#### Threads (8 errors) - -Source: Threads uses the same Graph API error format as Instagram. - -Match on `error.type` and `error.code`. Token errors: `OAuthException` or code `190`. - -| Code / Text | Message | Category | -|---|---|---| -| `OAuthException` / 190 | Threads token expired. Please reconnect. | TokenExpiredException | -| HTTP 400 + `text can't be blank` | Post text is required. | ContentPolicy | -| HTTP 400 + media processing error | Media processing failed. Please try again. | ServerError | -| HTTP 429 | Rate limit exceeded. Please try again later. | RateLimit | -| HTTP 500 | Threads server error. Please try again. | ServerError | - ---- - -#### Pinterest (6 errors) - -Source: https://developers.pinterest.com/docs/api/v5/ - -Match on HTTP status code and processing status. - -| Status / Text | Message | Category | -|---|---|---| -| HTTP 401 | Pinterest token expired. Please reconnect. | TokenExpiredException | -| HTTP 403 | Not authorized to create pins on this board. | Permission | -| HTTP 429 | Rate limit exceeded. Please try again later. | RateLimit | -| Processing status `failed` | Media processing failed. Please try a different file. | MediaFormat | -| HTTP 400 + board error | Invalid board. Please select a valid board. | ContentPolicy | -| HTTP 500 | Pinterest server error. Please try again. | ServerError | - ---- - -#### Bluesky (6 errors) - -Source: https://docs.bsky.app/docs/advanced-guides/posts - -Match on AT Protocol error strings and HTTP status. - -| Error / Status | Message | Category | -|---|---|---| -| `ExpiredToken` | Bluesky session expired. Please reconnect. | TokenExpiredException | -| `InvalidToken` | Bluesky token is invalid. Please reconnect. | TokenExpiredException | -| Blob size > 1MB | Image exceeds Bluesky's 1MB limit. | MediaFormat | -| HTTP 400 + `InvalidRequest` | Invalid post data. | ContentPolicy | -| HTTP 429 | Rate limit exceeded. Please try again later. | RateLimit | -| HTTP 500/502 | Bluesky server error. Please try again. | ServerError | - ---- - -#### Mastodon (7 errors) - -Source: https://docs.joinmastodon.org/methods/statuses/ - -Match on HTTP status code and error message text. - -| Status / Text | Message | Category | -|---|---|---| -| HTTP 401 | Mastodon token is invalid. Please reconnect. | TokenExpiredException | -| HTTP 403 | This action is not allowed. | Permission | -| HTTP 422 + `Text can't be blank` | Post text is required when no media is attached. | ContentPolicy | -| HTTP 422 + media error | Media validation failed. | MediaFormat | -| HTTP 413 | File is too large for this Mastodon instance. | MediaFormat | -| HTTP 429 | Rate limit exceeded. Please try again later. | RateLimit | -| HTTP 503 | Mastodon server error. Please try again. | ServerError | - -## Integration with Publishers - -Each publisher's `handleApiError` method is replaced with the platform exception: - -```php -// Before (every publisher): -private function handleApiError(Response $response, string $context): void -{ - $body = $response->json() ?? []; - $error = $body['error'] ?? []; - // ... manual token check ... - throw new \Exception("{$context}: {$message}"); -} - -// After: -private function handleApiError(Response $response): never -{ - // fromApiResponse handles token errors internally - // (throws TokenExpiredException for token issues) - throw InstagramPublishException::fromApiResponse($response); -} -``` - -## Integration with PublishToSocialPlatform Job - -```php -try { - $result = $publisher->publish($this->postPlatform); - $this->postPlatform->markAsPublished(...); -} catch (TokenExpiredException $e) { - Log::error('Token expired while publishing', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, - 'error' => $e->getMessage(), - ]); - $this->postPlatform->markAsFailed($e->getMessage()); - $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); -} catch (SocialPublishException $e) { - // context() is automatically included in the log by Laravel - Log::error('Social publish failed: ' . $e->userMessage); - $this->postPlatform->markAsFailed($e->userMessage); -} catch (\Throwable $e) { - Log::error('Unexpected publish error', [ - 'post_platform_id' => $this->postPlatform->id, - 'error' => $e->getMessage(), - ]); - $this->postPlatform->markAsFailed($e->getMessage()); -} -``` - -The `$e->userMessage` goes to `error_message` in the database (shown to user). The `context()` method automatically provides platform, category, error code, and raw response to Nightwatch/logs. - -## Testing - -Each platform exception gets a test file that verifies: -- Known error codes map to correct user messages and categories -- Token errors correctly throw `TokenExpiredException` (not `SocialPublishException`) -- Unknown errors fall through to generic message with `ErrorCategory::Unknown` - -## Files Changed - -- Create: `app/Exceptions/Social/ErrorCategory.php` -- Create: `app/Exceptions/Social/SocialPublishException.php` -- Create: 10 platform exception files (`InstagramPublishException.php`, etc.) -- Modify: 10 publisher files (replace `handleApiError` with platform exception) -- Modify: `app/Jobs/PublishToSocialPlatform.php` (add `SocialPublishException` catch) -- Create: 10 test files for platform exceptions diff --git a/docs/superpowers/specs/2026-04-01-publishing-engine-improvements-design.md b/docs/superpowers/specs/2026-04-01-publishing-engine-improvements-design.md deleted file mode 100644 index 8a487dfa..00000000 --- a/docs/superpowers/specs/2026-04-01-publishing-engine-improvements-design.md +++ /dev/null @@ -1,257 +0,0 @@ -# Publishing Engine Improvements - -Based on comparative analysis of Postiz's publishing engine vs ours. - -## 1. Rate Limit Retry (429 handling) - -### Problem - -When a platform API returns 429 (Too Many Requests), our publishers throw an exception and the post fails. The user has to manually retry. Postiz retries automatically with a 5-second delay, up to 3 times. - -### Solution - -Add a `retry()` middleware to all HTTP calls that hit social platform APIs. Laravel's HTTP client supports `retry()` natively. - -```php -// Before: -$response = Http::withToken($token)->post($url, $data); - -// After: -$response = Http::withToken($token) - ->retry(3, 5000, fn ($e, $request) => $e->response?->status() === 429) - ->post($url, $data); -``` - -### Implementation - -Create a trait `HasSocialHttpClient` that all publishers use: - -```php -trait HasSocialHttpClient -{ - protected function socialHttp(): PendingRequest - { - return Http::retry( - times: 3, - sleepMilliseconds: 5000, - when: fn ($exception, $request) => $exception->response?->status() === 429, - throw: false, - ); - } -} -``` - -Each publisher replaces `Http::withToken(...)` calls with `$this->socialHttp()->withToken(...)`. - -### Files Changed - -- Create: `app/Services/Social/Concerns/HasSocialHttpClient.php` -- Modify: All 11 publishers to use the trait - ---- - -## 2. Token Refresh Inline During Publishing - -### Problem - -We refresh tokens **before** publishing, but if the token expires **during** a long upload (e.g., 244MB YouTube video), the publish fails. Postiz retries up to 5 times with inline token refresh between attempts. - -### Solution - -Wrap the publish call in the `PublishToSocialPlatform` job with a retry loop that catches `TokenExpiredException`, refreshes the token, and retries: - -```php -// In PublishToSocialPlatform::handle() -$maxAttempts = 2; // 1 retry after token refresh - -for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { - try { - $result = $publisher->publish($this->postPlatform); - $this->postPlatform->markAsPublished(...); - break; - } catch (TokenExpiredException $e) { - if ($attempt < $maxAttempts) { - $this->refreshTokenAndRetry($e); - continue; - } - // Final attempt failed — disconnect account - $this->postPlatform->markAsFailed($e->getMessage()); - $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); - } -} -``` - -The `refreshTokenAndRetry` method calls the platform-specific refresh (same logic as `ConnectionVerifier::refreshTokenIfNeeded`). - -### Files Changed - -- Modify: `app/Jobs/PublishToSocialPlatform.php` -- Extract: Token refresh logic from `ConnectionVerifier` into a reusable `TokenRefresher` service - ---- - -## 3. Concurrency Control Per Platform - -### Problem - -If 50 posts are scheduled for the same time, all 50 jobs hit Instagram's API simultaneously, causing rate limits and failures. Postiz uses per-platform task queues with `maxConcurrentJob`. - -### Solution - -Use Horizon's queue configuration to create per-platform queues with max processes: - -```php -// config/horizon.php -'environments' => [ - 'production' => [ - 'social-instagram' => [ - 'connection' => 'redis', - 'queue' => ['social-instagram'], - 'maxProcesses' => 2, - 'timeout' => 630, - ], - 'social-tiktok' => [ - 'connection' => 'redis', - 'queue' => ['social-tiktok'], - 'maxProcesses' => 2, - 'timeout' => 630, - ], - // ... per platform - ], -], -``` - -The `PublishToSocialPlatform` job dispatches to the platform-specific queue: - -```php -public function __construct(public PostPlatform $postPlatform) -{ - $this->onQueue('social-' . $postPlatform->platform->value); -} -``` - -### Platform Concurrency Limits (from Postiz) - -| Platform | Max Concurrent | Our Queue maxProcesses | -|---|---|---| -| Instagram | 400 | 3 | -| TikTok | 300 | 2 | -| YouTube | 200 | 1 | -| Facebook | default | 3 | -| LinkedIn | default | 2 | -| X/Twitter | default | 2 | -| Threads | default | 2 | -| Pinterest | default | 2 | -| Bluesky | default | 2 | -| Mastodon | default | 2 | - -### Files Changed - -- Modify: `config/horizon.php` — add per-platform queues -- Modify: `app/Jobs/PublishToSocialPlatform.php` — dispatch to platform queue -- Modify: `app/Jobs/PublishPost.php` — pass platform info when dispatching - ---- - -## 4. Webhooks Post-Publish - -### Problem - -Users building integrations (Zapier, Make, custom CRM) can't programmatically know when a post is published. Postiz fires webhooks after each successful publish. - -### Solution - -Add a `Webhook` model and fire webhooks after post status changes. This is a larger feature that deserves its own spec. - -### High-Level Design - -- `webhooks` table: `id, workspace_id, url, events (json), secret, is_active` -- Events: `post.published`, `post.failed`, `account.disconnected` -- Fire webhook in `PublishToSocialPlatform` job after status update -- Sign payload with HMAC-SHA256 using the webhook secret -- Async dispatch via a `SendWebhook` job -- Retry 3x with exponential backoff - -### Files Changed - -- Create: Migration, Model, Controller, FormRequest for Webhooks CRUD -- Create: `app/Jobs/SendWebhook.php` -- Modify: `app/Jobs/PublishToSocialPlatform.php` — dispatch webhook after publish -- Create: Frontend components for webhook management - ---- - -## 5. Threads / Comments Support - -### Problem - -Postiz supports posting a main post + sequential comments (Twitter threads, Instagram first comment). We only post single posts. - -### Solution - -This requires significant data model changes: - -- A `Post` can have ordered child `Post` records (thread items) -- The publisher publishes the first post, then iterates over children posting each as a reply/comment -- Each platform's comment API is different (Twitter reply_to, Instagram comment endpoint, etc.) - -### High-Level Design - -- Add `parent_post_platform_id` to `post_platforms` table -- Add `delay_seconds` column for delayed comments -- Extend each publisher with a `comment()` method (like Postiz) -- The job publishes main post → waits for delay → publishes each comment - -This is the largest feature. Deserves its own dedicated spec + plan. - -### Files Changed - -- Migration: Add columns to `post_platforms` -- Modify: All publishers to add `comment()` method -- Modify: Frontend to support thread/comment creation UI -- Modify: `PublishToSocialPlatform` job to handle sequential publishing - ---- - -## 6. Proactive Token Refresh - -### Problem - -Currently we only refresh tokens reactively (when publishing) and via daily `CheckSocialConnections`. Postiz runs a dedicated workflow per integration that sleeps until token expiry and proactively refreshes. - -### Solution - -Create a scheduled command that runs every hour and refreshes tokens expiring in the next 2 hours: - -```php -// app/Console/Commands/RefreshExpiringTokens.php -SocialAccount::query() - ->where('status', Status::Connected) - ->whereNotNull('token_expires_at') - ->where('token_expires_at', '<=', now()->addHours(2)) - ->where('token_expires_at', '>', now()) - ->chunk(50, function ($accounts) { - foreach ($accounts as $account) { - RefreshSocialToken::dispatch($account); - } - }); -``` - -### Files Changed - -- Create: `app/Console/Commands/RefreshExpiringTokens.php` -- Create: `app/Jobs/RefreshSocialToken.php` -- Modify: `routes/console.php` — schedule hourly - ---- - -## Priority Order - -| # | Feature | Impact | Effort | When | -|---|---|---|---|---| -| 1 | Rate limit retry (429) | High | Low | This sprint | -| 2 | Token refresh inline | High | Medium | This sprint | -| 3 | Concurrency control | Medium | Medium | This sprint | -| 6 | Proactive token refresh | Medium | Low | This sprint | -| 4 | Webhooks | Medium | High | Next sprint | -| 5 | Threads/Comments | High | Very High | Future | diff --git a/docs/superpowers/specs/2026-04-01-publishing-hardening-design.md b/docs/superpowers/specs/2026-04-01-publishing-hardening-design.md deleted file mode 100644 index a3bc73e9..00000000 --- a/docs/superpowers/specs/2026-04-01-publishing-hardening-design.md +++ /dev/null @@ -1,298 +0,0 @@ -# Publishing Hardening — Best Practices from Postiz - -Improvements to the existing publishing flow. No new features — just making what we have more robust. - -## 1. Content Sanitization Before Publishing - -### Problem - -We send raw content to platform APIs. If the user pastes HTML from the editor or has formatting tags, they get sent as-is. Each platform has different rules: -- Instagram, TikTok, Pinterest, Bluesky: plain text only -- LinkedIn: supports bold/italic via Unicode characters -- X: plain text only -- Facebook, Threads: plain text only -- Mastodon: supports some HTML -- YouTube: plain text titles - -### Solution - -Create a `ContentSanitizer` service that strips/converts content per platform: - -```php -class ContentSanitizer -{ - public function sanitize(string $content, Platform $platform): string - { - return match ($platform) { - Platform::LinkedIn, Platform::LinkedInPage => $this->convertToUnicodeBold($this->stripHtml($content)), - Platform::Mastodon => $this->stripUnsafeHtml($content), - default => $this->stripHtml($content), - }; - } - - private function stripHtml(string $content): string - { - // Remove HTML tags, decode entities (& → &, → space, etc.) - } - - private function convertToUnicodeBold(string $content): string - { - // Convert text to Unicode bold characters (𝗯𝗼𝗹𝗱) - // Convert text to Unicode underline (t̲e̲x̲t̲) - } - - private function stripUnsafeHtml(string $content): string - { - // Allow only safe tags: