chore: working
This commit is contained in:
parent
1129205c22
commit
cb91529964
65 changed files with 3468 additions and 2486 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,3 +26,4 @@ yarn-error.log
|
|||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
/docs/
|
||||
|
|
|
|||
56
TIKTOK.md
Normal file
56
TIKTOK.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# TikTok Direct Post Audit
|
||||
|
||||
## Status
|
||||
|
||||
Audit application submitted on 2026-04-01. Waiting for TikTok review (2-4 weeks).
|
||||
|
||||
## Form Responses
|
||||
|
||||
### Step 1 — General Information
|
||||
|
||||
**Relationship with TikTok:**
|
||||
Social media management platform allowing customers to create, schedule, publish, and manage content in one place, including TikTok.
|
||||
|
||||
### Step 2 — API Client Information
|
||||
|
||||
**App ID:** 7473784331115481143
|
||||
|
||||
**Goal of the application:**
|
||||
TryPost is a social media management platform (trypost.it) that allows users to create, schedule, and publish content across multiple social networks from a single dashboard. The Content Posting API integration enables our users to schedule and directly publish video content to their TikTok accounts alongside other platforms like Instagram, YouTube, Facebook, and LinkedIn — all from one place. This saves creators and businesses significant time by eliminating the need to manually post on each platform individually. Direct posting is essential for our scheduled publishing feature, where posts are automatically published at the optimal time chosen by the user without requiring manual intervention.
|
||||
|
||||
**Daily users estimate:** Prefer not to say / Building a new application
|
||||
|
||||
**Justification (if asked):**
|
||||
We are an early-stage platform currently onboarding our first users. We expect gradual growth as we expand our user base.
|
||||
|
||||
### Step 3 — Supporting Documents
|
||||
|
||||
**Required:**
|
||||
- PDF with screenshots showing the TikTok integration flow (create post → select TikTok → schedule → publish)
|
||||
- Screen recording demonstrating the complete user workflow: account connection, post creation, and publishing
|
||||
|
||||
**API fields stored:**
|
||||
- `publish_id` — used to track publish status
|
||||
- `publicly_available_post_id` — used to build the post URL on TikTok
|
||||
|
||||
### Step 4 — Review
|
||||
|
||||
Verify all information and submit.
|
||||
|
||||
## Without Audit (Current Restrictions)
|
||||
|
||||
- All posts are restricted to `SELF_ONLY` (private) visibility
|
||||
- Maximum 5 users can post in a 24-hour window
|
||||
- User accounts must be set to private at the time of posting
|
||||
|
||||
## After Audit Approval
|
||||
|
||||
- Posts can be published with `PUBLIC_TO_EVERYONE` visibility
|
||||
- No user cap restrictions
|
||||
- Full Direct Post API access
|
||||
|
||||
## References
|
||||
|
||||
- [TikTok Content Sharing Guidelines](https://developers.tiktok.com/doc/content-sharing-guidelines)
|
||||
- [TikTok Direct Post API](https://developers.tiktok.com/doc/content-posting-api-reference-direct-post)
|
||||
- [TikTok Direct Post Audit Guide (Mixpost)](https://docs.mixpost.app/services/social/tik-tok/direct-post-audit/)
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
82
app/Http/Controllers/App/AnalyticsController.php
Normal file
82
app/Http/Controllers/App/AnalyticsController.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?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 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,
|
||||
];
|
||||
|
||||
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),
|
||||
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,7 @@ public function callback(Request $request): View|RedirectResponse
|
|||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->user();
|
||||
$socialUser = Socialite::driver($this->driver)->usingGraphVersion('v25.0')->user();
|
||||
|
||||
// Fetch pages the user manages
|
||||
$pages = $this->fetchPages($socialUser->token);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
282
app/Http/Controllers/Auth/InstagramFacebookController.php
Normal file
282
app/Http/Controllers/Auth/InstagramFacebookController.php
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<?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();
|
||||
|
||||
$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
|
||||
|
|
|
|||
|
|
@ -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 = str_replace('/v24.0', '', $account->platform->instagramGraphBaseUrl());
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$this->accessToken = $account->access_token;
|
||||
|
||||
$metrics = [];
|
||||
|
||||
$timeSeriesMetrics = $this->fetchTimeSeriesMetrics($account, $since, $until);
|
||||
$metrics = array_merge($metrics, $timeSeriesMetrics);
|
||||
|
||||
$totalValueMetrics = $this->fetchTotalValueMetrics($account, $since, $until);
|
||||
$metrics = array_merge($metrics, $totalValueMetrics);
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
private function fetchTimeSeriesMetrics(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
$response = $this->getHttpClient()
|
||||
->get("{$this->baseUrl}/v21.0/{$account->platform_user_id}/insights", [
|
||||
'metric' => 'reach,follower_count',
|
||||
'period' => 'day',
|
||||
'since' => $since->startOfDay()->unix(),
|
||||
'until' => $until->endOfDay()->unix(),
|
||||
'access_token' => $this->accessToken,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('Instagram insights (time series) fetch failed', [
|
||||
'body' => $this->redactResponseBody($response->body()),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = data_get($response->json(), 'data', []);
|
||||
$metrics = [];
|
||||
|
||||
foreach ($data as $metric) {
|
||||
$name = data_get($metric, 'name');
|
||||
$values = data_get($metric, 'values', []);
|
||||
|
||||
if (empty($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total = collect($values)->sum('value');
|
||||
|
||||
$label = match ($name) {
|
||||
'reach' => 'Reach',
|
||||
'follower_count' => 'Followers',
|
||||
default => ucfirst(str_replace('_', ' ', $name)),
|
||||
};
|
||||
|
||||
$metrics[] = ['label' => $label, 'value' => $total];
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
private function fetchTotalValueMetrics(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
$response = $this->getHttpClient()
|
||||
->get("{$this->baseUrl}/v21.0/{$account->platform_user_id}/insights", [
|
||||
'metric' => 'likes,comments,shares,saves,views,total_interactions',
|
||||
'metric_type' => 'total_value',
|
||||
'period' => 'day',
|
||||
'since' => $since->startOfDay()->unix(),
|
||||
'until' => $until->endOfDay()->unix(),
|
||||
'access_token' => $this->accessToken,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('Instagram insights (total value) fetch failed', [
|
||||
'body' => $this->redactResponseBody($response->body()),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = data_get($response->json(), 'data', []);
|
||||
$metrics = [];
|
||||
|
||||
foreach ($data as $metric) {
|
||||
$name = data_get($metric, 'name');
|
||||
$value = data_get($metric, 'total_value.value', 0);
|
||||
|
||||
$label = match ($name) {
|
||||
'total_interactions' => 'Interactions',
|
||||
default => ucfirst(str_replace('_', ' ', $name)),
|
||||
};
|
||||
|
||||
$metrics[] = ['label' => $label, 'value' => $value];
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
private function getHttpClient(): PendingRequest
|
||||
{
|
||||
return $this->socialHttp();
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
if ($account->platform === Platform::InstagramFacebook) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $account->refresh_token) {
|
||||
throw new TokenExpiredException('No refresh token available for Instagram account');
|
||||
}
|
||||
|
||||
$response = Http::get('https://graph.instagram.com/refresh_access_token', [
|
||||
'grant_type' => 'ig_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
|
||||
throw new TokenExpiredException('Instagram token refresh failed');
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$account->update([
|
||||
'access_token' => data_get($data, 'access_token'),
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
187
app/Services/Social/TikTokAnalytics.php
Normal file
187
app/Services/Social/TikTokAnalytics.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?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()->withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
if (! $account->refresh_token) {
|
||||
throw new TokenExpiredException('No refresh token available for TikTok account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post('https://open.tiktokapis.com/v2/oauth/token/', [
|
||||
'client_key' => config('services.tiktok.client_id'),
|
||||
'client_secret' => config('services.tiktok.client_secret'),
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
|
||||
throw new TokenExpiredException('TikTok token refresh failed');
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$account->update([
|
||||
'access_token' => data_get($data, 'access_token'),
|
||||
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -97,19 +97,43 @@ private function queryCreatorInfo(): array
|
|||
];
|
||||
}
|
||||
|
||||
private function buildPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array
|
||||
{
|
||||
$meta = $postPlatform->meta ?? [];
|
||||
|
||||
$privacyLevel = data_get($meta, 'privacy_level')
|
||||
?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY');
|
||||
|
||||
$postInfo = [
|
||||
'title' => $content ?? '',
|
||||
'privacy_level' => $privacyLevel,
|
||||
'disable_duet' => ! data_get($meta, 'allow_duet', false),
|
||||
'disable_comment' => ! data_get($meta, 'allow_comments', true),
|
||||
'disable_stitch' => ! data_get($meta, 'allow_stitch', false),
|
||||
];
|
||||
|
||||
if (data_get($meta, 'is_aigc', false)) {
|
||||
$postInfo['is_aigc'] = true;
|
||||
}
|
||||
|
||||
if (data_get($meta, 'brand_content_toggle', false)) {
|
||||
$postInfo['brand_content_toggle'] = true;
|
||||
}
|
||||
|
||||
if (data_get($meta, 'brand_organic_toggle', false)) {
|
||||
$postInfo['brand_organic_toggle'] = true;
|
||||
}
|
||||
|
||||
return $postInfo;
|
||||
}
|
||||
|
||||
private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array
|
||||
{
|
||||
$creatorInfo = $this->queryCreatorInfo();
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/video/init/", [
|
||||
'post_info' => [
|
||||
'title' => $content ?? '',
|
||||
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
|
||||
'disable_duet' => false,
|
||||
'disable_comment' => false,
|
||||
'disable_stitch' => false,
|
||||
],
|
||||
'post_info' => $this->buildPostInfo($postPlatform, $content, $creatorInfo),
|
||||
'source_info' => [
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'video_url' => $media->url,
|
||||
|
|
@ -155,13 +179,19 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st
|
|||
|
||||
$creatorInfo = $this->queryCreatorInfo();
|
||||
|
||||
$postInfo = $this->buildPostInfo($postPlatform, $content, $creatorInfo);
|
||||
// Photos don't support duet/stitch/is_aigc
|
||||
unset($postInfo['disable_duet'], $postInfo['disable_stitch'], $postInfo['is_aigc']);
|
||||
|
||||
// Auto add music is only for photos
|
||||
$meta = $postPlatform->meta ?? [];
|
||||
if (data_get($meta, 'auto_add_music', false)) {
|
||||
$postInfo['auto_add_music'] = true;
|
||||
}
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/content/init/", [
|
||||
'post_info' => [
|
||||
'title' => $content ?? '',
|
||||
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
|
||||
'disable_comment' => false,
|
||||
],
|
||||
'post_info' => $postInfo,
|
||||
'source_info' => [
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'photo_cover_index' => 0,
|
||||
|
|
|
|||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,9 @@
|
|||
'instagram' => [
|
||||
'enabled' => env('TRYPOST_INSTAGRAM_ENABLED', true),
|
||||
],
|
||||
'instagram-facebook' => [
|
||||
'enabled' => env('TRYPOST_INSTAGRAM_FACEBOOK_ENABLED', true),
|
||||
],
|
||||
'threads' => [
|
||||
'enabled' => env('TRYPOST_THREADS_ENABLED', true),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
114
resources/js/pages/analytics/Index.vue
Normal file
114
resources/js/pages/analytics/Index.vue
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<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 { 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"
|
||||
/>
|
||||
|
||||
<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',
|
||||
|
|
|
|||
|
|
@ -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