From 4d60a876af13ca45978dbcd122509299875c1589 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 20 Sep 2026 14:49:48 +0200 Subject: [PATCH] Add a read-only Google AdSense analytics module for admins. Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages. --- app/Console/Commands/AdsenseSyncCommand.php | 137 +++ .../Admin/AdsenseAnalyticsController.php | 266 +++++ app/Jobs/SyncAdsenseJob.php | 56 + app/Models/AdsenseConnection.php | 80 ++ app/Models/AdsenseDailyStat.php | 68 ++ app/Models/AdsenseEntity.php | 33 + .../Adsense/AdsenseAnalyticsQueryService.php | 288 +++++ app/Services/Adsense/AdsenseApiClient.php | 350 ++++++ app/Services/Adsense/AdsenseLogSanitizer.php | 68 ++ app/Services/Adsense/AdsenseOAuthService.php | 327 +++++ app/Services/Adsense/AdsenseReportParser.php | 216 ++++ app/Services/Adsense/AdsenseSyncResult.php | 32 + app/Services/Adsense/AdsenseSyncService.php | 358 ++++++ .../Exceptions/AdsenseApiException.php | 17 + .../AdsenseAuthorizationException.php | 7 + .../Adsense/Exceptions/AdsenseException.php | 9 + .../Exceptions/AdsenseOAuthException.php | 7 + config/adsense.php | 63 + ...120000_create_adsense_analytics_tables.php | 99 ++ docs/ADSENSE_ANALYTICS.md | 236 ++++ docs/SKINBASE_ADSENSE_ANALYTICS.md | 1060 +++++++++++++++++ phpunit.xml | 2 + resources/js/Layouts/AdminLayout.jsx | 1 + resources/js/Pages/Admin/Adsense/Index.jsx | 401 +++++++ resources/js/Pages/Admin/Dashboard.jsx | 28 +- routes/console.php | 12 + .../Adsense/AdsenseAdminDashboardTest.php | 195 +++ .../Feature/Adsense/AdsenseApiAndSyncTest.php | 408 +++++++ tests/Feature/Adsense/AdsenseOAuthTest.php | 180 +++ .../Unit/Adsense/AdsenseReportParserTest.php | 106 ++ 30 files changed, 5098 insertions(+), 12 deletions(-) create mode 100644 app/Console/Commands/AdsenseSyncCommand.php create mode 100644 app/Http/Controllers/Admin/AdsenseAnalyticsController.php create mode 100644 app/Jobs/SyncAdsenseJob.php create mode 100644 app/Models/AdsenseConnection.php create mode 100644 app/Models/AdsenseDailyStat.php create mode 100644 app/Models/AdsenseEntity.php create mode 100644 app/Services/Adsense/AdsenseAnalyticsQueryService.php create mode 100644 app/Services/Adsense/AdsenseApiClient.php create mode 100644 app/Services/Adsense/AdsenseLogSanitizer.php create mode 100644 app/Services/Adsense/AdsenseOAuthService.php create mode 100644 app/Services/Adsense/AdsenseReportParser.php create mode 100644 app/Services/Adsense/AdsenseSyncResult.php create mode 100644 app/Services/Adsense/AdsenseSyncService.php create mode 100644 app/Services/Adsense/Exceptions/AdsenseApiException.php create mode 100644 app/Services/Adsense/Exceptions/AdsenseAuthorizationException.php create mode 100644 app/Services/Adsense/Exceptions/AdsenseException.php create mode 100644 app/Services/Adsense/Exceptions/AdsenseOAuthException.php create mode 100644 config/adsense.php create mode 100644 database/migrations/2026_09_20_120000_create_adsense_analytics_tables.php create mode 100644 docs/ADSENSE_ANALYTICS.md create mode 100644 docs/SKINBASE_ADSENSE_ANALYTICS.md create mode 100644 resources/js/Pages/Admin/Adsense/Index.jsx create mode 100644 tests/Feature/Adsense/AdsenseAdminDashboardTest.php create mode 100644 tests/Feature/Adsense/AdsenseApiAndSyncTest.php create mode 100644 tests/Feature/Adsense/AdsenseOAuthTest.php create mode 100644 tests/Unit/Adsense/AdsenseReportParserTest.php diff --git a/app/Console/Commands/AdsenseSyncCommand.php b/app/Console/Commands/AdsenseSyncCommand.php new file mode 100644 index 00000000..4c75b64d --- /dev/null +++ b/app/Console/Commands/AdsenseSyncCommand.php @@ -0,0 +1,137 @@ +option('force')) { + $this->warn('AdSense synchronization is disabled. Use --force to run anyway.'); + + return self::SUCCESS; + } + + try { + [$from, $to] = $this->resolveRange(); + } catch (\InvalidArgumentException $exception) { + $this->error($exception->getMessage()); + + return self::INVALID; + } + + $connection = AdsenseConnection::current(); + if ($connection === null || ! $connection->hasRefreshToken()) { + $this->error('Google AdSense is not connected.'); + + return self::FAILURE; + } + + if ($connection->status === AdsenseConnection::STATUS_RECONNECT_REQUIRED) { + $this->error('AdSense authorization expired or was revoked. Reconnect Google AdSense.'); + + return self::FAILURE; + } + + if ((string) $connection->account_resource_name === '') { + $this->error('No AdSense account is selected.'); + + return self::FAILURE; + } + + $entitiesOnly = (bool) $this->option('entities-only'); + $result = $sync->sync($connection, $from, $to, $entitiesOnly); + + $this->renderResult($result, $entitiesOnly); + + if ($result->reconnectRequired) { + $this->error('AdSense authorization expired or was revoked. Reconnect Google AdSense.'); + + return self::FAILURE; + } + + if ($result->failedReports !== []) { + $this->warn('Synchronization completed with partial report failures: '.implode(', ', $result->failedReports)); + + return self::SUCCESS; + } + + $this->info('Synchronization completed successfully.'); + + return self::SUCCESS; + } + + /** + * @return array{0: Carbon, 1: Carbon} + */ + private function resolveRange(): array + { + $days = $this->option('days'); + $from = $this->option('from'); + $to = $this->option('to'); + + if ($days !== null && ($from !== null || $to !== null)) { + throw new \InvalidArgumentException('The --days option cannot be combined with --from or --to.'); + } + + if (($from !== null) !== ($to !== null)) { + throw new \InvalidArgumentException('Both --from and --to are required for a custom range.'); + } + + if (is_string($from) && is_string($to)) { + $start = Carbon::parse($from)->startOfDay(); + $end = Carbon::parse($to)->startOfDay(); + if ($end->lt($start)) { + throw new \InvalidArgumentException('The --to date must be on or after --from.'); + } + + return [$start, $end]; + } + + $trailingDays = $days !== null ? max(1, (int) $days) : 3; + $end = now()->startOfDay(); + $start = $end->copy()->subDays($trailingDays - 1); + + return [$start, $end]; + } + + private function renderResult(AdsenseSyncResult $result, bool $entitiesOnly): void + { + $this->line('AdSense account: '.($result->accountDisplayName !== '' ? $result->accountDisplayName : 'unknown')); + $this->line('Range: '.$result->from.' -> '.$result->to); + $this->newLine(); + $this->line('Entities:'); + $this->line(' Ad units: '.$result->adUnits); + $this->line(' Custom channels: '.$result->customChannels); + + if ($entitiesOnly) { + return; + } + + $this->newLine(); + $this->line('Reports:'); + $this->line(' Total: '.$result->totalRows); + $this->line(' Ad units: '.$result->adUnitRows); + $this->line(' Custom channels: '.$result->customChannelRows); + $this->line(' Platform: '.$result->platformRows); + $this->line(' Countries: '.$result->countryRows); + $this->newLine(); + } +} diff --git a/app/Http/Controllers/Admin/AdsenseAnalyticsController.php b/app/Http/Controllers/Admin/AdsenseAnalyticsController.php new file mode 100644 index 00000000..1b0565fd --- /dev/null +++ b/app/Http/Controllers/Admin/AdsenseAnalyticsController.php @@ -0,0 +1,266 @@ +query->resolveRange( + $request->query('period'), + $request->string('from')->toString() ?: null, + $request->string('to')->toString() ?: null, + ); + + $dashboard = null; + if ($connection !== null && $connection->isConnected() && filled($connection->account_resource_name)) { + $dashboard = $this->query->dashboard( + $connection, + $range['from'], + $range['to'], + $range['previous_from'], + $range['previous_to'], + ); + } + + return Inertia::render('Admin/Adsense/Index', [ + 'connection' => $this->connectionPayload($connection), + 'configured' => $this->oauth->isConfigured(), + 'pendingAccounts' => $request->session()->get(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, []), + 'recommendedPlacements' => array_values((array) config('adsense.recommended_placement_names', [])), + 'range' => [ + 'period' => $range['period'], + 'from' => $range['from']->toDateString(), + 'to' => $range['to']->toDateString(), + 'previous_from' => $range['previous_from']->toDateString(), + 'previous_to' => $range['previous_to']->toDateString(), + ], + 'dashboard' => $dashboard, + 'routes' => [ + 'index' => route('admin.adsense.index'), + 'connect' => route('admin.adsense.connect'), + 'account' => route('admin.adsense.account'), + 'sync' => route('admin.adsense.sync'), + 'disconnect' => route('admin.adsense.disconnect'), + ], + ]); + } + + public function connect(Request $request): RedirectResponse + { + if (! $this->oauth->isConfigured()) { + return redirect() + ->route('admin.adsense.index') + ->with('error', 'Google AdSense OAuth is not configured.'); + } + + try { + return redirect()->away($this->oauth->authorizationUrl($request, true)); + } catch (AdsenseOAuthException $exception) { + return redirect() + ->route('admin.adsense.index') + ->with('error', $exception->getMessage()); + } + } + + public function callback(Request $request): RedirectResponse + { + try { + $code = $this->oauth->assertValidCallback($request); + $tokens = $this->oauth->exchangeAuthorizationCode($code); + $connection = AdsenseConnection::current() ?? new AdsenseConnection; + $connection->status = AdsenseConnection::STATUS_PENDING; + $this->oauth->persistTokens($connection, $tokens, $request->user()?->id); + + $accounts = $this->api->listAccounts($connection); + } catch (AdsenseOAuthException|AdsenseAuthorizationException|AdsenseApiException $exception) { + Log::warning('AdSense OAuth callback failed.', AdsenseLogSanitizer::context([ + 'message' => $exception->getMessage(), + ])); + + return redirect() + ->route('admin.adsense.index') + ->with('error', $exception->getMessage()); + } + + $normalized = collect($accounts) + ->map(function (array $account): ?array { + $name = (string) ($account['name'] ?? ''); + if ($name === '') { + return null; + } + + return [ + 'resource_name' => $name, + 'display_name' => (string) ($account['displayName'] ?? $name), + ]; + }) + ->filter() + ->values() + ->all(); + + if ($normalized === []) { + $connection->status = AdsenseConnection::STATUS_ERROR; + $connection->last_error = 'No Google AdSense accounts were available for this Google account.'; + $connection->save(); + $request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY); + + return redirect() + ->route('admin.adsense.index') + ->with('error', $connection->last_error); + } + + if (count($normalized) === 1) { + $this->assignAccount($connection, $normalized[0]['resource_name'], $normalized[0]['display_name']); + $this->queueAdsenseSync($connection, 30); + $request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY); + + return redirect() + ->route('admin.adsense.index') + ->with('success', 'Google AdSense connected. Initial synchronization has started.'); + } + + $request->session()->put(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, $normalized); + $connection->status = AdsenseConnection::STATUS_PENDING; + $connection->save(); + + return redirect() + ->route('admin.adsense.index') + ->with('warning', 'Select the Google AdSense account to use for Skinbase analytics.'); + } + + public function selectAccount(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'account_resource_name' => ['required', 'string', 'max:128'], + ]); + + $connection = AdsenseConnection::current(); + if ($connection === null || ! $connection->hasRefreshToken()) { + return redirect() + ->route('admin.adsense.index') + ->with('error', 'Connect Google AdSense before selecting an account.'); + } + + $pending = collect($request->session()->get(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, [])); + $selected = $pending->firstWhere('resource_name', $validated['account_resource_name']); + + if (! is_array($selected)) { + return redirect() + ->route('admin.adsense.index') + ->with('error', 'The selected AdSense account is not available.'); + } + + $this->assignAccount( + $connection, + (string) $selected['resource_name'], + (string) ($selected['display_name'] ?? $selected['resource_name']), + ); + $this->queueAdsenseSync($connection, 30); + $request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY); + + return redirect() + ->route('admin.adsense.index') + ->with('success', 'AdSense account selected. Initial synchronization has started.'); + } + + public function sync(Request $request): RedirectResponse + { + $connection = AdsenseConnection::current(); + if ($connection === null || ! $connection->isConnected()) { + return redirect() + ->route('admin.adsense.index') + ->with('error', $connection?->needsReconnect() + ? 'AdSense authorization expired or was revoked. Reconnect Google AdSense.' + : 'Google AdSense is not connected.'); + } + + $this->queueAdsenseSync($connection, 3); + + return redirect() + ->route('admin.adsense.index') + ->with('success', 'AdSense synchronization has been queued.'); + } + + public function disconnect(): RedirectResponse + { + $connection = AdsenseConnection::current(); + if ($connection !== null) { + $this->oauth->disconnect($connection); + } + + return redirect() + ->route('admin.adsense.index') + ->with('success', 'Google AdSense has been disconnected. Historical statistics were kept.'); + } + + /** + * @return array|null + */ + private function connectionPayload(?AdsenseConnection $connection): ?array + { + if ($connection === null) { + return null; + } + + return [ + 'status' => $connection->status, + 'account_resource_name' => $connection->account_resource_name, + 'account_display_name' => $connection->account_display_name, + 'connected_at' => optional($connection->connected_at)?->toIso8601String(), + 'last_sync_attempt_at' => optional($connection->last_sync_attempt_at)?->toIso8601String(), + 'last_successful_sync_at' => optional($connection->last_successful_sync_at)?->toIso8601String(), + 'last_error' => $connection->last_error, + 'needs_reconnect' => $connection->needsReconnect(), + ]; + } + + private function assignAccount(AdsenseConnection $connection, string $resourceName, string $displayName): void + { + $connection->account_resource_name = $resourceName; + $connection->account_display_name = $displayName; + $connection->status = AdsenseConnection::STATUS_CONNECTED; + $connection->last_error = null; + if ($connection->connected_at === null) { + $connection->connected_at = now(); + } + $connection->save(); + } + + private function queueAdsenseSync(AdsenseConnection $connection, int $days): void + { + $to = now()->startOfDay(); + $from = $to->copy()->subDays(max(1, $days) - 1); + + SyncAdsenseJob::dispatch( + (int) $connection->id, + $from->toDateString(), + $to->toDateString(), + ); + } +} diff --git a/app/Jobs/SyncAdsenseJob.php b/app/Jobs/SyncAdsenseJob.php new file mode 100644 index 00000000..43cde491 --- /dev/null +++ b/app/Jobs/SyncAdsenseJob.php @@ -0,0 +1,56 @@ +connectionId; + } + + public function handle(AdsenseSyncService $sync): void + { + $connection = AdsenseConnection::query()->find($this->connectionId); + if ($connection === null || ! $connection->hasRefreshToken()) { + return; + } + + $sync->sync( + $connection, + Carbon::parse($this->from)->startOfDay(), + Carbon::parse($this->to)->startOfDay(), + $this->entitiesOnly, + ); + } +} diff --git a/app/Models/AdsenseConnection.php b/app/Models/AdsenseConnection.php new file mode 100644 index 00000000..d62101b3 --- /dev/null +++ b/app/Models/AdsenseConnection.php @@ -0,0 +1,80 @@ + 'encrypted', + 'connected_at' => 'datetime', + 'last_sync_attempt_at' => 'datetime', + 'last_successful_sync_at' => 'datetime', + ]; + } + + public function connectedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'connected_by_user_id'); + } + + public static function current(): ?self + { + return self::query()->latest('id')->first(); + } + + public function hasRefreshToken(): bool + { + return filled($this->encrypted_refresh_token); + } + + public function isConnected(): bool + { + return $this->status === self::STATUS_CONNECTED && $this->hasRefreshToken(); + } + + public function needsReconnect(): bool + { + return $this->status === self::STATUS_RECONNECT_REQUIRED || ( + in_array($this->status, [self::STATUS_PENDING, self::STATUS_CONNECTED, self::STATUS_ERROR], true) + && ! $this->hasRefreshToken() + ); + } + + public function isDisconnected(): bool + { + return $this->status === self::STATUS_DISCONNECTED || ! $this->hasRefreshToken(); + } +} diff --git a/app/Models/AdsenseDailyStat.php b/app/Models/AdsenseDailyStat.php new file mode 100644 index 00000000..93efdc32 --- /dev/null +++ b/app/Models/AdsenseDailyStat.php @@ -0,0 +1,68 @@ + 'date', + 'page_views' => 'integer', + 'ad_requests' => 'integer', + 'matched_ad_requests' => 'integer', + 'impressions' => 'integer', + 'clicks' => 'integer', + 'estimated_earnings' => 'decimal:6', + 'page_views_ctr' => 'decimal:8', + 'page_views_rpm' => 'decimal:6', + 'ad_requests_coverage' => 'decimal:8', + 'ad_requests_ctr' => 'decimal:8', + 'ad_requests_rpm' => 'decimal:6', + 'impressions_ctr' => 'decimal:8', + 'impressions_rpm' => 'decimal:6', + 'cost_per_click' => 'decimal:6', + 'last_synced_at' => 'datetime', + ]; + } +} diff --git a/app/Models/AdsenseEntity.php b/app/Models/AdsenseEntity.php new file mode 100644 index 00000000..6cecf26d --- /dev/null +++ b/app/Models/AdsenseEntity.php @@ -0,0 +1,33 @@ + 'boolean', + 'last_seen_at' => 'datetime', + ]; + } +} diff --git a/app/Services/Adsense/AdsenseAnalyticsQueryService.php b/app/Services/Adsense/AdsenseAnalyticsQueryService.php new file mode 100644 index 00000000..ce5e8de5 --- /dev/null +++ b/app/Services/Adsense/AdsenseAnalyticsQueryService.php @@ -0,0 +1,288 @@ +startOfDay(); + + [$start, $end] = match ($period) { + 'today' => [$today->copy(), $today->copy()], + 'yesterday' => [$today->copy()->subDay(), $today->copy()->subDay()], + '7d' => [$today->copy()->subDays(6), $today->copy()], + 'custom' => [ + Carbon::parse($from ?: $today->copy()->subDays(29)->toDateString())->startOfDay(), + Carbon::parse($to ?: $today->toDateString())->startOfDay(), + ], + default => [$today->copy()->subDays(29), $today->copy()], + }; + + if ($end->lt($start)) { + [$start, $end] = [$end, $start]; + } + + $days = $start->diffInDays($end) + 1; + $previousEnd = $start->copy()->subDay(); + $previousStart = $previousEnd->copy()->subDays($days - 1); + + return [ + 'from' => $start, + 'to' => $end, + 'previous_from' => $previousStart, + 'previous_to' => $previousEnd, + 'period' => $period === 'custom' ? 'custom' : (in_array($period, ['today', 'yesterday', '7d', '30d'], true) ? $period : '30d'), + ]; + } + + /** + * @return array + */ + public function dashboard(AdsenseConnection $connection, Carbon $from, Carbon $to, Carbon $previousFrom, Carbon $previousTo): array + { + $account = (string) $connection->account_resource_name; + $current = $this->totals($account, $from, $to); + $previous = $this->totals($account, $previousFrom, $previousTo); + $currency = $current['currency_code'] ?? $previous['currency_code'] ?? 'USD'; + + return [ + 'kpis' => [ + $this->kpi('estimated_earnings', 'Estimated Earnings', $current['estimated_earnings'], $previous['estimated_earnings'], 'money', $currency), + $this->kpi('page_views', 'Page Views', $current['page_views'], $previous['page_views'], 'integer'), + $this->kpi('impressions', 'Ad Impressions', $current['impressions'], $previous['impressions'], 'integer'), + $this->kpi('clicks', 'Clicks', $current['clicks'], $previous['clicks'], 'integer'), + $this->kpi('page_views_ctr', 'Page CTR', $this->ratio($current['clicks'], $current['page_views']), $this->ratio($previous['clicks'], $previous['page_views']), 'percent'), + $this->kpi('page_views_rpm', 'Page RPM', $this->rpm($current['estimated_earnings'], $current['page_views']), $this->rpm($previous['estimated_earnings'], $previous['page_views']), 'money', $currency), + $this->kpi('ad_requests_coverage', 'Ad Request Coverage', $this->ratio($current['matched_ad_requests'], $current['ad_requests']), $this->ratio($previous['matched_ad_requests'], $previous['ad_requests']), 'percent'), + $this->kpi('cost_per_click', 'CPC', $this->cpc($current['estimated_earnings'], $current['clicks']), $this->cpc($previous['estimated_earnings'], $previous['clicks']), 'money', $currency), + ], + 'chart' => $this->chart($account, $from, $to), + 'placements' => $this->dimensionTable($account, AdsenseDailyStat::DIMENSION_AD_UNIT, $from, $to, $currency), + 'platforms' => $this->dimensionTable($account, AdsenseDailyStat::DIMENSION_PLATFORM, $from, $to, $currency), + 'countries' => array_slice($this->dimensionTable($account, AdsenseDailyStat::DIMENSION_COUNTRY, $from, $to, $currency), 0, 20), + 'currency_code' => $currency, + ]; + } + + /** + * @return array + */ + private function totals(string $account, Carbon $from, Carbon $to): array + { + $row = AdsenseDailyStat::query() + ->where('account_resource_name', $account) + ->where('dimension_type', AdsenseDailyStat::DIMENSION_TOTAL) + ->whereDate('date', '>=', $from->toDateString()) + ->whereDate('date', '<=', $to->toDateString()) + ->selectRaw(' + SUM(page_views) as page_views, + SUM(ad_requests) as ad_requests, + SUM(matched_ad_requests) as matched_ad_requests, + SUM(impressions) as impressions, + SUM(clicks) as clicks, + SUM(estimated_earnings) as estimated_earnings, + MAX(currency_code) as currency_code + ') + ->first(); + + return [ + 'page_views' => (int) ($row?->page_views ?? 0), + 'ad_requests' => (int) ($row?->ad_requests ?? 0), + 'matched_ad_requests' => (int) ($row?->matched_ad_requests ?? 0), + 'impressions' => (int) ($row?->impressions ?? 0), + 'clicks' => (int) ($row?->clicks ?? 0), + 'estimated_earnings' => (string) ($row?->estimated_earnings ?? '0'), + 'currency_code' => $row?->currency_code, + ]; + } + + /** + * @return list> + */ + private function chart(string $account, Carbon $from, Carbon $to): array + { + $rows = AdsenseDailyStat::query() + ->where('account_resource_name', $account) + ->where('dimension_type', AdsenseDailyStat::DIMENSION_TOTAL) + ->whereDate('date', '>=', $from->toDateString()) + ->whereDate('date', '<=', $to->toDateString()) + ->orderBy('date') + ->get(); + + return $rows->map(function (AdsenseDailyStat $row): array { + return [ + 'date' => optional($row->date)?->toDateString(), + 'estimated_earnings' => (float) $row->estimated_earnings, + 'page_views' => (int) $row->page_views, + 'page_views_rpm' => (float) $row->page_views_rpm, + 'clicks' => (int) $row->clicks, + ]; + })->values()->all(); + } + + /** + * @return list> + */ + private function dimensionTable(string $account, string $dimensionType, Carbon $from, Carbon $to, ?string $currency): array + { + $rows = AdsenseDailyStat::query() + ->where('account_resource_name', $account) + ->where('dimension_type', $dimensionType) + ->whereDate('date', '>=', $from->toDateString()) + ->whereDate('date', '<=', $to->toDateString()) + ->selectRaw(' + dimension_key, + MAX(dimension_label) as dimension_label, + SUM(page_views) as page_views, + SUM(impressions) as impressions, + SUM(clicks) as clicks, + SUM(estimated_earnings) as estimated_earnings + ') + ->groupBy('dimension_key') + ->orderByDesc('estimated_earnings') + ->get(); + + $labels = $this->entityLabels($account, $dimensionType); + $countryNames = $dimensionType === AdsenseDailyStat::DIMENSION_COUNTRY ? $this->countryNames() : collect(); + + return $rows->map(function ($row) use ($labels, $countryNames, $currency, $dimensionType): array { + $key = (string) $row->dimension_key; + $label = (string) ($labels[$key] ?? $countryNames[$key] ?? $row->dimension_label ?? $key); + if ($dimensionType === AdsenseDailyStat::DIMENSION_PLATFORM) { + $label = $this->platformLabel($key, $label); + } + + $pageViews = (int) $row->page_views; + $impressions = (int) $row->impressions; + $clicks = (int) $row->clicks; + $earnings = (string) ($row->estimated_earnings ?? '0'); + + return [ + 'key' => $key, + 'label' => $label, + 'page_views' => $pageViews, + 'impressions' => $impressions, + 'clicks' => $clicks, + 'ctr' => $this->ratio($clicks, $pageViews), + 'rpm' => $this->rpm($earnings, $pageViews), + 'revenue' => $earnings, + 'currency_code' => $currency, + ]; + })->values()->all(); + } + + /** + * @return array + */ + private function entityLabels(string $account, string $dimensionType): array + { + if (! in_array($dimensionType, [AdsenseDailyStat::DIMENSION_AD_UNIT, AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL], true)) { + return []; + } + + return AdsenseEntity::query() + ->where('account_resource_name', $account) + ->where('type', $dimensionType) + ->pluck('display_name', 'reporting_dimension_id') + ->all(); + } + + /** + * @return Collection + */ + private function countryNames(): Collection + { + if (! Schema::hasTable('countries')) { + return collect(); + } + + return Country::query() + ->get(['iso2', 'name', 'name_common']) + ->mapWithKeys(function (Country $country): array { + $code = strtoupper((string) $country->iso2); + $name = (string) ($country->name_common ?: $country->name ?: $code); + + return $code !== '' ? [$code => $name] : []; + }); + } + + private function platformLabel(string $key, string $fallback): string + { + return match (strtoupper($key)) { + 'DESKTOP' => 'Desktop', + 'HIGH_END_MOBILE', 'MOBILE' => 'Mobile', + 'TABLET' => 'Tablet', + default => $fallback !== '' ? $fallback : $key, + }; + } + + /** + * @return array + */ + private function kpi(string $key, string $label, mixed $current, mixed $previous, string $format, ?string $currency = null): array + { + return [ + 'key' => $key, + 'label' => $label, + 'value' => $current, + 'previous' => $previous, + 'change_percent' => $this->changePercent($current, $previous), + 'format' => $format, + 'currency_code' => $currency, + ]; + } + + private function changePercent(mixed $current, mixed $previous): ?float + { + $current = (float) $current; + $previous = (float) $previous; + + if ($previous == 0.0) { + return null; + } + + return (($current - $previous) / $previous) * 100; + } + + private function ratio(int $numerator, int $denominator): ?float + { + if ($denominator === 0) { + return null; + } + + return $numerator / $denominator; + } + + private function rpm(string|float|int|null $earnings, int $pageViews): ?float + { + if ($pageViews === 0) { + return null; + } + + return ((float) $earnings / $pageViews) * 1000; + } + + private function cpc(string|float|int|null $earnings, int $clicks): ?float + { + if ($clicks === 0) { + return null; + } + + return (float) $earnings / $clicks; + } +} diff --git a/app/Services/Adsense/AdsenseApiClient.php b/app/Services/Adsense/AdsenseApiClient.php new file mode 100644 index 00000000..f7c5652e --- /dev/null +++ b/app/Services/Adsense/AdsenseApiClient.php @@ -0,0 +1,350 @@ +> + */ + public function listAccounts(AdsenseConnection $connection): array + { + return $this->paginate($connection, '/accounts', 'accounts'); + } + + /** + * @return list> + */ + public function listAdClients(AdsenseConnection $connection, string $accountResourceName): array + { + return $this->paginate($connection, '/'.$this->trimPath($accountResourceName).'/adclients', 'adClients'); + } + + /** + * @return list> + */ + public function listAdUnits(AdsenseConnection $connection, string $adClientResourceName): array + { + return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/adunits', 'adUnits'); + } + + /** + * @return list> + */ + public function listCustomChannels(AdsenseConnection $connection, string $adClientResourceName): array + { + return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/customchannels', 'customChannels'); + } + + /** + * @param list $dimensions + * @param list $metrics + * @return array + */ + public function generateReport( + AdsenseConnection $connection, + string $accountResourceName, + array $dimensions, + array $metrics, + Carbon $from, + Carbon $to, + string $report = '', + ): array { + $path = '/'.$this->trimPath($accountResourceName).'/reports:generate'; + $merged = [ + 'headers' => [], + 'rows' => [], + ]; + + $pageToken = null; + + do { + $query = $this->reportQuery($dimensions, $metrics, $from, $to, $pageToken); + $json = $this->getJson($connection, $path, $query, $report); + + if ($merged['headers'] === [] && isset($json['headers']) && is_array($json['headers'])) { + $merged['headers'] = $json['headers']; + } + + $rows = $json['rows'] ?? []; + if (is_array($rows) && $rows !== []) { + $merged['rows'] = array_merge($merged['rows'], $rows); + } + + foreach (['totals', 'averages', 'startDate', 'endDate', 'warnings'] as $metaKey) { + if (! isset($merged[$metaKey]) && isset($json[$metaKey])) { + $merged[$metaKey] = $json[$metaKey]; + } + } + + $pageToken = isset($json['nextPageToken']) && is_string($json['nextPageToken']) && $json['nextPageToken'] !== '' + ? $json['nextPageToken'] + : null; + } while ($pageToken !== null); + + return $merged; + } + + /** + * @param array $query + * @return list> + */ + private function paginate(AdsenseConnection $connection, string $path, string $itemsKey): array + { + $items = []; + $pageToken = null; + + do { + $query = []; + if ($pageToken !== null) { + $query['pageToken'] = $pageToken; + } + + $json = $this->getJson($connection, $path, $query); + $pageItems = $json[$itemsKey] ?? []; + if (is_array($pageItems)) { + foreach ($pageItems as $item) { + if (is_array($item)) { + $items[] = $item; + } + } + } + + $pageToken = isset($json['nextPageToken']) && is_string($json['nextPageToken']) && $json['nextPageToken'] !== '' + ? $json['nextPageToken'] + : null; + } while ($pageToken !== null); + + return $items; + } + + /** + * @param list $dimensions + * @param list $metrics + * @return array + */ + private function reportQuery(array $dimensions, array $metrics, Carbon $from, Carbon $to, ?string $pageToken): array + { + $query = [ + 'startDate.year' => $from->year, + 'startDate.month' => $from->month, + 'startDate.day' => $from->day, + 'endDate.year' => $to->year, + 'endDate.month' => $to->month, + 'endDate.day' => $to->day, + ]; + + foreach ($dimensions as $index => $dimension) { + $query['dimensions'.$index] = $dimension; + } + + foreach ($metrics as $index => $metric) { + $query['metrics'.$index] = $metric; + } + + if ($pageToken !== null) { + $query['pageToken'] = $pageToken; + } + + return $query; + } + + /** + * @param array $query + * @return array + */ + private function getJson(AdsenseConnection $connection, string $path, array $query = [], string $report = ''): array + { + $url = $this->url($path, $query); + $response = $this->send($connection, $url, $report); + $json = $response->json(); + + if (! is_array($json)) { + throw new AdsenseApiException('Malformed AdSense API response.', $response->status(), $report); + } + + return $json; + } + + private function send(AdsenseConnection $connection, string $url, string $report = ''): Response + { + $maxAttempts = max(1, (int) config('adsense.retry_times', 3)); + $sleepMs = max(0, (int) config('adsense.retry_sleep_ms', 400)); + $refreshed = false; + $attempt = 0; + + while (true) { + $attempt++; + + try { + $response = Http::acceptJson() + ->withToken($this->oauth->accessToken($connection)) + ->timeout((int) config('adsense.timeout', 20)) + ->connectTimeout((int) config('adsense.connect_timeout', 5)) + ->get($url); + } catch (AdsenseAuthorizationException $exception) { + throw $exception; + } catch (ConnectionException $exception) { + if ($attempt < $maxAttempts) { + $this->backoff($sleepMs, $attempt); + + continue; + } + + Log::warning('AdSense API connection failed.', AdsenseLogSanitizer::context([ + 'message' => $exception->getMessage(), + 'report' => $report, + ])); + + throw new AdsenseApiException('AdSense API connection failed.', 0, $report, $exception); + } + + $status = $response->status(); + + if ($status === 401 && ! $refreshed) { + $this->oauth->forgetAccessToken((int) $connection->id); + + try { + $this->oauth->accessToken($connection, true); + } catch (AdsenseAuthorizationException $exception) { + throw $exception; + } + + $refreshed = true; + $attempt--; + + continue; + } + + if (in_array($status, [429, 500, 502, 503, 504], true) && $attempt < $maxAttempts) { + $this->backoff($sleepMs, $attempt); + + continue; + } + + if ($status === 401 || $status === 403) { + $message = $this->errorMessage($response, 'AdSense API authorization failed.'); + Log::warning('AdSense API authorization failed.', AdsenseLogSanitizer::context([ + 'status' => $status, + 'report' => $report, + ])); + + if ($this->isRevoked($response, $message)) { + $this->oauth->markReconnectRequired($connection, 'AdSense authorization expired or was revoked.'); + + throw new AdsenseAuthorizationException('AdSense authorization expired or was revoked.'); + } + + throw new AdsenseApiException($message, $status, $report); + } + + if ($response->failed()) { + $message = $this->errorMessage($response, 'AdSense API request failed.'); + Log::warning('AdSense API request failed.', AdsenseLogSanitizer::context([ + 'status' => $status, + 'report' => $report, + ])); + + throw new AdsenseApiException($message, $status, $report); + } + + return $response; + } + } + + /** + * @param array $query + */ + private function url(string $path, array $query): string + { + $base = rtrim((string) config('adsense.api_base'), '/'); + $url = $base.'/'.$this->trimPath($path); + + $parts = []; + foreach ($query as $key => $value) { + if ($value === null || $value === '') { + continue; + } + + if (str_starts_with((string) $key, 'dimensions')) { + $parts[] = 'dimensions='.rawurlencode((string) $value); + + continue; + } + + if (str_starts_with((string) $key, 'metrics')) { + $parts[] = 'metrics='.rawurlencode((string) $value); + + continue; + } + + $parts[] = rawurlencode((string) $key).'='.rawurlencode((string) $value); + } + + if ($parts === []) { + return $url; + } + + return $url.'?'.implode('&', $parts); + } + + private function trimPath(string $path): string + { + return ltrim($path, '/'); + } + + private function backoff(int $sleepMs, int $attempt): void + { + if ($sleepMs <= 0) { + return; + } + + usleep($sleepMs * 1000 * $attempt); + } + + private function errorMessage(Response $response, string $fallback): string + { + $json = $response->json(); + if (! is_array($json)) { + return $fallback; + } + + $error = $json['error'] ?? null; + if (is_string($error) && $error !== '') { + return $error; + } + + if (is_array($error)) { + $status = (string) ($error['status'] ?? ''); + $message = (string) ($error['message'] ?? ''); + $combined = trim($status.' '.$message); + + return $combined !== '' ? $combined : $fallback; + } + + return $fallback; + } + + private function isRevoked(Response $response, string $message): bool + { + $haystack = Str::lower($message.' '.$response->body()); + + return str_contains($haystack, 'invalid_grant') + || str_contains($haystack, 'invalid credentials') + || str_contains($haystack, 'revoked'); + } +} diff --git a/app/Services/Adsense/AdsenseLogSanitizer.php b/app/Services/Adsense/AdsenseLogSanitizer.php new file mode 100644 index 00000000..7d0ba5dd --- /dev/null +++ b/app/Services/Adsense/AdsenseLogSanitizer.php @@ -0,0 +1,68 @@ + + */ + private const SENSITIVE_KEYS = [ + 'access_token', + 'refresh_token', + 'id_token', + 'token', + 'code', + 'client_secret', + 'authorization', + 'encrypted_refresh_token', + ]; + + /** + * @param array $context + * @return array + */ + public static function context(array $context): array + { + $clean = []; + + foreach ($context as $key => $value) { + $normalized = strtolower((string) $key); + + if (in_array($normalized, self::SENSITIVE_KEYS, true) || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) { + continue; + } + + if (is_array($value)) { + $clean[$key] = self::context($value); + + continue; + } + + if (is_string($value) && self::looksLikeSecret($value)) { + continue; + } + + $clean[$key] = $value; + } + + return $clean; + } + + public static function looksLikeSecret(string $value): bool + { + if ($value === '') { + return false; + } + + foreach (['refresh-secret', 'access-secret', 'ya29.', '1//'] as $marker) { + if (str_contains($value, $marker)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/Adsense/AdsenseOAuthService.php b/app/Services/Adsense/AdsenseOAuthService.php new file mode 100644 index 00000000..152f5ccd --- /dev/null +++ b/app/Services/Adsense/AdsenseOAuthService.php @@ -0,0 +1,327 @@ +clientId() !== '' && $this->clientSecret() !== '' && $this->redirectUri() !== ''; + } + + public function authorizationUrl(Request $request, bool $promptConsent = true): string + { + if (! $this->isConfigured()) { + throw new AdsenseOAuthException('Google AdSense OAuth is not configured.'); + } + + $state = bin2hex(random_bytes(32)); + $request->session()->put(self::SESSION_STATE_KEY, $state); + + $query = [ + 'client_id' => $this->clientId(), + 'redirect_uri' => $this->redirectUri(), + 'response_type' => 'code', + 'scope' => (string) config('adsense.scope'), + 'access_type' => 'offline', + 'include_granted_scopes' => 'true', + 'state' => $state, + ]; + + if ($promptConsent) { + $query['prompt'] = 'consent'; + } + + return (string) config('adsense.oauth_authorize_url').'?'.http_build_query($query); + } + + /** + * @return array{access_token: string, refresh_token: ?string, expires_in: int} + */ + public function exchangeAuthorizationCode(string $code): array + { + return $this->requestToken([ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->redirectUri(), + 'client_id' => $this->clientId(), + 'client_secret' => $this->clientSecret(), + ]); + } + + /** + * @return array{access_token: string, refresh_token: ?string, expires_in: int} + */ + public function refreshAccessToken(string $refreshToken): array + { + try { + return $this->requestToken([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => $this->clientId(), + 'client_secret' => $this->clientSecret(), + ]); + } catch (AdsenseOAuthException $exception) { + if ($this->isInvalidGrant($exception)) { + throw new AdsenseAuthorizationException( + 'AdSense authorization expired or was revoked.', + 0, + $exception, + ); + } + + throw $exception; + } + } + + public function persistTokens(AdsenseConnection $connection, array $tokens, ?int $userId = null): void + { + $refreshToken = $tokens['refresh_token'] ?? null; + if (is_string($refreshToken) && $refreshToken !== '') { + $connection->encrypted_refresh_token = $refreshToken; + } + + if (! $connection->hasRefreshToken()) { + throw new AdsenseOAuthException('Google did not return a refresh token. Reconnect with consent.'); + } + + if ($userId !== null) { + $connection->connected_by_user_id = $userId; + } + + if ($connection->connected_at === null) { + $connection->connected_at = now(); + } + + if ($connection->status === AdsenseConnection::STATUS_DISCONNECTED) { + $connection->status = AdsenseConnection::STATUS_PENDING; + } + + $connection->last_error = null; + $connection->save(); + + if (isset($tokens['access_token']) && is_string($tokens['access_token']) && $tokens['access_token'] !== '') { + $this->cacheAccessToken( + (int) $connection->id, + $tokens['access_token'], + (int) ($tokens['expires_in'] ?? 3600), + ); + } + } + + public function accessToken(AdsenseConnection $connection, bool $forceRefresh = false): string + { + if (! $connection->hasRefreshToken()) { + $this->markReconnectRequired($connection, 'AdSense authorization expired or was revoked.'); + + throw new AdsenseAuthorizationException('AdSense authorization expired or was revoked.'); + } + + $cacheKey = $this->cacheKey((int) $connection->id); + + if (! $forceRefresh) { + $cached = Cache::get($cacheKey); + if (is_string($cached) && $cached !== '') { + return $cached; + } + } + + try { + $tokens = $this->refreshAccessToken((string) $connection->encrypted_refresh_token); + } catch (AdsenseAuthorizationException $exception) { + $this->forgetAccessToken((int) $connection->id); + $this->markReconnectRequired($connection, $exception->getMessage()); + + throw $exception; + } + + $accessToken = (string) ($tokens['access_token'] ?? ''); + if ($accessToken === '') { + throw new AdsenseOAuthException('Google did not return an access token.'); + } + + if (isset($tokens['refresh_token']) && is_string($tokens['refresh_token']) && $tokens['refresh_token'] !== '') { + $connection->encrypted_refresh_token = $tokens['refresh_token']; + $connection->save(); + } + + $this->cacheAccessToken((int) $connection->id, $accessToken, (int) ($tokens['expires_in'] ?? 3600)); + + return $accessToken; + } + + public function cacheAccessToken(int $connectionId, string $accessToken, int $expiresIn): void + { + $ttl = max(30, $expiresIn - (int) config('adsense.access_token_skew_seconds', 60)); + Cache::put($this->cacheKey($connectionId), $accessToken, $ttl); + } + + public function forgetAccessToken(int $connectionId): void + { + Cache::forget($this->cacheKey($connectionId)); + } + + public function markReconnectRequired(AdsenseConnection $connection, string $message): void + { + $connection->status = AdsenseConnection::STATUS_RECONNECT_REQUIRED; + $connection->last_error = $message; + $connection->save(); + } + + public function disconnect(AdsenseConnection $connection): void + { + $refreshToken = $connection->encrypted_refresh_token; + $this->forgetAccessToken((int) $connection->id); + + if (is_string($refreshToken) && $refreshToken !== '') { + $this->revokeToken($refreshToken); + } + + $connection->encrypted_refresh_token = null; + $connection->status = AdsenseConnection::STATUS_DISCONNECTED; + $connection->last_error = null; + $connection->save(); + } + + public function revokeToken(string $token): void + { + try { + Http::asForm() + ->timeout((int) config('adsense.timeout', 20)) + ->connectTimeout((int) config('adsense.connect_timeout', 5)) + ->post((string) config('adsense.oauth_revoke_url'), [ + 'token' => $token, + ]); + } catch (\Throwable $exception) { + Log::warning('AdSense token revocation failed.', AdsenseLogSanitizer::context([ + 'message' => $exception->getMessage(), + ])); + } + } + + public function assertValidCallback(Request $request): string + { + $error = trim((string) $request->query('error', '')); + if ($error !== '') { + $description = trim((string) $request->query('error_description', '')); + $request->session()->forget(self::SESSION_STATE_KEY); + + throw new AdsenseOAuthException( + $description !== '' + ? 'Google AdSense authorization was denied: '.$description + : 'Google AdSense authorization was denied.' + ); + } + + $expected = (string) $request->session()->pull(self::SESSION_STATE_KEY, ''); + $provided = (string) $request->query('state', ''); + + if ($expected === '' || $provided === '' || ! hash_equals($expected, $provided)) { + throw new AdsenseOAuthException('Invalid OAuth state.'); + } + + $code = trim((string) $request->query('code', '')); + if ($code === '') { + throw new AdsenseOAuthException('Missing authorization code.'); + } + + return $code; + } + + /** + * @param array $payload + * @return array{access_token: string, refresh_token: ?string, expires_in: int} + */ + private function requestToken(array $payload): array + { + try { + $response = Http::asForm() + ->acceptJson() + ->timeout((int) config('adsense.timeout', 20)) + ->connectTimeout((int) config('adsense.connect_timeout', 5)) + ->post((string) config('adsense.oauth_token_url'), $payload); + } catch (ConnectionException $exception) { + Log::warning('AdSense OAuth token request failed.', AdsenseLogSanitizer::context([ + 'message' => $exception->getMessage(), + ])); + + throw new AdsenseOAuthException('Unable to contact Google OAuth.', 0, $exception); + } + + $json = $response->json(); + $json = is_array($json) ? $json : []; + + if ($response->failed()) { + $error = $json['error'] ?? 'oauth_error'; + if (is_array($error)) { + $error = (string) ($error['message'] ?? $error['status'] ?? 'oauth_error'); + } else { + $error = (string) $error; + } + Log::warning('AdSense OAuth token request rejected.', AdsenseLogSanitizer::context([ + 'status' => $response->status(), + 'error' => $error, + ])); + + throw new AdsenseOAuthException($error, $response->status()); + } + + $accessToken = (string) ($json['access_token'] ?? ''); + if ($accessToken === '') { + throw new AdsenseOAuthException('Google did not return an access token.'); + } + + $refreshToken = $json['refresh_token'] ?? null; + + return [ + 'access_token' => $accessToken, + 'refresh_token' => is_string($refreshToken) && $refreshToken !== '' ? $refreshToken : null, + 'expires_in' => (int) ($json['expires_in'] ?? 3600), + ]; + } + + private function isInvalidGrant(AdsenseOAuthException $exception): bool + { + $message = Str::lower($exception->getMessage()); + + return str_contains($message, 'invalid_grant') + || str_contains($message, 'revoked') + || str_contains($message, 'invalid_token'); + } + + private function cacheKey(int $connectionId): string + { + return (string) config('adsense.access_token_cache_prefix', 'adsense.access_token.').$connectionId; + } + + private function clientId(): string + { + return trim((string) config('adsense.client_id')); + } + + private function clientSecret(): string + { + return trim((string) config('adsense.client_secret')); + } + + public function redirectUri(): string + { + return trim((string) config('adsense.redirect_uri')); + } +} diff --git a/app/Services/Adsense/AdsenseReportParser.php b/app/Services/Adsense/AdsenseReportParser.php new file mode 100644 index 00000000..8bac999b --- /dev/null +++ b/app/Services/Adsense/AdsenseReportParser.php @@ -0,0 +1,216 @@ + + */ + private const METRIC_COLUMNS = [ + 'ESTIMATED_EARNINGS' => 'estimated_earnings', + 'PAGE_VIEWS' => 'page_views', + 'PAGE_VIEWS_CTR' => 'page_views_ctr', + 'PAGE_VIEWS_RPM' => 'page_views_rpm', + 'AD_REQUESTS' => 'ad_requests', + 'AD_REQUESTS_COVERAGE' => 'ad_requests_coverage', + 'AD_REQUESTS_CTR' => 'ad_requests_ctr', + 'AD_REQUESTS_RPM' => 'ad_requests_rpm', + 'MATCHED_AD_REQUESTS' => 'matched_ad_requests', + 'IMPRESSIONS' => 'impressions', + 'IMPRESSIONS_CTR' => 'impressions_ctr', + 'IMPRESSIONS_RPM' => 'impressions_rpm', + 'CLICKS' => 'clicks', + 'COST_PER_CLICK' => 'cost_per_click', + ]; + + /** + * @var list + */ + private const INTEGER_COLUMNS = [ + 'page_views', + 'ad_requests', + 'matched_ad_requests', + 'impressions', + 'clicks', + ]; + + /** + * @param array $report + * @return list> + */ + public function parse(array $report, string $dimensionType, string $accountResourceName): array + { + $indexByName = $this->headerIndex($report['headers'] ?? []); + $currencyCode = $this->currencyCode($report['headers'] ?? []); + $rows = []; + + foreach ($report['rows'] ?? [] as $row) { + if (! is_array($row)) { + continue; + } + + $cells = $row['cells'] ?? []; + if (! is_array($cells)) { + continue; + } + + $date = $this->cell($cells, $indexByName, 'DATE'); + if (! is_string($date) || $date === '') { + continue; + } + + $dimensionKey = $this->dimensionKey($cells, $indexByName, $dimensionType); + if ($dimensionKey === null || $dimensionKey === '') { + continue; + } + + $parsed = [ + 'date' => $date, + 'account_resource_name' => $accountResourceName, + 'dimension_type' => $dimensionType, + 'dimension_key' => $dimensionKey, + 'dimension_label' => $this->dimensionLabel($cells, $indexByName, $dimensionType, $dimensionKey), + 'currency_code' => $currencyCode, + ]; + + foreach (self::METRIC_COLUMNS as $header => $column) { + $parsed[$column] = $this->metricValue($cells, $indexByName, $header, $column); + } + + $rows[] = $parsed; + } + + return $rows; + } + + public function currencyCode(array $headers): ?string + { + foreach ($headers as $header) { + if (! is_array($header)) { + continue; + } + + $type = strtoupper((string) ($header['type'] ?? '')); + $code = trim((string) ($header['currencyCode'] ?? '')); + if ($type === 'METRIC_CURRENCY' && $code !== '') { + return $code; + } + } + + return null; + } + + /** + * @return array + */ + private function headerIndex(mixed $headers): array + { + $index = []; + if (! is_array($headers)) { + return $index; + } + + foreach ($headers as $position => $header) { + if (! is_array($header)) { + continue; + } + + $name = strtoupper(trim((string) ($header['name'] ?? ''))); + if ($name === '') { + continue; + } + + $index[$name] = (int) $position; + } + + return $index; + } + + /** + * @param array $cells + * @param array $indexByName + */ + private function dimensionKey(array $cells, array $indexByName, string $dimensionType): ?string + { + return match ($dimensionType) { + AdsenseDailyStat::DIMENSION_TOTAL => AdsenseDailyStat::TOTAL_DIMENSION_KEY, + AdsenseDailyStat::DIMENSION_AD_UNIT => $this->cell($cells, $indexByName, 'AD_UNIT_ID') + ?? $this->cell($cells, $indexByName, 'AD_UNIT_NAME'), + AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_ID') + ?? $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_NAME'), + AdsenseDailyStat::DIMENSION_PLATFORM => $this->cell($cells, $indexByName, 'PLATFORM_TYPE_CODE') + ?? $this->cell($cells, $indexByName, 'PLATFORM_TYPE_NAME'), + AdsenseDailyStat::DIMENSION_COUNTRY => $this->cell($cells, $indexByName, 'COUNTRY_CODE') + ?? $this->cell($cells, $indexByName, 'COUNTRY_NAME'), + default => null, + }; + } + + /** + * @param array $cells + * @param array $indexByName + */ + private function dimensionLabel(array $cells, array $indexByName, string $dimensionType, string $dimensionKey): string + { + $label = match ($dimensionType) { + AdsenseDailyStat::DIMENSION_TOTAL => 'Total', + AdsenseDailyStat::DIMENSION_AD_UNIT => $this->cell($cells, $indexByName, 'AD_UNIT_NAME'), + AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_NAME'), + AdsenseDailyStat::DIMENSION_PLATFORM => $this->cell($cells, $indexByName, 'PLATFORM_TYPE_NAME') + ?? $this->cell($cells, $indexByName, 'PLATFORM_TYPE_CODE'), + AdsenseDailyStat::DIMENSION_COUNTRY => $this->cell($cells, $indexByName, 'COUNTRY_NAME') + ?? $this->cell($cells, $indexByName, 'COUNTRY_CODE'), + default => $dimensionKey, + }; + + return is_string($label) && $label !== '' ? $label : $dimensionKey; + } + + /** + * @param array $cells + * @param array $indexByName + */ + private function cell(array $cells, array $indexByName, string $name): ?string + { + $name = strtoupper($name); + if (! array_key_exists($name, $indexByName)) { + return null; + } + + $cell = $cells[$indexByName[$name]] ?? null; + if (is_array($cell)) { + $value = $cell['value'] ?? null; + + return is_scalar($value) ? (string) $value : null; + } + + return is_scalar($cell) ? (string) $cell : null; + } + + /** + * @param array $cells + * @param array $indexByName + */ + private function metricValue(array $cells, array $indexByName, string $header, string $column): int|string|null + { + $raw = $this->cell($cells, $indexByName, $header); + if ($raw === null || $raw === '') { + return null; + } + + if (in_array($column, self::INTEGER_COLUMNS, true)) { + return (int) $raw; + } + + if (! is_numeric($raw)) { + return null; + } + + return $raw; + } +} diff --git a/app/Services/Adsense/AdsenseSyncResult.php b/app/Services/Adsense/AdsenseSyncResult.php new file mode 100644 index 00000000..1e772290 --- /dev/null +++ b/app/Services/Adsense/AdsenseSyncResult.php @@ -0,0 +1,32 @@ + $failedReports + */ + public function __construct( + public string $accountDisplayName = '', + public string $from = '', + public string $to = '', + public int $adUnits = 0, + public int $customChannels = 0, + public int $totalRows = 0, + public int $adUnitRows = 0, + public int $customChannelRows = 0, + public int $platformRows = 0, + public int $countryRows = 0, + public array $failedReports = [], + public bool $entitiesSynced = false, + public bool $reconnectRequired = false, + ) {} + + public function succeeded(): bool + { + return ! $this->reconnectRequired && $this->failedReports === []; + } +} diff --git a/app/Services/Adsense/AdsenseSyncService.php b/app/Services/Adsense/AdsenseSyncService.php new file mode 100644 index 00000000..0f02b4ee --- /dev/null +++ b/app/Services/Adsense/AdsenseSyncService.php @@ -0,0 +1,358 @@ +account_display_name ?: $connection->account_resource_name), + from: $from->toDateString(), + to: $to->toDateString(), + ); + + $connection->last_sync_attempt_at = now(); + $connection->save(); + + try { + $this->syncEntities($connection, $result); + + if (! $entitiesOnly) { + $this->syncReports($connection, $from, $to, $result); + } + + if ($result->reconnectRequired) { + return $result; + } + + $connection->last_successful_sync_at = now(); + $connection->status = $result->failedReports === [] + ? AdsenseConnection::STATUS_CONNECTED + : AdsenseConnection::STATUS_ERROR; + $connection->last_error = $result->failedReports === [] + ? null + : 'Partial AdSense sync failure: '.implode(', ', $result->failedReports); + $connection->save(); + } catch (AdsenseAuthorizationException $exception) { + $result->reconnectRequired = true; + $this->oauth->markReconnectRequired($connection, $exception->getMessage()); + } catch (AdsenseOAuthException $exception) { + $result->failedReports[] = 'oauth'; + $connection->status = AdsenseConnection::STATUS_ERROR; + $connection->last_error = $exception->getMessage(); + $connection->save(); + } catch (AdsenseApiException $exception) { + $result->failedReports[] = 'entities'; + Log::warning('AdSense entity synchronization failed.', AdsenseLogSanitizer::context([ + 'status' => $exception->status, + 'message' => $exception->getMessage(), + ])); + $connection->status = AdsenseConnection::STATUS_ERROR; + $connection->last_error = $exception->getMessage(); + $connection->save(); + } + + return $result; + } + + public function syncEntities(AdsenseConnection $connection, ?AdsenseSyncResult $result = null): AdsenseSyncResult + { + $result ??= new AdsenseSyncResult( + accountDisplayName: (string) ($connection->account_display_name ?: $connection->account_resource_name), + ); + + $account = (string) $connection->account_resource_name; + if ($account === '') { + throw new AdsenseApiException('No AdSense account is selected.'); + } + + $adClients = $this->api->listAdClients($connection, $account); + $now = now(); + $adUnits = 0; + $customChannels = 0; + + foreach ($adClients as $client) { + $clientName = (string) ($client['name'] ?? ''); + if ($clientName === '' || ! $this->supportsInventory($client)) { + continue; + } + + try { + foreach ($this->api->listAdUnits($connection, $clientName) as $unit) { + $this->upsertEntity($account, $clientName, AdsenseEntity::TYPE_AD_UNIT, $unit, $now); + $adUnits++; + } + } catch (AdsenseAuthorizationException $exception) { + throw $exception; + } catch (AdsenseApiException $exception) { + $this->logSkippedClient($clientName, 'ad_units', $exception); + } + + try { + foreach ($this->api->listCustomChannels($connection, $clientName) as $channel) { + $this->upsertEntity($account, $clientName, AdsenseEntity::TYPE_CUSTOM_CHANNEL, $channel, $now); + $customChannels++; + } + } catch (AdsenseAuthorizationException $exception) { + throw $exception; + } catch (AdsenseApiException $exception) { + $this->logSkippedClient($clientName, 'custom_channels', $exception); + } + } + + $result->adUnits = $adUnits; + $result->customChannels = $customChannels; + $result->entitiesSynced = true; + + return $result; + } + + private function syncReports( + AdsenseConnection $connection, + Carbon $from, + Carbon $to, + AdsenseSyncResult $result, + ): void { + $account = (string) $connection->account_resource_name; + $metrics = array_values((array) config('adsense.metrics', [])); + + $definitions = [ + AdsenseDailyStat::DIMENSION_TOTAL => ['DATE'], + AdsenseDailyStat::DIMENSION_AD_UNIT => ['DATE', 'AD_UNIT_ID'], + AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => ['DATE', 'CUSTOM_CHANNEL_ID'], + AdsenseDailyStat::DIMENSION_PLATFORM => ['DATE', 'PLATFORM_TYPE_CODE'], + AdsenseDailyStat::DIMENSION_COUNTRY => ['DATE', 'COUNTRY_CODE'], + ]; + + foreach ($definitions as $dimensionType => $dimensions) { + try { + $report = $this->api->generateReport( + $connection, + $account, + $dimensions, + $metrics, + $from, + $to, + $dimensionType, + ); + $rows = $this->parser->parse($report, $dimensionType, $account); + $this->applyEntityLabels($rows, $account, $dimensionType); + $saved = $this->upsertStats($rows); + + match ($dimensionType) { + AdsenseDailyStat::DIMENSION_TOTAL => $result->totalRows = $saved, + AdsenseDailyStat::DIMENSION_AD_UNIT => $result->adUnitRows = $saved, + AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $result->customChannelRows = $saved, + AdsenseDailyStat::DIMENSION_PLATFORM => $result->platformRows = $saved, + AdsenseDailyStat::DIMENSION_COUNTRY => $result->countryRows = $saved, + default => null, + }; + } catch (AdsenseAuthorizationException $exception) { + $result->reconnectRequired = true; + throw $exception; + } catch (AdsenseApiException $exception) { + $result->failedReports[] = $dimensionType; + Log::warning('AdSense report synchronization failed.', AdsenseLogSanitizer::context([ + 'report' => $dimensionType, + 'status' => $exception->status, + 'message' => $exception->getMessage(), + ])); + } + } + } + + /** + * Website content ad clients (AFC / ca-pub-...) expose ad units. + * YouTube host clients (ca-yt-host-pub-...) are listed by Google but 404 on adunits. + * + * @param array $client + */ + private function supportsInventory(array $client): bool + { + $name = strtolower((string) ($client['name'] ?? '')); + $reportingId = strtolower((string) ($client['reportingDimensionId'] ?? '')); + $haystack = $name.' '.$reportingId; + + foreach ((array) config('adsense.inventory_excluded_client_prefixes', ['ca-yt-host-']) as $prefix) { + $prefix = strtolower(trim((string) $prefix)); + if ($prefix !== '' && str_contains($haystack, $prefix)) { + return false; + } + } + + $product = strtoupper(trim((string) ($client['productCode'] ?? ''))); + $allowed = array_values(array_filter(array_map( + static fn (mixed $code): string => strtoupper(trim((string) $code)), + (array) config('adsense.inventory_product_codes', ['AFC']), + ))); + + if ($product !== '' && $allowed !== [] && ! in_array($product, $allowed, true)) { + return false; + } + + return true; + } + + private function logSkippedClient(string $clientName, string $kind, AdsenseApiException $exception): void + { + Log::warning('AdSense skipped an ad client inventory request.', AdsenseLogSanitizer::context([ + 'client' => $clientName, + 'kind' => $kind, + 'status' => $exception->status, + 'message' => $exception->getMessage(), + ])); + } + + /** + * @param array $entity + */ + private function upsertEntity( + string $account, + string $adClient, + string $type, + array $entity, + Carbon $seenAt, + ): void { + $resourceName = (string) ($entity['name'] ?? ''); + $reportingId = (string) ($entity['reportingDimensionId'] ?? ''); + if ($reportingId === '' && $resourceName !== '') { + $reportingId = (string) str($resourceName)->afterLast('/'); + } + if ($reportingId === '') { + return; + } + + $state = strtoupper((string) ($entity['state'] ?? '')); + $active = $state === '' ? null : $state === 'ACTIVE' || $state === 'READY'; + + AdsenseEntity::query()->updateOrCreate( + [ + 'account_resource_name' => $account, + 'type' => $type, + 'reporting_dimension_id' => $reportingId, + ], + [ + 'ad_client_resource_name' => $adClient, + 'resource_name' => $resourceName !== '' ? $resourceName : null, + 'display_name' => (string) ($entity['displayName'] ?? $reportingId), + 'active' => $active, + 'last_seen_at' => $seenAt, + ], + ); + } + + /** + * @param list> $rows + */ + private function applyEntityLabels(array &$rows, string $account, string $dimensionType): void + { + if (! in_array($dimensionType, [AdsenseDailyStat::DIMENSION_AD_UNIT, AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL], true)) { + return; + } + + $labels = AdsenseEntity::query() + ->where('account_resource_name', $account) + ->where('type', $dimensionType) + ->pluck('display_name', 'reporting_dimension_id'); + + foreach ($rows as &$row) { + $key = (string) ($row['dimension_key'] ?? ''); + if ($key !== '' && isset($labels[$key]) && is_string($labels[$key]) && $labels[$key] !== '') { + $row['dimension_label'] = $labels[$key]; + } + } + unset($row); + } + + /** + * @param list> $rows + */ + private function upsertStats(array $rows): int + { + if ($rows === []) { + return 0; + } + + $now = now(); + $payload = []; + + foreach ($rows as $row) { + $payload[] = [ + 'date' => $row['date'], + 'account_resource_name' => $row['account_resource_name'], + 'dimension_type' => $row['dimension_type'], + 'dimension_key' => $row['dimension_key'], + 'dimension_label' => $row['dimension_label'] ?? null, + 'currency_code' => $row['currency_code'] ?? null, + 'page_views' => $row['page_views'] ?? null, + 'ad_requests' => $row['ad_requests'] ?? null, + 'matched_ad_requests' => $row['matched_ad_requests'] ?? null, + 'impressions' => $row['impressions'] ?? null, + 'clicks' => $row['clicks'] ?? null, + 'estimated_earnings' => $row['estimated_earnings'] ?? null, + 'page_views_ctr' => $row['page_views_ctr'] ?? null, + 'page_views_rpm' => $row['page_views_rpm'] ?? null, + 'ad_requests_coverage' => $row['ad_requests_coverage'] ?? null, + 'ad_requests_ctr' => $row['ad_requests_ctr'] ?? null, + 'ad_requests_rpm' => $row['ad_requests_rpm'] ?? null, + 'impressions_ctr' => $row['impressions_ctr'] ?? null, + 'impressions_rpm' => $row['impressions_rpm'] ?? null, + 'cost_per_click' => $row['cost_per_click'] ?? null, + 'last_synced_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + foreach (array_chunk($payload, 200) as $chunk) { + AdsenseDailyStat::query()->upsert( + $chunk, + ['account_resource_name', 'date', 'dimension_type', 'dimension_key'], + [ + 'dimension_label', + 'currency_code', + 'page_views', + 'ad_requests', + 'matched_ad_requests', + 'impressions', + 'clicks', + 'estimated_earnings', + 'page_views_ctr', + 'page_views_rpm', + 'ad_requests_coverage', + 'ad_requests_ctr', + 'ad_requests_rpm', + 'impressions_ctr', + 'impressions_rpm', + 'cost_per_click', + 'last_synced_at', + 'updated_at', + ], + ); + } + + return count($payload); + } +} diff --git a/app/Services/Adsense/Exceptions/AdsenseApiException.php b/app/Services/Adsense/Exceptions/AdsenseApiException.php new file mode 100644 index 00000000..3a1aa085 --- /dev/null +++ b/app/Services/Adsense/Exceptions/AdsenseApiException.php @@ -0,0 +1,17 @@ + env('ADSENSE_CLIENT_ID'), + 'client_secret' => env('ADSENSE_CLIENT_SECRET'), + + 'redirect_uri' => env( + 'ADSENSE_REDIRECT_URI', + rtrim((string) env('APP_URL', 'http://localhost'), '/').'/admin/adsense/oauth/callback' + ), + + 'scope' => 'https://www.googleapis.com/auth/adsense.readonly', + + 'sync_enabled' => filter_var(env('ADSENSE_SYNC_ENABLED', true), FILTER_VALIDATE_BOOLEAN), + + 'api_base' => env('ADSENSE_API_BASE', 'https://adsense.googleapis.com/v2'), + 'oauth_authorize_url' => env('ADSENSE_OAUTH_AUTHORIZE_URL', 'https://accounts.google.com/o/oauth2/v2/auth'), + 'oauth_token_url' => env('ADSENSE_OAUTH_TOKEN_URL', 'https://oauth2.googleapis.com/token'), + 'oauth_revoke_url' => env('ADSENSE_OAUTH_REVOKE_URL', 'https://oauth2.googleapis.com/revoke'), + + 'timeout' => max(5, (int) env('ADSENSE_HTTP_TIMEOUT', 20)), + 'connect_timeout' => max(1, (int) env('ADSENSE_HTTP_CONNECT_TIMEOUT', 5)), + 'retry_times' => max(1, (int) env('ADSENSE_HTTP_RETRY_TIMES', 3)), + 'retry_sleep_ms' => max(0, (int) env('ADSENSE_HTTP_RETRY_SLEEP_MS', 400)), + + 'access_token_cache_prefix' => 'adsense.access_token.', + 'access_token_skew_seconds' => 60, + + // Ad clients whose productCode is set to something else (YouTube host, video, search) + // are skipped. Empty productCode still syncs unless the resource name is excluded. + 'inventory_product_codes' => ['AFC'], + 'inventory_excluded_client_prefixes' => [ + 'ca-yt-host-', + ], + + 'metrics' => [ + 'ESTIMATED_EARNINGS', + 'PAGE_VIEWS', + 'PAGE_VIEWS_CTR', + 'PAGE_VIEWS_RPM', + 'AD_REQUESTS', + 'AD_REQUESTS_COVERAGE', + 'AD_REQUESTS_CTR', + 'AD_REQUESTS_RPM', + 'MATCHED_AD_REQUESTS', + 'IMPRESSIONS', + 'IMPRESSIONS_CTR', + 'IMPRESSIONS_RPM', + 'CLICKS', + 'COST_PER_CLICK', + ], + + 'recommended_placement_names' => [ + 'Skinbase_Artwork_BeforeComments', + 'Skinbase_Home_AfterTrending', + 'Skinbase_Home_AfterFresh', + 'Skinbase_Browse_InFeed', + 'Skinbase_Search_InFeed', + 'Skinbase_News_InArticle', + ], +]; diff --git a/database/migrations/2026_09_20_120000_create_adsense_analytics_tables.php b/database/migrations/2026_09_20_120000_create_adsense_analytics_tables.php new file mode 100644 index 00000000..4ace23fe --- /dev/null +++ b/database/migrations/2026_09_20_120000_create_adsense_analytics_tables.php @@ -0,0 +1,99 @@ +id(); + $table->string('account_resource_name', 128)->nullable(); + $table->string('account_display_name', 191)->nullable(); + $table->text('encrypted_refresh_token')->nullable(); + $table->string('status', 32)->default('pending')->index(); + $table->unsignedBigInteger('connected_by_user_id')->nullable(); + $table->timestamp('connected_at')->nullable(); + $table->timestamp('last_sync_attempt_at')->nullable(); + $table->timestamp('last_successful_sync_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + + $table->foreign('connected_by_user_id') + ->references('id') + ->on('users') + ->nullOnDelete(); + }); + } + + if (! Schema::hasTable('adsense_entities')) { + Schema::create('adsense_entities', function (Blueprint $table): void { + $table->id(); + $table->string('account_resource_name', 128); + $table->string('ad_client_resource_name', 191)->nullable(); + $table->string('type', 32); + $table->string('resource_name', 191)->nullable(); + $table->string('reporting_dimension_id', 128); + $table->string('display_name', 191); + $table->boolean('active')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + + $table->unique( + ['account_resource_name', 'type', 'reporting_dimension_id'], + 'adsense_entities_unique' + ); + $table->index(['account_resource_name', 'type'], 'adsense_entities_account_type_idx'); + }); + } + + if (! Schema::hasTable('adsense_daily_stats')) { + Schema::create('adsense_daily_stats', function (Blueprint $table): void { + $table->id(); + $table->date('date'); + $table->string('account_resource_name', 128); + $table->string('dimension_type', 32); + $table->string('dimension_key', 128); + $table->string('dimension_label', 191)->nullable(); + $table->string('currency_code', 8)->nullable(); + $table->unsignedBigInteger('page_views')->nullable(); + $table->unsignedBigInteger('ad_requests')->nullable(); + $table->unsignedBigInteger('matched_ad_requests')->nullable(); + $table->unsignedBigInteger('impressions')->nullable(); + $table->unsignedBigInteger('clicks')->nullable(); + $table->decimal('estimated_earnings', 16, 6)->nullable(); + $table->decimal('page_views_ctr', 12, 8)->nullable(); + $table->decimal('page_views_rpm', 16, 6)->nullable(); + $table->decimal('ad_requests_coverage', 12, 8)->nullable(); + $table->decimal('ad_requests_ctr', 12, 8)->nullable(); + $table->decimal('ad_requests_rpm', 16, 6)->nullable(); + $table->decimal('impressions_ctr', 12, 8)->nullable(); + $table->decimal('impressions_rpm', 16, 6)->nullable(); + $table->decimal('cost_per_click', 16, 6)->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->unique( + ['account_resource_name', 'date', 'dimension_type', 'dimension_key'], + 'adsense_daily_stats_unique' + ); + $table->index( + ['account_resource_name', 'dimension_type', 'date'], + 'adsense_daily_stats_lookup_idx' + ); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('adsense_daily_stats'); + Schema::dropIfExists('adsense_entities'); + Schema::dropIfExists('adsense_connections'); + } +}; diff --git a/docs/ADSENSE_ANALYTICS.md b/docs/ADSENSE_ANALYTICS.md new file mode 100644 index 00000000..e24b0d8f --- /dev/null +++ b/docs/ADSENSE_ANALYTICS.md @@ -0,0 +1,236 @@ +# Skinbase AdSense Analytics Module + +Status: Implemented +Project: Skinbase.org +Integration: Google AdSense Management API v2 +Authorization: OAuth 2.0 Web Server Flow +Access level: Read-only (`adsense.readonly`) + +This document is the implementation and operations guide for the admin AdSense analytics module. Public advertising behavior is unchanged: registered users remain ad-free; anonymous visitors may see AdSense units. + +--- + +## 1. What the module does + +Administrators can: + +- connect Skinbase to Google AdSense with OAuth 2.0 (offline / refresh token); +- discover AdSense accounts, ad clients, ad units and custom channels; +- synchronize daily reporting into local MySQL tables; +- view an admin dashboard with totals, placement comparison, platform and country breakdowns; +- run hourly/nightly scheduled syncs or a manual **Sync now**; +- reconnect after Google revokes authorization. + +No public page calls the AdSense Management API. The dashboard reads only local tables. + +--- + +## 2. Google Cloud setup + +1. Create or select a Google Cloud project. +2. Enable **Google AdSense Management API**. +3. Configure the OAuth consent screen (Internal or production publishing as required). +4. Create an OAuth Client ID of type **Web application**. +5. Add this exact authorized redirect URI: + +```text +https://skinbase.org/admin/adsense/oauth/callback +``` + +6. Copy the client ID and client secret into the Skinbase environment (never into git). +7. Review Google OAuth publishing / testing limits before relying on unattended production refresh tokens. + +--- + +## 3. Environment + +Add to the deployment environment (see `.env.example`): + +```env +ADSENSE_CLIENT_ID= +ADSENSE_CLIENT_SECRET= +ADSENSE_REDIRECT_URI=https://skinbase.org/admin/adsense/oauth/callback +ADSENSE_SYNC_ENABLED=true +``` + +Configuration lives in `config/adsense.php`. Do not store the Google client secret in the database. + +--- + +## 4. Deploy commands + +```bash +php artisan migrate --force +php artisan config:clear +php artisan route:clear +php artisan view:clear +``` + +Scheduler already loads `routes/console.php`. Ensure the production scheduler / cron is running (`php artisan schedule:run` every minute). Horizon / Redis queues must be running so `SyncAdsenseJob` can process manual and post-OAuth synchronizations. + +--- + +## 5. Connect from Skinbase admin + +1. Sign in as an **admin** (not manager/editorial). +2. Open **AdSense Analytics** (`/admin/adsense`) from the admin sidebar. +3. Click **Connect Google AdSense**. +4. Approve **read-only** AdSense access. +5. If multiple AdSense accounts exist, select one. +6. Initial sync imports entities and the previous 30 days. +7. Confirm the dashboard shows the account and last synchronization time. + +Reconnect uses `prompt=consent` so Google issues a new refresh token. Token refresh never sends `prompt=consent`. + +--- + +## 6. Artisan + +```bash +php artisan adsense:sync +php artisan adsense:sync --days=3 +php artisan adsense:sync --days=30 +php artisan adsense:sync --from=2026-09-01 --to=2026-09-20 +php artisan adsense:sync --entities-only +php artisan adsense:sync --force +``` + +`--force` is required when `ADSENSE_SYNC_ENABLED=false`. `--days` cannot be combined with `--from`/`--to`. + +The command never prints tokens. + +--- + +## 7. Scheduler + +Registered in `routes/console.php`: + +| Cadence | Command | Why | +| --- | --- | --- | +| Hourly at :08 | `adsense:sync --days=3` | Recent totals, including today, can still change | +| Nightly at 04:35 | `adsense:sync --days=30` | Late adjustments to the last month | + +Both use `withoutOverlapping()` and `runInBackground()`. The project does not use `onOneServer()`. + +--- + +## 8. Schema + +### `adsense_connections` + +Encrypted refresh token (`encrypted` Eloquent cast), status (`pending`, `connected`, `reconnect_required`, `error`, `disconnected`), selected account, sync timestamps, last error. Access tokens are cached, not stored permanently. + +### `adsense_entities` + +Upserted metadata for `ad_unit` and `custom_channel`. Entities are not deleted if they disappear from a single API page. + +Inventory sync uses AdSense for Content (`AFC` / `ca-pub-...`) clients. YouTube host clients (`ca-yt-host-pub-...`) are listed by Google but do not expose ad units; they are skipped. A 404 on a single ad client does not fail the rest of the synchronization. + +### `adsense_daily_stats` + +Idempotent daily rows keyed by `account_resource_name + date + dimension_type + dimension_key`. + +Dimension types: `total`, `ad_unit`, `custom_channel`, `platform`, `country`. + +Monetary columns are `DECIMAL`, not floats. + +Disconnect removes credentials and cached access tokens. Historical statistics are retained. + +--- + +## 9. Reports + +Separate report definitions against `GET /v2/{account}/reports:generate`: + +| Report | Dimensions | +| --- | --- | +| A total | `DATE` | +| B ad unit | `DATE`, `AD_UNIT_ID` | +| C custom channel | `DATE`, `CUSTOM_CHANNEL_ID` | +| D platform | `DATE`, `PLATFORM_TYPE_CODE` | +| E country | `DATE`, `COUNTRY_CODE` | + +The parser maps cells by header **name**, not position, and reads currency from `METRIC_CURRENCY` headers. + +Country (and any other isolated report) can fail without rolling back successful reports. + +--- + +## 10. Public advertising (unchanged) + +- Guests may see AdSense (`AdSenseUnit` / `` / homepage `ins.adsbygoogle`). +- Authenticated users do not receive those units. +- Artwork placement remains below Tags & Categories and before Comments. + +Recommended Google AdSense unit names (rename inside Google AdSense; Skinbase is read-only): + +```text +Skinbase_Artwork_BeforeComments +Skinbase_Home_AfterTrending +Skinbase_Home_AfterFresh +Skinbase_Browse_InFeed +Skinbase_Search_InFeed +Skinbase_News_InArticle +``` + +Current known slots: + +| Surface | Slot | Recommended name | +| --- | --- | --- | +| Artwork before comments | `4398418697` | `Skinbase_Artwork_BeforeComments` | +| Homepage after trending (guest) | `8766048277` | `Skinbase_Home_AfterTrending` | + +--- + +## 11. Security + +- OAuth routes: `auth` + `admin.role`. +- Cryptographic `state` stored in the admin session and verified on callback. +- Authorization code exchange is server-side only. +- Refresh tokens encrypted at rest; access tokens cached. +- Tokens, authorization codes and client secrets are never sent to Inertia/JS and must not be logged. +- Scope is only `https://www.googleapis.com/auth/adsense.readonly`. +- Production redirect URI is HTTPS: `https://skinbase.org/admin/adsense/oauth/callback`. + +--- + +## 12. Architecture + +```text +App\Services\Adsense\AdsenseOAuthService +App\Services\Adsense\AdsenseApiClient +App\Services\Adsense\AdsenseReportParser +App\Services\Adsense\AdsenseSyncService +App\Services\Adsense\AdsenseAnalyticsQueryService +App\Http\Controllers\Admin\AdsenseAnalyticsController +App\Jobs\SyncAdsenseJob +App\Console\Commands\AdsenseSyncCommand +``` + +HTTP uses Laravel's HTTP client. The Google PHP SDK is not required. + +--- + +## 13. Routes + +| Method | Path | Name | +| --- | --- | --- | +| GET | `/admin/adsense` | `admin.adsense.index` | +| GET | `/admin/adsense/connect` | `admin.adsense.connect` | +| GET | `/admin/adsense/oauth/callback` | `admin.adsense.callback` | +| POST | `/admin/adsense/account` | `admin.adsense.account` | +| POST | `/admin/adsense/sync` | `admin.adsense.sync` | +| DELETE | `/admin/adsense/disconnect` | `admin.adsense.disconnect` | + +These are registered **before** the legacy `/admin/{path}` → `/moderation/{path}` redirect so the Google redirect URI stays exact. + +--- + +## 14. Tests + +```bash +php artisan test --testsuite=Unit --filter=Adsense +php artisan test tests/Feature/Adsense +``` + +All Google HTTP is faked. Tests never require live Google credentials. diff --git a/docs/SKINBASE_ADSENSE_ANALYTICS.md b/docs/SKINBASE_ADSENSE_ANALYTICS.md new file mode 100644 index 00000000..b66b2dbc --- /dev/null +++ b/docs/SKINBASE_ADSENSE_ANALYTICS.md @@ -0,0 +1,1060 @@ +# Skinbase AdSense Analytics Module + +Status: Planned +Project: Skinbase.org +Integration: Google AdSense Management API v2 +Authorization: OAuth 2.0 Web Server Flow +Access level: Read-only + +--- + +# 1. Purpose + +Implement an internal AdSense analytics module inside the Skinbase administration area. + +The module must allow an administrator to: + +- connect the Skinbase application to a Google AdSense account using OAuth 2.0; +- securely store long-lived authorization; +- automatically synchronize AdSense statistics; +- store historical statistics locally; +- display AdSense performance inside the Skinbase admin; +- compare performance of individual Skinbase ad placements; +- inspect revenue, RPM, CTR, clicks, impressions and coverage; +- inspect desktop/mobile/platform performance; +- optionally inspect country-level performance; +- manually trigger synchronization; +- detect broken/revoked OAuth authorization and request reconnect. + +This is an internal administration feature. + +No AdSense credentials, statistics or management endpoints may be exposed to normal Skinbase users. + +--- + +# 2. Current Skinbase advertising strategy + +Skinbase currently displays ads only to anonymous / non-authenticated visitors. + +Registered users have an ad-free experience. + +Current known placement: + +- Artwork page + - advertisement below Tags & Categories + - advertisement before Comments + +Additional placements may exist or be introduced later, for example: + +- Homepage placement #1 +- Homepage placement #2 +- Browse/category pages +- Search pages +- News pages + +The analytics architecture must therefore support multiple ad units / placements without requiring schema changes. + +--- + +# 3. Google API + +Use Google AdSense Management API v2. + +Base API URL: + +```text +https://adsense.googleapis.com/v2 +``` + +OAuth authorization endpoint: + +```text +https://accounts.google.com/o/oauth2/v2/auth +``` + +OAuth token endpoint: + +```text +https://oauth2.googleapis.com/token +``` + +Required OAuth scope: + +```text +https://www.googleapis.com/auth/adsense.readonly +``` + +Do NOT request full AdSense management access. + +The module is analytics/read-only. + +--- + +# 4. OAuth configuration + +Production redirect URI: + +```text +https://skinbase.org/admin/adsense/oauth/callback +``` + +The redirect URI configured in Google Cloud must exactly match the redirect URI sent by Skinbase. + +Environment configuration: + +```env +ADSENSE_CLIENT_ID= +ADSENSE_CLIENT_SECRET= +ADSENSE_REDIRECT_URI=https://skinbase.org/admin/adsense/oauth/callback +ADSENSE_SYNC_ENABLED=true +``` + +Do not commit real credentials. + +Add equivalent keys to `.env.example` without secrets. + +Create: + +```text +config/adsense.php +``` + +Suggested configuration: + +```php +return [ + 'client_id' => env('ADSENSE_CLIENT_ID'), + 'client_secret' => env('ADSENSE_CLIENT_SECRET'), + + 'redirect_uri' => env( + 'ADSENSE_REDIRECT_URI', + rtrim(env('APP_URL'), '/') . '/admin/adsense/oauth/callback' + ), + + 'scope' => 'https://www.googleapis.com/auth/adsense.readonly', + + 'sync_enabled' => env('ADSENSE_SYNC_ENABLED', true), +]; +``` + +--- + +# 5. OAuth flow + +```text +Admin + ↓ +AdSense Analytics + ↓ +Connect Google AdSense + ↓ +Google OAuth + ↓ +Skinbase callback + ↓ +Token exchange + ↓ +Discover available AdSense accounts + ↓ +Select account if more than one + ↓ +Connection active +``` + +OAuth authorization request must use: + +```text +response_type=code +access_type=offline +include_granted_scopes=true +scope=https://www.googleapis.com/auth/adsense.readonly +state= +``` + +For an explicit new Connect/Reconnect operation use: + +```text +prompt=consent +``` + +Never use `prompt=consent` for normal token refreshes. + +--- + +# 6. OAuth security requirements + +OAuth implementation MUST: + +- generate a cryptographically secure `state`; +- store the state in the authenticated admin session; +- verify the callback state; +- reject missing or invalid state; +- reject callbacks containing OAuth errors; +- exchange authorization code server-side; +- never expose client secret to JavaScript; +- never expose refresh/access tokens to JavaScript; +- never log OAuth tokens; +- encrypt refresh tokens at rest; +- limit OAuth routes to authorized administrators; +- use HTTPS in production. + +The refresh token is the important long-lived credential. + +Access tokens should preferably be cached and refreshed as required instead of being permanently stored in plain form. + +--- + +# 7. Important Google OAuth production note + +The Google Cloud OAuth application's publishing state must be considered. + +During development/testing, Google may issue refresh tokens with limited lifetime. + +Before relying on unattended production synchronization, verify that the OAuth application is configured appropriately for production. + +The admin page should detect an invalid or revoked refresh token and show: + +```text +AdSense authorization expired or was revoked. +Reconnect Google AdSense. +``` + +--- + +# 8. Suggested database design + +## 8.1 `adsense_connections` + +Suggested fields: + +```text +id +account_resource_name +account_display_name +encrypted_refresh_token +status +connected_by_user_id +connected_at +last_sync_attempt_at +last_successful_sync_at +last_error +created_at +updated_at +``` + +Possible `status` values: + +```text +pending +connected +reconnect_required +error +``` + +Use Laravel encryption / encrypted casts for the refresh token. + +## 8.2 `adsense_entities` + +Suggested fields: + +```text +id +account_resource_name +ad_client_resource_name nullable +type +resource_name nullable +reporting_dimension_id +display_name +active nullable +last_seen_at +created_at +updated_at +``` + +Supported entity types initially: + +```text +ad_unit +custom_channel +``` + +Use upsert semantics. + +## 8.3 `adsense_daily_stats` + +Suggested fields: + +```text +id +date +account_resource_name +dimension_type +dimension_key +dimension_label nullable +currency_code +page_views +ad_requests +matched_ad_requests +impressions +clicks +estimated_earnings +page_views_ctr +page_views_rpm +ad_requests_coverage +ad_requests_ctr +ad_requests_rpm +impressions_ctr +impressions_rpm +cost_per_click +last_synced_at +created_at +updated_at +``` + +Supported `dimension_type` values: + +```text +total +ad_unit +custom_channel +platform +country +``` + +For total rows: + +```text +dimension_type = total +dimension_key = __total__ +``` + +Unique index: + +```text +account_resource_name +date +dimension_type +dimension_key +``` + +Synchronization MUST be idempotent and use upsert/update semantics. + +--- + +# 9. AdSense entities synchronization + +After account connection and during regular synchronization: + +1. Fetch AdSense accounts. +2. Use selected account. +3. Fetch ad clients. +4. For each relevant ad client: + - fetch ad units; + - fetch custom channels. +5. Store/update metadata in `adsense_entities`. + +API resources: + +```text +GET /v2/accounts +GET /v2/{account}/adclients +GET /v2/{account}/adclients/{adclient}/adunits +GET /v2/{account}/adclients/{adclient}/customchannels +``` + +Implement pagination correctly. + +Do not assume one response page. + +--- + +# 10. Read-only inventory policy + +Do not implement creation, deletion or modification of AdSense: + +- ad units; +- custom channels; +- sites; +- account settings. + +AdSense inventory configuration remains managed in Google AdSense. + +Skinbase only reads the data. + +--- + +# 11. Reporting API + +Generate reports using: + +```text +GET /v2/{account}/reports:generate +``` + +The parser must NOT assume fixed array positions. + +Parse returned rows using the report header names. + +Use header information to identify monetary metrics and their currency. + +--- + +# 12. Core metrics + +Synchronize these metrics where compatible: + +```text +ESTIMATED_EARNINGS +PAGE_VIEWS +PAGE_VIEWS_CTR +PAGE_VIEWS_RPM +AD_REQUESTS +AD_REQUESTS_COVERAGE +AD_REQUESTS_CTR +AD_REQUESTS_RPM +MATCHED_AD_REQUESTS +IMPRESSIONS +IMPRESSIONS_CTR +IMPRESSIONS_RPM +CLICKS +COST_PER_CLICK +``` + +Do not manually derive Google metrics if the API already provides them. + +Ratios should be stored as decimals as returned by Google. + +--- + +# 13. Report dimensions + +## Report A — Daily totals + +Dimensions: + +```text +DATE +``` + +## Report B — Ad units + +Dimensions: + +```text +DATE +AD_UNIT_ID +``` + +Map `AD_UNIT_ID` to the local entity display name. + +Recommended placement names: + +```text +Skinbase_Artwork_BeforeComments +Skinbase_Home_AfterTrending +Skinbase_Home_AfterFresh +Skinbase_Browse_InFeed +Skinbase_Search_InFeed +Skinbase_News_InArticle +``` + +## Report C — Custom channels + +Dimensions: + +```text +DATE +CUSTOM_CHANNEL_ID +``` + +## Report D — Platform + +Dimensions: + +```text +DATE +PLATFORM_TYPE_CODE +``` + +## Report E — Country + +Dimensions: + +```text +DATE +COUNTRY_CODE +``` + +Country statistics are secondary and must not block successful core synchronization. + +--- + +# 14. Partial failure behavior + +Each report must be isolated. + +Example: + +```text +total succeeded +ad_unit succeeded +platform succeeded +country failed +``` + +The first three successful datasets must still be saved. + +Do not roll back all reporting because one optional report failed. + +--- + +# 15. Suggested service architecture + +```text +App\Services\Adsense\AdsenseOAuthService +App\Services\Adsense\AdsenseApiClient +App\Services\Adsense\AdsenseReportParser +App\Services\Adsense\AdsenseSyncService +``` + +Adapt namespaces and class names to the existing Skinbase conventions. + +## `AdsenseOAuthService` + +- OAuth authorization URL; +- authorization-code exchange; +- refresh access token; +- refresh-token lifecycle. + +## `AdsenseApiClient` + +- authenticated requests; +- pagination; +- retries; +- response validation. + +## `AdsenseReportParser` + +- parse headers by name; +- map cells; +- detect currency; +- normalize integer/decimal values. + +## `AdsenseSyncService` + +- synchronize metadata; +- synchronize reports; +- persist normalized rows; +- update connection status; +- isolate partial failures. + +--- + +# 16. Token handling + +Normal flow: + +```text +stored encrypted refresh token + ↓ +request access token + ↓ +cache short-lived access token + ↓ +call AdSense API +``` + +If an API call fails due to an expired token: + +```text +refresh access token +retry request once +``` + +If refresh fails permanently: + +```text +connection.status = reconnect_required +``` + +Never loop indefinitely. + +--- + +# 17. Artisan command + +Create: + +```bash +php artisan adsense:sync +``` + +Options: + +```bash +php artisan adsense:sync --days=3 +php artisan adsense:sync --days=30 +php artisan adsense:sync --from=2026-09-01 --to=2026-09-20 +php artisan adsense:sync --entities-only +``` + +Optional: + +```bash +php artisan adsense:sync --force +``` + +Never print tokens. + +--- + +# 18. Scheduler + +Use the existing Laravel scheduling architecture. + +Suggested schedule: + +- hourly: synchronize previous 3 days; +- nightly: synchronize previous 30 days. + +Use `withoutOverlapping()`. + +If the existing deployment uses distributed scheduler locking, follow its existing `onOneServer()` convention. + +--- + +# 19. Manual sync + +Admin dashboard must provide: + +```text +Sync now +``` + +The action must use admin authorization and CSRF protection. + +For large historical syncs, use the project's existing queue architecture rather than blocking the HTTP request. + +--- + +# 20. Admin routes + +Suggested routes: + +```text +GET /admin/adsense +GET /admin/adsense/connect +GET /admin/adsense/oauth/callback +POST /admin/adsense/account +POST /admin/adsense/sync +DELETE /admin/adsense/disconnect +``` + +Use existing admin middleware and authorization. + +--- + +# 21. Admin dashboard + +Create a page consistent with the current Skinbase admin design. + +Connection card: + +```text +Google AdSense +Connected + +Account: Skinbase / pub-... +Last sync: 12 minutes ago + +[ Sync now ] +[ Reconnect ] +[ Disconnect ] +``` + +Support statuses: + +```text +Not connected +Connected +Synchronization error +Reconnect required +``` + +--- + +# 22. Period selector + +Support: + +```text +Today +Yesterday +Last 7 days +Last 30 days +Custom range +``` + +Default: + +```text +Last 30 days +``` + +--- + +# 23. KPI cards + +Show: + +```text +Estimated earnings +Page views +Ad impressions +Clicks +Page CTR +Page RPM +Ad request coverage +CPC +``` + +Where useful, compare with the immediately previous equal-length period. + +Never divide by zero. + +--- + +# 24. Revenue chart + +Primary chart: + +```text +Estimated earnings by day +``` + +Reuse the chart library already available in Skinbase. + +Optional toggles: + +```text +Revenue +Page views +Page RPM +Clicks +``` + +--- + +# 25. Placement performance table + +Primary table: + +```text +Ad placement Views Impressions Clicks CTR RPM Revenue +------------------------------------------------------------------------------------ +Artwork Before Comments ... ... ... ... ... ... +Homepage Trending ... ... ... ... ... ... +Homepage Fresh ... ... ... ... ... ... +``` + +Default sort: + +```text +Revenue descending +``` + +This is a key part of the module because Skinbase must be able to evaluate individual ad placements. + +--- + +# 26. Platform breakdown + +Show performance by platform: + +```text +Desktop +Mobile +Tablet +``` + +Metrics: + +```text +Page views +Impressions +Clicks +RPM +Revenue +``` + +--- + +# 27. Country breakdown + +Optional table: + +```text +Country +Page views +Impressions +Clicks +RPM +Revenue +``` + +Default: + +```text +Top 20 countries by revenue +``` + +--- + +# 28. Data freshness + +Display the last synchronization time and show: + +```text +Today's AdSense revenue is estimated and may change. +``` + +Do not imply that current-day values are final. + +--- + +# 29. Disconnect behavior + +Disconnect must: + +1. require authenticated admin; +2. use POST/DELETE + CSRF; +3. remove local refresh credentials; +4. clear cached access token; +5. retain historical statistics. + +Historical reporting data must NOT be deleted automatically. + +Optional: best-effort Google token revocation. + +--- + +# 30. Error handling + +Handle at minimum: + +```text +OAuth callback denied +invalid state +missing authorization code +invalid_grant +expired/revoked refresh token +AdSense API 401 +AdSense API 403 +AdSense API 429 +Google 5xx +network timeout +no AdSense accounts +multiple AdSense accounts +invalid report combination +malformed report response +``` + +Use bounded retry/backoff for transient errors. + +--- + +# 31. Logging + +Never log: + +```text +client_secret +access_token +refresh_token +authorization code +``` + +Use sanitized logging only. + +--- + +# 32. Testing + +Implement automated tests covering at least: + +## OAuth + +- admin can start OAuth; +- non-admin cannot; +- correct readonly scope; +- offline access; +- secure state generation; +- valid callback; +- invalid state; +- OAuth errors; +- mocked token exchange; +- refresh token encrypted at rest. + +## API + +- access-token refresh; +- accounts list; +- pagination; +- ad units; +- custom channels; +- 401 handling; +- 429 retry; +- revoked token -> reconnect required. + +## Reporting + +- headers mapped by name; +- parser independent from column order; +- currency extraction; +- total report; +- ad-unit report; +- platform report; +- country report; +- decimal ratio handling; +- idempotent upsert; +- repeat sync updates existing rows. + +## Admin + +- dashboard admin-only; +- connection states; +- period filters; +- placement aggregation; +- manual sync authorization. + +All Google traffic must be mocked in tests. + +--- + +# 33. Privacy and security + +This module imports aggregated advertising performance data only. + +Do not store visitor-level Google advertising data, IP addresses, Google user profiles or visitor identifiers. + +Do not add new public tracking as part of this module. + +--- + +# 34. Public-site performance requirement + +No public Skinbase page may call the AdSense Management API. + +Management API calls are only allowed from: + +```text +OAuth flow +admin manual sync +scheduled synchronization +``` + +The admin dashboard reads statistics from the local database. + +Existing advertising behavior remains: + +- registered users: ad-free; +- anonymous visitors: ads may be displayed. + +--- + +# 35. Initial synchronization + +After first successful OAuth connection: + +1. synchronize entity metadata; +2. synchronize previous 30 days; +3. mark connection successful; +4. redirect to the AdSense dashboard. + +Do not automatically import an unbounded history. + +Longer history can later be imported manually: + +```bash +php artisan adsense:sync --from=2026-01-01 --to=2026-09-20 +``` + +--- + +# 36. Google Cloud setup documentation + +Document these manual steps: + +1. Create/select Google Cloud project. +2. Enable Google AdSense Management API. +3. Configure OAuth consent screen. +4. Create OAuth Client ID of type Web application. +5. Add exact redirect URI: + +```text +https://skinbase.org/admin/adsense/oauth/callback +``` + +6. Configure environment variables. +7. Deploy. +8. Open Skinbase admin. +9. Click Connect Google AdSense. +10. Grant access. +11. Verify account. +12. Verify first synchronization. + +Before unattended production sync is relied on, review the OAuth application's production/publishing configuration. + +--- + +# 37. Definition of Done + +The module is complete when: + +- an administrator can connect Google AdSense; +- OAuth uses `adsense.readonly`; +- offline access / refresh token works; +- refresh token is encrypted; +- available AdSense accounts can be discovered and selected; +- ad units are discovered; +- custom channels are discovered; +- daily reports synchronize; +- statistics are persisted locally; +- repeated synchronization is idempotent; +- scheduler refreshes recent data; +- manual synchronization works; +- dashboard displays revenue and performance; +- dashboard displays placement breakdown; +- dashboard displays platform breakdown; +- invalid authorization produces reconnect state; +- credentials never appear in logs; +- tests pass; +- `.env.example` is updated; +- Google Cloud setup is documented; +- no public Skinbase page depends on the management API. + +--- + +# 38. Future extension — not part of initial implementation + +A later milestone may combine AdSense aggregates with Skinbase-owned analytics such as: + +```text +anonymous page views +registrations +downloads +artwork views +guest -> registration conversion +ad revenue +``` + +Possible future metrics: + +```text +Ad revenue / 1,000 anonymous Skinbase visits +Revenue per artwork page +Guest registration rate before/after advertising +Revenue impact by page type +``` + +Do not scope-creep this into the first implementation. diff --git a/phpunit.xml b/phpunit.xml index 9856bf80..8fc191be 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -41,5 +41,7 @@ + + diff --git a/resources/js/Layouts/AdminLayout.jsx b/resources/js/Layouts/AdminLayout.jsx index 5849c221..43f81b53 100644 --- a/resources/js/Layouts/AdminLayout.jsx +++ b/resources/js/Layouts/AdminLayout.jsx @@ -8,6 +8,7 @@ const buildAdminNavGroups = (isAdmin) => [ { label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true }, { label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day' }, { label: 'Online Users', href: '/moderation/traffic/online', icon: 'fa-solid fa-user-check' }, + ...(isAdmin ? [{ label: 'AdSense Analytics', href: '/admin/adsense', icon: 'fa-solid fa-chart-column' }] : []), ], }, { diff --git a/resources/js/Pages/Admin/Adsense/Index.jsx b/resources/js/Pages/Admin/Adsense/Index.jsx new file mode 100644 index 00000000..145a159b --- /dev/null +++ b/resources/js/Pages/Admin/Adsense/Index.jsx @@ -0,0 +1,401 @@ +import React, { useMemo, useState } from 'react' +import { Head, router, usePage } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' + +function formatNumber(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return '—' + } + + return Number(value).toLocaleString() +} + +function formatMoney(value, currency) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return '—' + } + + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency || 'USD', + maximumFractionDigits: 2, + }).format(Number(value)) + } catch { + return `${Number(value).toFixed(2)} ${currency || ''}`.trim() + } +} + +function formatPercent(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return '—' + } + + return `${(Number(value) * 100).toFixed(2)}%` +} + +function formatKpi(kpi) { + if (kpi.format === 'money') { + return formatMoney(kpi.value, kpi.currency_code) + } + + if (kpi.format === 'percent') { + return formatPercent(kpi.value) + } + + return formatNumber(kpi.value) +} + +function formatChange(change) { + if (change === null || change === undefined) { + return 'No previous period' + } + + const prefix = Number(change) > 0 ? '+' : '' + return `${prefix}${Number(change).toFixed(1)}% vs previous` +} + +function formatTimestamp(value) { + if (!value) { + return 'Never' + } + + return new Intl.DateTimeFormat('en-GB', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)) +} + +function statusCopy(connection) { + if (!connection) { + return { label: 'Not connected', className: 'border-slate-400/20 bg-slate-500/10 text-slate-200' } + } + + if (connection.needs_reconnect || connection.status === 'reconnect_required') { + return { label: 'Reconnect required', className: 'border-amber-400/20 bg-amber-500/10 text-amber-200' } + } + + if (connection.status === 'error') { + return { label: 'Synchronization error', className: 'border-rose-400/20 bg-rose-500/10 text-rose-200' } + } + + if (connection.status === 'connected') { + return { label: 'Connected', className: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200' } + } + + if (connection.status === 'pending') { + return { label: 'Pending account selection', className: 'border-sky-400/20 bg-sky-500/10 text-sky-200' } + } + + return { label: 'Not connected', className: 'border-slate-400/20 bg-slate-500/10 text-slate-200' } +} + +function Sparkline({ points, metric }) { + if (!points.length) { + return

No chart data for this range.

+ } + + const values = points.map((point) => Number(point[metric] || 0)) + const max = Math.max(...values, 0.0001) + const width = 640 + const height = 180 + const step = values.length === 1 ? width : width / (values.length - 1) + const path = values + .map((value, index) => { + const x = index * step + const y = height - (value / max) * (height - 16) - 8 + return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}` + }) + .join(' ') + + return ( + + + + ) +} + +function SortableTable({ rows, currency }) { + const [sortKey, setSortKey] = useState('revenue') + const [direction, setDirection] = useState('desc') + + const sorted = useMemo(() => { + return [...rows].sort((a, b) => { + const left = a[sortKey] + const right = b[sortKey] + const numeric = typeof left === 'number' || typeof right === 'number' || sortKey !== 'label' + if (numeric) { + return direction === 'asc' ? Number(left || 0) - Number(right || 0) : Number(right || 0) - Number(left || 0) + } + + return direction === 'asc' + ? String(left || '').localeCompare(String(right || '')) + : String(right || '').localeCompare(String(left || '')) + }) + }, [rows, sortKey, direction]) + + const toggle = (key) => { + if (sortKey === key) { + setDirection(direction === 'asc' ? 'desc' : 'asc') + return + } + + setSortKey(key) + setDirection(key === 'label' ? 'asc' : 'desc') + } + + const header = (key, label) => ( + + ) + + if (!rows.length) { + return

No rows for this range.

+ } + + return ( +
+ + + + + + + + + + + + + + {sorted.map((row) => ( + + + + + + + + + + ))} + +
{header('label', 'Placement')}{header('page_views', 'Page Views')}{header('impressions', 'Impressions')}{header('clicks', 'Clicks')}{header('ctr', 'CTR')}{header('rpm', 'RPM')}{header('revenue', 'Revenue')}
{row.label}{formatNumber(row.page_views)}{formatNumber(row.impressions)}{formatNumber(row.clicks)}{formatPercent(row.ctr)}{formatMoney(row.rpm, currency)}{formatMoney(row.revenue, currency)}
+
+ ) +} + +export default function AdsenseIndex({ + connection, + configured, + pendingAccounts = [], + recommendedPlacements = [], + range, + dashboard, + routes, +}) { + const flash = usePage().props.flash ?? {} + const [chartMetric, setChartMetric] = useState('estimated_earnings') + const status = statusCopy(connection) + const query = new URLSearchParams() + query.set('period', range.period) + if (range.period === 'custom') { + query.set('from', range.from) + query.set('to', range.to) + } + + const applyPeriod = (period, from = null, to = null) => { + const params = { period } + if (period === 'custom' && from && to) { + params.from = from + params.to = to + } + router.get(routes.index, params, { preserveState: true, replace: true }) + } + + return ( + + + +
+ {flash.success ? ( +
{flash.success}
+ ) : null} + {flash.warning ? ( +
{flash.warning}
+ ) : null} + {flash.error ? ( +
{flash.error}
+ ) : null} + +
+
+
+

Google AdSense

+
+ + {status.label} + +
+

+ Account: {connection?.account_display_name || 'Not selected'} + {connection?.account_resource_name ? ` / ${connection.account_resource_name.replace('accounts/', '')}` : ''} +

+

Last synchronized: {formatTimestamp(connection?.last_successful_sync_at)}

+ {connection?.last_error ?

{connection.last_error}

: null} + {connection?.needs_reconnect ? ( +

AdSense authorization expired or was revoked. Reconnect Google AdSense.

+ ) : null} + {!configured ?

ADSENSE_CLIENT_ID and ADSENSE_CLIENT_SECRET are not configured.

: null} +
+
+ + {connection?.needs_reconnect || connection?.status === 'reconnect_required' ? 'Reconnect' : 'Connect Google AdSense'} + + {connection?.status === 'connected' ? ( + + ) : null} + {connection && connection.status !== 'disconnected' ? ( + + ) : null} +
+
+
+ + {pendingAccounts.length ? ( +
+

Select AdSense account

+
+ {pendingAccounts.map((account) => ( + + ))} +
+
+ ) : null} + +
+
+
+

Period

+

{range.from} to {range.to}

+
+
+ {[ + ['today', 'Today'], + ['yesterday', 'Yesterday'], + ['7d', 'Last 7 days'], + ['30d', 'Last 30 days'], + ].map(([value, label]) => ( + + ))} +
+
+
{ + event.preventDefault() + const form = new FormData(event.currentTarget) + applyPeriod('custom', form.get('from'), form.get('to')) + }} + > + + + +
+

Today's AdSense data is estimated and may change.

+
+ + {dashboard ? ( + <> +
+ {dashboard.kpis.map((kpi) => ( +
+

{kpi.label}

+

{formatKpi(kpi)}

+

{formatChange(kpi.change_percent)}

+
+ ))} +
+ +
+
+

Daily trend

+
+ {[ + ['estimated_earnings', 'Revenue'], + ['page_views', 'Page Views'], + ['page_views_rpm', 'Page RPM'], + ['clicks', 'Clicks'], + ].map(([value, label]) => ( + + ))} +
+
+ +
+ +
+

Placement performance

+ +
+ +
+

Platform breakdown

+ +
+ +
+

Top countries by revenue

+ +
+ + ) : null} + +
+

Recommended AdSense unit names

+

Rename units inside Google AdSense. Skinbase imports names and does not modify inventory.

+
    + {recommendedPlacements.map((name) => ( +
  • {name}
  • + ))} +
+
+
+
+ ) +} diff --git a/resources/js/Pages/Admin/Dashboard.jsx b/resources/js/Pages/Admin/Dashboard.jsx index fe1b8bcb..de1dc364 100644 --- a/resources/js/Pages/Admin/Dashboard.jsx +++ b/resources/js/Pages/Admin/Dashboard.jsx @@ -1,6 +1,6 @@ import React from 'react' import AdminLayout from '../../Layouts/AdminLayout' -import { Head } from '@inertiajs/react' +import { Head, usePage } from '@inertiajs/react' function StatCard({ icon, label, value, color = 'sky' }) { const colors = { @@ -26,6 +26,20 @@ function StatCard({ icon, label, value, color = 'sky' }) { } export default function Dashboard({ stats }) { + const isAdmin = Boolean(usePage().props.auth?.user?.is_admin) + const quickActions = [ + { label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day', desc: 'Review everything created or moderated on a selected day' }, + { label: 'Manage Users', href: '/moderation/users', icon: 'fa-solid fa-users', desc: 'Search, promote or demote users' }, + { label: 'Staff Roles', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved', desc: 'View all admins, managers and editorial staff' }, + { label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge', desc: 'Review pending username requests' }, + { label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up', desc: 'Moderate pending artwork submissions' }, + { label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed', desc: 'Browse all creator stories' }, + { label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' }, + { label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' }, + { label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' }, + ...(isAdmin ? [{ label: 'AdSense Analytics', href: '/admin/adsense', icon: 'fa-solid fa-chart-column', desc: 'Review Google AdSense revenue and placement performance' }] : []), + ] + return ( @@ -42,17 +56,7 @@ export default function Dashboard({ stats }) {

Quick Actions

- {[ - { label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day', desc: 'Review everything created or moderated on a selected day' }, - { label: 'Manage Users', href: '/moderation/users', icon: 'fa-solid fa-users', desc: 'Search, promote or demote users' }, - { label: 'Staff Roles', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved', desc: 'View all admins, managers and editorial staff' }, - { label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge', desc: 'Review pending username requests' }, - { label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up', desc: 'Moderate pending artwork submissions' }, - { label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed', desc: 'Browse all creator stories' }, - { label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' }, - { label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' }, - { label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' }, - ].map((item) => ( + {quickActions.map((item) => ( withoutOverlapping() ->runInBackground(); +Schedule::command('adsense:sync --days=3') + ->hourlyAt(8) + ->name('adsense-sync-hourly') + ->withoutOverlapping() + ->runInBackground(); + +Schedule::command('adsense:sync --days=30') + ->dailyAt('04:35') + ->name('adsense-sync-nightly') + ->withoutOverlapping() + ->runInBackground(); + Schedule::command('health:tick') ->everyMinute() ->name('health-scheduler-tick') diff --git a/tests/Feature/Adsense/AdsenseAdminDashboardTest.php b/tests/Feature/Adsense/AdsenseAdminDashboardTest.php new file mode 100644 index 00000000..609bbc99 --- /dev/null +++ b/tests/Feature/Adsense/AdsenseAdminDashboardTest.php @@ -0,0 +1,195 @@ + 'test-client-id', + 'adsense.client_secret' => 'test-client-secret', + 'adsense.redirect_uri' => 'https://skinbase.org/admin/adsense/oauth/callback', + 'adsense.sync_enabled' => true, + ]); +}); + +function dashboardConnection(): AdsenseConnection +{ + $connection = new AdsenseConnection; + $connection->account_resource_name = 'accounts/pub-123'; + $connection->account_display_name = 'Skinbase'; + $connection->status = AdsenseConnection::STATUS_CONNECTED; + $connection->connected_at = now()->subHour(); + $connection->last_successful_sync_at = now()->subMinutes(12); + $connection->encrypted_refresh_token = 'refresh-secret'; + $connection->save(); + + return $connection; +} + +function seedStat(string $date, string $type, string $key, array $metrics, ?string $label = null): void +{ + AdsenseDailyStat::query()->create(array_merge([ + 'date' => $date, + 'account_resource_name' => 'accounts/pub-123', + 'dimension_type' => $type, + 'dimension_key' => $key, + 'dimension_label' => $label ?? $key, + 'currency_code' => 'EUR', + 'last_synced_at' => now(), + ], $metrics)); +} + +it('requires an admin for the adsense dashboard', function (): void { + $this->get(route('admin.adsense.index'))->assertRedirect(route('login')); + + $user = User::factory()->create(['role' => 'user']); + $this->actingAs($user)->get(route('admin.adsense.index'))->assertForbidden(); +}); + +it('renders connection status and period filters from local statistics', function (): void { + Http::fake(); + $admin = User::factory()->create(['role' => 'admin']); + dashboardConnection(); + AdsenseEntity::query()->create([ + 'account_resource_name' => 'accounts/pub-123', + 'type' => AdsenseEntity::TYPE_AD_UNIT, + 'reporting_dimension_id' => '111', + 'display_name' => 'Skinbase_Artwork_BeforeComments', + 'last_seen_at' => now(), + ]); + + seedStat(now()->toDateString(), 'total', '__total__', [ + 'page_views' => 100, + 'impressions' => 80, + 'clicks' => 4, + 'estimated_earnings' => '5.00', + 'ad_requests' => 90, + 'matched_ad_requests' => 80, + ]); + seedStat(now()->subDays(7)->toDateString(), 'total', '__total__', [ + 'page_views' => 50, + 'impressions' => 40, + 'clicks' => 1, + 'estimated_earnings' => '1.00', + 'ad_requests' => 45, + 'matched_ad_requests' => 40, + ]); + seedStat(now()->toDateString(), 'ad_unit', '111', [ + 'page_views' => 100, + 'impressions' => 80, + 'clicks' => 4, + 'estimated_earnings' => '5.00', + ], 'Skinbase_Artwork_BeforeComments'); + seedStat(now()->toDateString(), 'platform', 'DESKTOP', [ + 'page_views' => 60, + 'impressions' => 50, + 'clicks' => 3, + 'estimated_earnings' => '3.00', + ]); + seedStat(now()->toDateString(), 'country', 'SI', [ + 'page_views' => 20, + 'impressions' => 18, + 'clicks' => 2, + 'estimated_earnings' => '1.50', + ]); + + $this->actingAs($admin) + ->get(route('admin.adsense.index', ['period' => 'today'])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Admin/Adsense/Index') + ->where('connection.status', 'connected') + ->where('range.period', 'today') + ->where('dashboard.placements.0.label', 'Skinbase_Artwork_BeforeComments') + ->where('dashboard.kpis.0.key', 'estimated_earnings') + ->missing('connection.encrypted_refresh_token')); + + $this->actingAs($admin) + ->get(route('admin.adsense.index', ['period' => 'yesterday'])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->where('range.period', 'yesterday')); + + $this->actingAs($admin) + ->get(route('admin.adsense.index', ['period' => '7d'])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->where('range.period', '7d')); + + $this->actingAs($admin) + ->get(route('admin.adsense.index', ['period' => '30d'])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->where('range.period', '30d')); + + $from = now()->subDays(2)->toDateString(); + $to = now()->toDateString(); + $this->actingAs($admin) + ->get(route('admin.adsense.index', ['period' => 'custom', 'from' => $from, 'to' => $to])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('range.period', 'custom') + ->where('range.from', $from) + ->where('range.to', $to)); + + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'adsense.googleapis.com') + || str_contains($request->url(), 'oauth2.googleapis.com')); +}); + +it('authorizes manual sync and keeps historical statistics after disconnect', function (): void { + Queue::fake(); + $admin = User::factory()->create(['role' => 'admin']); + $connection = dashboardConnection(); + seedStat(now()->toDateString(), 'total', '__total__', [ + 'page_views' => 10, + 'clicks' => 1, + 'estimated_earnings' => '2.00', + ]); + + $user = User::factory()->create(['role' => 'user']); + $this->actingAs($user)->post(route('admin.adsense.sync'))->assertForbidden(); + $this->actingAs($user)->delete(route('admin.adsense.disconnect'))->assertForbidden(); + + $this->actingAs($admin) + ->post(route('admin.adsense.sync')) + ->assertRedirect(route('admin.adsense.index')); + Queue::assertPushed(SyncAdsenseJob::class); + + Http::fake([ + 'https://oauth2.googleapis.com/revoke' => Http::response([], 200), + ]); + + $this->actingAs($admin) + ->delete(route('admin.adsense.disconnect')) + ->assertRedirect(route('admin.adsense.index')); + + expect($connection->fresh()->encrypted_refresh_token)->toBeNull() + ->and($connection->fresh()->status)->toBe(AdsenseConnection::STATUS_DISCONNECTED) + ->and(AdsenseDailyStat::query()->count())->toBe(1); +}); + +it('registers hourly and nightly adsense scheduler entries', function (): void { + Artisan::call('schedule:list'); + $output = Artisan::output(); + + expect($output)->toContain('adsense:sync --days=3') + ->and($output)->toContain('adsense:sync --days=30'); +}); + +it('does not call the adsense management api from the public homepage', function (): void { + Http::fake(); + + $this->get('/')->assertOk(); + + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'adsense.googleapis.com') + || str_contains($request->url(), 'oauth2.googleapis.com')); +}); diff --git a/tests/Feature/Adsense/AdsenseApiAndSyncTest.php b/tests/Feature/Adsense/AdsenseApiAndSyncTest.php new file mode 100644 index 00000000..f21b4839 --- /dev/null +++ b/tests/Feature/Adsense/AdsenseApiAndSyncTest.php @@ -0,0 +1,408 @@ + 'test-client-id', + 'adsense.client_secret' => 'test-client-secret', + 'adsense.redirect_uri' => 'https://skinbase.org/admin/adsense/oauth/callback', + 'adsense.sync_enabled' => true, + 'adsense.retry_times' => 3, + 'adsense.retry_sleep_ms' => 0, + ]); +}); + +function adsenseConnection(array $overrides = []): AdsenseConnection +{ + $connection = new AdsenseConnection; + $connection->fill(array_merge([ + 'account_resource_name' => 'accounts/pub-123', + 'account_display_name' => 'Skinbase', + 'status' => AdsenseConnection::STATUS_CONNECTED, + 'connected_at' => now(), + ], $overrides)); + $connection->encrypted_refresh_token = $overrides['encrypted_refresh_token'] ?? 'refresh-secret'; + $connection->save(); + + return $connection->fresh(); +} + +function adsenseReportPayload(array $headers, array $rows): array +{ + return [ + 'headers' => $headers, + 'rows' => array_map(fn (array $values): array => [ + 'cells' => array_map(fn (string $value): array => ['value' => $value], $values), + ], $rows), + ]; +} + +it('refreshes a cached access token from the google token endpoint', function (): void { + $connection = adsenseConnection(); + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response([ + 'access_token' => 'fresh-access-token', + 'expires_in' => 3600, + ]), + ]); + + $token = app(AdsenseOAuthService::class)->accessToken($connection); + + expect($token)->toBe('fresh-access-token'); + Http::assertSent(fn ($request): bool => str_contains($request->url(), 'oauth2.googleapis.com/token') + && $request['grant_type'] === 'refresh_token' + && $request['refresh_token'] === 'refresh-secret' + && ! str_contains($request->body(), 'prompt=consent')); +}); + +it('discovers accounts with pagination', function (): void { + $connection = adsenseConnection(); + Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300); + + $calls = 0; + Http::fake(function ($request) use (&$calls) { + expect($request->url())->toContain('/accounts'); + $calls++; + if ($calls === 1) { + return Http::response([ + 'accounts' => [['name' => 'accounts/pub-1', 'displayName' => 'One']], + 'nextPageToken' => 'page-2', + ]); + } + + return Http::response([ + 'accounts' => [['name' => 'accounts/pub-2', 'displayName' => 'Two']], + ]); + }); + + $accounts = app(AdsenseApiClient::class)->listAccounts($connection); + + expect($accounts)->toHaveCount(2) + ->and($calls)->toBe(2); +}); + +it('synchronizes ad clients units and custom channels', function (): void { + $connection = adsenseConnection(); + Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300); + + Http::fake(function ($request) { + $url = $request->url(); + if (str_ends_with(parse_url($url, PHP_URL_PATH), '/adclients')) { + return Http::response([ + 'adClients' => [[ + 'name' => 'accounts/pub-123/adclients/ca-pub-123', + 'reportingDimensionId' => 'ca-pub-123', + 'state' => 'READY', + ]], + ]); + } + if (str_contains($url, '/adunits')) { + return Http::response([ + 'adUnits' => [[ + 'name' => 'accounts/pub-123/adclients/ca-pub-123/adunits/111', + 'reportingDimensionId' => '111', + 'displayName' => 'Skinbase_Artwork_BeforeComments', + 'state' => 'ACTIVE', + ]], + ]); + } + if (str_contains($url, '/customchannels')) { + return Http::response([ + 'customChannels' => [[ + 'name' => 'accounts/pub-123/adclients/ca-pub-123/customchannels/222', + 'reportingDimensionId' => '222', + 'displayName' => 'Artwork', + 'state' => 'ACTIVE', + ]], + ]); + } + + return Http::response(['error' => ['message' => 'unexpected '.$url]], 500); + }); + + $result = app(AdsenseSyncService::class)->syncEntities($connection); + + expect($result->adUnits)->toBe(1) + ->and($result->customChannels)->toBe(1) + ->and(AdsenseEntity::query()->count())->toBe(2) + ->and(AdsenseEntity::query()->where('display_name', 'Skinbase_Artwork_BeforeComments')->exists())->toBeTrue(); +}); + +it('skips youtube host ad clients and continues when an inventory client returns 404', function (): void { + $connection = adsenseConnection(); + Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300); + + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, '/adclients') && ! str_contains($url, '/adunits') && ! str_contains($url, '/customchannels')) { + return Http::response([ + 'adClients' => [ + [ + 'name' => 'accounts/pub-123/adclients/ca-yt-host-pub-123', + 'reportingDimensionId' => 'ca-yt-host-pub-123', + 'productCode' => 'AFV', + 'state' => 'READY', + ], + [ + 'name' => 'accounts/pub-123/adclients/ca-pub-missing', + 'reportingDimensionId' => 'ca-pub-missing', + 'productCode' => 'AFC', + 'state' => 'READY', + ], + [ + 'name' => 'accounts/pub-123/adclients/ca-pub-123', + 'reportingDimensionId' => 'ca-pub-123', + 'productCode' => 'AFC', + 'state' => 'READY', + ], + ], + ]); + } + if (str_contains($url, 'ca-yt-host-pub-123')) { + return Http::response(['error' => ['status' => 'NOT_FOUND', 'message' => "Couldn't find the ad client"]], 404); + } + if (str_contains($url, 'ca-pub-missing')) { + return Http::response(['error' => ['status' => 'NOT_FOUND', 'message' => "Couldn't find the ad client"]], 404); + } + if (str_contains($url, '/adunits')) { + return Http::response([ + 'adUnits' => [[ + 'name' => 'accounts/pub-123/adclients/ca-pub-123/adunits/111', + 'reportingDimensionId' => '111', + 'displayName' => 'Skinbase_Artwork_BeforeComments', + 'state' => 'ACTIVE', + ]], + ]); + } + if (str_contains($url, '/customchannels')) { + return Http::response(['customChannels' => []]); + } + + return Http::response(['error' => ['message' => 'unexpected '.$url]], 500); + }); + + $result = app(AdsenseSyncService::class)->sync( + $connection, + Carbon::parse('2026-09-18'), + Carbon::parse('2026-09-18'), + true, + ); + + expect($result->adUnits)->toBe(1) + ->and($result->reconnectRequired)->toBeFalse() + ->and($result->failedReports)->toBe([]) + ->and($connection->fresh()->status)->toBe(AdsenseConnection::STATUS_CONNECTED) + ->and(AdsenseEntity::query()->where('display_name', 'Skinbase_Artwork_BeforeComments')->exists())->toBeTrue(); + + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'ca-yt-host-pub-123')); + Http::assertSent(fn ($request): bool => str_contains($request->url(), 'ca-pub-missing') && str_contains($request->url(), '/adunits')); +}); + +it('retries 401 by refreshing the access token once', function (): void { + $connection = adsenseConnection(); + Cache::put('adsense.access_token.'.$connection->id, 'expired-access', 300); + $accountCalls = 0; + + Http::fake(function ($request) use (&$accountCalls) { + if (str_contains($request->url(), 'oauth2.googleapis.com/token')) { + return Http::response([ + 'access_token' => 'rotated-access', + 'expires_in' => 3600, + ]); + } + + $accountCalls++; + if ($accountCalls === 1) { + return Http::response(['error' => ['message' => 'Unauthenticated']], 401); + } + + return Http::response([ + 'accounts' => [['name' => 'accounts/pub-123', 'displayName' => 'Skinbase']], + ]); + }); + + $accounts = app(AdsenseApiClient::class)->listAccounts($connection); + + expect($accounts)->toHaveCount(1) + ->and($accountCalls)->toBe(2); +}); + +it('retries 429 and 5xx responses with a bound', function (): void { + $connection = adsenseConnection(); + Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300); + $calls = 0; + + Http::fake(function () use (&$calls) { + $calls++; + if ($calls === 1) { + return Http::response(['error' => ['message' => 'rate limit']], 429); + } + if ($calls === 2) { + return Http::response(['error' => ['message' => 'unavailable']], 503); + } + + return Http::response(['accounts' => []]); + }); + + expect(app(AdsenseApiClient::class)->listAccounts($connection))->toBe([]) + ->and($calls)->toBe(3); +}); + +it('marks reconnect_required when the refresh token is revoked', function (): void { + $connection = adsenseConnection(); + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response(['error' => 'invalid_grant'], 400), + ]); + + expect(fn () => app(AdsenseOAuthService::class)->accessToken($connection)) + ->toThrow(AdsenseAuthorizationException::class); + + expect($connection->fresh()->status)->toBe(AdsenseConnection::STATUS_RECONNECT_REQUIRED); +}); + +it('upserts daily statistics idempotently and preserves successful reports on partial failure', function (): void { + $connection = adsenseConnection(); + Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300); + + $from = Carbon::parse('2026-09-18'); + $to = Carbon::parse('2026-09-18'); + + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, '/adclients') && ! str_contains($url, '/adunits') && ! str_contains($url, '/customchannels')) { + return Http::response(['adClients' => [['name' => 'accounts/pub-123/adclients/ca-pub-123']]]); + } + if (str_contains($url, '/adunits')) { + return Http::response(['adUnits' => [[ + 'name' => 'accounts/pub-123/adclients/ca-pub-123/adunits/111', + 'reportingDimensionId' => '111', + 'displayName' => 'Skinbase_Artwork_BeforeComments', + 'state' => 'ACTIVE', + ]]]); + } + if (str_contains($url, '/customchannels')) { + return Http::response(['customChannels' => []]); + } + if (str_contains($url, 'reports:generate') && str_contains($url, 'COUNTRY_CODE')) { + return Http::response(['error' => ['message' => 'Invalid combination']], 400); + } + if (str_contains($url, 'reports:generate') && str_contains($url, 'AD_UNIT_ID')) { + return Http::response(adsenseReportPayload([ + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'AD_UNIT_ID', 'type' => 'DIMENSION'], + ['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'], + ['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'EUR'], + ], [['2026-09-18', '111', '80', '2.50']])); + } + if (str_contains($url, 'reports:generate') && str_contains($url, 'PLATFORM_TYPE_CODE')) { + return Http::response(adsenseReportPayload([ + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'PLATFORM_TYPE_CODE', 'type' => 'DIMENSION'], + ['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'], + ['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'EUR'], + ], [['2026-09-18', 'DESKTOP', '70', '2.00']])); + } + if (str_contains($url, 'reports:generate') && str_contains($url, 'CUSTOM_CHANNEL_ID')) { + return Http::response(adsenseReportPayload([ + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'CUSTOM_CHANNEL_ID', 'type' => 'DIMENSION'], + ['name' => 'CLICKS', 'type' => 'METRIC_TALLY'], + ], [])); + } + if (str_contains($url, 'reports:generate')) { + return Http::response(adsenseReportPayload([ + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'EUR'], + ['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'], + ['name' => 'CLICKS', 'type' => 'METRIC_TALLY'], + ], [['2026-09-18', '3.00', '100', '4']])); + } + + return Http::response(['error' => ['message' => 'unexpected '.$url]], 500); + }); + + $sync = app(AdsenseSyncService::class); + $first = $sync->sync($connection, $from, $to); + $second = $sync->sync($connection, $from, $to); + + expect($first->failedReports)->toBe([AdsenseDailyStat::DIMENSION_COUNTRY]) + ->and($second->totalRows)->toBe($first->totalRows) + ->and(AdsenseDailyStat::query()->count())->toBe(3) + ->and(AdsenseDailyStat::query()->where('dimension_type', 'total')->value('estimated_earnings'))->toBe('3.000000') + ->and(AdsenseDailyStat::query()->where('dimension_type', 'country')->count())->toBe(0); + + AdsenseDailyStat::query()->where('dimension_type', 'total')->update(['estimated_earnings' => '1.000000']); + $sync->sync($connection, $from, $to); + expect(AdsenseDailyStat::query()->where('dimension_type', 'total')->value('estimated_earnings'))->toBe('3.000000') + ->and(AdsenseDailyStat::query()->count())->toBe(3); +}); + +it('runs the artisan command and rejects incompatible options', function (): void { + $connection = adsenseConnection(); + Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300); + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, '/adclients')) { + return Http::response(['adClients' => []]); + } + if (str_contains($url, 'reports:generate')) { + return Http::response(['headers' => [['name' => 'DATE']], 'rows' => []]); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + artisan('adsense:sync', ['--days' => 3]) + ->expectsOutputToContain('AdSense account: Skinbase') + ->expectsOutputToContain('Synchronization completed successfully.') + ->assertSuccessful(); + + artisan('adsense:sync', ['--days' => 3, '--from' => '2026-09-01']) + ->assertExitCode(2); + + artisan('adsense:sync', ['--from' => '2026-09-01']) + ->assertExitCode(2); +}); + +it('skips scheduled sync when disabled unless --force is used', function (): void { + config(['adsense.sync_enabled' => false]); + adsenseConnection(); + + artisan('adsense:sync') + ->expectsOutputToContain('AdSense synchronization is disabled') + ->assertSuccessful(); + + $connection = AdsenseConnection::current(); + Cache::put('adsense.access_token.'.$connection->id, 'cached-access', 300); + + Http::fake(function ($request) { + if (str_contains($request->url(), 'oauth2.googleapis.com/token')) { + return Http::response(['access_token' => 'access-secret', 'expires_in' => 3600]); + } + if (str_contains($request->url(), '/adclients')) { + return Http::response(['adClients' => []]); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + artisan('adsense:sync', ['--force' => true, '--entities-only' => true]) + ->assertSuccessful(); +}); diff --git a/tests/Feature/Adsense/AdsenseOAuthTest.php b/tests/Feature/Adsense/AdsenseOAuthTest.php new file mode 100644 index 00000000..7d442b45 --- /dev/null +++ b/tests/Feature/Adsense/AdsenseOAuthTest.php @@ -0,0 +1,180 @@ + 'test-client-id', + 'adsense.client_secret' => 'test-client-secret', + 'adsense.redirect_uri' => 'https://skinbase.org/admin/adsense/oauth/callback', + 'adsense.sync_enabled' => true, + 'adsense.retry_sleep_ms' => 0, + ]); +}); + +it('lets an admin start the oauth connection with readonly offline access', function (): void { + $admin = User::factory()->create(['role' => 'admin']); + + $first = $this->actingAs($admin)->get(route('admin.adsense.connect')); + $first->assertRedirect(); + $location = (string) $first->headers->get('Location'); + $state = session(AdsenseOAuthService::SESSION_STATE_KEY); + + expect($location)->toContain('https://accounts.google.com/o/oauth2/v2/auth') + ->and($location)->toContain(urlencode('https://www.googleapis.com/auth/adsense.readonly')) + ->and($location)->toContain('access_type=offline') + ->and($location)->toContain('prompt=consent') + ->and($location)->toContain('include_granted_scopes=true') + ->and($location)->toContain('response_type=code') + ->and($location)->toContain(urlencode('https://skinbase.org/admin/adsense/oauth/callback')) + ->and($state)->toBeString() + ->and(strlen((string) $state))->toBe(64); + + $second = $this->actingAs($admin)->get(route('admin.adsense.connect')); + $secondState = session(AdsenseOAuthService::SESSION_STATE_KEY); + + expect($secondState)->not->toBe($state); +}); + +it('rejects oauth start for guests and non-admins', function (): void { + $this->get(route('admin.adsense.connect'))->assertRedirect(route('login')); + + $user = User::factory()->create(['role' => 'user']); + $this->actingAs($user)->get(route('admin.adsense.connect'))->assertForbidden(); + + $manager = User::factory()->create(['role' => 'manager']); + $this->actingAs($manager)->get(route('admin.adsense.connect'))->assertForbidden(); +}); + +it('rejects invalid missing and denied oauth callbacks', function (): void { + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin) + ->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state']) + ->get(route('admin.adsense.callback', ['code' => 'abc', 'state' => 'wrong-state'])) + ->assertRedirect(route('admin.adsense.index')) + ->assertSessionHas('error', 'Invalid OAuth state.'); + + $this->actingAs($admin) + ->get(route('admin.adsense.callback', ['code' => 'abc', 'state' => 'anything'])) + ->assertRedirect(route('admin.adsense.index')) + ->assertSessionHas('error', 'Invalid OAuth state.'); + + $this->actingAs($admin) + ->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state']) + ->get(route('admin.adsense.callback', ['state' => 'valid-state'])) + ->assertRedirect(route('admin.adsense.index')) + ->assertSessionHas('error', 'Missing authorization code.'); + + $this->actingAs($admin) + ->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state']) + ->get(route('admin.adsense.callback', [ + 'state' => 'valid-state', + 'error' => 'access_denied', + 'error_description' => 'The user denied access', + ])) + ->assertRedirect(route('admin.adsense.index')); +}); + +it('exchanges the authorization code and stores an encrypted refresh token', function (): void { + Queue::fake(); + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response([ + 'access_token' => 'access-secret', + 'refresh_token' => 'refresh-secret', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + ]), + 'https://adsense.googleapis.com/v2/accounts*' => Http::response([ + 'accounts' => [[ + 'name' => 'accounts/pub-1234567890', + 'displayName' => 'Skinbase', + ]], + ]), + ]); + + $admin = User::factory()->create(['role' => 'admin']); + + $response = $this->actingAs($admin) + ->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state']) + ->get(route('admin.adsense.callback', [ + 'state' => 'valid-state', + 'code' => 'auth-code-secret', + ])); + + $response->assertRedirect(route('admin.adsense.index')) + ->assertSessionHas('success'); + + $connection = AdsenseConnection::current(); + expect($connection)->not->toBeNull() + ->and($connection->encrypted_refresh_token)->toBe('refresh-secret') + ->and($connection->account_resource_name)->toBe('accounts/pub-1234567890') + ->and($connection->status)->toBe(AdsenseConnection::STATUS_CONNECTED); + + $raw = DB::table('adsense_connections')->value('encrypted_refresh_token'); + expect($raw)->not->toBe('refresh-secret') + ->and($raw)->not->toContain('refresh-secret'); + + $this->actingAs($admin) + ->get(route('admin.adsense.index')) + ->assertOk() + ->assertDontSee('refresh-secret') + ->assertDontSee('access-secret') + ->assertDontSee('auth-code-secret') + ->assertDontSee('test-client-secret') + ->assertInertia(fn (Assert $page) => $page + ->component('Admin/Adsense/Index') + ->missing('connection.encrypted_refresh_token') + ->where('connection.status', 'connected')); + + Queue::assertPushed(SyncAdsenseJob::class); +}); + +it('asks the administrator to choose when multiple adsense accounts exist', function (): void { + Queue::fake(); + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response([ + 'access_token' => 'access-secret', + 'refresh_token' => 'refresh-secret', + 'expires_in' => 3600, + ]), + 'https://adsense.googleapis.com/v2/accounts*' => Http::response([ + 'accounts' => [ + ['name' => 'accounts/pub-1', 'displayName' => 'Skinbase'], + ['name' => 'accounts/pub-2', 'displayName' => 'Other'], + ], + ]), + ]); + + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin) + ->withSession([AdsenseOAuthService::SESSION_STATE_KEY => 'valid-state']) + ->get(route('admin.adsense.callback', [ + 'state' => 'valid-state', + 'code' => 'auth-code-secret', + ])) + ->assertRedirect(route('admin.adsense.index')); + + expect(session(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY))->toHaveCount(2); + Queue::assertNothingPushed(); + + $this->actingAs($admin) + ->post(route('admin.adsense.account'), ['account_resource_name' => 'accounts/pub-1']) + ->assertRedirect(route('admin.adsense.index')); + + expect(AdsenseConnection::current()?->account_resource_name)->toBe('accounts/pub-1'); + Queue::assertPushed(SyncAdsenseJob::class); +}); diff --git a/tests/Unit/Adsense/AdsenseReportParserTest.php b/tests/Unit/Adsense/AdsenseReportParserTest.php new file mode 100644 index 00000000..576fdc8d --- /dev/null +++ b/tests/Unit/Adsense/AdsenseReportParserTest.php @@ -0,0 +1,106 @@ + $headers, + 'rows' => [ + [ + 'cells' => array_map(fn (string $value): array => ['value' => $value], $rowValues), + ], + ], + ]; +} + +it('maps report cells by header name rather than position', function (): void { + $parser = new AdsenseReportParser; + $report = adsenseReport([ + ['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'EUR'], + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'], + ['name' => 'PAGE_VIEWS_CTR', 'type' => 'METRIC_RATIO'], + ], ['12.50', '2026-09-18', '1000', '0.0125']); + + $rows = $parser->parse($report, AdsenseDailyStat::DIMENSION_TOTAL, 'accounts/pub-1'); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['date'])->toBe('2026-09-18') + ->and($rows[0]['dimension_key'])->toBe(AdsenseDailyStat::TOTAL_DIMENSION_KEY) + ->and($rows[0]['estimated_earnings'])->toBe('12.50') + ->and($rows[0]['page_views'])->toBe(1000) + ->and($rows[0]['page_views_ctr'])->toBe('0.0125') + ->and($rows[0]['currency_code'])->toBe('EUR'); +}); + +it('parses the same values when Google returns columns in another order', function (): void { + $parser = new AdsenseReportParser; + $first = $parser->parse(adsenseReport([ + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'AD_UNIT_ID', 'type' => 'DIMENSION'], + ['name' => 'CLICKS', 'type' => 'METRIC_TALLY'], + ['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'USD'], + ], ['2026-09-18', 'unit-1', '8', '4.25']), AdsenseDailyStat::DIMENSION_AD_UNIT, 'accounts/pub-1'); + + $second = $parser->parse(adsenseReport([ + ['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'USD'], + ['name' => 'CLICKS', 'type' => 'METRIC_TALLY'], + ['name' => 'AD_UNIT_ID', 'type' => 'DIMENSION'], + ['name' => 'DATE', 'type' => 'DIMENSION'], + ], ['4.25', '8', 'unit-1', '2026-09-18']), AdsenseDailyStat::DIMENSION_AD_UNIT, 'accounts/pub-1'); + + expect($first[0]['dimension_key'])->toBe('unit-1') + ->and($first[0]['clicks'])->toBe($second[0]['clicks']) + ->and($first[0]['estimated_earnings'])->toBe($second[0]['estimated_earnings']) + ->and($first[0]['date'])->toBe($second[0]['date']); +}); + +it('detects monetary currency from metric headers', function (): void { + $parser = new AdsenseReportParser; + $code = $parser->currencyCode([ + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'ESTIMATED_EARNINGS', 'type' => 'METRIC_CURRENCY', 'currencyCode' => 'GBP'], + ]); + + expect($code)->toBe('GBP'); +}); + +it('keeps ratio metrics as google decimals', function (): void { + $parser = new AdsenseReportParser; + $rows = $parser->parse(adsenseReport([ + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'PLATFORM_TYPE_CODE', 'type' => 'DIMENSION'], + ['name' => 'AD_REQUESTS_COVERAGE', 'type' => 'METRIC_RATIO'], + ['name' => 'IMPRESSIONS_CTR', 'type' => 'METRIC_RATIO'], + ], ['2026-09-19', 'DESKTOP', '0.84', '0.031']), AdsenseDailyStat::DIMENSION_PLATFORM, 'accounts/pub-1'); + + expect($rows[0]['ad_requests_coverage'])->toBe('0.84') + ->and($rows[0]['impressions_ctr'])->toBe('0.031') + ->and($rows[0]['dimension_key'])->toBe('DESKTOP'); +}); + +it('parses country and custom channel dimension keys', function (): void { + $parser = new AdsenseReportParser; + + $country = $parser->parse(adsenseReport([ + ['name' => 'COUNTRY_CODE', 'type' => 'DIMENSION'], + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'PAGE_VIEWS', 'type' => 'METRIC_TALLY'], + ], ['SI', '2026-09-19', '12']), AdsenseDailyStat::DIMENSION_COUNTRY, 'accounts/pub-1'); + + $channel = $parser->parse(adsenseReport([ + ['name' => 'DATE', 'type' => 'DIMENSION'], + ['name' => 'CUSTOM_CHANNEL_ID', 'type' => 'DIMENSION'], + ['name' => 'CLICKS', 'type' => 'METRIC_TALLY'], + ], ['2026-09-19', 'ch-9', '3']), AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL, 'accounts/pub-1'); + + expect($country[0]['dimension_key'])->toBe('SI') + ->and($channel[0]['dimension_key'])->toBe('ch-9'); +});