Add a read-only Google AdSense analytics module for admins.

Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
This commit is contained in:
test
2026-09-20 14:49:48 +02:00
parent 17df4d0ad7
commit 4d60a876af
30 changed files with 5098 additions and 12 deletions
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AdsenseConnection;
use App\Services\Adsense\AdsenseSyncResult;
use App\Services\Adsense\AdsenseSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
final class AdsenseSyncCommand extends Command
{
protected $signature = 'adsense:sync
{--days= : Number of trailing days to synchronize, including today}
{--from= : Inclusive start date (YYYY-MM-DD)}
{--to= : Inclusive end date (YYYY-MM-DD)}
{--entities-only : Synchronize ad units and custom channels without reports}
{--force : Run even when ADSENSE_SYNC_ENABLED is false}';
protected $description = 'Synchronize Google AdSense entities and daily reporting into Skinbase';
public function handle(AdsenseSyncService $sync): int
{
if (! (bool) config('adsense.sync_enabled', true) && ! $this->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();
}
}
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\SyncAdsenseJob;
use App\Models\AdsenseConnection;
use App\Services\Adsense\AdsenseAnalyticsQueryService;
use App\Services\Adsense\AdsenseApiClient;
use App\Services\Adsense\AdsenseLogSanitizer;
use App\Services\Adsense\AdsenseOAuthService;
use App\Services\Adsense\Exceptions\AdsenseApiException;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
final class AdsenseAnalyticsController extends Controller
{
public function __construct(
private readonly AdsenseOAuthService $oauth,
private readonly AdsenseApiClient $api,
private readonly AdsenseAnalyticsQueryService $query,
) {}
public function index(Request $request): Response
{
$connection = AdsenseConnection::current();
$range = $this->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<string, mixed>|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(),
);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\AdsenseConnection;
use App\Services\Adsense\AdsenseSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
final class SyncAdsenseJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 180;
public int $uniqueFor = 600;
public function __construct(
public readonly int $connectionId,
public readonly string $from,
public readonly string $to,
public readonly bool $entitiesOnly = false,
) {}
public function uniqueId(): string
{
return (string) $this->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,
);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class AdsenseConnection extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_CONNECTED = 'connected';
public const STATUS_RECONNECT_REQUIRED = 'reconnect_required';
public const STATUS_ERROR = 'error';
public const STATUS_DISCONNECTED = 'disconnected';
protected $fillable = [
'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',
];
protected $hidden = [
'encrypted_refresh_token',
];
protected function casts(): array
{
return [
'encrypted_refresh_token' => '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();
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class AdsenseDailyStat extends Model
{
public const DIMENSION_TOTAL = 'total';
public const DIMENSION_AD_UNIT = 'ad_unit';
public const DIMENSION_CUSTOM_CHANNEL = 'custom_channel';
public const DIMENSION_PLATFORM = 'platform';
public const DIMENSION_COUNTRY = 'country';
public const TOTAL_DIMENSION_KEY = '__total__';
protected $fillable = [
'date',
'account_resource_name',
'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',
];
protected function casts(): array
{
return [
'date' => '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',
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class AdsenseEntity extends Model
{
public const TYPE_AD_UNIT = 'ad_unit';
public const TYPE_CUSTOM_CHANNEL = 'custom_channel';
protected $fillable = [
'account_resource_name',
'ad_client_resource_name',
'type',
'resource_name',
'reporting_dimension_id',
'display_name',
'active',
'last_seen_at',
];
protected function casts(): array
{
return [
'active' => 'boolean',
'last_seen_at' => 'datetime',
];
}
}
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseConnection;
use App\Models\AdsenseDailyStat;
use App\Models\AdsenseEntity;
use App\Models\Country;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
final class AdsenseAnalyticsQueryService
{
/**
* @return array{from: Carbon, to: Carbon, previous_from: Carbon, previous_to: Carbon, period: string}
*/
public function resolveRange(?string $period, ?string $from, ?string $to): array
{
$period = $period ?: '30d';
$today = now()->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<string, mixed>
*/
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<string, mixed>
*/
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<array<string, mixed>>
*/
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<array<string, mixed>>
*/
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<string, string>
*/
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<string, string>
*/
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<string, mixed>
*/
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;
}
}
+350
View File
@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseConnection;
use App\Services\Adsense\Exceptions\AdsenseApiException;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class AdsenseApiClient
{
public function __construct(private readonly AdsenseOAuthService $oauth) {}
/**
* @return list<array<string, mixed>>
*/
public function listAccounts(AdsenseConnection $connection): array
{
return $this->paginate($connection, '/accounts', 'accounts');
}
/**
* @return list<array<string, mixed>>
*/
public function listAdClients(AdsenseConnection $connection, string $accountResourceName): array
{
return $this->paginate($connection, '/'.$this->trimPath($accountResourceName).'/adclients', 'adClients');
}
/**
* @return list<array<string, mixed>>
*/
public function listAdUnits(AdsenseConnection $connection, string $adClientResourceName): array
{
return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/adunits', 'adUnits');
}
/**
* @return list<array<string, mixed>>
*/
public function listCustomChannels(AdsenseConnection $connection, string $adClientResourceName): array
{
return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/customchannels', 'customChannels');
}
/**
* @param list<string> $dimensions
* @param list<string> $metrics
* @return array<string, mixed>
*/
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<string, scalar|null> $query
* @return list<array<string, mixed>>
*/
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<string> $dimensions
* @param list<string> $metrics
* @return array<string, scalar>
*/
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<string, scalar|null> $query
* @return array<string, mixed>
*/
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<string, scalar|null> $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');
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense;
final class AdsenseLogSanitizer
{
/**
* @var list<string>
*/
private const SENSITIVE_KEYS = [
'access_token',
'refresh_token',
'id_token',
'token',
'code',
'client_secret',
'authorization',
'encrypted_refresh_token',
];
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
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;
}
}
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseConnection;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class AdsenseOAuthService
{
public const SESSION_STATE_KEY = 'adsense.oauth_state';
public const SESSION_PENDING_ACCOUNTS_KEY = 'adsense.pending_accounts';
public function isConfigured(): bool
{
return $this->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<string, string> $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'));
}
}
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseDailyStat;
final class AdsenseReportParser
{
/**
* @var array<string, string>
*/
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<string>
*/
private const INTEGER_COLUMNS = [
'page_views',
'ad_requests',
'matched_ad_requests',
'impressions',
'clicks',
];
/**
* @param array<string, mixed> $report
* @return list<array<string, mixed>>
*/
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<string, int>
*/
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<int, mixed> $cells
* @param array<string, int> $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<int, mixed> $cells
* @param array<string, int> $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<int, mixed> $cells
* @param array<string, int> $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<int, mixed> $cells
* @param array<string, int> $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;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense;
final class AdsenseSyncResult
{
/**
* @param list<string> $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 === [];
}
}
+358
View File
@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseConnection;
use App\Models\AdsenseDailyStat;
use App\Models\AdsenseEntity;
use App\Services\Adsense\Exceptions\AdsenseApiException;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
final class AdsenseSyncService
{
public function __construct(
private readonly AdsenseApiClient $api,
private readonly AdsenseReportParser $parser,
private readonly AdsenseOAuthService $oauth,
) {}
public function sync(
AdsenseConnection $connection,
Carbon $from,
Carbon $to,
bool $entitiesOnly = false,
): AdsenseSyncResult {
$result = new AdsenseSyncResult(
accountDisplayName: (string) ($connection->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<string, mixed> $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<string, mixed> $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<array<string, mixed>> $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<array<string, mixed>> $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);
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense\Exceptions;
final class AdsenseApiException extends AdsenseException
{
public function __construct(
string $message,
public readonly int $status = 0,
public readonly string $report = '',
?\Throwable $previous = null,
) {
parent::__construct($message, $status, $previous);
}
}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense\Exceptions;
final class AdsenseAuthorizationException extends AdsenseException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense\Exceptions;
use RuntimeException;
class AdsenseException extends RuntimeException {}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Services\Adsense\Exceptions;
final class AdsenseOAuthException extends AdsenseException {}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
return [
'client_id' => 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',
],
];
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('adsense_connections')) {
Schema::create('adsense_connections', function (Blueprint $table): void {
$table->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');
}
};
+236
View File
@@ -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` / `<x-ad-unit>` / 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.
File diff suppressed because it is too large Load Diff
+2
View File
@@ -41,5 +41,7 @@
<env name="COLLECTIONS_V5_DISPATCH_ENABLED" value="true"/> <env name="COLLECTIONS_V5_DISPATCH_ENABLED" value="true"/>
<env name="HTTP_SLOW_REQUEST_LOG_ENABLED" value="false"/> <env name="HTTP_SLOW_REQUEST_LOG_ENABLED" value="false"/>
<env name="HTTP_SLOW_REQUEST_MS" value="750"/> <env name="HTTP_SLOW_REQUEST_MS" value="750"/>
<env name="ADSENSE_SYNC_ENABLED" value="true"/>
<env name="ADSENSE_HTTP_RETRY_SLEEP_MS" value="0"/>
</php> </php>
</phpunit> </phpunit>
+1
View File
@@ -8,6 +8,7 @@ const buildAdminNavGroups = (isAdmin) => [
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true }, { 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: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day' },
{ label: 'Online Users', href: '/moderation/traffic/online', icon: 'fa-solid fa-user-check' }, { 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' }] : []),
], ],
}, },
{ {
+401
View File
@@ -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 <p className="px-4 py-10 text-sm text-slate-500">No chart data for this range.</p>
}
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 (
<svg viewBox={`0 0 ${width} ${height}`} className="h-48 w-full text-rose-300" role="img" aria-label="AdSense trend">
<path d={path} fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
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) => (
<button type="button" onClick={() => toggle(key)} className="font-semibold uppercase tracking-[0.16em] text-slate-500">
{label}
</button>
)
if (!rows.length) {
return <p className="px-4 py-8 text-sm text-slate-500">No rows for this range.</p>
}
return (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-white/10">
<th className="px-4 py-3">{header('label', 'Placement')}</th>
<th className="px-4 py-3">{header('page_views', 'Page Views')}</th>
<th className="px-4 py-3">{header('impressions', 'Impressions')}</th>
<th className="px-4 py-3">{header('clicks', 'Clicks')}</th>
<th className="px-4 py-3">{header('ctr', 'CTR')}</th>
<th className="px-4 py-3">{header('rpm', 'RPM')}</th>
<th className="px-4 py-3">{header('revenue', 'Revenue')}</th>
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={row.key} className="border-b border-white/5 text-slate-200">
<td className="px-4 py-3 font-medium text-white">{row.label}</td>
<td className="px-4 py-3">{formatNumber(row.page_views)}</td>
<td className="px-4 py-3">{formatNumber(row.impressions)}</td>
<td className="px-4 py-3">{formatNumber(row.clicks)}</td>
<td className="px-4 py-3">{formatPercent(row.ctr)}</td>
<td className="px-4 py-3">{formatMoney(row.rpm, currency)}</td>
<td className="px-4 py-3">{formatMoney(row.revenue, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
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 (
<AdminLayout title="AdSense Analytics" subtitle="Read-only Google AdSense reporting for Skinbase administrators.">
<Head title="Admin · AdSense Analytics" />
<div className="space-y-6">
{flash.success ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div>
) : null}
{flash.warning ? (
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">{flash.warning}</div>
) : null}
{flash.error ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div>
) : null}
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Google AdSense</p>
<div className="mt-3 flex items-center gap-3">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${status.className}`}>
{status.label}
</span>
</div>
<p className="mt-4 text-sm text-slate-300">
Account: {connection?.account_display_name || 'Not selected'}
{connection?.account_resource_name ? ` / ${connection.account_resource_name.replace('accounts/', '')}` : ''}
</p>
<p className="mt-1 text-sm text-slate-400">Last synchronized: {formatTimestamp(connection?.last_successful_sync_at)}</p>
{connection?.last_error ? <p className="mt-2 text-sm text-rose-200">{connection.last_error}</p> : null}
{connection?.needs_reconnect ? (
<p className="mt-2 text-sm text-amber-200">AdSense authorization expired or was revoked. Reconnect Google AdSense.</p>
) : null}
{!configured ? <p className="mt-2 text-sm text-amber-200">ADSENSE_CLIENT_ID and ADSENSE_CLIENT_SECRET are not configured.</p> : null}
</div>
<div className="flex flex-wrap gap-2">
<a href={routes.connect} className="rounded-xl border border-white/10 bg-rose-500/20 px-4 py-2 text-sm font-semibold text-rose-100 hover:bg-rose-500/30">
{connection?.needs_reconnect || connection?.status === 'reconnect_required' ? 'Reconnect' : 'Connect Google AdSense'}
</a>
{connection?.status === 'connected' ? (
<button type="button" onClick={() => router.post(routes.sync)} className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">
Sync now
</button>
) : null}
{connection && connection.status !== 'disconnected' ? (
<button type="button" onClick={() => router.delete(routes.disconnect)} className="rounded-xl border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-slate-300 hover:bg-black/40">
Disconnect
</button>
) : null}
</div>
</div>
</section>
{pendingAccounts.length ? (
<section className="rounded-[28px] border border-amber-400/20 bg-amber-500/5 p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-200">Select AdSense account</p>
<div className="mt-4 space-y-2">
{pendingAccounts.map((account) => (
<button
key={account.resource_name}
type="button"
onClick={() => router.post(routes.account, { account_resource_name: account.resource_name })}
className="block w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-left text-sm text-white hover:border-white/20"
>
<span className="font-semibold">{account.display_name}</span>
<span className="ml-2 text-slate-400">{account.resource_name.replace('accounts/', '')}</span>
</button>
))}
</div>
</section>
) : null}
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Period</p>
<p className="mt-2 text-sm text-slate-300">{range.from} to {range.to}</p>
</div>
<div className="flex flex-wrap gap-2">
{[
['today', 'Today'],
['yesterday', 'Yesterday'],
['7d', 'Last 7 days'],
['30d', 'Last 30 days'],
].map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => applyPeriod(value)}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${range.period === value ? 'border-rose-400/30 bg-rose-500/15 text-rose-100' : 'border-white/10 text-slate-400 hover:text-white'}`}
>
{label}
</button>
))}
</div>
</div>
<form
className="mt-4 flex flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
applyPeriod('custom', form.get('from'), form.get('to'))
}}
>
<label className="text-xs uppercase tracking-[0.16em] text-slate-500">
From
<input type="date" name="from" defaultValue={range.from} className="mt-1 block rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" />
</label>
<label className="text-xs uppercase tracking-[0.16em] text-slate-500">
To
<input type="date" name="to" defaultValue={range.to} className="mt-1 block rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" />
</label>
<button type="submit" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white">
Custom range
</button>
</form>
<p className="mt-4 text-xs text-slate-500">Today's AdSense data is estimated and may change.</p>
</section>
{dashboard ? (
<>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{dashboard.kpis.map((kpi) => (
<div key={kpi.key} className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{kpi.label}</p>
<p className="mt-3 text-2xl font-bold text-white">{formatKpi(kpi)}</p>
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">{formatChange(kpi.change_percent)}</p>
</div>
))}
</section>
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Daily trend</p>
<div className="flex flex-wrap gap-2">
{[
['estimated_earnings', 'Revenue'],
['page_views', 'Page Views'],
['page_views_rpm', 'Page RPM'],
['clicks', 'Clicks'],
].map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setChartMetric(value)}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${chartMetric === value ? 'border-rose-400/30 bg-rose-500/15 text-rose-100' : 'border-white/10 text-slate-400'}`}
>
{label}
</button>
))}
</div>
</div>
<Sparkline points={dashboard.chart || []} metric={chartMetric} />
</section>
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Placement performance</p>
<SortableTable rows={dashboard.placements || []} currency={dashboard.currency_code} />
</section>
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Platform breakdown</p>
<SortableTable rows={dashboard.platforms || []} currency={dashboard.currency_code} />
</section>
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Top countries by revenue</p>
<SortableTable rows={dashboard.countries || []} currency={dashboard.currency_code} />
</section>
</>
) : null}
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Recommended AdSense unit names</p>
<p className="mt-2 text-sm text-slate-400">Rename units inside Google AdSense. Skinbase imports names and does not modify inventory.</p>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{recommendedPlacements.map((name) => (
<li key={name} className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-slate-200">{name}</li>
))}
</ul>
</section>
</div>
</AdminLayout>
)
}
+16 -12
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import AdminLayout from '../../Layouts/AdminLayout' import AdminLayout from '../../Layouts/AdminLayout'
import { Head } from '@inertiajs/react' import { Head, usePage } from '@inertiajs/react'
function StatCard({ icon, label, value, color = 'sky' }) { function StatCard({ icon, label, value, color = 'sky' }) {
const colors = { const colors = {
@@ -26,6 +26,20 @@ function StatCard({ icon, label, value, color = 'sky' }) {
} }
export default function Dashboard({ stats }) { 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 ( return (
<AdminLayout title="Dashboard" subtitle="Overview of your Skinbase platform"> <AdminLayout title="Dashboard" subtitle="Overview of your Skinbase platform">
<Head title="Admin Dashboard" /> <Head title="Admin Dashboard" />
@@ -42,17 +56,7 @@ export default function Dashboard({ stats }) {
<div className="mt-10"> <div className="mt-10">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-slate-500">Quick Actions</h2> <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-slate-500">Quick Actions</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[ {quickActions.map((item) => (
{ 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) => (
<a <a
key={item.href} key={item.href}
href={item.href} href={item.href}
+12
View File
@@ -283,6 +283,18 @@ Schedule::command('skinbase:sync-countries')
->withoutOverlapping() ->withoutOverlapping()
->runInBackground(); ->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') Schedule::command('health:tick')
->everyMinute() ->everyMinute()
->name('health-scheduler-tick') ->name('health-scheduler-tick')
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncAdsenseJob;
use App\Models\AdsenseConnection;
use App\Models\AdsenseDailyStat;
use App\Models\AdsenseEntity;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'adsense.client_id' => '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'));
});
@@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
use App\Models\AdsenseConnection;
use App\Models\AdsenseDailyStat;
use App\Models\AdsenseEntity;
use App\Services\Adsense\AdsenseApiClient;
use App\Services\Adsense\AdsenseOAuthService;
use App\Services\Adsense\AdsenseSyncService;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\artisan;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'adsense.client_id' => '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();
});
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncAdsenseJob;
use App\Models\AdsenseConnection;
use App\Models\User;
use App\Services\Adsense\AdsenseOAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'adsense.client_id' => '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);
});
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use App\Models\AdsenseDailyStat;
use App\Services\Adsense\AdsenseReportParser;
use Tests\TestCase;
uses(TestCase::class);
function adsenseReport(array $headers, array $rowValues): array
{
return [
'headers' => $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');
});