Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/Model/GoogleOptimizationScore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Fopost\Sdk\Model;

/** Google's estimate of how well the account is set up, from 0 to 1. */
final class GoogleOptimizationScore extends Model
{
/** @param array<int, GoogleOptimizationScoreCampaign> $campaigns */
private function __construct(
array $raw,
public readonly ?float $score,
public readonly ?float $weight,
public readonly array $campaigns,
) {
parent::__construct($raw);
}

public static function fromArray(mixed $data): static
{
$data = is_array($data) ? $data : [];

return new self(
$data,
self::float($data, 'score'),
self::float($data, 'weight'),
GoogleOptimizationScoreCampaign::listFrom($data['campaigns'] ?? []),
);
}
}
30 changes: 30 additions & 0 deletions src/Model/GoogleOptimizationScoreCampaign.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Fopost\Sdk\Model;

/** One campaign's optimization score. */
final class GoogleOptimizationScoreCampaign extends Model
{
private function __construct(
array $raw,
public readonly string $id,
public readonly string $name,
public readonly ?float $score,
) {
parent::__construct($raw);
}

public static function fromArray(mixed $data): static
{
$data = is_array($data) ? $data : [];

return new self(
$data,
self::requiredStr($data, 'id'),
self::requiredStr($data, 'name'),
self::float($data, 'score'),
);
}
}
42 changes: 42 additions & 0 deletions src/Model/GoogleRecommendation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Fopost\Sdk\Model;

/**
* One of Google's own recommendations for the account. The id is the Google
* resource name rather than the `~` form other objects use, because a
* recommendation is not an object you address again: it is what apply and
* dismiss take.
*/
final class GoogleRecommendation extends Model
{
private function __construct(
array $raw,
public readonly string $id,
public readonly string $type,
public readonly ?string $campaignId,
public readonly ?string $adGroupId,
public readonly bool $dismissed,
public readonly ?GoogleRecommendationImpact $impact,
) {
parent::__construct($raw);
}

public static function fromArray(mixed $data): static
{
$data = is_array($data) ? $data : [];
$impact = $data['impact'] ?? null;

return new self(
$data,
self::requiredStr($data, 'id'),
self::requiredStr($data, 'type'),
self::str($data, 'campaignId'),
self::str($data, 'adGroupId'),
self::bool($data, 'dismissed') ?? false,
is_array($impact) ? GoogleRecommendationImpact::fromArray($impact) : null,
);
}
}
36 changes: 36 additions & 0 deletions src/Model/GoogleRecommendationImpact.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Fopost\Sdk\Model;

/** What Google projects applying a recommendation would change. */
final class GoogleRecommendationImpact extends Model
{
private function __construct(
array $raw,
public readonly ?float $baseClicks,
public readonly ?float $potentialClicks,
public readonly ?int $baseCostMinor,
public readonly ?int $potentialCostMinor,
public readonly ?float $baseConversions,
public readonly ?float $potentialConversions,
) {
parent::__construct($raw);
}

public static function fromArray(mixed $data): static
{
$data = is_array($data) ? $data : [];

return new self(
$data,
self::float($data, 'baseClicks'),
self::float($data, 'potentialClicks'),
self::int($data, 'baseCostMinor'),
self::int($data, 'potentialCostMinor'),
self::float($data, 'baseConversions'),
self::float($data, 'potentialConversions'),
);
}
}
14 changes: 14 additions & 0 deletions src/Model/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ protected static function int(array $data, string $name): ?int
return null;
}

/** @param array<string, mixed> $data */
protected static function float(array $data, string $name): ?float
{
$value = self::field($data, $name);
if (is_int($value) || is_float($value)) {
return (float) $value;
}
if (is_string($value) && is_numeric($value)) {
return (float) $value;
}

return null;
}

/** @param array<string, mixed> $data */
protected static function bool(array $data, string $name): ?bool
{
Expand Down
71 changes: 71 additions & 0 deletions src/Resource/GoogleAdsResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Fopost\Sdk\Model\GoogleKeyword;
use Fopost\Sdk\Model\GoogleKeywordIdea;
use Fopost\Sdk\Model\GoogleLocalServicesLead;
use Fopost\Sdk\Model\GoogleOptimizationScore;
use Fopost\Sdk\Model\GoogleRecommendation;
use Fopost\Sdk\Model\GoogleSearchTerm;
use Fopost\Sdk\Model\GoogleSharedSet;

Expand Down Expand Up @@ -446,6 +448,75 @@ public function localServicesLeads(
)));
}

// ── Recommendations ──

/**
* Google's own read on what the account should change next.
*
* @param array<int, string> $types narrows to those recommendation types
* @return array<int, GoogleRecommendation>
*/
public function recommendations(
string $connectionId,
string $customerId,
array $types = [],
?string $workspaceId = null,
): array {
$extra = $types === [] ? [] : ['types' => implode(',', $types)];

return GoogleRecommendation::listFrom(self::unwrap($this->http->get(
'/ads/google/recommendations',
self::params($connectionId, $customerId, $workspaceId, $extra),
)));
}

/** The account's score and weight, and the score of each live campaign. */
public function optimizationScore(
string $connectionId,
string $customerId,
?string $workspaceId = null,
): GoogleOptimizationScore {
return GoogleOptimizationScore::fromArray(self::unwrap($this->http->get(
'/ads/google/optimization-score',
self::params($connectionId, $customerId, $workspaceId),
)));
}

/**
* Applies each one, which changes what the live account serves or bids, and
* reports how many landed. Needs `publish` as well as `ads`.
*
* @param array<int, string> $ids
*/
public function applyRecommendations(
string $workspaceId,
string $connectionId,
string $customerId,
array $ids,
): int {
$body = self::scope($workspaceId, $connectionId, $customerId) + ['ids' => array_values($ids)];
$result = self::unwrap($this->http->post('/ads/google/recommendations/apply', $body));

return is_array($result) && is_int($result['applied'] ?? null) ? $result['applied'] : 0;
}

/**
* Hides each one so Google stops surfacing it. Needs `publish` as well as `ads`.
*
* @param array<int, string> $ids
*/
public function dismissRecommendations(
string $workspaceId,
string $connectionId,
string $customerId,
array $ids,
): int {
$body = self::scope($workspaceId, $connectionId, $customerId) + ['ids' => array_values($ids)];
$result = self::unwrap($this->http->post('/ads/google/recommendations/dismiss', $body));

return is_array($result) && is_int($result['dismissed'] ?? null) ? $result['dismissed'] : 0;
}

// ── Conversions ──

/** @return array<int, GoogleConversionAction> */
Expand Down
9 changes: 8 additions & 1 deletion tests/AdsTikTokTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ public function testIdentitiesAndSparkPostsReadTheRightPaths(): void

public function testSparkPostIdAndSmartPlusTravelInTheBody(): void
{
$this->transport->push(201, ['data' => ['id' => 'ad_1', 'workspaceId' => 'w_1', 'kind' => 'ad', 'name' => 'Spark', 'goal' => 'traffic', 'status' => 'paused']]);
$this->transport->push(201, ['data' => [
'id' => 'ad_1',
'workspaceId' => 'w_1',
'kind' => 'ad',
'name' => 'Spark',
'goal' => 'traffic',
'status' => 'paused',
]]);
$this->client()->ads()->create(
'w_1',
'conn_1',
Expand Down
36 changes: 36 additions & 0 deletions tests/GoogleAdsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,42 @@ public function testQueryReturnsRowsAsGoogleSendsThem(): void
$this->assertStringContainsString('/ads/insights/query', $this->transport->last()['url']);
}

public function testRecommendationsJoinTheTypesFilter(): void
{
$this->transport->push(200, ['data' => [[
'id' => 'customers/1234567890/recommendations/ABC~1',
'type' => 'KEYWORD',
'campaignId' => '1234567890~campaign~55',
'dismissed' => false,
'impact' => ['baseClicks' => 10, 'potentialClicks' => 25],
]]]);

$rows = $this->client()->ads()->google()->recommendations(
'conn_1',
'1234567890',
['KEYWORD', 'TARGET_CPA_OPT_IN'],
);

$this->assertSame('KEYWORD', $rows[0]->type);
$this->assertSame(25.0, $rows[0]->impact?->potentialClicks);
$this->assertStringContainsString('types=KEYWORD%2CTARGET_CPA_OPT_IN', $this->transport->last()['url']);
}

public function testApplyRecommendationsSendsTheIds(): void
{
$this->transport->push(200, ['data' => ['applied' => 1]]);

$applied = $this->client()->ads()->google()->applyRecommendations(
'ws_1',
'conn_1',
'1234567890',
['customers/1234567890/recommendations/ABC~1'],
);

$this->assertSame(1, $applied);
$this->assertStringContainsString('/ads/google/recommendations/apply', $this->transport->last()['url']);
}

public function testAuthorizeGoogleHasItsOwnRoute(): void
{
$this->transport->push(200, ['data' => ['url' => 'https://accounts.google.com/o/x']]);
Expand Down
Loading