diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index 8470e930..e3205698 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -14,6 +14,7 @@ use App\Services\Social\ThreadsAnalytics; use App\Services\Social\TikTokAnalytics; use App\Services\Social\XAnalytics; +use App\Services\Social\YouTubeAnalytics; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -32,6 +33,7 @@ class AnalyticsController extends Controller Platform::X, Platform::LinkedInPage, Platform::Pinterest, + Platform::YouTube, ]; public function index(Request $request): Response @@ -74,6 +76,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse Platform::X => app(XAnalytics::class)->getMetrics($account, $since, $until), Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until), Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), + Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), default => [], }; diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index 6ec81df4..4786d536 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -26,6 +26,7 @@ class YouTubeController extends SocialController 'https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube.readonly', 'https://www.googleapis.com/auth/youtube.force-ssl', + 'https://www.googleapis.com/auth/yt-analytics.readonly', ]; public function connect(Request $request): Response|RedirectResponse diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php new file mode 100644 index 00000000..043cd6de --- /dev/null +++ b/app/Services/Social/YouTubeAnalytics.php @@ -0,0 +1,128 @@ +subDays(7); + $until ??= now(); + + $cacheKey = "analytics:youtube:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}"; + $cacheTtl = app()->isProduction() ? 3600 : 1; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) { + return $this->fetchMetricsFromApi($account, $since, $until); + }); + } + + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + { + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + + $this->accessToken = $account->access_token; + + $response = $this->getHttpClient() + ->get("{$this->baseUrl}/reports", [ + 'ids' => 'channel==MINE', + 'startDate' => $since->format('Y-m-d'), + 'endDate' => $until->format('Y-m-d'), + 'metrics' => 'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,subscribersLost,likes', + ]); + + if ($response->failed()) { + Log::warning('YouTube Analytics fetch failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + + return []; + } + + $json = $response->json(); + $rows = data_get($json, 'rows', []); + + if (empty($rows)) { + return []; + } + + $columnHeaders = data_get($json, 'columnHeaders', []); + $metricNames = collect($columnHeaders)->pluck('name')->toArray(); + $values = data_get($rows, '0', []); + + $metrics = []; + + foreach ($metricNames as $index => $name) { + $value = data_get($values, $index, 0); + + $label = match ($name) { + 'views' => 'Views', + 'estimatedMinutesWatched' => 'Minutes Watched', + 'averageViewDuration' => 'Avg. View Duration (s)', + 'averageViewPercentage' => 'Avg. View Percentage', + 'subscribersGained' => 'Subscribers Gained', + 'subscribersLost' => 'Subscribers Lost', + 'likes' => 'Likes', + default => ucfirst(str_replace('_', ' ', $name)), + }; + + $metrics[] = ['label' => $label, 'value' => round((float) $value, 1)]; + } + + return $metrics; + } + + private function getHttpClient(): PendingRequest + { + return $this->socialHttp()->withToken($this->accessToken); + } + + private function refreshToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for YouTube account'); + } + + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ]); + + if ($response->failed()) { + Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + + throw new TokenExpiredException('Failed to refresh YouTube token'); + } + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + } +} diff --git a/resources/js/components/analytics/YouTubeAnalytics.vue b/resources/js/components/analytics/YouTubeAnalytics.vue new file mode 100644 index 00000000..a84aff44 --- /dev/null +++ b/resources/js/components/analytics/YouTubeAnalytics.vue @@ -0,0 +1,87 @@ + + + diff --git a/resources/js/pages/analytics/Index.vue b/resources/js/pages/analytics/Index.vue index b267c946..b04cf6c5 100644 --- a/resources/js/pages/analytics/Index.vue +++ b/resources/js/pages/analytics/Index.vue @@ -11,6 +11,7 @@ import PinterestAnalytics from '@/components/analytics/PinterestAnalytics.vue'; import ThreadsAnalytics from '@/components/analytics/ThreadsAnalytics.vue'; import TikTokAnalytics from '@/components/analytics/TikTokAnalytics.vue'; import XAnalytics from '@/components/analytics/XAnalytics.vue'; +import YouTubeAnalytics from '@/components/analytics/YouTubeAnalytics.vue'; import { DateRangePicker } from '@/components/ui/date-range-picker'; import dayjs from '@/dayjs'; import AppLayout from '@/layouts/AppLayout.vue'; @@ -104,6 +105,12 @@ const platformSupportsDateRange = computed(() => { :date-range="dateRange" /> + +
{{ $t('analytics.no_data') }}
diff --git a/tests/Feature/YouTubeAnalyticsTest.php b/tests/Feature/YouTubeAnalyticsTest.php new file mode 100644 index 00000000..c5282027 --- /dev/null +++ b/tests/Feature/YouTubeAnalyticsTest.php @@ -0,0 +1,240 @@ +user = User::factory()->create(['setup' => Setup::Completed]); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + + $this->youtubeAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::YouTube, + 'platform_user_id' => 'UC_test_channel_123', + 'username' => 'testchannel', + 'display_name' => 'Test Channel', + 'access_token' => 'ya29.test_access_token', + 'refresh_token' => 'refresh_token_123', + 'token_expires_at' => now()->addHours(2), + 'status' => AccountStatus::Connected, + 'is_active' => true, + 'meta' => [ + 'channel_id' => 'UC_test_channel_123', + 'google_user_id' => 'google_user_123', + ], + ]); + + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('youtube analytics returns metrics from api', function () { + Http::fake([ + 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([ + 'columnHeaders' => [ + ['name' => 'views'], + ['name' => 'estimatedMinutesWatched'], + ['name' => 'averageViewDuration'], + ['name' => 'averageViewPercentage'], + ['name' => 'subscribersGained'], + ['name' => 'subscribersLost'], + ['name' => 'likes'], + ], + 'rows' => [ + [1500, 3200, 128, 45.5, 50, 5, 200], + ], + ], 200), + ]); + + $analytics = app(YouTubeAnalytics::class); + $metrics = $analytics->getMetrics($this->youtubeAccount); + + expect($metrics)->toBeArray() + ->and($metrics)->toHaveCount(7) + ->and($metrics[0])->toMatchArray(['label' => 'Views', 'value' => 1500]) + ->and($metrics[1])->toMatchArray(['label' => 'Minutes Watched', 'value' => 3200]) + ->and($metrics[2])->toMatchArray(['label' => 'Avg. View Duration (s)', 'value' => 128]) + ->and($metrics[3])->toMatchArray(['label' => 'Avg. View Percentage', 'value' => 45.5]) + ->and($metrics[4])->toMatchArray(['label' => 'Subscribers Gained', 'value' => 50]) + ->and($metrics[5])->toMatchArray(['label' => 'Subscribers Lost', 'value' => 5]) + ->and($metrics[6])->toMatchArray(['label' => 'Likes', 'value' => 200]); +}); + +test('youtube analytics returns empty array on api failure', function () { + Http::fake([ + 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([], 403), + ]); + + $analytics = app(YouTubeAnalytics::class); + $metrics = $analytics->getMetrics($this->youtubeAccount); + + expect($metrics)->toBeArray()->toBeEmpty(); +}); + +test('youtube analytics returns empty array when no rows', function () { + Http::fake([ + 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([ + 'columnHeaders' => [ + ['name' => 'views'], + ], + 'rows' => [], + ], 200), + ]); + + $analytics = app(YouTubeAnalytics::class); + $metrics = $analytics->getMetrics($this->youtubeAccount); + + expect($metrics)->toBeArray()->toBeEmpty(); +}); + +test('youtube analytics caches results', function () { + Http::fake([ + 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([ + 'columnHeaders' => [ + ['name' => 'views'], + ], + 'rows' => [ + [500], + ], + ], 200), + ]); + + $analytics = app(YouTubeAnalytics::class); + $analytics->getMetrics($this->youtubeAccount); + $analytics->getMetrics($this->youtubeAccount); + + Http::assertSentCount(1); +}); + +test('youtube analytics supports date range', function () { + Http::fake([ + 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([ + 'columnHeaders' => [ + ['name' => 'views'], + ], + 'rows' => [ + [1000], + ], + ], 200), + ]); + + $analytics = app(YouTubeAnalytics::class); + $metrics = $analytics->getMetrics( + $this->youtubeAccount, + now()->subDays(30), + now(), + ); + + expect($metrics)->toBeArray()->toHaveCount(1); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'startDate=') + && str_contains($request->url(), 'endDate=') + ); +}); + +test('youtube analytics refreshes expired token', function () { + $this->youtubeAccount->update(['token_expires_at' => now()->subMinutes(5)]); + + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response([ + 'access_token' => 'new_access_token', + 'expires_in' => 3600, + ], 200), + 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([ + 'columnHeaders' => [ + ['name' => 'views'], + ], + 'rows' => [ + [100], + ], + ], 200), + ]); + + $analytics = app(YouTubeAnalytics::class); + $metrics = $analytics->getMetrics($this->youtubeAccount); + + expect($metrics)->toBeArray()->toHaveCount(1); + + $this->youtubeAccount->refresh(); + expect($this->youtubeAccount->access_token)->toBe('new_access_token'); +}); + +test('youtube analytics throws exception when no refresh token', function () { + $this->youtubeAccount->update([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => null, + ]); + + $analytics = app(YouTubeAnalytics::class); + $analytics->getMetrics($this->youtubeAccount); +})->throws(TokenExpiredException::class); + +test('youtube analytics throws exception on token refresh failure', function () { + $this->youtubeAccount->update(['token_expires_at' => now()->subMinutes(5)]); + + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response(['error' => 'invalid_grant'], 400), + ]); + + $analytics = app(YouTubeAnalytics::class); + $analytics->getMetrics($this->youtubeAccount); +})->throws(TokenExpiredException::class); + +test('youtube is in supported analytics platforms', function () { + config(['trypost.self_hosted' => true]); + + $response = $this->actingAs($this->user) + ->get(route('app.analytics')); + + $response->assertOk(); + + $accounts = $response->original->getData()['page']['props']['accounts']; + $youtubeAccount = collect($accounts)->firstWhere('platform', Platform::YouTube->value); + + expect($youtubeAccount)->not->toBeNull() + ->and($youtubeAccount['id'])->toBe($this->youtubeAccount->id); +}); + +test('youtube analytics show endpoint returns metrics', function () { + config(['trypost.self_hosted' => true]); + + Http::fake([ + 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([ + 'columnHeaders' => [ + ['name' => 'views'], + ['name' => 'likes'], + ], + 'rows' => [ + [500, 30], + ], + ], 200), + ]); + + $response = $this->actingAs($this->user) + ->getJson(route('app.analytics.show', $this->youtubeAccount)); + + $response->assertOk() + ->assertJsonStructure(['metrics']) + ->assertJsonCount(2, 'metrics'); +}); + +test('youtube analytics show endpoint rejects other workspace accounts', function () { + config(['trypost.self_hosted' => true]); + + $otherUser = User::factory()->create(['setup' => Setup::Completed]); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]); + $otherUser->update(['current_workspace_id' => $otherWorkspace->id]); + + $response = $this->actingAs($otherUser) + ->getJson(route('app.analytics.show', $this->youtubeAccount)); + + $response->assertForbidden(); +});