Merge remote-tracking branch 'origin/main' into feature/google-auth-toggle
This commit is contained in:
commit
1a7af9728f
69 changed files with 3906 additions and 2498 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,3 +26,4 @@ yarn-error.log
|
|||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
/docs/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
85
app/Http/Controllers/App/AnalyticsController.php
Normal file
85
app/Http/Controllers/App/AnalyticsController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
288
app/Http/Controllers/Auth/InstagramFacebookController.php
Normal file
288
app/Http/Controllers/Auth/InstagramFacebookController.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
88
app/Services/Social/FacebookAnalytics.php
Normal file
88
app/Services/Social/FacebookAnalytics.php
Normal 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();
|
||||
}
|
||||
}
|
||||
174
app/Services/Social/InstagramAnalytics.php
Normal file
174
app/Services/Social/InstagramAnalytics.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
223
app/Services/Social/LinkedInPageAnalytics.php
Normal file
223
app/Services/Social/LinkedInPageAnalytics.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
134
app/Services/Social/PinterestAnalytics.php
Normal file
134
app/Services/Social/PinterestAnalytics.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
110
app/Services/Social/ThreadsAnalytics.php
Normal file
110
app/Services/Social/ThreadsAnalytics.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
184
app/Services/Social/TikTokAnalytics.php
Normal file
184
app/Services/Social/TikTokAnalytics.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
|
|
|
|||
182
app/Services/Social/XAnalytics.php
Normal file
182
app/Services/Social/XAnalytics.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
128
app/Services/Social/YouTubeAnalytics.php
Normal file
128
app/Services/Social/YouTubeAnalytics.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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)
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
@ -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 |
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
# Publishing Hardening — Best Practices from Postiz
|
||||
|
||||
Improvements to the existing publishing flow. No new features — just making what we have more robust.
|
||||
|
||||
## 1. Content Sanitization Before Publishing
|
||||
|
||||
### Problem
|
||||
|
||||
We send raw content to platform APIs. If the user pastes HTML from the editor or has formatting tags, they get sent as-is. Each platform has different rules:
|
||||
- Instagram, TikTok, Pinterest, Bluesky: plain text only
|
||||
- LinkedIn: supports bold/italic via Unicode characters
|
||||
- X: plain text only
|
||||
- Facebook, Threads: plain text only
|
||||
- Mastodon: supports some HTML
|
||||
- YouTube: plain text titles
|
||||
|
||||
### Solution
|
||||
|
||||
Create a `ContentSanitizer` service that strips/converts content per platform:
|
||||
|
||||
```php
|
||||
class ContentSanitizer
|
||||
{
|
||||
public function sanitize(string $content, Platform $platform): string
|
||||
{
|
||||
return match ($platform) {
|
||||
Platform::LinkedIn, Platform::LinkedInPage => $this->convertToUnicodeBold($this->stripHtml($content)),
|
||||
Platform::Mastodon => $this->stripUnsafeHtml($content),
|
||||
default => $this->stripHtml($content),
|
||||
};
|
||||
}
|
||||
|
||||
private function stripHtml(string $content): string
|
||||
{
|
||||
// Remove HTML tags, decode entities (& → &, → space, etc.)
|
||||
}
|
||||
|
||||
private function convertToUnicodeBold(string $content): string
|
||||
{
|
||||
// Convert <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 |
|
||||
|
|
@ -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
8
lang/en/analytics.php
Normal 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.',
|
||||
];
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
'support' => 'Support',
|
||||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
|
||||
'posts' => [
|
||||
'calendar' => 'Calendar',
|
||||
'all' => 'All',
|
||||
|
|
|
|||
|
|
@ -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
8
lang/es/analytics.php
Normal 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.',
|
||||
];
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
'support' => 'Soporte',
|
||||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
|
||||
'posts' => [
|
||||
'calendar' => 'Calendario',
|
||||
'all' => 'Todos',
|
||||
|
|
|
|||
|
|
@ -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
8
lang/pt-br/analytics.php
Normal 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.',
|
||||
];
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
'support' => 'Suporte',
|
||||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
|
||||
'posts' => [
|
||||
'calendar' => 'Calendário',
|
||||
'all' => 'Todos',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
77
resources/js/components/analytics/AnalyticsSidebar.vue
Normal file
77
resources/js/components/analytics/AnalyticsSidebar.vue
Normal 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>
|
||||
87
resources/js/components/analytics/FacebookAnalytics.vue
Normal file
87
resources/js/components/analytics/FacebookAnalytics.vue
Normal 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>
|
||||
87
resources/js/components/analytics/InstagramAnalytics.vue
Normal file
87
resources/js/components/analytics/InstagramAnalytics.vue
Normal 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>
|
||||
87
resources/js/components/analytics/LinkedInPageAnalytics.vue
Normal file
87
resources/js/components/analytics/LinkedInPageAnalytics.vue
Normal 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>
|
||||
87
resources/js/components/analytics/PinterestAnalytics.vue
Normal file
87
resources/js/components/analytics/PinterestAnalytics.vue
Normal 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>
|
||||
87
resources/js/components/analytics/ThreadsAnalytics.vue
Normal file
87
resources/js/components/analytics/ThreadsAnalytics.vue
Normal 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>
|
||||
76
resources/js/components/analytics/TikTokAnalytics.vue
Normal file
76
resources/js/components/analytics/TikTokAnalytics.vue
Normal 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>
|
||||
87
resources/js/components/analytics/XAnalytics.vue
Normal file
87
resources/js/components/analytics/XAnalytics.vue
Normal 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>
|
||||
87
resources/js/components/analytics/YouTubeAnalytics.vue
Normal file
87
resources/js/components/analytics/YouTubeAnalytics.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
@ -47,6 +47,7 @@ const previewComponent = computed(() => {
|
|||
case 'facebook':
|
||||
return FacebookPreview;
|
||||
case 'instagram':
|
||||
case 'instagram-facebook':
|
||||
return InstagramPreview;
|
||||
case 'threads':
|
||||
return ThreadsPreview;
|
||||
|
|
|
|||
170
resources/js/components/ui/date-range-picker/DateRangePicker.vue
Normal file
170
resources/js/components/ui/date-range-picker/DateRangePicker.vue
Normal 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>
|
||||
1
resources/js/components/ui/date-range-picker/index.ts
Normal file
1
resources/js/components/ui/date-range-picker/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as DateRangePicker } from "./DateRangePicker.vue"
|
||||
106
resources/js/pages/accounts/InstagramFacebookPageSelect.vue
Normal file
106
resources/js/pages/accounts/InstagramFacebookPageSelect.vue
Normal 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>
|
||||
121
resources/js/pages/analytics/Index.vue
Normal file
121
resources/js/pages/analytics/Index.vue
Normal 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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
221
tests/Feature/InstagramFacebookIntegrationTest.php
Normal file
221
tests/Feature/InstagramFacebookIntegrationTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
240
tests/Feature/YouTubeAnalyticsTest.php
Normal file
240
tests/Feature/YouTubeAnalyticsTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue