Add project files.

This commit is contained in:
Grae Jones
2026-02-03 15:04:37 -08:00
parent a4838b594d
commit 8e7e03702e
65 changed files with 6227 additions and 0 deletions

View File

@@ -0,0 +1,576 @@
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
using Google.Ads.GoogleAds;
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Common;
using Google.Ads.GoogleAds.V22.Enums;
using Google.Ads.GoogleAds.V22.Errors;
using Google.Ads.GoogleAds.V22.Resources;
using Google.Ads.GoogleAds.V22.Services;
namespace GoogleApi.Services;
// ✅ IMPORTANT: force "Services" to mean Google.Ads.GoogleAds.Services (not GoogleApi.Services)
using GAdsServices = global::Google.Ads.GoogleAds.Services;
// ✅ Avoid name collision with Google.Ads.GoogleAds.V22.Resources.BiddingStrategy
using ModelBiddingStrategy = GoogleApi.Models.BiddingStrategy;
public sealed class GoogleAdsService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<GoogleAdsService> _logger;
public GoogleAdsService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<GoogleAdsService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
public async Task<ProviderResponse> ExecuteAsync(ProviderRequest request, CancellationToken ct)
{
var requestId = request.RequestId ?? Guid.NewGuid().ToString("N");
var operation = (request.Operation ?? string.Empty).Trim();
_logger.LogInformation(
"[GoogleAds] Executing {Operation} | RequestId={RequestId} TenantId={TenantId} RealApi={RealApi}",
operation, requestId, request.TenantId, _clientFactory.IsRealApiEnabled);
try
{
var context = new GoogleAdsContext
{
CustomerId = GoogleAdsClientFactory.NormalizeCustomerId(request.TenantId ?? string.Empty),
LoginCustomerId = request.LoginCustomerId
};
var result = operation switch
{
"Ping" => Ping(requestId),
"TestPing" => Ping(requestId),
"CreateCampaign" => await CreateCampaignAsync(request, context, requestId, ct),
"GetCampaign" => await GetCampaignAsync(request, context, requestId, ct),
"UpdateCampaign" => await UpdateCampaignAsync(request, context, requestId, ct),
"ListCampaigns" => await ListCampaignsAsync(request, context, requestId, ct),
"GetCampaignStats" => GetCampaignStats(request, requestId),
"GetAccountStats" => GetAccountStats(request, requestId),
"ListAccessibleCustomers" => await ListAccessibleCustomersAsync(context, requestId, ct),
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
};
_logger.LogInformation(
"[GoogleAds] Completed {Operation} | RequestId={RequestId} Ok={Ok}",
operation, requestId, result.Ok);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "[GoogleAds] Error in {Operation} | RequestId={RequestId}", operation, requestId);
return ProviderResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message);
}
}
private ProviderResponse Ping(string requestId)
=> ProviderResponse.Success(requestId, new
{
message = "GoogleApi provider is healthy",
service = "GoogleApi",
realApiEnabled = _clientFactory.IsRealApiEnabled,
apiVersion = _config.ApiVersion,
timestamp = DateTimeOffset.UtcNow
});
private async Task<ProviderResponse> CreateCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<CreateCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.Name))
return ProviderResponse.Fail(requestId, "VALIDATION", "Campaign name is required");
if (payload.BudgetMicros <= 0)
return ProviderResponse.Fail(requestId, "VALIDATION", "BudgetMicros must be > 0");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await CreateCampaignRealAsync(payload, context, requestId, ct);
var externalId = $"customers/{context.CustomerId}/campaigns/{GenerateId()}";
_logger.LogInformation("[GoogleAds] EMULATED: Created campaign {CampaignName} => {CampaignId}", payload.Name, externalId);
return ProviderResponse.Success(requestId, new
{
externalId,
name = payload.Name,
type = payload.Type.ToString(),
status = "ENABLED",
budgetMicros = payload.BudgetMicros,
biddingStrategy = payload.BiddingStrategy.ToString(),
createdAt = DateTimeOffset.UtcNow,
emulated = true
});
}
private async Task<ProviderResponse> CreateCampaignRealAsync(
CreateCampaignPayload payload, GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
// 1) Budget
CampaignBudgetServiceClient budgetService =
client.GetService(GAdsServices.V22.CampaignBudgetService);
var budget = new CampaignBudget
{
Name = $"{payload.Name} Budget ({DateTime.UtcNow:yyyyMMddHHmmss})",
AmountMicros = payload.BudgetMicros,
DeliveryMethod = BudgetDeliveryMethodEnum.Types.BudgetDeliveryMethod.Standard,
ExplicitlyShared = false
};
var budgetResponse = await budgetService.MutateCampaignBudgetsAsync(
new MutateCampaignBudgetsRequest
{
CustomerId = context.CustomerId,
Operations = { new CampaignBudgetOperation { Create = budget } }
},
cancellationToken: ct);
var budgetResourceName = budgetResponse.Results.FirstOrDefault()?.ResourceName;
if (string.IsNullOrWhiteSpace(budgetResourceName))
return ProviderResponse.Fail(requestId, "API_ERROR", "Budget create returned no resource name");
// 2) Campaign
CampaignServiceClient campaignService =
client.GetService(GAdsServices.V22.CampaignService);
var campaign = new Campaign
{
Name = payload.Name,
Status = CampaignStatusEnum.Types.CampaignStatus.Enabled,
AdvertisingChannelType = MapChannelType(payload.Type),
CampaignBudget = budgetResourceName
};
// Dates must be yyyyMMdd for Google Ads API
if (!string.IsNullOrWhiteSpace(payload.StartDate)) campaign.StartDate = payload.StartDate;
if (!string.IsNullOrWhiteSpace(payload.EndDate)) campaign.EndDate = payload.EndDate;
// ✅ Apply bidding in a way that does NOT rely on Campaign.MaximizeClicks property existing
ApplyBiddingStrategySafe(campaign, payload.BiddingStrategy);
var campResponse = await campaignService.MutateCampaignsAsync(
new MutateCampaignsRequest
{
CustomerId = context.CustomerId,
Operations = { new CampaignOperation { Create = campaign } }
},
cancellationToken: ct);
var campaignResourceName = campResponse.Results.FirstOrDefault()?.ResourceName;
return ProviderResponse.Success(requestId, new
{
campaignResourceName,
budgetResourceName,
name = payload.Name,
type = payload.Type.ToString(),
status = "ENABLED",
budgetMicros = payload.BudgetMicros,
biddingStrategy = payload.BiddingStrategy.ToString(),
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error creating campaign | RequestId={RequestId}", requestId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create campaign via real API");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
private async Task<ProviderResponse> GetCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<GetCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await GetCampaignRealAsync(payload.CampaignId, context, requestId, ct);
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved campaign {CampaignId}", payload.CampaignId);
return ProviderResponse.Success(requestId, new
{
externalId = payload.CampaignId,
name = "Sample Campaign",
type = CampaignType.Search.ToString(),
status = "ENABLED",
budgetMicros = 10_000_000L,
// NOTE: GetCampaignPayload doesn't have BiddingStrategy — so don't reference it
biddingStrategy = ModelBiddingStrategy.MaximizeClicks.ToString(),
createdAt = DateTimeOffset.UtcNow.AddDays(-7),
emulated = true
});
}
private Task<ProviderResponse> GetCampaignRealAsync(
string campaignId, GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
GoogleAdsServiceClient googleAdsService =
client.GetService(GAdsServices.V22.GoogleAdsService);
var isResourceName = campaignId.Contains("/campaigns/", StringComparison.OrdinalIgnoreCase);
var where = isResourceName
? $"campaign.resource_name = '{campaignId}'"
: $"campaign.id = {campaignId}";
var query = $@"
SELECT
campaign.resource_name,
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign_budget.amount_micros
FROM campaign
WHERE {where}
LIMIT 1";
var resp = googleAdsService.Search(new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
});
var row = resp.FirstOrDefault();
if (row == null)
return Task.FromResult(ProviderResponse.Fail(requestId, "NOT_FOUND", "Campaign not found"));
return Task.FromResult(ProviderResponse.Success(requestId, new
{
campaign = new
{
resourceName = row.Campaign.ResourceName,
id = row.Campaign.Id,
name = row.Campaign.Name,
status = row.Campaign.Status.ToString(),
channelType = row.Campaign.AdvertisingChannelType.ToString(),
budgetMicros = row.CampaignBudget?.AmountMicros
},
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error getting campaign | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get campaign via real API");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private Task<ProviderResponse> UpdateCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<UpdateCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return Task.FromResult(ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"));
_logger.LogInformation("[GoogleAds] EMULATED: Updated campaign {CampaignId}", payload.CampaignId);
return Task.FromResult(ProviderResponse.Success(requestId, new
{
updated = true,
campaignId = payload.CampaignId,
updatedAt = DateTimeOffset.UtcNow,
emulated = true
}));
}
private async Task<ProviderResponse> ListCampaignsAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await ListCampaignsRealAsync(context, requestId, ct);
_logger.LogInformation("[GoogleAds] EMULATED: Listed campaigns for tenant {TenantId}", request.TenantId);
var campaigns = new[]
{
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Brand Campaign", status = "Enabled", budgetMicros = 5_000_000L },
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Product Campaign", status = "Enabled", budgetMicros = 10_000_000L },
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Retargeting", status = "Paused", budgetMicros = 3_000_000L }
};
return ProviderResponse.Success(requestId, new
{
campaigns,
totalCount = campaigns.Length,
emulated = true
});
}
private Task<ProviderResponse> ListCampaignsRealAsync(
GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
GoogleAdsServiceClient googleAdsService =
client.GetService(GAdsServices.V22.GoogleAdsService);
var query = @"
SELECT
campaign.resource_name,
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign_budget.amount_micros
FROM campaign
ORDER BY campaign.name";
var results = new List<object>();
var resp = googleAdsService.Search(new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
});
foreach (var row in resp)
{
ct.ThrowIfCancellationRequested();
results.Add(new
{
resourceName = row.Campaign.ResourceName,
id = row.Campaign.Id,
name = row.Campaign.Name,
status = row.Campaign.Status.ToString(),
channelType = row.Campaign.AdvertisingChannelType.ToString(),
budgetMicros = row.CampaignBudget?.AmountMicros
});
}
return Task.FromResult(ProviderResponse.Success(requestId, new
{
campaigns = results,
totalCount = results.Count,
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error listing campaigns | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list campaigns via real API");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private ProviderResponse GetCampaignStats(ProviderRequest request, string requestId)
{
var payload = request.GetPayload<CampaignStatsPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved stats for campaign {CampaignId}", payload.CampaignId);
return ProviderResponse.Success(requestId, new
{
campaignId = payload.CampaignId,
dateRange = new { start = payload.StartDate ?? "2026-01-01", end = payload.EndDate ?? "2026-01-27" },
metrics = new
{
impressions = 15_234L,
clicks = 487L,
costMicros = 2_543_000L,
conversions = 23,
ctr = 0.032,
averageCpcMicros = 5_222L
},
emulated = true
});
}
private ProviderResponse GetAccountStats(ProviderRequest request, string requestId)
{
var payload = request.GetPayload<AccountStatsPayload>();
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved account stats for tenant {TenantId}", request.TenantId);
return ProviderResponse.Success(requestId, new
{
tenantId = request.TenantId,
dateRange = new { start = payload.StartDate ?? "2026-01-01", end = payload.EndDate ?? "2026-01-27" },
metrics = new
{
totalCampaigns = 5,
activeCampaigns = 3,
totalImpressions = 152_340L,
totalClicks = 4_870L,
totalCostMicros = 25_430_000L,
totalConversions = 234
},
emulated = true
});
}
private Task<ProviderResponse> ListAccessibleCustomersAsync(
GoogleAdsContext context, string requestId, CancellationToken ct)
{
if (!_clientFactory.IsRealApiEnabled)
{
return Task.FromResult(ProviderResponse.Success(requestId, new
{
customers = new[] { "1234567890", "9876543210" },
emulated = true
}));
}
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
CustomerServiceClient customerService =
client.GetService(GAdsServices.V22.CustomerService);
var resp = customerService.ListAccessibleCustomers(new ListAccessibleCustomersRequest());
var customers = resp.ResourceNames
.Select(rn => rn.Split('/').LastOrDefault() ?? rn)
.ToArray();
return Task.FromResult(ProviderResponse.Success(requestId, new
{
customers,
rawResourceNames = resp.ResourceNames.ToArray(),
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error listing accessible customers | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list accessible customers");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private static string GenerateId() => Guid.NewGuid().ToString("N")[..12];
private static AdvertisingChannelTypeEnum.Types.AdvertisingChannelType MapChannelType(CampaignType type)
=> type switch
{
CampaignType.Search => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Search,
CampaignType.Display => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Display,
CampaignType.Shopping => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Shopping,
CampaignType.Video => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Video,
CampaignType.PerformanceMax => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.PerformanceMax,
_ => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Search
};
// ✅ Strategy application that avoids Campaign.MaximizeClicks property dependency
private static void ApplyBiddingStrategySafe(Campaign campaign, ModelBiddingStrategy strategy)
{
// Try to set the enum safely without compile-time dependency on the member name.
// Different library/proto generations sometimes change the C# member casing.
static BiddingStrategyTypeEnum.Types.BiddingStrategyType ParseBst(params string[] names)
{
foreach (var n in names)
{
if (Enum.TryParse<BiddingStrategyTypeEnum.Types.BiddingStrategyType>(n, ignoreCase: true, out var v))
return v;
}
return BiddingStrategyTypeEnum.Types.BiddingStrategyType.Unspecified;
}
campaign.BiddingStrategyType = strategy switch
{
ModelBiddingStrategy.ManualCpc =>
ParseBst("ManualCpc", "MANUAL_CPC"),
ModelBiddingStrategy.MaximizeClicks =>
ParseBst("MaximizeClicks", "MAXIMIZE_CLICKS", "MaximizeClick"),
ModelBiddingStrategy.MaximizeConversions =>
ParseBst("MaximizeConversions", "MAXIMIZE_CONVERSIONS"),
ModelBiddingStrategy.TargetCpa =>
ParseBst("TargetCpa", "TARGET_CPA"),
ModelBiddingStrategy.TargetRoas =>
ParseBst("TargetRoas", "TARGET_ROAS"),
_ =>
ParseBst("MaximizeClicks", "MAXIMIZE_CLICKS", "Unspecified")
};
// Optional: set oneof objects ONLY when you know your generated Campaign has them.
// ManualCpc is the most consistently present.
if (strategy == ModelBiddingStrategy.ManualCpc)
{
campaign.ManualCpc = new ManualCpc();
}
// If your Campaign class DOES have these properties in your build, you can uncomment:
// if (strategy == ModelBiddingStrategy.MaximizeClicks) campaign.MaximizeClicks = new MaximizeClicks();
// if (strategy == ModelBiddingStrategy.MaximizeConversions) campaign.MaximizeConversions = new MaximizeConversions();
}
private static ProviderResponse HandleGoogleAdsException(GoogleAdsException gex, string requestId)
{
var errorDetails = gex.Failure?.Errors?.Select(e => new
{
errorCode = e.ErrorCode?.ToString(),
message = e.Message,
trigger = e.Trigger?.StringValue,
location = e.Location?.FieldPathElements?.Select(f => f.FieldName).ToArray()
}).ToList();
return ProviderResponse.Fail(requestId, "GOOGLE_ADS_ERROR", gex.Message, new
{
googleRequestId = gex.RequestId,
errors = errorDetails
});
}
}