Merge remote-tracking branch 'origin/main' into feature/google-auth-toggle

This commit is contained in:
Paulo Castellano 2026-04-14 14:29:56 -03:00
commit 1a7af9728f
69 changed files with 3906 additions and 2498 deletions

1
.gitignore vendored
View file

@ -26,3 +26,4 @@ yarn-error.log
/.nova
/.vscode
/.zed
/docs/

View file

@ -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,

View file

@ -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);

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Enums\SocialAccount\Platform;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Services\Social\FacebookAnalytics;
use App\Services\Social\InstagramAnalytics;
use App\Services\Social\LinkedInPageAnalytics;
use App\Services\Social\PinterestAnalytics;
use App\Services\Social\ThreadsAnalytics;
use App\Services\Social\TikTokAnalytics;
use App\Services\Social\XAnalytics;
use App\Services\Social\YouTubeAnalytics;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class AnalyticsController extends Controller
{
private const SUPPORTED_PLATFORMS = [
Platform::TikTok,
Platform::Instagram,
Platform::InstagramFacebook,
Platform::Threads,
Platform::Facebook,
Platform::X,
Platform::LinkedInPage,
Platform::Pinterest,
Platform::YouTube,
];
public function index(Request $request): Response
{
$workspace = $request->user()->currentWorkspace;
$accounts = $workspace->socialAccounts()
->where('is_active', true)
->whereIn('platform', self::SUPPORTED_PLATFORMS)
->get()
->map(fn (SocialAccount $account) => [
'id' => $account->id,
'platform' => $account->platform->value,
'display_name' => $account->display_name,
'username' => $account->username,
'avatar_url' => $account->avatar_url,
]);
return Inertia::render('analytics/Index', [
'accounts' => $accounts,
]);
}
public function show(Request $request, SocialAccount $account): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
if ($account->workspace_id !== $workspace->id) {
abort(HttpResponse::HTTP_FORBIDDEN);
}
$since = $request->has('since') ? Carbon::parse($request->input('since')) : null;
$until = $request->has('until') ? Carbon::parse($request->input('until')) : null;
$metrics = match ($account->platform) {
Platform::TikTok => app(TikTokAnalytics::class)->getMetrics($account),
Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->getMetrics($account, $since, $until),
Platform::Threads => app(ThreadsAnalytics::class)->getMetrics($account, $since, $until),
Platform::Facebook => app(FacebookAnalytics::class)->getMetrics($account, $since, $until),
Platform::X => app(XAnalytics::class)->getMetrics($account, $since, $until),
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until),
Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until),
Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until),
default => [],
};
return response()->json(['metrics' => $metrics]);
}
}

View file

@ -28,6 +28,7 @@ class FacebookController extends SocialController
'pages_show_list',
'pages_read_engagement',
'pages_manage_posts',
'read_insights',
];
public function connect(Request $request): Response|RedirectResponse
@ -61,6 +62,7 @@ public function connect(Request $request): Response|RedirectResponse
return Inertia::location(
Socialite::driver($this->driver)
->usingGraphVersion('v25.0')
->setScopes($this->scopes)
->redirect()
->getTargetUrl()
@ -90,7 +92,14 @@ public function callback(Request $request): View|RedirectResponse
}
try {
$socialUser = Socialite::driver($this->driver)->user();
$socialUser = Socialite::driver($this->driver)->usingGraphVersion('v25.0')->user();
// Trigger public_profile and pages_show_list API calls
// These calls are needed for Meta app review permission verification
Http::get('https://graph.facebook.com/v25.0/me', [
'fields' => 'id,name',
'access_token' => $socialUser->token,
]);
// Fetch pages the user manages
$pages = $this->fetchPages($socialUser->token);

View file

@ -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

View file

@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
class InstagramFacebookController extends SocialController
{
protected string $driver = 'facebook';
protected SocialPlatform $platform = SocialPlatform::InstagramFacebook;
protected array $scopes = [
'public_profile',
'pages_show_list',
'pages_read_engagement',
'business_management',
'instagram_basic',
'instagram_content_publish',
'instagram_manage_insights',
];
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => $existingAccount?->id,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
$url = Socialite::driver($this->driver)
->usingGraphVersion('v20.0')
->setScopes($this->scopes)
->redirectUrl(route('app.social.instagram-facebook.callback'))
->redirect()
->getTargetUrl();
return Inertia::location($url);
}
public function callback(Request $request): View|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
$reconnectId = session('social_reconnect_id');
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
try {
$socialUser = Socialite::driver($this->driver)
->usingGraphVersion('v20.0')
->redirectUrl(route('app.social.instagram-facebook.callback'))
->user();
// Trigger public_profile API call for Meta app review verification
Http::get('https://graph.facebook.com/v20.0/me', [
'fields' => 'id,name',
'access_token' => $socialUser->token,
]);
$pages = $this->fetchPagesWithInstagram($socialUser->token);
if (empty($pages)) {
return $this->popupCallback(false, 'No Facebook Pages with linked Instagram accounts found.', $this->platform->value);
}
if (count($pages) === 1) {
return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount);
}
// Multiple pages — show selection
session([
'instagram_facebook_oauth' => [
'user_token' => $socialUser->token,
'pages' => $pages,
'reconnect_id' => $reconnectId,
],
]);
return redirect()->route('app.social.instagram-facebook.select-page');
} catch (\Exception $e) {
Log::error('Instagram via Facebook OAuth Error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
public function selectPage(Request $request)
{
$oauthData = session('instagram_facebook_oauth');
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
session()->flash('flash.banner', 'Session expired. Please try again.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('app.accounts');
}
$workspace = Workspace::find($workspaceId);
if (! $workspace) {
return redirect()->route('app.accounts');
}
$pages = collect(data_get($oauthData, 'pages'))
->map(fn ($page) => Arr::except($page, ['page_access_token']))
->toArray();
return Inertia::render('accounts/InstagramFacebookPageSelect', [
'workspace' => $workspace,
'pages' => $pages,
]);
}
public function select(Request $request): View
{
$request->validate([
'page_id' => 'required|string',
]);
$oauthData = session('instagram_facebook_oauth');
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
$reconnectId = data_get($oauthData, 'reconnect_id');
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
try {
$selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('page_id', $request->page_id);
if (! $selectedPage) {
return $this->popupCallback(false, 'Page not found.', $this->platform->value);
}
$result = $this->connectInstagramAccount($workspace, $selectedPage, $existingAccount);
session()->forget(['instagram_facebook_oauth', 'social_reconnect_id']);
return $result;
} catch (\Exception $e) {
Log::error('Instagram via Facebook page selection error', ['error' => $e->getMessage()]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
private function connectInstagramAccount(Workspace $workspace, array $pageData, $existingAccount): View
{
$avatarPath = data_get($pageData, 'ig_picture') ? uploadFromUrl(data_get($pageData, 'ig_picture')) : null;
$accountData = [
'platform_user_id' => data_get($pageData, 'ig_id'),
'username' => data_get($pageData, 'ig_username'),
'display_name' => data_get($pageData, 'ig_name', data_get($pageData, 'ig_username')),
'avatar_url' => $avatarPath,
'access_token' => data_get($pageData, 'page_access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'meta' => [
'page_id' => data_get($pageData, 'page_id'),
'page_name' => data_get($pageData, 'page_name'),
],
];
if ($existingAccount) {
$existingAccount->update($accountData);
$existingAccount->markAsConnected();
session()->forget('social_reconnect_id');
return $this->popupCallback(true, 'Instagram account reconnected!', $this->platform->value);
}
$account = $workspace->socialAccounts()->create(array_merge($accountData, [
'platform' => $this->platform->value,
'status' => Status::Connected,
]));
$isOnboarding = session('social_connect_onboarding', false);
return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value, $isOnboarding);
}
private function fetchPagesWithInstagram(string $userToken): array
{
try {
$response = Http::get('https://graph.facebook.com/v20.0/me/accounts', [
'access_token' => $userToken,
'fields' => 'id,name,username,picture{url},access_token,instagram_business_account',
]);
if ($response->failed()) {
Log::error('Instagram via Facebook pages fetch failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return [];
}
$pages = data_get($response->json(), 'data', []);
$results = [];
foreach ($pages as $page) {
$igAccountId = data_get($page, 'instagram_business_account.id');
if (! $igAccountId) {
continue;
}
// Fetch IG account details
$igResponse = Http::get("https://graph.facebook.com/v20.0/{$igAccountId}", [
'access_token' => data_get($page, 'access_token'),
'fields' => 'username,name,profile_picture_url',
]);
$igData = $igResponse->successful() ? $igResponse->json() : [];
$results[] = [
'page_id' => data_get($page, 'id'),
'page_name' => data_get($page, 'name'),
'page_picture' => data_get($page, 'picture.data.url'),
'page_access_token' => data_get($page, 'access_token'),
'ig_id' => $igAccountId,
'ig_username' => data_get($igData, 'username'),
'ig_name' => data_get($igData, 'name'),
'ig_picture' => data_get($igData, 'profile_picture_url'),
];
}
return $results;
} catch (\Exception $e) {
Log::error('Instagram via Facebook pages fetch error', ['error' => $e->getMessage()]);
return [];
}
}
}

View file

@ -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

View file

@ -23,7 +23,10 @@ class TikTokController extends SocialController
protected array $scopes = [
'user.info.basic',
'user.info.profile',
'user.info.stats',
'video.publish',
'video.upload',
'video.list',
];
public function connect(Request $request): Response|RedirectResponse

View file

@ -26,6 +26,7 @@ class YouTubeController extends SocialController
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/yt-analytics.readonly',
];
public function connect(Request $request): Response|RedirectResponse

View file

@ -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)],
];

View file

@ -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),

View file

@ -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',

View file

@ -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,
};

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class FacebookAnalytics
{
use HasSocialHttpClient;
private string $baseUrl = 'https://graph.facebook.com/v20.0';
private string $accessToken;
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
{
$since ??= now()->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();
}
}

View file

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class InstagramAnalytics
{
use HasSocialHttpClient;
private string $baseUrl;
private string $accessToken;
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
{
$since ??= now()->subDays(7);
$until ??= now();
$cacheKey = "analytics:instagram:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}";
$cacheTtl = app()->isProduction() ? 3600 : 1;
return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) {
return $this->fetchMetricsFromApi($account, $since, $until);
});
}
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
$this->baseUrl = preg_replace('#/v[\d.]+$#', '', $account->platform->instagramGraphBaseUrl());
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$this->accessToken = $account->access_token;
$metrics = [];
$timeSeriesMetrics = $this->fetchTimeSeriesMetrics($account, $since, $until);
$metrics = array_merge($metrics, $timeSeriesMetrics);
$totalValueMetrics = $this->fetchTotalValueMetrics($account, $since, $until);
$metrics = array_merge($metrics, $totalValueMetrics);
return $metrics;
}
private function fetchTimeSeriesMetrics(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
$response = $this->getHttpClient()
->get("{$this->baseUrl}/v21.0/{$account->platform_user_id}/insights", [
'metric' => 'reach,follower_count',
'period' => 'day',
'since' => $since->startOfDay()->unix(),
'until' => $until->endOfDay()->unix(),
'access_token' => $this->accessToken,
]);
if ($response->failed()) {
Log::warning('Instagram insights (time series) fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return [];
}
$data = data_get($response->json(), 'data', []);
$metrics = [];
foreach ($data as $metric) {
$name = data_get($metric, 'name');
$values = data_get($metric, 'values', []);
if (empty($values)) {
continue;
}
$total = collect($values)->sum('value');
$label = match ($name) {
'reach' => 'Reach',
'follower_count' => 'Followers',
default => ucfirst(str_replace('_', ' ', $name)),
};
$metrics[] = ['label' => $label, 'value' => $total];
}
return $metrics;
}
private function fetchTotalValueMetrics(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
$response = $this->getHttpClient()
->get("{$this->baseUrl}/v21.0/{$account->platform_user_id}/insights", [
'metric' => 'likes,comments,shares,saves,views,total_interactions',
'metric_type' => 'total_value',
'period' => 'day',
'since' => $since->startOfDay()->unix(),
'until' => $until->endOfDay()->unix(),
'access_token' => $this->accessToken,
]);
if ($response->failed()) {
Log::warning('Instagram insights (total value) fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return [];
}
$data = data_get($response->json(), 'data', []);
$metrics = [];
foreach ($data as $metric) {
$name = data_get($metric, 'name');
$value = data_get($metric, 'total_value.value', 0);
$label = match ($name) {
'total_interactions' => 'Interactions',
default => ucfirst(str_replace('_', ' ', $name)),
};
$metrics[] = ['label' => $label, 'value' => $value];
}
return $metrics;
}
private function getHttpClient(): PendingRequest
{
return $this->socialHttp();
}
private function refreshToken(SocialAccount $account): void
{
if ($account->platform === Platform::InstagramFacebook) {
return;
}
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for Instagram account');
}
$response = Http::get('https://graph.instagram.com/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Instagram token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -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,

View file

@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LinkedInPageAnalytics
{
use HasSocialHttpClient;
private string $baseUrl = 'https://api.linkedin.com/v2';
private string $accessToken;
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
{
$since ??= now()->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,
]);
}
}

View file

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PinterestAnalytics
{
use HasSocialHttpClient;
private string $baseUrl = 'https://api.pinterest.com/v5';
private string $accessToken;
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
{
$since ??= now()->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,
]);
}
}

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ThreadsAnalytics
{
use HasSocialHttpClient;
private string $baseUrl = 'https://graph.threads.net/v1.0';
private string $accessToken;
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
{
$since ??= now()->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,
]);
}
}

View file

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokAnalytics
{
use HasSocialHttpClient;
private string $baseUrl = 'https://open.tiktokapis.com/v2';
private string $accessToken;
public function getMetrics(SocialAccount $account): array
{
$cacheKey = "analytics:tiktok:{$account->id}";
$cacheTtl = app()->isProduction() ? 3600 : 1;
return Cache::remember($cacheKey, $cacheTtl, function () use ($account) {
return $this->fetchMetricsFromApi($account);
});
}
private function fetchMetricsFromApi(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$this->accessToken = $account->access_token;
$metrics = [];
$userStats = $this->fetchUserStats();
$metrics = array_merge($metrics, $userStats);
$videoMetrics = $this->fetchVideoMetrics();
$metrics = array_merge($metrics, $videoMetrics);
return $metrics;
}
private function fetchUserStats(): array
{
$response = $this->getHttpClient()
->get("{$this->baseUrl}/user/info/", [
'fields' => 'follower_count,following_count,likes_count,video_count',
]);
if ($response->failed()) {
Log::warning('TikTok user stats fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);
return [];
}
$user = data_get($response->json(), 'data.user', []);
$metrics = [];
if (($value = data_get($user, 'follower_count')) !== null) {
$metrics[] = ['label' => 'Followers', 'value' => $value];
}
if (($value = data_get($user, 'following_count')) !== null) {
$metrics[] = ['label' => 'Following', 'value' => $value];
}
if (($value = data_get($user, 'likes_count')) !== null) {
$metrics[] = ['label' => 'Total Likes', 'value' => $value];
}
if (($value = data_get($user, 'video_count')) !== null) {
$metrics[] = ['label' => 'Videos', 'value' => $value];
}
return $metrics;
}
private function fetchVideoMetrics(): array
{
$videoListResponse = $this->getHttpClient()
->post("{$this->baseUrl}/video/list/?fields=id", [
'max_count' => 20,
]);
if ($videoListResponse->failed()) {
Log::warning('TikTok video list fetch failed', [
'body' => $this->redactResponseBody($videoListResponse->body()),
]);
return [];
}
$videos = data_get($videoListResponse->json(), 'data.videos', []);
if (empty($videos)) {
return [];
}
$videoIds = array_map(fn ($v) => $v['id'], $videos);
$queryResponse = $this->getHttpClient()
->post("{$this->baseUrl}/video/query/?fields=id,like_count,comment_count,share_count,view_count", [
'filters' => ['video_ids' => $videoIds],
]);
if ($queryResponse->failed()) {
Log::warning('TikTok video query failed', [
'body' => $this->redactResponseBody($queryResponse->body()),
]);
return [];
}
$videoDetails = data_get($queryResponse->json(), 'data.videos', []);
if (empty($videoDetails)) {
return [];
}
$totalViews = 0;
$totalLikes = 0;
$totalComments = 0;
$totalShares = 0;
foreach ($videoDetails as $video) {
$totalViews += data_get($video, 'view_count', 0);
$totalLikes += data_get($video, 'like_count', 0);
$totalComments += data_get($video, 'comment_count', 0);
$totalShares += data_get($video, 'share_count', 0);
}
return [
['label' => 'Views', 'value' => $totalViews],
['label' => 'Recent Likes', 'value' => $totalLikes],
['label' => 'Recent Comments', 'value' => $totalComments],
['label' => 'Recent Shares', 'value' => $totalShares],
];
}
private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post('https://open.tiktokapis.com/v2/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('TikTok token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -60,16 +60,13 @@ public function publish(PostPlatform $postPlatform): array
private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/json; charset=UTF-8',
]);
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
private function queryCreatorInfo(): array
{
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/creator_info/query/");
->post("{$this->baseUrl}/post/publish/creator_info/query/", []);
if ($response->failed()) {
Log::warning('TikTok creator_info query failed', ['body' => $this->redactResponseBody($response->body())]);
@ -97,19 +94,43 @@ private function queryCreatorInfo(): array
];
}
private function buildPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array
{
$meta = $postPlatform->meta ?? [];
$privacyLevel = data_get($meta, 'privacy_level')
?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY');
$postInfo = [
'title' => $content ?? '',
'privacy_level' => $privacyLevel,
'disable_duet' => ! data_get($meta, 'allow_duet', false),
'disable_comment' => ! data_get($meta, 'allow_comments', true),
'disable_stitch' => ! data_get($meta, 'allow_stitch', false),
];
if (data_get($meta, 'is_aigc', false)) {
$postInfo['is_aigc'] = true;
}
if (data_get($meta, 'brand_content_toggle', false)) {
$postInfo['brand_content_toggle'] = true;
}
if (data_get($meta, 'brand_organic_toggle', false)) {
$postInfo['brand_organic_toggle'] = true;
}
return $postInfo;
}
private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array
{
$creatorInfo = $this->queryCreatorInfo();
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/video/init/", [
'post_info' => [
'title' => $content ?? '',
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
'disable_duet' => false,
'disable_comment' => false,
'disable_stitch' => false,
],
'post_info' => $this->buildPostInfo($postPlatform, $content, $creatorInfo),
'source_info' => [
'source' => 'PULL_FROM_URL',
'video_url' => $media->url,
@ -133,11 +154,12 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte
}
// Wait for processing and get final status
$this->waitForPublishStatus($publishId);
$statusData = $this->waitForPublishStatus($publishId);
$postId = data_get($statusData, 'publicaly_available_post_id.0');
return [
'id' => $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount),
'id' => $postId ?? $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId),
];
}
@ -155,13 +177,19 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st
$creatorInfo = $this->queryCreatorInfo();
$postInfo = $this->buildPostInfo($postPlatform, $content, $creatorInfo);
// Photos don't support duet/stitch/is_aigc
unset($postInfo['disable_duet'], $postInfo['disable_stitch'], $postInfo['is_aigc']);
// Auto add music is only for photos
$meta = $postPlatform->meta ?? [];
if (data_get($meta, 'auto_add_music', false)) {
$postInfo['auto_add_music'] = true;
}
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/content/init/", [
'post_info' => [
'title' => $content ?? '',
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
'disable_comment' => false,
],
'post_info' => $postInfo,
'source_info' => [
'source' => 'PULL_FROM_URL',
'photo_cover_index' => 0,
@ -188,11 +216,12 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st
}
// Wait for processing and get final status
$this->waitForPublishStatus($publishId);
$statusData = $this->waitForPublishStatus($publishId);
$postId = data_get($statusData, 'publicaly_available_post_id.0');
return [
'id' => $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount),
'id' => $postId ?? $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId),
];
}
@ -235,10 +264,14 @@ private function waitForPublishStatus(string $publishId, int $maxAttempts = 20):
return ['publish_id' => $publishId];
}
private function buildTikTokUrl(SocialAccount $account): ?string
private function buildTikTokUrl(SocialAccount $account, ?string $postId = null): ?string
{
$username = $account->username;
if ($username && $postId) {
return "https://www.tiktok.com/@{$username}/video/{$postId}";
}
if ($username) {
return "https://www.tiktok.com/@{$username}";
}

View file

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class XAnalytics
{
use HasSocialHttpClient;
private string $baseUrl = 'https://api.x.com/2';
private string $accessToken;
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
{
$since ??= now()->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,
]);
}
}

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class YouTubeAnalytics
{
use HasSocialHttpClient;
private string $baseUrl = 'https://youtubeanalytics.googleapis.com/v2';
private string $accessToken;
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
{
$since ??= now()->subDays(7);
$until ??= now();
$cacheKey = "analytics:youtube:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}";
$cacheTtl = app()->isProduction() ? 3600 : 1;
return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) {
return $this->fetchMetricsFromApi($account, $since, $until);
});
}
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$this->accessToken = $account->access_token;
$response = $this->getHttpClient()
->get("{$this->baseUrl}/reports", [
'ids' => 'channel==MINE',
'startDate' => $since->format('Y-m-d'),
'endDate' => $until->format('Y-m-d'),
'metrics' => 'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,subscribersLost,likes',
]);
if ($response->failed()) {
Log::warning('YouTube Analytics fetch failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
return [];
}
$json = $response->json();
$rows = data_get($json, 'rows', []);
if (empty($rows)) {
return [];
}
$columnHeaders = data_get($json, 'columnHeaders', []);
$metricNames = collect($columnHeaders)->pluck('name')->toArray();
$values = data_get($rows, '0', []);
$metrics = [];
foreach ($metricNames as $index => $name) {
$value = data_get($values, $index, 0);
$label = match ($name) {
'views' => 'Views',
'estimatedMinutesWatched' => 'Minutes Watched',
'averageViewDuration' => 'Avg. View Duration (s)',
'averageViewPercentage' => 'Avg. View Percentage',
'subscribersGained' => 'Subscribers Gained',
'subscribersLost' => 'Subscribers Lost',
'likes' => 'Likes',
default => ucfirst(str_replace('_', ' ', $name)),
};
$metrics[] = ['label' => $label, 'value' => round((float) $value, 1)];
}
return $metrics;
}
private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -61,6 +61,9 @@
'instagram' => [
'enabled' => env('INSTAGRAM_ENABLED', true),
],
'instagram-facebook' => [
'enabled' => env('TRYPOST_INSTAGRAM_FACEBOOK_ENABLED', true),
],
'threads' => [
'enabled' => env('THREADS_ENABLED', true),
],

View file

@ -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
```

View file

@ -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
```

View file

@ -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
<?php
declare(strict_types=1);
namespace App\Services\Social\Concerns;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
trait HasSocialHttpClient
{
protected function socialHttp(): PendingRequest
{
return Http::retry(
times: 3,
sleepMilliseconds: 5000,
when: fn ($exception, $request) => $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
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TokenRefresher
{
public function refresh(SocialAccount $account): void
{
match ($account->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
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\SocialAccount;
use App\Services\Social\TokenRefresher;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class RefreshSocialToken implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public SocialAccount $account) {}
public function handle(TokenRefresher $refresher): void
{
try {
$refresher->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
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use Illuminate\Console\Command;
class RefreshExpiringTokens extends Command
{
protected $signature = 'social:refresh-expiring-tokens';
protected $description = 'Proactively refresh tokens expiring in the next 2 hours';
public function handle(): void
{
SocialAccount::query()
->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)

View file

@ -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
<?php
namespace App\Services\Media;
use App\Enums\SocialAccount\Platform;
use Intervention\Image\ImageManager;
class MediaOptimizer
{
private ImageManager $manager;
public function __construct()
{
$this->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`

View file

@ -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
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
enum ErrorCategory: string
{
case MediaFormat = 'media_format';
case RateLimit = 'rate_limit';
case Permission = 'permission';
case ContentPolicy = 'content_policy';
case ServerError = 'server_error';
case Unknown = 'unknown';
}
```
### SocialPublishException (Base Class)
Uses Laravel's native `context()` method for structured logging. The `report()` method is not overridden — Laravel's default logging handles it, and the `context()` data is automatically included in every log entry and Nightwatch.
```php
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use Exception;
abstract class SocialPublishException extends Exception
{
public function __construct(
public readonly string $userMessage,
public readonly ErrorCategory $category,
public readonly ?string $platformErrorCode = null,
public readonly ?string $rawResponse = null,
) {
parent::__construct($userMessage);
}
/**
* Laravel automatically includes this context in all log entries.
*
* @return array<string, mixed>
*/
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
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
class InstagramPublishException extends SocialPublishException
{
protected static function platform(): string
{
return 'instagram';
}
public static function fromApiResponse(mixed $response): static
{
$json = $response->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

View file

@ -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 |

View file

@ -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 (&amp;&, &nbsp; → space, etc.)
}
private function convertToUnicodeBold(string $content): string
{
// Convert <strong>text</strong> to Unicode bold characters (𝗯𝗼𝗹𝗱)
// Convert <u>text</u> to Unicode underline (t̲e̲x̲t̲)
}
private function stripUnsafeHtml(string $content): string
{
// Allow only safe tags: <p>, <strong>, <em>, <a>, <br>
// Strip everything else
}
}
```
Each publisher calls `$this->sanitizeContent($content, $platform)` before sending to the API.
### Files Changed
- Create: `app/Services/Social/ContentSanitizer.php`
- Modify: All 11 publishers — sanitize content before API call
- Test: `tests/Unit/Services/Social/ContentSanitizerTest.php`
---
## 2. Backend Content Length Validation Before Publishing
### Problem
We validate content length in the frontend only. If the frontend has a bug or someone uses the API directly, content that exceeds platform limits goes through and fails with a cryptic API error.
### Solution
Validate content length in each publisher's `publish()` method before making any API calls:
```php
// In each publisher, at the start of publish():
$maxLength = $postPlatform->platform->maxContentLength();
if ($postPlatform->content && mb_strlen($postPlatform->content) > $maxLength) {
throw new \Exception("Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters.");
}
```
Better: extract to the `HasSocialHttpClient` trait as a shared method:
```php
protected function validateContentLength(PostPlatform $postPlatform): void
{
$maxLength = $postPlatform->platform->maxContentLength();
if ($postPlatform->content && mb_strlen($postPlatform->content) > $maxLength) {
throw new \Exception(
"Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters."
);
}
}
```
### Files Changed
- Modify: `app/Services/Social/Concerns/HasSocialHttpClient.php` — add `validateContentLength()`
- Modify: All 11 publishers — call `$this->validateContentLength($postPlatform)` at start of `publish()`
- Test: Add tests for content length validation
---
## 3. Scope Verification Before Publishing
### Problem
We save scopes during OAuth connection but never verify them again. If the user revokes a permission (e.g., removes `instagram_business_content_publish` from the app), we only find out when the post fails with a confusing error.
### Solution
Add a `requiredScopes()` method to the `Platform` enum and verify before publishing:
```php
// In Platform enum:
public function requiredPublishScopes(): array
{
return match ($this) {
self::Instagram => ['instagram_business_content_publish'],
self::Facebook => ['pages_manage_posts'],
self::TikTok => ['video.publish'],
self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'],
self::LinkedIn, self::LinkedInPage => ['w_member_social'],
self::X => ['tweet.write'],
self::Threads => ['threads_basic', 'threads_content_publish'],
self::Pinterest => ['pins:write'],
self::Bluesky => [], // no scopes, uses app password
self::Mastodon => ['write:statuses'],
};
}
```
Check in `PublishToSocialPlatform` before calling the publisher:
```php
$requiredScopes = $this->postPlatform->platform->requiredPublishScopes();
$accountScopes = $this->postPlatform->socialAccount->scopes ?? [];
$missingScopes = array_diff($requiredScopes, $accountScopes);
if (! empty($missingScopes)) {
$this->postPlatform->markAsFailed(
'Missing permissions: ' . implode(', ', $missingScopes) . '. Please reconnect your account.'
);
$this->postPlatform->socialAccount->markAsDisconnected('Missing required scopes');
return;
}
```
### Files Changed
- Modify: `app/Enums/SocialAccount/Platform.php` — add `requiredPublishScopes()`
- Modify: `app/Jobs/PublishToSocialPlatform.php` — add scope check before publish
- Test: Add test for missing scopes scenario
---
## 4. "Refresh Needed" State (separate from "Disconnected")
### Problem
When a token refresh fails, we immediately mark the account as "disconnected". This is too aggressive — the account isn't disconnected, it just needs a new token. The user sees "disconnected" and thinks something is broken, when they just need to re-authenticate.
Postiz has three separate states: `connected`, `refreshNeeded`, `disabled`.
### Solution
Add a `TokenExpired` status to the SocialAccount status enum (we already have it but don't use it consistently):
```php
enum Status: string
{
case Connected = 'connected';
case Disconnected = 'disconnected';
case TokenExpired = 'token_expired'; // exists but underused
}
```
When token refresh fails during publishing:
1. First failure → mark as `TokenExpired` (not `Disconnected`)
2. User gets notification: "Your Instagram token expired, please reconnect"
3. Daily `CheckSocialConnections` → if still `TokenExpired`, try refresh again
4. If still failing after daily check → then mark as `Disconnected`
### Files Changed
- Modify: `app/Jobs/PublishToSocialPlatform.php` — use `TokenExpired` instead of `Disconnected` on first failure
- Modify: `app/Jobs/VerifyWorkspaceConnections.php` — handle `TokenExpired` state
- Modify: Frontend — show different UI for `TokenExpired` vs `Disconnected`
- Test: Add tests for state transitions
---
## 5. Error Context Saved in Database
### Problem
When a post fails, we save only `error_message` (the user-facing string). We have no record of what content/media was sent, what the API returned, etc. Debugging requires checking logs.
### Solution
Save structured error context in the existing `meta` JSON column on `post_platforms`:
```php
// In PublishToSocialPlatform when marking as failed:
$this->postPlatform->update([
'status' => Status::Failed,
'error_message' => $e->userMessage,
'meta' => array_merge($this->postPlatform->meta ?? [], [
'error_context' => [
'category' => $e->category->value,
'platform_error_code' => $e->platformErrorCode,
'failed_at' => now()->toIso8601String(),
'content_length' => mb_strlen($this->postPlatform->content ?? ''),
'media_count' => $this->postPlatform->media->count(),
],
]),
]);
```
No raw API response in the database — that stays in logs only. Just enough context to understand what happened.
### Files Changed
- Modify: `app/Jobs/PublishToSocialPlatform.php` — save error context to meta
- Modify: `app/Models/PostPlatform.php` — add helper `markAsFailedWithContext()`
- Frontend: Show error context in post detail view (optional)
---
## 6. Stuck Post Recovery
### Problem
If a post gets stuck in `publishing` status (job crashed, worker died, etc.), it stays there forever. The user sees "Publishing..." indefinitely.
Postiz has `searchForMissingThreeHoursPosts()` that finds stuck posts and re-dispatches.
### Solution
Create a scheduled command that runs every 30 minutes:
```php
class RecoverStuckPosts extends Command
{
protected $signature = 'social:recover-stuck-posts';
public function handle(): void
{
// Find posts stuck in "publishing" for more than 1 hour
Post::query()
->where('status', PostStatus::Publishing)
->where('updated_at', '<=', now()->subHour())
->each(function (Post $post) {
// Check if any platform is still actively processing
$activeJobs = $post->postPlatforms()
->where('enabled', true)
->where('status', PostPlatformStatus::Publishing)
->count();
if ($activeJobs === 0) {
// All platforms finished but post status wasn't updated (race condition)
$this->recalculatePostStatus($post);
} else {
// Mark stuck platforms as failed
$post->postPlatforms()
->where('status', PostPlatformStatus::Publishing)
->where('updated_at', '<=', now()->subHour())
->update([
'status' => PostPlatformStatus::Failed,
'error_message' => 'Publishing timed out. Please try again.',
]);
$this->recalculatePostStatus($post);
}
});
}
}
```
### Files Changed
- Create: `app/Console/Commands/RecoverStuckPosts.php`
- Modify: `routes/console.php` — schedule every 30 minutes
- Test: `tests/Feature/Commands/RecoverStuckPostsTest.php`
---
## Priority
| # | Improvement | Impact | Effort |
|---|---|---|---|
| 1 | Content sanitization | High — prevents HTML in posts | Medium |
| 2 | Backend content length validation | High — prevents cryptic API errors | Low |
| 3 | Scope verification | Medium — early error detection | Low |
| 5 | Error context in database | Medium — easier debugging | Low |
| 6 | Stuck post recovery | High — prevents stuck UI | Low |
| 4 | Refresh needed state | Medium — better UX | Medium |

View file

@ -12,6 +12,12 @@
'view_profile' => 'View profile',
'disconnect' => 'Disconnect',
'tooltips' => [
'instagram_facebook' => 'Connects via your Facebook Page. Recommended for business accounts linked to a Facebook Page.',
'instagram_direct' => 'Connects directly through Instagram. For professional/creator accounts without a Facebook Page.',
'bluesky' => "We don't currently support two-factor authentication. If it's enabled on Bluesky, you'll need to disable it.",
],
'disconnect_modal' => [
'title' => 'Disconnect Account',
'description' => 'Are you sure you want to disconnect this account? You can reconnect it at any time.',
@ -49,6 +55,13 @@
'page_label' => 'Facebook Page',
],
'instagram_facebook' => [
'title' => 'Select Instagram Account',
'description' => 'Choose which Instagram account you want to connect',
'no_pages' => 'No Instagram accounts found',
'no_pages_description' => 'No Facebook Pages with linked Instagram Business accounts were found.',
],
'linkedin' => [
'title' => 'Select LinkedIn Page',
'description' => 'Choose which page you want to connect',

8
lang/en/analytics.php Normal file
View file

@ -0,0 +1,8 @@
<?php
return [
'channels' => 'Channels',
'no_accounts' => 'No connected accounts with analytics.',
'select_account' => 'Select an account to view analytics.',
'no_data' => 'No analytics data available.',
];

View file

@ -33,6 +33,30 @@
'drag_to_reorder' => 'Drag to reorder',
'caption' => 'Caption',
'write_caption' => 'Write your caption...',
'tiktok' => [
'settings' => 'TikTok Settings',
'privacy_level' => 'Who can see this video?',
'privacy' => [
'public' => 'Public to everyone',
'friends' => 'Mutual follow friends',
'followers' => 'Followers',
'private' => 'Only me',
],
'privacy_hint' => 'The available options depend on your TikTok account settings.',
'auto_add_music' => 'Auto add music',
'auto_add_music_hint' => 'This feature is available only for photos. It will add a default music that you can change later.',
'yes' => 'Yes',
'no' => 'No',
'allow_users' => 'Allow users to:',
'comments' => 'Comment',
'duet' => 'Duet',
'stitch' => 'Stitch',
'is_aigc' => 'Video made with AI',
'brand_content' => 'Disclose paid partnership',
'brand_content_hint' => 'This video promotes a third-party business, brand, or product.',
'brand_organic' => 'Disclose your own brand',
'brand_organic_hint' => 'This video promotes your own business, brand, or product.',
],
],
'status' => [

View file

@ -26,6 +26,8 @@
'support' => 'Support',
],
'analytics' => 'Analytics',
'posts' => [
'calendar' => 'Calendar',
'all' => 'All',

View file

@ -12,6 +12,12 @@
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'tooltips' => [
'instagram_facebook' => 'Conecta a través de tu Página de Facebook. Recomendado para cuentas business vinculadas a una Página de Facebook.',
'instagram_direct' => 'Conecta directamente por Instagram. Para cuentas profesionales/creadores sin Página de Facebook.',
'bluesky' => 'No soportamos autenticación de dos factores. Si está activada en Bluesky, necesitarás desactivarla.',
],
'disconnect_modal' => [
'title' => 'Desconectar cuenta',
'description' => '¿Estás seguro de que deseas desconectar esta cuenta? Puedes volver a conectarla en cualquier momento.',
@ -49,6 +55,13 @@
'page_label' => 'Página de Facebook',
],
'instagram_facebook' => [
'title' => 'Seleccionar cuenta de Instagram',
'description' => 'Elige qué cuenta de Instagram deseas conectar',
'no_pages' => 'No se encontraron cuentas de Instagram',
'no_pages_description' => 'No se encontraron páginas de Facebook con cuentas Instagram Business vinculadas.',
],
'linkedin' => [
'title' => 'Seleccionar página de LinkedIn',
'description' => 'Elige qué página deseas conectar',

8
lang/es/analytics.php Normal file
View file

@ -0,0 +1,8 @@
<?php
return [
'channels' => 'Canales',
'no_accounts' => 'No hay cuentas conectadas con analytics.',
'select_account' => 'Selecciona una cuenta para ver analytics.',
'no_data' => 'No hay datos de analytics disponibles.',
];

View file

@ -33,6 +33,30 @@
'drag_to_reorder' => 'Arrastra para reordenar',
'caption' => 'Descripción',
'write_caption' => 'Escribe tu descripción...',
'tiktok' => [
'settings' => 'Configuración de TikTok',
'privacy_level' => '¿Quién puede ver este video?',
'privacy' => [
'public' => 'Público para todos',
'friends' => 'Amigos mutuos',
'followers' => 'Seguidores',
'private' => 'Solo yo',
],
'privacy_hint' => 'Las opciones disponibles dependen de la configuración de tu cuenta de TikTok.',
'auto_add_music' => 'Agregar música automáticamente',
'auto_add_music_hint' => 'Disponible solo para fotos. Agrega una música predeterminada que puedes cambiar después.',
'yes' => 'Sí',
'no' => 'No',
'allow_users' => 'Permitir a los usuarios:',
'comments' => 'Comentar',
'duet' => 'Dueto',
'stitch' => 'Stitch',
'is_aigc' => 'Video hecho con IA',
'brand_content' => 'Divulgar asociación pagada',
'brand_content_hint' => 'Este video promueve un negocio, marca o producto de terceros.',
'brand_organic' => 'Divulgar tu propia marca',
'brand_organic_hint' => 'Este video promueve tu propio negocio, marca o producto.',
],
],
'status' => [

View file

@ -26,6 +26,8 @@
'support' => 'Soporte',
],
'analytics' => 'Analytics',
'posts' => [
'calendar' => 'Calendario',
'all' => 'Todos',

View file

@ -12,6 +12,12 @@
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'tooltips' => [
'instagram_facebook' => 'Conecta via sua Página do Facebook. Recomendado para contas business vinculadas a uma Página do Facebook.',
'instagram_direct' => 'Conecta direto pelo Instagram. Para contas profissionais/criadores sem Página do Facebook.',
'bluesky' => 'Não suportamos autenticação de dois fatores. Se estiver ativada no Bluesky, será necessário desativá-la.',
],
'disconnect_modal' => [
'title' => 'Desconectar Conta',
'description' => 'Tem certeza que deseja desconectar esta conta? Você pode reconectá-la a qualquer momento.',
@ -49,6 +55,13 @@
'page_label' => 'Página do Facebook',
],
'instagram_facebook' => [
'title' => 'Selecionar Conta do Instagram',
'description' => 'Escolha qual conta do Instagram você deseja conectar',
'no_pages' => 'Nenhuma conta do Instagram encontrada',
'no_pages_description' => 'Nenhuma Página do Facebook com conta Instagram Business vinculada foi encontrada.',
],
'linkedin' => [
'title' => 'Selecionar Página do LinkedIn',
'description' => 'Escolha qual página você deseja conectar',

8
lang/pt-br/analytics.php Normal file
View file

@ -0,0 +1,8 @@
<?php
return [
'channels' => 'Canais',
'no_accounts' => 'Nenhuma conta conectada com analytics.',
'select_account' => 'Selecione uma conta para ver analytics.',
'no_data' => 'Nenhum dado de analytics disponível.',
];

View file

@ -33,6 +33,30 @@
'drag_to_reorder' => 'Arraste para reordenar',
'caption' => 'Legenda',
'write_caption' => 'Escreva sua legenda...',
'tiktok' => [
'settings' => 'Configurações do TikTok',
'privacy_level' => 'Quem pode ver este vídeo?',
'privacy' => [
'public' => 'Público para todos',
'friends' => 'Amigos em comum',
'followers' => 'Seguidores',
'private' => 'Apenas eu',
],
'privacy_hint' => 'As opções disponíveis dependem das configurações da sua conta TikTok.',
'auto_add_music' => 'Adicionar música automaticamente',
'auto_add_music_hint' => 'Disponível apenas para fotos. Adiciona uma música padrão que pode ser alterada depois.',
'yes' => 'Sim',
'no' => 'Não',
'allow_users' => 'Permitir que usuários:',
'comments' => 'Comentem',
'duet' => 'Dueto',
'stitch' => 'Stitch',
'is_aigc' => 'Vídeo feito com IA',
'brand_content' => 'Divulgar parceria paga',
'brand_content_hint' => 'Este vídeo promove um negócio, marca ou produto de terceiros.',
'brand_organic' => 'Divulgar sua própria marca',
'brand_organic_hint' => 'Este vídeo promove seu próprio negócio, marca ou produto.',
],
],
'status' => [

View file

@ -26,6 +26,8 @@
'support' => 'Suporte',
],
'analytics' => 'Analytics',
'posts' => [
'calendar' => 'Calendário',
'all' => 'Todos',

View file

@ -3,6 +3,7 @@ import { Link, router, usePage } from '@inertiajs/vue3';
import {
IconAffiliate,
IconCalendar,
IconChartBar,
IconChevronRight,
IconClock,
IconFileCheck,
@ -42,7 +43,7 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { accounts, calendar } from '@/routes/app';
import { accounts, analytics, calendar } from '@/routes/app';
import { index as hashtags } from '@/routes/app/hashtags';
import { index as labels } from '@/routes/app/labels';
import { edit as editProfile } from '@/routes/app/profile';
@ -62,12 +63,20 @@ const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Wor
const { state: sidebarState } = useSidebar();
const postsNavItems = computed<NavItem[]>(() => [
const mainNavItems = computed<NavItem[]>(() => [
{
title: trans('sidebar.posts.calendar'),
href: calendar.url(),
icon: IconCalendar,
},
{
title: trans('sidebar.analytics'),
href: analytics.url(),
icon: IconChartBar,
},
]);
const postsNavItems = computed<NavItem[]>(() => [
{
title: trans('sidebar.posts.all'),
href: postsIndex.url(),
@ -189,6 +198,7 @@ const switchWorkspace = (workspaceId: string) => {
</Link>
</div>
<NavMain v-if="currentWorkspace" :items="mainNavItems" />
<NavMain v-if="currentWorkspace" :items="postsNavItems" :label="$t('sidebar.groups.posts')" />
<NavMain v-if="currentWorkspace" :items="configNavItems" :label="$t('sidebar.groups.configuration')" />
</SidebarContent>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconAlertCircle, IconCheck, IconExternalLink, IconRefresh, IconTrash } from '@tabler/icons-vue';
import { IconAlertCircle, IconCheck, IconExternalLink, IconInfoCircle, IconRefresh, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onMounted, onUnmounted } from 'vue';
@ -108,6 +108,7 @@ const getPlatformLogo = (platform: string): string => {
'x': '/images/accounts/x.png',
'tiktok': '/images/accounts/tiktok.png',
'instagram': '/images/accounts/instagram.png',
'instagram-facebook': '/images/accounts/instagram.png',
'facebook': '/images/accounts/facebook.png',
'youtube': '/images/accounts/youtube.png',
'threads': '/images/accounts/threads.png',
@ -132,6 +133,7 @@ const getProfileUrl = (platform: string, username: string | null, platformUserId
'x': `https://x.com/${username}`,
'tiktok': `https://tiktok.com/@${username}`,
'instagram': `https://instagram.com/${username}`,
'instagram-facebook': `https://instagram.com/${username}`,
'youtube': `https://youtube.com/@${username}`,
'threads': `https://threads.net/@${username}`,
'bluesky': `https://bsky.app/profile/${username}`,
@ -140,6 +142,15 @@ const getProfileUrl = (platform: string, username: string | null, platformUserId
return urls[platform] || null;
};
const getPlatformTooltip = (platform: string): string | null => {
const tooltips: Record<string, string> = {
'instagram-facebook': trans('accounts.tooltips.instagram_facebook'),
'instagram': trans('accounts.tooltips.instagram_direct'),
'bluesky': trans('accounts.tooltips.bluesky'),
};
return tooltips[platform] || null;
};
const isDisconnected = (account: SocialAccount | null): boolean => {
if (!account) return false;
return account.status === 'disconnected' || account.status === 'token_expired';
@ -157,24 +168,41 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<div class="flex items-center gap-3 p-4">
<div class="relative">
<img :src="getPlatformLogo(platform.value)" :alt="platform.label"
class="h-12 w-12 rounded-lg object-contain" :class="{ 'opacity-40': platform.connected && platform.account && !platform.account.is_active }" />
class="h-10 w-10 rounded-full object-contain"
:class="{ 'opacity-40': platform.connected && platform.account && !platform.account.is_active }" />
<div v-if="platform.connected && !isDisconnected(platform.account)"
class="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-neutral-900">
<IconCheck class="h-3 w-3" />
class="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-neutral-900">
<IconCheck class="h-2 w-2" />
</div>
<div v-else-if="platform.connected && isDisconnected(platform.account)"
class="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-white dark:ring-neutral-900">
<IconAlertCircle class="h-3 w-3" />
class="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-white dark:ring-neutral-900">
<IconAlertCircle class="h-2 w-2" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-2">
<h3 class="font-semibold truncate">{{ platform.label }}</h3>
<Switch
v-if="platform.connected && platform.account"
:model-value="platform.account.is_active"
@update:model-value="handleToggle(platform.account.id)"
/>
<div class="flex items-center gap-1 min-w-0">
<h3 class="font-semibold leading-tight">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}<br />
<span class="text-xs font-normal text-muted-foreground">({{
platform.label.split('(')[1] }}</span>
</template>
<template v-else>{{ platform.label }}</template>
</h3>
<TooltipProvider v-if="getPlatformTooltip(platform.value)">
<Tooltip>
<TooltipTrigger as-child>
<IconInfoCircle class="h-4 w-4 shrink-0 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" class="max-w-[250px]">
<p>{{ getPlatformTooltip(platform.value) }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch v-if="platform.connected && platform.account" :model-value="platform.account.is_active"
@update:model-value="handleToggle(platform.account.id)" />
</div>
<p v-if="platform.connected && platform.account" class="text-sm text-muted-foreground truncate">
@{{ platform.account.username || platform.account.display_name }}
@ -216,7 +244,9 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<TooltipProvider v-if="showReconnect && isDisconnected(platform.account)">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8 text-amber-600 hover:text-amber-700" @click="openOAuthPopup(platform.value)">
<Button variant="ghost" size="icon"
class="size-8 text-amber-600 hover:text-amber-700"
@click="openOAuthPopup(platform.value)">
<IconRefresh class="size-4" />
</Button>
</TooltipTrigger>
@ -230,7 +260,8 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<a :href="getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)!" target="_blank">
<a :href="getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)!"
target="_blank">
<IconExternalLink class="size-4" />
</a>
</Button>
@ -243,7 +274,8 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<TooltipProvider v-if="showDisconnect">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="emit('disconnect', platform.account.id)">
<Button variant="ghost" size="icon" class="size-8"
@click="emit('disconnect', platform.account.id)">
<IconTrash class="size-4" />
</Button>
</TooltipTrigger>

View file

@ -0,0 +1,77 @@
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
export interface AnalyticsAccount {
id: string;
platform: string;
display_name: string;
username: string | null;
avatar_url: string | null;
}
defineProps<{
accounts: AnalyticsAccount[];
selectedId: string | null;
}>();
const emit = defineEmits<{
select: [accountId: string];
}>();
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
tiktok: '/images/accounts/tiktok.png',
instagram: '/images/accounts/instagram.png',
'instagram-facebook': '/images/accounts/instagram.png',
facebook: '/images/accounts/facebook.png',
youtube: '/images/accounts/youtube.png',
linkedin: '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
x: '/images/accounts/x.png',
threads: '/images/accounts/threads.png',
pinterest: '/images/accounts/pinterest.png',
bluesky: '/images/accounts/bluesky.png',
mastodon: '/images/accounts/mastodon.png',
};
return logos[platform] || '/images/accounts/default.png';
};
</script>
<template>
<div class="flex h-full w-64 shrink-0 flex-col border-r">
<div class="border-b px-4 py-3">
<h2 class="text-sm font-semibold">{{ $t('analytics.channels') }}</h2>
</div>
<div class="flex-1 overflow-y-auto p-2">
<div v-if="accounts.length === 0" class="px-2 py-8 text-center text-sm text-muted-foreground">
{{ $t('analytics.no_accounts') }}
</div>
<button
v-for="account in accounts"
:key="account.id"
type="button"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors"
:class="selectedId === account.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'"
@click="emit('select', account.id)"
>
<div class="relative">
<Avatar class="h-8 w-8">
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" :alt="account.display_name" />
<AvatarFallback>{{ account.display_name?.charAt(0) }}</AvatarFallback>
</Avatar>
<img
:src="getPlatformLogo(account.platform)"
:alt="account.platform"
class="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full border border-background"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs text-muted-foreground">
@{{ account.username }}
</p>
</div>
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
dateRange: { start: Date; end: Date };
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId, {
query: {
since: dayjs(props.dateRange.start).format('YYYY-MM-DD'),
until: dayjs(props.dateRange.end).format('YYYY-MM-DD'),
},
}));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(() => props.accountId, () => {
fetchMetrics();
});
watch(() => props.dateRange, () => {
fetchMetrics();
}, { deep: true });
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
dateRange: { start: Date; end: Date };
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId, {
query: {
since: dayjs(props.dateRange.start).format('YYYY-MM-DD'),
until: dayjs(props.dateRange.end).format('YYYY-MM-DD'),
},
}));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(() => props.accountId, () => {
fetchMetrics();
});
watch(() => props.dateRange, () => {
fetchMetrics();
}, { deep: true });
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
dateRange: { start: Date; end: Date };
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId, {
query: {
since: dayjs(props.dateRange.start).format('YYYY-MM-DD'),
until: dayjs(props.dateRange.end).format('YYYY-MM-DD'),
},
}));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(() => props.accountId, () => {
fetchMetrics();
});
watch(() => props.dateRange, () => {
fetchMetrics();
}, { deep: true });
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
dateRange: { start: Date; end: Date };
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId, {
query: {
since: dayjs(props.dateRange.start).format('YYYY-MM-DD'),
until: dayjs(props.dateRange.end).format('YYYY-MM-DD'),
},
}));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(() => props.accountId, () => {
fetchMetrics();
});
watch(() => props.dateRange, () => {
fetchMetrics();
}, { deep: true });
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
dateRange: { start: Date; end: Date };
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId, {
query: {
since: dayjs(props.dateRange.start).format('YYYY-MM-DD'),
until: dayjs(props.dateRange.end).format('YYYY-MM-DD'),
},
}));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(() => props.accountId, () => {
fetchMetrics();
});
watch(() => props.dateRange, () => {
fetchMetrics();
}, { deep: true });
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(() => props.accountId, () => {
fetchMetrics();
});
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: false });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
dateRange: { start: Date; end: Date };
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId, {
query: {
since: dayjs(props.dateRange.start).format('YYYY-MM-DD'),
until: dayjs(props.dateRange.end).format('YYYY-MM-DD'),
},
}));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(() => props.accountId, () => {
fetchMetrics();
});
watch(() => props.dateRange, () => {
fetchMetrics();
}, { deep: true });
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
dateRange: { start: Date; end: Date };
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId, {
query: {
since: dayjs(props.dateRange.start).format('YYYY-MM-DD'),
until: dayjs(props.dateRange.end).format('YYYY-MM-DD'),
},
}));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(() => props.accountId, () => {
fetchMetrics();
});
watch(() => props.dateRange, () => {
fetchMetrics();
}, { deep: true });
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -10,10 +10,12 @@ import {
IconCheck,
IconChevronDown,
IconSearch,
IconChevronUp,
} from '@tabler/icons-vue';
import { FocusScope } from 'reka-ui';
import { computed, ref } from 'vue';
import { Checkbox } from '@/components/ui/checkbox';
import {
Combobox,
ComboboxAnchor,
@ -26,6 +28,7 @@ import {
ComboboxTrigger,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useMediaRules } from '@/composables/useMediaRules';
@ -113,6 +116,66 @@ const selectedBoard = computed({
},
});
// TikTok settings
const isTikTok = computed(() => props.platform === 'tiktok');
const tiktokSettingsOpen = ref(true);
const tiktokPrivacyLevel = computed({
get: () => props.meta?.privacy_level || 'SELF_ONLY',
set: (value: string) => {
emit('update:meta', { ...props.meta, privacy_level: value });
},
});
const tiktokAllowComments = computed({
get: () => props.meta?.allow_comments ?? true,
set: (value: boolean) => {
emit('update:meta', { ...props.meta, allow_comments: value });
},
});
const tiktokAllowDuet = computed({
get: () => props.meta?.allow_duet ?? false,
set: (value: boolean) => {
emit('update:meta', { ...props.meta, allow_duet: value });
},
});
const tiktokAllowStitch = computed({
get: () => props.meta?.allow_stitch ?? false,
set: (value: boolean) => {
emit('update:meta', { ...props.meta, allow_stitch: value });
},
});
const tiktokAutoAddMusic = computed({
get: () => props.meta?.auto_add_music ?? false,
set: (value: boolean) => {
emit('update:meta', { ...props.meta, auto_add_music: value });
},
});
const tiktokIsAigc = computed({
get: () => props.meta?.is_aigc ?? false,
set: (value: boolean) => {
emit('update:meta', { ...props.meta, is_aigc: value });
},
});
const tiktokBrandContentToggle = computed({
get: () => props.meta?.brand_content_toggle ?? false,
set: (value: boolean) => {
emit('update:meta', { ...props.meta, brand_content_toggle: value });
},
});
const tiktokBrandOrganicToggle = computed({
get: () => props.meta?.brand_organic_toggle ?? false,
set: (value: boolean) => {
emit('update:meta', { ...props.meta, brand_organic_toggle: value });
},
});
// Computed
const hasMultipleContentTypes = computed(() => props.contentTypeOptions.length > 1);
const canAddMore = computed(() => props.media.length < mediaRules.value.maxFiles);
@ -406,6 +469,94 @@ const handleDropOnItem = (e: DragEvent, targetId: string) => {
<Textarea :model-value="content" @update:model-value="emit('update:content', $event as string)"
:placeholder="$t('posts.form.write_caption')" class="min-h-[120px] resize-none" :disabled="props.disabled" />
</div>
<!-- TikTok Settings -->
<div v-if="isTikTok" class="rounded-lg border">
<button type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
@click="tiktokSettingsOpen = !tiktokSettingsOpen">
{{ $t('posts.form.tiktok.settings') }}
<IconChevronUp v-if="tiktokSettingsOpen" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
</button>
<div v-if="tiktokSettingsOpen" class="space-y-5 border-t px-4 pb-4 pt-4">
<!-- Privacy Level -->
<div class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.privacy_level') }}</Label>
<Select v-model="tiktokPrivacyLevel" :disabled="props.disabled">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PUBLIC_TO_EVERYONE">{{ $t('posts.form.tiktok.privacy.public') }}</SelectItem>
<SelectItem value="MUTUAL_FOLLOW_FRIENDS">{{ $t('posts.form.tiktok.privacy.friends') }}</SelectItem>
<SelectItem value="FOLLOWER_OF_CREATOR">{{ $t('posts.form.tiktok.privacy.followers') }}</SelectItem>
<SelectItem value="SELF_ONLY">{{ $t('posts.form.tiktok.privacy.private') }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.privacy_hint') }}</p>
</div>
<!-- Auto Add Music (photos only) -->
<div class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.auto_add_music') }}</Label>
<Select :model-value="tiktokAutoAddMusic ? 'yes' : 'no'" @update:model-value="tiktokAutoAddMusic = $event === 'yes'" :disabled="props.disabled">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">{{ $t('posts.form.tiktok.yes') }}</SelectItem>
<SelectItem value="no">{{ $t('posts.form.tiktok.no') }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.auto_add_music_hint') }}</p>
</div>
<!-- Allow User To -->
<div class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.allow_users') }}</Label>
<div class="flex items-center gap-6">
<label class="flex items-center gap-2 text-sm">
<Checkbox :checked="tiktokAllowComments" @update:checked="tiktokAllowComments = $event" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.comments') }}
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox :checked="tiktokAllowDuet" @update:checked="tiktokAllowDuet = $event" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.duet') }}
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox :checked="tiktokAllowStitch" @update:checked="tiktokAllowStitch = $event" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.stitch') }}
</label>
</div>
</div>
<!-- Content Disclosure -->
<div class="space-y-3">
<div>
<label class="flex items-center gap-2 text-sm">
<Checkbox :checked="tiktokIsAigc" @update:checked="tiktokIsAigc = $event" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.is_aigc') }}
</label>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 text-sm">
<Checkbox :checked="tiktokBrandContentToggle" @update:checked="tiktokBrandContentToggle = $event" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.brand_content') }}
</label>
<p v-if="tiktokBrandContentToggle" class="text-xs text-muted-foreground ml-6">{{ $t('posts.form.tiktok.brand_content_hint') }}</p>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 text-sm">
<Checkbox :checked="tiktokBrandOrganicToggle" @update:checked="tiktokBrandOrganicToggle = $event" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.brand_organic') }}
</label>
<p v-if="tiktokBrandOrganicToggle" class="text-xs text-muted-foreground ml-6">{{ $t('posts.form.tiktok.brand_organic_hint') }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
33

View file

@ -47,6 +47,7 @@ const previewComponent = computed(() => {
case 'facebook':
return FacebookPreview;
case 'instagram':
case 'instagram-facebook':
return InstagramPreview;
case 'threads':
return ThreadsPreview;

View file

@ -0,0 +1,170 @@
<script setup lang="ts">
import type { DateRange } from "reka-ui"
import type { Ref } from "vue"
import {
CalendarDate,
getLocalTimeZone,
} from "@internationalized/date"
import { IconCalendar } from "@tabler/icons-vue"
import { computed, nextTick, ref, watch } from "vue"
import { useWindowSize } from "@vueuse/core"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { RangeCalendar } from "@/components/ui/range-calendar"
import { cn } from "@/lib/utils"
import dayjs from "@/dayjs"
const props = defineProps<{
modelValue: { start: Date, end: Date }
}>()
const emit = defineEmits<{
'update:modelValue': [value: { start: Date, end: Date }]
}>()
const toCalendarDate = (dateValue: Date) => {
return new CalendarDate(
dateValue.getFullYear(),
dateValue.getMonth() + 1,
dateValue.getDate(),
)
}
const toDate = (calendarDate: any) => {
if (!calendarDate) return new Date()
return calendarDate.toDate(getLocalTimeZone())
}
const value = ref({
start: toCalendarDate(props.modelValue.start),
end: toCalendarDate(props.modelValue.end),
}) as Ref<DateRange>
const isUpdating = ref(false)
const isOpen = ref(false)
const { width } = useWindowSize()
const numberOfMonths = computed(() => width.value < 640 ? 1 : 2)
const range = (start: dayjs.Dayjs, end: dayjs.Dayjs) => ({
start: toCalendarDate(start.toDate()),
end: toCalendarDate(end.toDate()),
})
const presetGroups = [
[
{ label: "Today", getValue: () => range(dayjs(), dayjs()) },
{ label: "Yesterday", getValue: () => range(dayjs().subtract(1, "day"), dayjs().subtract(1, "day")) },
],
[
{ label: "Last 7 days", getValue: () => range(dayjs().subtract(6, "day"), dayjs()) },
{ label: "Last 30 days", getValue: () => range(dayjs().subtract(29, "day"), dayjs()) },
{ label: "Last 3 months", getValue: () => range(dayjs().subtract(3, "month"), dayjs()) },
{ label: "Last 6 months", getValue: () => range(dayjs().subtract(6, "month"), dayjs()) },
{ label: "Last 12 months", getValue: () => range(dayjs().subtract(12, "month").add(1, "day"), dayjs()) },
],
[
{ label: "This month", getValue: () => range(dayjs().startOf("month"), dayjs().endOf("month")) },
{ label: "Last month", getValue: () => range(dayjs().subtract(1, "month").startOf("month"), dayjs().subtract(1, "month").endOf("month")) },
{ label: "Year to date", getValue: () => range(dayjs().startOf("year"), dayjs()) },
{ label: "Last year", getValue: () => range(dayjs().subtract(1, "year").startOf("year"), dayjs().subtract(1, "year").endOf("year")) },
],
]
type Preset = { label: string, getValue: () => { start: any, end: any } }
const applyPreset = (preset: Preset) => {
value.value = preset.getValue()
isOpen.value = false
}
watch(
() => props.modelValue,
(newVal) => {
if (!isUpdating.value) {
value.value = {
start: toCalendarDate(newVal.start),
end: toCalendarDate(newVal.end),
}
}
},
{ deep: true },
)
watch(
value,
(newVal) => {
if (newVal.start && newVal.end) {
isUpdating.value = true
emit("update:modelValue", {
start: toDate(newVal.start),
end: toDate(newVal.end),
})
nextTick(() => {
isUpdating.value = false
})
}
},
{ deep: true },
)
</script>
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
:class="cn(
'w-full justify-start text-left font-normal sm:w-auto',
!value && 'text-muted-foreground',
)"
>
<template v-if="value.start">
<template v-if="value.end">
{{ dayjs(toDate(value.start)).format('MMM D, YYYY') }} -
{{ dayjs(toDate(value.end)).format('MMM D, YYYY') }}
</template>
<template v-else>
{{ dayjs(toDate(value.start)).format('MMM D, YYYY') }}
</template>
</template>
<template v-else>
Pick a date range
</template>
<IconCalendar class="ml-auto size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="end">
<div class="flex flex-col sm:flex-row">
<div class="hidden flex-col border-b border-border py-2 sm:flex sm:w-[150px] sm:shrink-0 sm:border-b-0 sm:border-r">
<template v-for="(group, groupIndex) in presetGroups" :key="groupIndex">
<div v-if="groupIndex > 0" class="border-t border-border my-1" />
<div class="space-y-0.5 px-2">
<Button
v-for="preset in group"
:key="preset.label"
variant="ghost"
size="sm"
class="w-full justify-start text-xs font-normal h-7"
@click="applyPreset(preset)"
>
{{ preset.label }}
</Button>
</div>
</template>
</div>
<div class="shrink-0">
<RangeCalendar
v-model="value"
initial-focus
:number-of-months="numberOfMonths"
/>
</div>
</div>
</PopoverContent>
</Popover>
</template>

View file

@ -0,0 +1 @@
export { default as DateRangePicker } from "./DateRangePicker.vue"

View file

@ -0,0 +1,106 @@
<script setup lang="ts">
import { IconBrandInstagram, IconCheck } from '@tabler/icons-vue';
import { ref } from 'vue';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { select as selectPage } from '@/routes/app/social/instagram-facebook';
interface Page {
page_id: string;
page_name: string;
page_picture: string | null;
ig_id: string;
ig_username: string;
ig_name: string | null;
ig_picture: string | null;
}
interface Workspace {
id: string;
name: string;
}
interface Props {
workspace: Workspace;
pages: Page[];
error?: string;
}
defineProps<Props>();
const formRef = ref<HTMLFormElement | null>(null);
const selectedPageId = ref<string | null>(null);
const handleSelectPage = (page: Page) => {
selectedPageId.value = page.page_id;
setTimeout(() => formRef.value?.submit(), 0);
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
</script>
<template>
<PopupLayout :title="$t('accounts.instagram_facebook.title')">
<form ref="formRef" :action="selectPage.url()" method="POST" class="hidden">
<input type="hidden" name="_token" :value="csrfToken" />
<input type="hidden" name="page_id" :value="selectedPageId" />
</form>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<img src="/images/accounts/instagram.png" alt="Instagram" class="h-10 w-10" />
<div>
<h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.instagram_facebook.title') }}</h1>
<p class="text-sm text-muted-foreground">{{ $t('accounts.instagram_facebook.description') }}</p>
</div>
</div>
<Alert v-if="error" variant="destructive">
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<div v-if="pages.length === 0 && !error" class="text-center py-12">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<IconBrandInstagram class="h-7 w-7 text-muted-foreground" />
</div>
<h3 class="mt-4 text-lg font-semibold">{{ $t('accounts.instagram_facebook.no_pages') }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('accounts.instagram_facebook.no_pages_description') }}
</p>
</div>
<div v-else class="grid gap-3">
<button
v-for="page in pages"
:key="page.ig_id"
@click="handleSelectPage(page)"
class="group relative overflow-hidden rounded-lg border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
<div class="flex items-center gap-4">
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="page.ig_picture" :src="page.ig_picture" class="object-cover" />
<AvatarFallback class="rounded-lg bg-pink-100 dark:bg-pink-900">
<IconBrandInstagram class="h-6 w-6 text-pink-600 dark:text-pink-400" />
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<h3 class="font-semibold truncate group-hover:text-primary transition-colors">
@{{ page.ig_username }}
</h3>
<p class="text-sm text-muted-foreground truncate">
{{ page.page_name }}
</p>
</div>
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCheck class="h-4 w-4" />
</div>
</div>
</div>
</button>
</div>
</div>
</PopupLayout>
</template>

View file

@ -0,0 +1,121 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import AnalyticsSidebar, { type AnalyticsAccount } from '@/components/analytics/AnalyticsSidebar.vue';
import FacebookAnalytics from '@/components/analytics/FacebookAnalytics.vue';
import InstagramAnalytics from '@/components/analytics/InstagramAnalytics.vue';
import LinkedInPageAnalytics from '@/components/analytics/LinkedInPageAnalytics.vue';
import PinterestAnalytics from '@/components/analytics/PinterestAnalytics.vue';
import ThreadsAnalytics from '@/components/analytics/ThreadsAnalytics.vue';
import TikTokAnalytics from '@/components/analytics/TikTokAnalytics.vue';
import XAnalytics from '@/components/analytics/XAnalytics.vue';
import YouTubeAnalytics from '@/components/analytics/YouTubeAnalytics.vue';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { analytics } from '@/routes/app';
import { type BreadcrumbItemType } from '@/types';
const props = defineProps<{
accounts: AnalyticsAccount[];
}>();
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('sidebar.analytics'), href: analytics.url() },
]);
const selectedAccountId = ref<string | null>(props.accounts[0]?.id ?? null);
const dateRange = ref({
start: dayjs().subtract(6, 'day').toDate(),
end: dayjs().toDate(),
});
const selectedAccount = computed(() =>
props.accounts.find((a) => a.id === selectedAccountId.value),
);
const platformSupportsDateRange = computed(() => {
if (!selectedAccount.value) return false;
return ['instagram', 'instagram-facebook', 'facebook', 'youtube', 'pinterest', 'threads', 'x', 'linkedin-page'].includes(selectedAccount.value.platform);
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :full-width="true">
<template #header-right>
<DateRangePicker v-if="platformSupportsDateRange" v-model="dateRange" />
</template>
<Head :title="trans('sidebar.analytics')" />
<div class="flex h-full">
<AnalyticsSidebar
:accounts="accounts"
:selected-id="selectedAccountId"
@select="selectedAccountId = $event"
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex-1 overflow-y-auto p-6">
<div v-if="!selectedAccountId" class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.select_account') }}
</div>
<TikTokAnalytics
v-else-if="selectedAccount?.platform === 'tiktok'"
:account-id="selectedAccountId"
/>
<InstagramAnalytics
v-else-if="selectedAccount?.platform === 'instagram' || selectedAccount?.platform === 'instagram-facebook'"
:account-id="selectedAccountId"
:date-range="dateRange"
/>
<ThreadsAnalytics
v-else-if="selectedAccount?.platform === 'threads'"
:account-id="selectedAccountId"
:date-range="dateRange"
/>
<FacebookAnalytics
v-else-if="selectedAccount?.platform === 'facebook'"
:account-id="selectedAccountId"
:date-range="dateRange"
/>
<XAnalytics
v-else-if="selectedAccount?.platform === 'x'"
:account-id="selectedAccountId"
:date-range="dateRange"
/>
<LinkedInPageAnalytics
v-else-if="selectedAccount?.platform === 'linkedin-page'"
:account-id="selectedAccountId"
:date-range="dateRange"
/>
<PinterestAnalytics
v-else-if="selectedAccount?.platform === 'pinterest'"
:account-id="selectedAccountId"
:date-range="dateRange"
/>
<YouTubeAnalytics
v-else-if="selectedAccount?.platform === 'youtube'"
:account-id="selectedAccountId"
:date-range="dateRange"
/>
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
</div>
</div>
</div>
</AppLayout>
</template>

View file

@ -268,6 +268,7 @@ const getPlatformLogo = (platform: string): string => {
'youtube': '/images/accounts/youtube.png',
'facebook': '/images/accounts/facebook.png',
'instagram': '/images/accounts/instagram.png',
'instagram-facebook': '/images/accounts/instagram.png',
'threads': '/images/accounts/threads.png',
'pinterest': '/images/accounts/pinterest.png',
'bluesky': '/images/accounts/bluesky.png',
@ -289,6 +290,7 @@ const getPlatformIcon = (platform: string): Component => {
'youtube': IconBrandYoutube,
'facebook': IconBrandFacebook,
'instagram': IconBrandInstagram,
'instagram-facebook': IconBrandInstagram,
'threads': IconBrandThreads,
'pinterest': IconBrandPinterest,
'bluesky': IconBrandBluesky,
@ -321,6 +323,7 @@ const formatDateTime = (date: string | null): string => {
// Content type options per platform
const contentTypeKeys: Record<string, string[]> = {
'instagram': ['instagram_feed', 'instagram_reel', 'instagram_story'],
'instagram-facebook': ['instagram_feed', 'instagram_reel', 'instagram_story'],
'linkedin': ['linkedin_post', 'linkedin_carousel'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel'],
'facebook': ['facebook_post', 'facebook_reel', 'facebook_story'],
@ -336,6 +339,7 @@ const contentTypeKeys: Record<string, string[]> = {
const getDefaultContentType = (platform: string): string => {
const defaults: Record<string, string> = {
'instagram': 'instagram_feed',
'instagram-facebook': 'instagram_feed',
'linkedin': 'linkedin_post',
'linkedin-page': 'linkedin_page_post',
'facebook': 'facebook_post',

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Http\Controllers\App\AnalyticsController;
use App\Http\Controllers\App\ApiKeyController;
use App\Http\Controllers\App\BillingController;
use App\Http\Controllers\App\MediaController;
@ -18,6 +19,7 @@
use App\Http\Controllers\Auth\BlueskyController;
use App\Http\Controllers\Auth\FacebookController;
use App\Http\Controllers\Auth\InstagramController;
use App\Http\Controllers\Auth\InstagramFacebookController;
use App\Http\Controllers\Auth\LinkedInController;
use App\Http\Controllers\Auth\LinkedInPageController;
use App\Http\Controllers\Auth\MastodonController;
@ -82,6 +84,11 @@
Route::get('accounts/instagram/select', [InstagramController::class, 'selectAccount'])->name('app.social.instagram.select-account');
Route::post('accounts/instagram/select', [InstagramController::class, 'select'])->name('app.social.instagram.select');
Route::get('connect/instagram-facebook', [InstagramFacebookController::class, 'connect'])->name('app.social.instagram-facebook.connect');
Route::get('accounts/instagram-facebook/callback', [InstagramFacebookController::class, 'callback'])->name('app.social.instagram-facebook.callback');
Route::get('accounts/instagram-facebook/select-page', [InstagramFacebookController::class, 'selectPage'])->name('app.social.instagram-facebook.select-page');
Route::post('accounts/instagram-facebook/select', [InstagramFacebookController::class, 'select'])->name('app.social.instagram-facebook.select');
Route::get('connect/threads', [ThreadsController::class, 'connect'])->name('app.social.threads.connect');
Route::get('accounts/threads/callback', [ThreadsController::class, 'callback'])->name('app.social.threads.callback');
@ -116,6 +123,10 @@
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
Route::put('accounts/{account}/toggle', [SocialController::class, 'toggleActive'])->name('app.accounts.toggle');
// Analytics
Route::get('analytics', [AnalyticsController::class, 'index'])->name('app.analytics');
Route::get('analytics/{account}', [AnalyticsController::class, 'show'])->name('app.analytics.show');
// Calendar
Route::get('calendar', [PostController::class, 'calendar'])->name('app.calendar');

View file

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\InstagramAnalytics;
use App\Services\Social\InstagramPublisher;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->instagramFacebookAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::InstagramFacebook,
'platform_user_id' => 'ig_fb_123',
'username' => 'testuser',
'display_name' => 'Test User',
'access_token' => 'page_token_123',
'refresh_token' => null,
'token_expires_at' => null,
'status' => AccountStatus::Connected,
'is_active' => true,
'meta' => [
'page_id' => 'page_123',
'page_name' => 'Test Page',
],
]);
});
test('instagram facebook platform uses graph.facebook.com base url', function () {
expect(Platform::InstagramFacebook->instagramGraphBaseUrl())
->toContain('graph.facebook.com');
expect(Platform::Instagram->instagramGraphBaseUrl())
->toContain('graph.instagram.com');
});
test('instagram facebook platform has correct label', function () {
expect(Platform::InstagramFacebook->label())
->toBe('Instagram (Facebook Business)');
});
test('instagram facebook shares same content types as instagram', function () {
expect(ContentType::defaultFor(Platform::InstagramFacebook))
->toBe(ContentType::InstagramFeed);
});
test('instagram facebook platform has correct media types', function () {
$types = Platform::InstagramFacebook->allowedMediaTypes();
expect($types)->toBe(Platform::Instagram->allowedMediaTypes());
});
test('instagram facebook platform has correct max content length', function () {
expect(Platform::InstagramFacebook->maxContentLength())
->toBe(Platform::Instagram->maxContentLength());
});
test('instagram facebook platform has correct max images', function () {
expect(Platform::InstagramFacebook->maxImages())
->toBe(Platform::Instagram->maxImages());
});
test('instagram facebook does not support text only', function () {
expect(Platform::InstagramFacebook->supportsTextOnly())->toBeFalse();
});
test('instagram facebook has its own queue', function () {
expect(Platform::InstagramFacebook->queue())
->toBe('social-instagram-facebook');
});
test('instagram facebook publisher uses graph.facebook.com', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->instagramFacebookAccount->id,
'platform' => Platform::InstagramFacebook,
'content_type' => ContentType::InstagramFeed,
'content' => 'Test post via Facebook Business',
]);
$postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/test.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
]);
Http::fake([
'https://graph.facebook.com/*' => Http::response([
'id' => 'container_123',
'status_code' => 'FINISHED',
'permalink' => 'https://instagram.com/p/test123',
], 200),
]);
$publisher = new InstagramPublisher;
$result = $publisher->publish($postPlatform);
expect($result)->toHaveKey('id');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'graph.facebook.com');
});
Http::assertNotSent(function ($request) {
return str_contains($request->url(), 'graph.instagram.com');
});
});
test('instagram standalone publisher uses graph.instagram.com', function () {
$standaloneAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
'platform_user_id' => 'ig_standalone_123',
'access_token' => 'ig_token_123',
'token_expires_at' => now()->addDays(30),
'status' => AccountStatus::Connected,
'is_active' => true,
]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $standaloneAccount->id,
'platform' => Platform::Instagram,
'content_type' => ContentType::InstagramFeed,
'content' => 'Test post via standalone',
]);
$postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/test.jpg',
'original_filename' => 'test.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
]);
Http::fake([
'https://graph.instagram.com/*' => Http::response([
'id' => 'container_456',
'status_code' => 'FINISHED',
'permalink' => 'https://instagram.com/p/test456',
], 200),
]);
$publisher = new InstagramPublisher;
$result = $publisher->publish($postPlatform);
expect($result)->toHaveKey('id');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'graph.instagram.com');
});
Http::assertNotSent(function ($request) {
return str_contains($request->url(), 'graph.facebook.com');
});
});
test('instagram facebook does not refresh token', function () {
// InstagramFacebook uses page tokens that don't expire
expect($this->instagramFacebookAccount->refresh_token)->toBeNull();
expect($this->instagramFacebookAccount->token_expires_at)->toBeNull();
});
test('analytics service supports instagram facebook', function () {
Http::fake([
'https://graph.facebook.com/*' => Http::response([
'data' => [],
], 200),
]);
$analytics = app(InstagramAnalytics::class);
$metrics = $analytics->getMetrics($this->instagramFacebookAccount);
expect($metrics)->toBeArray();
Http::assertSent(function ($request) {
return str_contains($request->url(), 'graph.facebook.com');
});
});
test('instagram facebook platform is included in all queues', function () {
$queues = Platform::allQueues();
expect($queues)->toContain('social-instagram-facebook');
});
test('instagram facebook is in supported analytics platforms', function () {
Http::fake([
'https://graph.facebook.com/*' => Http::response(['data' => []], 200),
]);
$analytics = app(InstagramAnalytics::class);
$metrics = $analytics->getMetrics($this->instagramFacebookAccount);
expect($metrics)->toBeArray();
});

View file

@ -49,12 +49,10 @@
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock([
'user' => $socialiteUser,
]));
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
Http::fake([
'https://graph.facebook.com/v24.0/me/accounts*' => Http::response([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
'data' => [
[
'id' => 'page_123',
@ -94,12 +92,10 @@
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock([
'user' => $socialiteUser,
]));
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
Http::fake([
'https://graph.facebook.com/v24.0/me/accounts*' => Http::response([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
'data' => [
[
'id' => 'page_1',
@ -136,12 +132,10 @@
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock([
'user' => $socialiteUser,
]));
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
Http::fake([
'https://graph.facebook.com/v24.0/me/accounts*' => Http::response([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
'data' => [],
], 200),
]);
@ -179,12 +173,10 @@
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock([
'user' => $socialiteUser,
]));
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
Http::fake([
'https://graph.facebook.com/v24.0/me/accounts*' => Http::response([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
'data' => [
[
'id' => 'page_new',
@ -221,12 +213,10 @@
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock([
'user' => $socialiteUser,
]));
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
Http::fake([
'https://graph.facebook.com/v24.0/me/accounts*' => Http::response([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
'data' => [
[
'id' => 'page_123',

View file

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Enums\User\Setup;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\YouTubeAnalytics;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->youtubeAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::YouTube,
'platform_user_id' => 'UC_test_channel_123',
'username' => 'testchannel',
'display_name' => 'Test Channel',
'access_token' => 'ya29.test_access_token',
'refresh_token' => 'refresh_token_123',
'token_expires_at' => now()->addHours(2),
'status' => AccountStatus::Connected,
'is_active' => true,
'meta' => [
'channel_id' => 'UC_test_channel_123',
'google_user_id' => 'google_user_123',
],
]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('youtube analytics returns metrics from api', function () {
Http::fake([
'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
'columnHeaders' => [
['name' => 'views'],
['name' => 'estimatedMinutesWatched'],
['name' => 'averageViewDuration'],
['name' => 'averageViewPercentage'],
['name' => 'subscribersGained'],
['name' => 'subscribersLost'],
['name' => 'likes'],
],
'rows' => [
[1500, 3200, 128, 45.5, 50, 5, 200],
],
], 200),
]);
$analytics = app(YouTubeAnalytics::class);
$metrics = $analytics->getMetrics($this->youtubeAccount);
expect($metrics)->toBeArray()
->and($metrics)->toHaveCount(7)
->and($metrics[0])->toMatchArray(['label' => 'Views', 'value' => 1500])
->and($metrics[1])->toMatchArray(['label' => 'Minutes Watched', 'value' => 3200])
->and($metrics[2])->toMatchArray(['label' => 'Avg. View Duration (s)', 'value' => 128])
->and($metrics[3])->toMatchArray(['label' => 'Avg. View Percentage', 'value' => 45.5])
->and($metrics[4])->toMatchArray(['label' => 'Subscribers Gained', 'value' => 50])
->and($metrics[5])->toMatchArray(['label' => 'Subscribers Lost', 'value' => 5])
->and($metrics[6])->toMatchArray(['label' => 'Likes', 'value' => 200]);
});
test('youtube analytics returns empty array on api failure', function () {
Http::fake([
'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([], 403),
]);
$analytics = app(YouTubeAnalytics::class);
$metrics = $analytics->getMetrics($this->youtubeAccount);
expect($metrics)->toBeArray()->toBeEmpty();
});
test('youtube analytics returns empty array when no rows', function () {
Http::fake([
'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
'columnHeaders' => [
['name' => 'views'],
],
'rows' => [],
], 200),
]);
$analytics = app(YouTubeAnalytics::class);
$metrics = $analytics->getMetrics($this->youtubeAccount);
expect($metrics)->toBeArray()->toBeEmpty();
});
test('youtube analytics caches results', function () {
Http::fake([
'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
'columnHeaders' => [
['name' => 'views'],
],
'rows' => [
[500],
],
], 200),
]);
$analytics = app(YouTubeAnalytics::class);
$analytics->getMetrics($this->youtubeAccount);
$analytics->getMetrics($this->youtubeAccount);
Http::assertSentCount(1);
});
test('youtube analytics supports date range', function () {
Http::fake([
'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
'columnHeaders' => [
['name' => 'views'],
],
'rows' => [
[1000],
],
], 200),
]);
$analytics = app(YouTubeAnalytics::class);
$metrics = $analytics->getMetrics(
$this->youtubeAccount,
now()->subDays(30),
now(),
);
expect($metrics)->toBeArray()->toHaveCount(1);
Http::assertSent(fn ($request) => str_contains($request->url(), 'startDate=')
&& str_contains($request->url(), 'endDate=')
);
});
test('youtube analytics refreshes expired token', function () {
$this->youtubeAccount->update(['token_expires_at' => now()->subMinutes(5)]);
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response([
'access_token' => 'new_access_token',
'expires_in' => 3600,
], 200),
'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
'columnHeaders' => [
['name' => 'views'],
],
'rows' => [
[100],
],
], 200),
]);
$analytics = app(YouTubeAnalytics::class);
$metrics = $analytics->getMetrics($this->youtubeAccount);
expect($metrics)->toBeArray()->toHaveCount(1);
$this->youtubeAccount->refresh();
expect($this->youtubeAccount->access_token)->toBe('new_access_token');
});
test('youtube analytics throws exception when no refresh token', function () {
$this->youtubeAccount->update([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => null,
]);
$analytics = app(YouTubeAnalytics::class);
$analytics->getMetrics($this->youtubeAccount);
})->throws(TokenExpiredException::class);
test('youtube analytics throws exception on token refresh failure', function () {
$this->youtubeAccount->update(['token_expires_at' => now()->subMinutes(5)]);
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response(['error' => 'invalid_grant'], 400),
]);
$analytics = app(YouTubeAnalytics::class);
$analytics->getMetrics($this->youtubeAccount);
})->throws(TokenExpiredException::class);
test('youtube is in supported analytics platforms', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)
->get(route('app.analytics'));
$response->assertOk();
$accounts = $response->original->getData()['page']['props']['accounts'];
$youtubeAccount = collect($accounts)->firstWhere('platform', Platform::YouTube->value);
expect($youtubeAccount)->not->toBeNull()
->and($youtubeAccount['id'])->toBe($this->youtubeAccount->id);
});
test('youtube analytics show endpoint returns metrics', function () {
config(['trypost.self_hosted' => true]);
Http::fake([
'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
'columnHeaders' => [
['name' => 'views'],
['name' => 'likes'],
],
'rows' => [
[500, 30],
],
], 200),
]);
$response = $this->actingAs($this->user)
->getJson(route('app.analytics.show', $this->youtubeAccount));
$response->assertOk()
->assertJsonStructure(['metrics'])
->assertJsonCount(2, 'metrics');
});
test('youtube analytics show endpoint rejects other workspace accounts', function () {
config(['trypost.self_hosted' => true]);
$otherUser = User::factory()->create(['setup' => Setup::Completed]);
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
$otherUser->update(['current_workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($otherUser)
->getJson(route('app.analytics.show', $this->youtubeAccount));
$response->assertForbidden();
});

View file

@ -12,7 +12,8 @@
expect(Platform::TikTok->label())->toBe('TikTok');
expect(Platform::YouTube->label())->toBe('YouTube Shorts');
expect(Platform::Facebook->label())->toBe('Facebook Page');
expect(Platform::Instagram->label())->toBe('Instagram');
expect(Platform::Instagram->label())->toBe('Instagram (Standalone)');
expect(Platform::InstagramFacebook->label())->toBe('Instagram (Facebook Business)');
expect(Platform::Threads->label())->toBe('Threads');
expect(Platform::Pinterest->label())->toBe('Pinterest');
expect(Platform::Bluesky->label())->toBe('Bluesky');

View file

@ -17,7 +17,7 @@
$mail = new AccountDisconnected($account);
expect($mail->envelope()->subject)->toBe('Your Instagram account in My Workspace needs to be reconnected');
expect($mail->envelope()->subject)->toBe('Your Instagram (Standalone) account in My Workspace needs to be reconnected');
});
test('account disconnected mail has correct content', function () {

View file

@ -355,3 +355,195 @@
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TikTokPublishException::class);
});
test('tiktok publisher sends meta settings in video publish request', function () {
$this->postPlatform->update([
'meta' => [
'privacy_level' => 'PUBLIC_TO_EVERYONE',
'allow_comments' => true,
'allow_duet' => false,
'allow_stitch' => true,
'is_aigc' => true,
'brand_content_toggle' => true,
'brand_organic_toggle' => false,
],
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'SELF_ONLY'],
],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_meta_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/video/init/')) {
return false;
}
$body = json_decode($request->body(), true);
$postInfo = data_get($body, 'post_info');
return $postInfo['privacy_level'] === 'PUBLIC_TO_EVERYONE'
&& $postInfo['disable_comment'] === false
&& $postInfo['disable_duet'] === true
&& $postInfo['disable_stitch'] === false
&& $postInfo['is_aigc'] === true
&& $postInfo['brand_content_toggle'] === true
&& ! isset($postInfo['brand_organic_toggle']);
});
});
test('tiktok publisher sends auto_add_music for photo posts', function () {
$this->postPlatform->update([
'meta' => [
'privacy_level' => 'SELF_ONLY',
'allow_comments' => true,
'auto_add_music' => true,
],
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'image',
'path' => 'media/2026-01/photo.jpg',
'original_filename' => 'photo.jpg',
'mime_type' => 'image/jpeg',
'size' => 512000,
'order' => 0,
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => ['privacy_level_options' => ['SELF_ONLY']],
], 200),
'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_music_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/content/init/')) {
return false;
}
$body = json_decode($request->body(), true);
$postInfo = data_get($body, 'post_info');
return $postInfo['auto_add_music'] === true
&& ! isset($postInfo['disable_duet'])
&& ! isset($postInfo['disable_stitch'])
&& ! isset($postInfo['is_aigc']);
});
});
test('tiktok publisher does not send auto_add_music for video posts', function () {
$this->postPlatform->update([
'meta' => [
'privacy_level' => 'SELF_ONLY',
'auto_add_music' => true,
],
]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/video.mp4',
'original_filename' => 'video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => ['privacy_level_options' => ['SELF_ONLY']],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_vid_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/video/init/')) {
return false;
}
$body = json_decode($request->body(), true);
$postInfo = data_get($body, 'post_info');
return ! isset($postInfo['auto_add_music']);
});
});
test('tiktok publisher uses default settings when meta is empty', function () {
$this->postPlatform->update(['meta' => null]);
$this->postPlatform->media()->create([
'collection' => 'default',
'type' => 'video',
'path' => 'media/2026-01/test-video.mp4',
'original_filename' => 'test-video.mp4',
'mime_type' => 'video/mp4',
'size' => 1024000,
'order' => 0,
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'],
],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_default_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/video/init/')) {
return false;
}
$body = json_decode($request->body(), true);
$postInfo = data_get($body, 'post_info');
// When meta is empty, uses creator_info privacy and defaults
return $postInfo['privacy_level'] === 'PUBLIC_TO_EVERYONE'
&& $postInfo['disable_comment'] === false
&& $postInfo['disable_duet'] === true
&& $postInfo['disable_stitch'] === true
&& ! isset($postInfo['is_aigc'])
&& ! isset($postInfo['brand_content_toggle']);
});
});