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'); +});