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/TIKTOK.md b/TIKTOK.md new file mode 100644 index 00000000..bc0ed7e1 --- /dev/null +++ b/TIKTOK.md @@ -0,0 +1,56 @@ +# TikTok Direct Post Audit + +## Status + +Audit application submitted on 2026-04-01. Waiting for TikTok review (2-4 weeks). + +## Form Responses + +### Step 1 — General Information + +**Relationship with TikTok:** +Social media management platform allowing customers to create, schedule, publish, and manage content in one place, including TikTok. + +### Step 2 — API Client Information + +**App ID:** 7473784331115481143 + +**Goal of the application:** +TryPost is a social media management platform (trypost.it) that allows users to create, schedule, and publish content across multiple social networks from a single dashboard. The Content Posting API integration enables our users to schedule and directly publish video content to their TikTok accounts alongside other platforms like Instagram, YouTube, Facebook, and LinkedIn — all from one place. This saves creators and businesses significant time by eliminating the need to manually post on each platform individually. Direct posting is essential for our scheduled publishing feature, where posts are automatically published at the optimal time chosen by the user without requiring manual intervention. + +**Daily users estimate:** Prefer not to say / Building a new application + +**Justification (if asked):** +We are an early-stage platform currently onboarding our first users. We expect gradual growth as we expand our user base. + +### Step 3 — Supporting Documents + +**Required:** +- PDF with screenshots showing the TikTok integration flow (create post → select TikTok → schedule → publish) +- Screen recording demonstrating the complete user workflow: account connection, post creation, and publishing + +**API fields stored:** +- `publish_id` — used to track publish status +- `publicly_available_post_id` — used to build the post URL on TikTok + +### Step 4 — Review + +Verify all information and submit. + +## Without Audit (Current Restrictions) + +- All posts are restricted to `SELF_ONLY` (private) visibility +- Maximum 5 users can post in a 24-hour window +- User accounts must be set to private at the time of posting + +## After Audit Approval + +- Posts can be published with `PUBLIC_TO_EVERYONE` visibility +- No user cap restrictions +- Full Direct Post API access + +## References + +- [TikTok Content Sharing Guidelines](https://developers.tiktok.com/doc/content-sharing-guidelines) +- [TikTok Direct Post API](https://developers.tiktok.com/doc/content-posting-api-reference-direct-post) +- [TikTok Direct Post Audit Guide (Mixpost)](https://docs.mixpost.app/services/social/tik-tok/direct-post-audit/) 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..8470e930 --- /dev/null +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -0,0 +1,82 @@ +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), + default => [], + }; + + return response()->json(['metrics' => $metrics]); + } +} diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 9847afad..8c1fc076 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,7 @@ public function callback(Request $request): View|RedirectResponse } try { - $socialUser = Socialite::driver($this->driver)->user(); + $socialUser = Socialite::driver($this->driver)->usingGraphVersion('v25.0')->user(); // 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..199811e9 --- /dev/null +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -0,0 +1,282 @@ +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(); + + $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/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..beeaabc5 --- /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 = str_replace('/v24.0', '', $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..b8400526 --- /dev/null +++ b/app/Services/Social/TikTokAnalytics.php @@ -0,0 +1,187 @@ +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()->withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/json; charset=UTF-8', + ]); + } + + 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 0e39595e..e3c1ccda 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -97,19 +97,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, @@ -155,13 +179,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, 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/config/trypost.php b/config/trypost.php index f860264f..20dd458e 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -49,6 +49,9 @@ 'instagram' => [ 'enabled' => env('TRYPOST_INSTAGRAM_ENABLED', true), ], + 'instagram-facebook' => [ + 'enabled' => env('TRYPOST_INSTAGRAM_FACEBOOK_ENABLED', true), + ], 'threads' => [ 'enabled' => env('TRYPOST_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: