Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -1,259 +0,0 @@
# Google Ads API Configuration Guide
## Overview
This document describes how to configure the GoogleApi service to connect to the real Google Ads API. The service supports both **emulated mode** (for testing without Google credentials) and **real API mode**.
## Configuration Levels
| Level | Storage | Examples |
|-------|---------|----------|
| **Platform secrets** | Azure Key Vault | Developer token, OAuth client secret |
| **Platform config** | App Settings / appsettings.json | API version, timeouts |
| **Per-account credentials** | Database (tbGoogleCredential) | Refresh tokens per linked account |
## Quick Start (Test Account)
1. Create a Google Ads test manager account
2. Get a developer token (works immediately for test accounts)
3. Set up OAuth credentials in Google Cloud Console
4. Configure the environment variables below
## Environment Variables for Azure Container Apps
> **Note:** This service runs **server-to-server**. There is **no interactive OAuth UI** at runtime.
> Generate the refresh token once (out-of-band) and store it securely (Key Vault / secrets).
### GoogleApi Service
```bash
# ==========================================
# Core Settings
# ==========================================
# Enable real Google Ads API calls (default: false)
GoogleAds__EnableRealApi=true
# API version (default: v22)
GoogleAds__ApiVersion=v22
# ==========================================
# Authentication - Developer Token
# Required for all API calls
# ==========================================
# Your developer token from Google Ads API Center
# Format: 22-character alphanumeric string
# Get from: https://ads.google.com/aw/apicenter
GoogleAds__DeveloperToken=YOUR_DEVELOPER_TOKEN_HERE
# ==========================================
# Authentication - OAuth 2.0
# Required for authenticating API requests
# ==========================================
# OAuth Client ID from Google Cloud Console
GoogleAds__OAuth__ClientId=YOUR_CLIENT_ID.apps.googleusercontent.com
# OAuth Client Secret from Google Cloud Console
# SENSITIVE - Use Key Vault reference in production
GoogleAds__OAuth__ClientSecret=YOUR_CLIENT_SECRET
# Refresh token for platform-level access
# Generated via OAuth flow or gcloud CLI
# SENSITIVE - Use Key Vault reference in production
GoogleAds__OAuth__RefreshToken=YOUR_REFRESH_TOKEN
# ==========================================
# Manager Account (Optional)
# Required if accessing client accounts under a manager
# ==========================================
# Default login customer ID (manager account)
# Format: 1234567890 (no dashes)
GoogleAds__DefaultLoginCustomerId=1234567890
# ==========================================
# Internal Authentication
# For Gateway -> GoogleApi communication
# ==========================================
# Shared secret for internal API authentication
# SENSITIVE - Use Key Vault reference
GOOGLE_INTERNAL_KEY=your-secure-internal-key
# ==========================================
# Optional Settings
# ==========================================
# HTTP timeout in seconds (default: 60)
GoogleAds__TimeoutSeconds=60
# Max retry attempts (default: 3)
GoogleAds__MaxRetries=3
```
### Azure Key Vault References
For sensitive values, use Key Vault references in Azure Container Apps:
```bash
# Instead of plain values:
GoogleAds__DeveloperToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsDeveloperToken/)
GoogleAds__OAuth__ClientSecret=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsClientSecret/)
GoogleAds__OAuth__RefreshToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsRefreshToken/)
GOOGLE_INTERNAL_KEY=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleInternalKey/)
```
## Step-by-Step Setup
### 1. Create Google Ads Manager Account
1. Go to https://ads.google.com/aw/apicenter
2. Sign in with a Google account NOT linked to production ads
3. Create a new manager account
4. For test accounts, click "Create a test manager account" link
### 2. Get Developer Token
1. In your manager account, go to **Tools & Settings > API Center**
2. Your developer token will be displayed
3. For test accounts: Token works immediately
4. For production: Apply for Basic Access (takes a few days)
### 3. Create Google Cloud Project
1. Go to https://console.cloud.google.com
2. Create a new project (or use existing)
3. Enable the **Google Ads API**:
- Go to APIs & Services > Library
- Search "Google Ads API"
- Click Enable
### 4. Create OAuth Credentials
1. Go to APIs & Services > Credentials
2. Click **Create Credentials > OAuth client ID**
3. Application type: **Desktop app** (for initial testing)
4. Download the JSON file
5. Note the Client ID and Client Secret
### 5. Generate Refresh Token
Option A: Using gcloud CLI
```bash
# Install gcloud CLI if not installed
gcloud auth login --cred-file=path/to/client_secret.json
gcloud auth print-access-token \
--scopes='https://www.googleapis.com/auth/adwords'
```
Option B: Using OAuth Playground
1. Go to https://developers.google.com/oauthplayground/
2. Click gear icon > Use your own credentials
3. Enter your Client ID and Secret
4. Select Google Ads API scope: `https://www.googleapis.com/auth/adwords`
5. Click Authorize APIs, sign in
6. Click "Exchange authorization code for tokens"
7. Copy the Refresh Token
### 6. Create Test Client Account
1. In your test manager account
2. Click Accounts > + > Create new account
3. This creates a test client account under your manager
4. Note the Customer ID (format: XXX-XXX-XXXX)
### 7. Configure Azure Container App
In Azure Portal > Container Apps > Your GoogleApi App > Settings > Environment Variables:
Add each variable from the list above, using Key Vault references for sensitive values.
## Testing the Configuration
### Check Health Endpoint
```bash
curl https://your-googleapi-url/health
```
Expected response:
```json
{
"service": "GoogleApi",
"status": "healthy",
"config": {
"realApiEnabled": true,
"apiVersion": "v18",
"developerTokenSet": true,
"oauthConfigured": true,
"defaultLoginCustomerId": "1234567890"
}
}
```
### Test API Call (via Gateway)
```bash
curl -X POST https://your-gateway-url/api/execution/request \
-H "Content-Type: application/json" \
-H "X-Dev-ClientId: test-client" \
-H "X-Dev-TenantId: 1234567890" \
-d '{
"operation": "ListAccessibleCustomers",
"payload": {}
}'
```
## Credential Flow Diagram
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Gateway │────▶│ GoogleApi │────▶│ Google Ads │
└─────────────┘ └─────────────┘ │ API │
│ └─────────────┘
┌──────┴──────┐
│ │
┌─────▼─────┐ ┌────▼────┐
│ Config │ │ Database │
│(env vars) │ │(per-acct)│
└───────────┘ └──────────┘
Config provides: Database provides:
- Developer Token - Per-account refresh tokens
- OAuth Client ID/Secret - Account-specific credentials
- Default refresh token - Linked customer IDs
```
## Troubleshooting
### "UNAUTHENTICATED" Error
- Check developer token is correct
- Verify OAuth credentials
- Ensure refresh token hasn't expired
### "PERMISSION_DENIED" Error
- Developer token may not be approved for production
- Verify account access permissions
- Check login-customer-id is correct
### "INVALID_CUSTOMER_ID" Error
- Customer ID format should be 10 digits, no dashes
- Verify account exists and is accessible
### Token Exchange Fails
- Client ID/Secret mismatch
- Refresh token was revoked
- OAuth consent was withdrawn
## Security Best Practices
1. **Never commit secrets** to source control
2. **Use Azure Key Vault** for all sensitive values
3. **Rotate refresh tokens** periodically
4. **Audit API access** via tbAdpApiLog
5. **Limit developer token access** - one token per application
6. **Use test accounts** for development and testing

View File

@@ -12,8 +12,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Google.Ads.GoogleAds" Version="24.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
<PackageReference Include="Google.Ads.GoogleAds" Version="25.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,58 @@
namespace GoogleApi.Models;
/// <summary>
/// Represents an audience segment from Google Ads (affinity, in-market, life events, etc.)
/// </summary>
public class AudienceSegment
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty; // AFFINITY, IN_MARKET, LIFE_EVENT, DETAILED_DEMOGRAPHIC
public string? ParentName { get; set; }
public int? ParentId { get; set; }
}
/// <summary>
/// Response containing all available audience segments
/// </summary>
public class AudienceSegmentsResponse
{
public List<AudienceSegment> Affinity { get; set; } = new();
public List<AudienceSegment> InMarket { get; set; } = new();
public List<AudienceSegment> LifeEvents { get; set; } = new();
public List<AudienceSegment> DetailedDemographics { get; set; } = new();
public int TotalCount => Affinity.Count + InMarket.Count + LifeEvents.Count + DetailedDemographics.Count;
public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Geo target constant for location targeting
/// </summary>
public class GeoTarget
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string CanonicalName { get; set; } = string.Empty;
public string TargetType { get; set; } = string.Empty; // City, State, Country, etc.
public string? CountryCode { get; set; }
public string? ParentGeoTarget { get; set; }
}
/// <summary>
/// Response for geo target search
/// </summary>
public class GeoTargetSearchResponse
{
public List<GeoTarget> Results { get; set; } = new();
public string Query { get; set; } = string.Empty;
}
/// <summary>
/// Payload for searching geo targets
/// </summary>
public class GeoTargetSearchPayload
{
public string Query { get; set; } = string.Empty;
public string? CountryCode { get; set; }
public int MaxResults { get; set; } = 20;
}

View File

@@ -0,0 +1,59 @@
namespace GoogleApi.Models;
#region Forecast Payloads
/// <summary>
/// Payload for KeywordForecast operation.
/// Gateway sends targeting + budget; we translate to GenerateKeywordForecastMetrics.
/// </summary>
public sealed class KeywordForecastPayload
{
/// <summary>Keywords to forecast (from wizard Step 1 URL analysis)</summary>
public List<string> Keywords { get; set; } = new();
/// <summary>Geo target IDs (Google Ads geo constants)</summary>
public List<long> GeoTargetIds { get; set; } = new();
/// <summary>Monthly budget in whole currency units allocated to this channel</summary>
public decimal MonthlyBudget { get; set; }
/// <summary>Currency code</summary>
public string CurrencyCode { get; set; } = "USD";
/// <summary>Forecast period in days (default 30)</summary>
public int ForecastDays { get; set; } = 30;
/// <summary>Campaign type for bid simulation</summary>
public CampaignType CampaignType { get; set; } = CampaignType.Search;
}
/// <summary>
/// Response from keyword forecast — monthly estimated metrics.
/// </summary>
public sealed class KeywordForecastResponse
{
public string Provider { get; set; } = "google";
public ForecastEstimates Monthly { get; set; } = new();
public ForecastMetrics Metrics { get; set; } = new();
public string Confidence { get; set; } = "none";
public string DataSource { get; set; } = "emulated";
}
public sealed class ForecastEstimates
{
public double Impressions { get; set; }
public double Clicks { get; set; }
public decimal Cost { get; set; }
public double Conversions { get; set; }
public double? Reach { get; set; }
}
public sealed class ForecastMetrics
{
public decimal AvgCpc { get; set; }
public decimal AvgCpm { get; set; }
public double Ctr { get; set; }
public decimal? EstimatedCpa { get; set; }
}
#endregion

View File

@@ -36,6 +36,25 @@ public sealed class ListCampaignsPayload
#endregion
#region Account Payloads
public sealed class CreateCustomerClientPayload
{
/// <summary>Display name for the new sub-account (used for billing reconciliation)</summary>
public string AccountName { get; set; } = string.Empty;
/// <summary>Currency code (e.g. "USD")</summary>
public string CurrencyCode { get; set; } = "USD";
/// <summary>Time zone (e.g. "America/Los_Angeles")</summary>
public string TimeZone { get; set; } = "America/Los_Angeles";
/// <summary>Optional descriptive name visible in MCC (defaults to AccountName)</summary>
public string? DescriptiveName { get; set; }
}
#endregion
#region Reporting Payloads
public sealed class CampaignStatsPayload

View File

@@ -47,6 +47,9 @@ builder.Services.AddSwaggerGen(c =>
// Core services
builder.Services.AddSingleton<GoogleAdsClientFactory>();
builder.Services.AddSingleton<AudienceService>();
builder.Services.AddSingleton<ReportingService>();
builder.Services.AddSingleton<KeywordForecastService>();
builder.Services.AddSingleton<GoogleAdsService>();
// Auth filter for internal calls from Gateway

View File

@@ -0,0 +1,318 @@
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Services;
using Google.Ads.GoogleAds.V22.Resources;
using Google.Ads.GoogleAds.V22.Enums;
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
// Alias to avoid namespace conflicts
using GAdsServices = global::Google.Ads.GoogleAds.Services;
namespace GoogleApi.Services;
/// <summary>
/// Service for querying Google Ads audience segments and geo targets.
/// </summary>
public sealed class AudienceService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<AudienceService> _logger;
public AudienceService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<AudienceService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
/// <summary>
/// Get all available audience segments (affinity, in-market, life events, detailed demographics)
/// </summary>
public async Task<ProviderResponse> GetAudienceSegmentsAsync(
GoogleAdsContext context,
string requestId,
CancellationToken ct)
{
_logger.LogInformation("[Audience] Fetching audience segments | RequestId={RequestId}", requestId);
if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId))
{
return GetEmulatedAudienceSegments(requestId);
}
try
{
var client = _clientFactory.CreateClient(context);
var googleAdsService = client.GetService(GAdsServices.V22.GoogleAdsService);
var customerId = context.CustomerId;
var response = new AudienceSegmentsResponse();
// Query user interests (Affinity + In-Market)
var userInterestQuery = @"
SELECT
user_interest.user_interest_id,
user_interest.name,
user_interest.taxonomy_type,
user_interest.availabilities
FROM user_interest
WHERE user_interest.taxonomy_type IN ('AFFINITY', 'IN_MARKET')";
var userInterestResults = googleAdsService.Search(customerId, userInterestQuery);
foreach (var row in userInterestResults)
{
var ui = row.UserInterest;
var segment = new AudienceSegment
{
Id = ui.UserInterestId,
Name = ui.Name,
Type = ui.TaxonomyType.ToString()
};
if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.Affinity)
response.Affinity.Add(segment);
else if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.InMarket)
response.InMarket.Add(segment);
}
// Query life events
var lifeEventQuery = @"
SELECT
life_event.id,
life_event.name
FROM life_event";
var lifeEventResults = googleAdsService.Search(customerId, lifeEventQuery);
foreach (var row in lifeEventResults)
{
var le = row.LifeEvent;
response.LifeEvents.Add(new AudienceSegment
{
Id = le.Id,
Name = le.Name,
Type = "LIFE_EVENT"
});
}
// Query detailed demographics
var detailedDemoQuery = @"
SELECT
detailed_demographic.id,
detailed_demographic.name
FROM detailed_demographic";
var detailedDemoResults = googleAdsService.Search(customerId, detailedDemoQuery);
foreach (var row in detailedDemoResults)
{
var dd = row.DetailedDemographic;
response.DetailedDemographics.Add(new AudienceSegment
{
Id = dd.Id,
Name = dd.Name,
Type = "DETAILED_DEMOGRAPHIC"
});
}
_logger.LogInformation(
"[Audience] Retrieved {Total} segments (Affinity={Affinity}, InMarket={InMarket}, LifeEvents={Life}, Demographics={Demo}) | RequestId={RequestId}",
response.TotalCount, response.Affinity.Count, response.InMarket.Count,
response.LifeEvents.Count, response.DetailedDemographics.Count, requestId);
return ProviderResponse.Success(requestId, response);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Audience] Failed to fetch segments | RequestId={RequestId}", requestId);
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
/// <summary>
/// Search for geo target constants by name
/// </summary>
public async Task<ProviderResponse> SearchGeoTargetsAsync(
GeoTargetSearchPayload payload,
GoogleAdsContext context,
string requestId,
CancellationToken ct)
{
_logger.LogInformation("[Audience] Searching geo targets: {Query} | RequestId={RequestId}",
payload.Query, requestId);
if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId))
{
return GetEmulatedGeoTargets(payload.Query, requestId);
}
try
{
var client = _clientFactory.CreateClient(context);
var geoService = client.GetService(GAdsServices.V22.GeoTargetConstantService);
var request = new SuggestGeoTargetConstantsRequest
{
Locale = "en",
CountryCode = payload.CountryCode ?? "US",
LocationNames = new SuggestGeoTargetConstantsRequest.Types.LocationNames()
};
request.LocationNames.Names.Add(payload.Query);
var response = await geoService.SuggestGeoTargetConstantsAsync(request);
var results = response.GeoTargetConstantSuggestions
.Take(payload.MaxResults)
.Select(s => new GeoTarget
{
Id = s.GeoTargetConstant.Id,
Name = s.GeoTargetConstant.Name,
CanonicalName = s.GeoTargetConstant.CanonicalName,
TargetType = s.GeoTargetConstant.TargetType,
CountryCode = s.GeoTargetConstant.CountryCode,
ParentGeoTarget = s.GeoTargetConstant.ParentGeoTarget
})
.ToList();
_logger.LogInformation("[Audience] Found {Count} geo targets for '{Query}' | RequestId={RequestId}",
results.Count, payload.Query, requestId);
return ProviderResponse.Success(requestId, new GeoTargetSearchResponse
{
Query = payload.Query,
Results = results
});
}
catch (Exception ex)
{
_logger.LogError(ex, "[Audience] Failed to search geo targets | RequestId={RequestId}", requestId);
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
#region Emulated Responses
private ProviderResponse GetEmulatedAudienceSegments(string requestId)
{
_logger.LogInformation("[Audience] Returning emulated audience segments | RequestId={RequestId}", requestId);
var response = new AudienceSegmentsResponse
{
Affinity = new List<AudienceSegment>
{
new() { Id = 80001, Name = "Sports & Fitness/Sports Fans", Type = "AFFINITY" },
new() { Id = 80002, Name = "Sports & Fitness/Health & Fitness Buffs", Type = "AFFINITY" },
new() { Id = 80003, Name = "Technology/Technophiles", Type = "AFFINITY" },
new() { Id = 80004, Name = "Travel/Travel Buffs", Type = "AFFINITY" },
new() { Id = 80005, Name = "Food & Dining/Foodies", Type = "AFFINITY" },
new() { Id = 80006, Name = "Home & Garden/Home Decor Enthusiasts", Type = "AFFINITY" },
new() { Id = 80007, Name = "Media & Entertainment/Movie Lovers", Type = "AFFINITY" },
new() { Id = 80008, Name = "Media & Entertainment/Music Lovers", Type = "AFFINITY" },
new() { Id = 80009, Name = "Shoppers/Value Shoppers", Type = "AFFINITY" },
new() { Id = 80010, Name = "Shoppers/Luxury Shoppers", Type = "AFFINITY" },
new() { Id = 80011, Name = "Lifestyles & Hobbies/Pet Lovers", Type = "AFFINITY" },
new() { Id = 80012, Name = "Lifestyles & Hobbies/Outdoor Enthusiasts", Type = "AFFINITY" },
new() { Id = 80013, Name = "News & Politics/Avid News Readers", Type = "AFFINITY" },
new() { Id = 80014, Name = "Beauty & Wellness/Beauty Mavens", Type = "AFFINITY" },
new() { Id = 80015, Name = "Vehicles & Transportation/Auto Enthusiasts", Type = "AFFINITY" },
},
InMarket = new List<AudienceSegment>
{
new() { Id = 90001, Name = "Apparel & Accessories/Athletic Apparel", Type = "IN_MARKET" },
new() { Id = 90002, Name = "Autos & Vehicles/Motor Vehicles (New)", Type = "IN_MARKET" },
new() { Id = 90003, Name = "Autos & Vehicles/Motor Vehicles (Used)", Type = "IN_MARKET" },
new() { Id = 90004, Name = "Business Services/Advertising & Marketing Services", Type = "IN_MARKET" },
new() { Id = 90005, Name = "Consumer Electronics/Computers & Peripherals", Type = "IN_MARKET" },
new() { Id = 90006, Name = "Consumer Electronics/Mobile Phones", Type = "IN_MARKET" },
new() { Id = 90007, Name = "Education/Primary & Secondary Schools (K-12)", Type = "IN_MARKET" },
new() { Id = 90008, Name = "Employment/Jobs", Type = "IN_MARKET" },
new() { Id = 90009, Name = "Financial Services/Insurance/Auto Insurance", Type = "IN_MARKET" },
new() { Id = 90010, Name = "Financial Services/Investment Services", Type = "IN_MARKET" },
new() { Id = 90011, Name = "Home & Garden/Home Improvement", Type = "IN_MARKET" },
new() { Id = 90012, Name = "Real Estate/Residential Properties", Type = "IN_MARKET" },
new() { Id = 90013, Name = "Software/Business Software", Type = "IN_MARKET" },
new() { Id = 90014, Name = "Travel/Hotels & Accommodations", Type = "IN_MARKET" },
new() { Id = 90015, Name = "Travel/Air Travel", Type = "IN_MARKET" },
},
LifeEvents = new List<AudienceSegment>
{
new() { Id = 70001, Name = "About to graduate college", Type = "LIFE_EVENT" },
new() { Id = 70002, Name = "Getting married soon", Type = "LIFE_EVENT" },
new() { Id = 70003, Name = "Recently married", Type = "LIFE_EVENT" },
new() { Id = 70004, Name = "Moving soon", Type = "LIFE_EVENT" },
new() { Id = 70005, Name = "Recently moved", Type = "LIFE_EVENT" },
new() { Id = 70006, Name = "Purchasing a home soon", Type = "LIFE_EVENT" },
new() { Id = 70007, Name = "Recently purchased a home", Type = "LIFE_EVENT" },
new() { Id = 70008, Name = "Starting a new job", Type = "LIFE_EVENT" },
new() { Id = 70009, Name = "Retiring soon", Type = "LIFE_EVENT" },
new() { Id = 70010, Name = "Recently started a business", Type = "LIFE_EVENT" },
},
DetailedDemographics = new List<AudienceSegment>
{
new() { Id = 60001, Name = "Parental status/Parents/Parents of infants (0-1 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60002, Name = "Parental status/Parents/Parents of toddlers (1-3 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60003, Name = "Parental status/Parents/Parents of preschoolers (3-5 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60004, Name = "Parental status/Parents/Parents of grade schoolers (6-12 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60005, Name = "Parental status/Parents/Parents of teens (13-17 years)", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60006, Name = "Marital status/Single", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60007, Name = "Marital status/In a relationship", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60008, Name = "Marital status/Married", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60009, Name = "Education/Current college students", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60010, Name = "Education/Bachelor's degree", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60011, Name = "Education/Advanced degree", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60012, Name = "Homeownership/Homeowners", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60013, Name = "Homeownership/Renters", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60014, Name = "Employment/Industry/Technology", Type = "DETAILED_DEMOGRAPHIC" },
new() { Id = 60015, Name = "Employment/Company size/Large employers (10,000+)", Type = "DETAILED_DEMOGRAPHIC" },
},
RetrievedAt = DateTimeOffset.UtcNow
};
return ProviderResponse.Success(requestId, response);
}
private ProviderResponse GetEmulatedGeoTargets(string query, string requestId)
{
_logger.LogInformation("[Audience] Returning emulated geo targets for '{Query}' | RequestId={RequestId}",
query, requestId);
var allTargets = new List<GeoTarget>
{
new() { Id = 9061285, Name = "Huntington Beach", CanonicalName = "Huntington Beach,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 1013962, Name = "Orange County", CanonicalName = "Orange County,California,United States", TargetType = "County", CountryCode = "US" },
new() { Id = 21137, Name = "California", CanonicalName = "California,United States", TargetType = "State", CountryCode = "US" },
new() { Id = 1014221, Name = "Los Angeles", CanonicalName = "Los Angeles,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 1014218, Name = "Los Angeles County", CanonicalName = "Los Angeles County,California,United States", TargetType = "County", CountryCode = "US" },
new() { Id = 9031936, Name = "Irvine", CanonicalName = "Irvine,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 9031935, Name = "Costa Mesa", CanonicalName = "Costa Mesa,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 9031938, Name = "Newport Beach", CanonicalName = "Newport Beach,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 9031937, Name = "Santa Ana", CanonicalName = "Santa Ana,California,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 1023191, Name = "New York", CanonicalName = "New York,New York,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 21167, Name = "New York", CanonicalName = "New York,United States", TargetType = "State", CountryCode = "US" },
new() { Id = 1014895, Name = "Chicago", CanonicalName = "Chicago,Illinois,United States", TargetType = "City", CountryCode = "US" },
new() { Id = 2840, Name = "United States", CanonicalName = "United States", TargetType = "Country", CountryCode = "US" },
};
// Simple filter by query
var queryLower = query.ToLowerInvariant();
var matches = allTargets
.Where(t => t.Name.ToLowerInvariant().Contains(queryLower) ||
t.CanonicalName.ToLowerInvariant().Contains(queryLower))
.Take(10)
.ToList();
return ProviderResponse.Success(requestId, new GeoTargetSearchResponse
{
Query = query,
Results = matches
});
}
#endregion
}

View File

@@ -1,4 +1,4 @@
using GoogleApi.Configuration;
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
@@ -23,15 +23,24 @@ public sealed class GoogleAdsService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly AudienceService _audienceService;
private readonly ReportingService _reportingService;
private readonly KeywordForecastService _forecastService;
private readonly ILogger<GoogleAdsService> _logger;
public GoogleAdsService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
AudienceService audienceService,
ReportingService reportingService,
KeywordForecastService forecastService,
ILogger<GoogleAdsService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_audienceService = audienceService;
_reportingService = reportingService;
_forecastService = forecastService;
_logger = logger;
}
@@ -65,7 +74,20 @@ public sealed class GoogleAdsService
"GetCampaignStats" => GetCampaignStats(request, requestId),
"GetAccountStats" => GetAccountStats(request, requestId),
// Reporting (Campaign Intelligence)
"GetCampaignReport" => await _reportingService.GetCampaignReportAsync(request, context, requestId, ct),
"GetAccountReport" => await _reportingService.GetAccountReportAsync(request, context, requestId, ct),
"ListAccessibleCustomers" => await ListAccessibleCustomersAsync(context, requestId, ct),
"CreateCustomerClient" => await CreateCustomerClientAsync(request, context, requestId, ct),
// Audience Operations
"GetAudienceSegments" => await _audienceService.GetAudienceSegmentsAsync(context, requestId, ct),
"SearchGeoTargets" => await _audienceService.SearchGeoTargetsAsync(
request.GetPayload<GeoTargetSearchPayload>(), context, requestId, ct),
// Forecast
"KeywordForecast" => await ExecuteForecastAsync(request, context, requestId, ct),
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
@@ -94,6 +116,14 @@ public sealed class GoogleAdsService
timestamp = DateTimeOffset.UtcNow
});
private async Task<ProviderResponse> ExecuteForecastAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<KeywordForecastPayload>();
var result = await _forecastService.ForecastAsync(payload, context, ct);
return ProviderResponse.Success(requestId, result);
}
private async Task<ProviderResponse> CreateCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
@@ -495,6 +525,100 @@ ORDER BY campaign.name";
}
}
private async Task<ProviderResponse> CreateCustomerClientAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<CreateCustomerClientPayload>();
if (string.IsNullOrWhiteSpace(payload.AccountName))
return ProviderResponse.Fail(requestId, "VALIDATION", "AccountName is required");
// For emulated mode
if (!_clientFactory.IsRealApiEnabled)
{
var fakeId = new Random().NextInt64(1000000000, 9999999999).ToString();
_logger.LogInformation("[GoogleAds] EMULATED: Created customer client {AccountName} => {CustomerId}",
payload.AccountName, fakeId);
return ProviderResponse.Success(requestId, new
{
customerId = fakeId,
accountName = payload.AccountName,
currencyCode = payload.CurrencyCode,
timeZone = payload.TimeZone,
emulated = true
});
}
try
{
// CreateCustomerClient runs against the MCC (manager account).
// The context.CustomerId should be the MCC ID.
var mccId = GoogleAdsClientFactory.NormalizeCustomerId(
context.CustomerId ?? request.TenantId ?? string.Empty);
if (string.IsNullOrWhiteSpace(mccId))
return ProviderResponse.Fail(requestId, "VALIDATION",
"TenantId (MCC customer ID) is required to create a sub-account");
// Force LoginCustomerId = MCC for this call
var mccContext = new GoogleAdsContext
{
CustomerId = mccId,
LoginCustomerId = mccId
};
GoogleAdsClient client = _clientFactory.CreateClient(mccContext);
CustomerServiceClient customerService =
client.GetService(GAdsServices.V22.CustomerService);
var customerClient = new Customer
{
DescriptiveName = payload.DescriptiveName ?? payload.AccountName,
CurrencyCode = payload.CurrencyCode,
TimeZone = payload.TimeZone
};
var response = await customerService.CreateCustomerClientAsync(
new CreateCustomerClientRequest
{
CustomerId = mccId,
CustomerClient = customerClient
},
cancellationToken: ct);
// Response contains the resource name like "customers/9336988646/customerClients/1234567890"
var newCustomerId = response.ResourceName?.Split('/').LastOrDefault() ?? response.ResourceName;
_logger.LogInformation(
"[GoogleAds] Created customer client {AccountName} => {NewCustomerId} under MCC {MccId}",
payload.AccountName, newCustomerId, mccId);
return ProviderResponse.Success(requestId, new
{
customerId = newCustomerId,
resourceName = response.ResourceName,
accountName = payload.AccountName,
descriptiveName = payload.DescriptiveName ?? payload.AccountName,
currencyCode = payload.CurrencyCode,
timeZone = payload.TimeZone,
mccId = mccId,
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error creating customer client | RequestId={RequestId}", requestId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create customer client via real API");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
private static string GenerateId() => Guid.NewGuid().ToString("N")[..12];
private static AdvertisingChannelTypeEnum.Types.AdvertisingChannelType MapChannelType(CampaignType type)

View File

@@ -0,0 +1,219 @@
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Services;
using Google.Ads.GoogleAds.V22.Common;
using Google.Ads.GoogleAds.V22.Enums;
using Google.Ads.GoogleAds.V22.Resources;
namespace GoogleApi.Services;
using GAdsServices = global::Google.Ads.GoogleAds.Services;
/// <summary>
/// Generates keyword-level forecast metrics via Google Ads KeywordPlanIdeaService.
/// Used by the wizard to show estimated performance before campaign creation.
///
/// EnableRealApi=false → emulated estimates based on budget + keyword count.
/// EnableRealApi=true → calls GenerateKeywordForecastMetrics.
/// </summary>
public sealed class KeywordForecastService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<KeywordForecastService> _logger;
public KeywordForecastService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<KeywordForecastService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
public async Task<KeywordForecastResponse> ForecastAsync(
KeywordForecastPayload payload,
GoogleAdsContext context,
CancellationToken ct)
{
if (payload.Keywords.Count == 0)
{
_logger.LogWarning("[KeywordForecast] No keywords provided");
return EmptyForecast();
}
if (_clientFactory.IsRealApiEnabled)
{
try
{
return await ForecastRealAsync(payload, context, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "[KeywordForecast] Real API failed, falling back to emulated");
return ForecastEmulated(payload);
}
}
return ForecastEmulated(payload);
}
// ================================================================
// Real API: GenerateKeywordForecastMetrics
// ================================================================
private async Task<KeywordForecastResponse> ForecastRealAsync(
KeywordForecastPayload payload,
GoogleAdsContext context,
CancellationToken ct)
{
_logger.LogInformation(
"[KeywordForecast] Real API | Keywords={Count} Budget={Budget} Geos={Geos}",
payload.Keywords.Count, payload.MonthlyBudget, payload.GeoTargetIds.Count);
var client = _clientFactory.CreateClient(context);
var service = client.GetService(GAdsServices.V22.KeywordPlanIdeaService);
// Build campaign to forecast
var campaign = new CampaignToForecast
{
KeywordPlanNetwork = KeywordPlanNetworkEnum.Types.KeywordPlanNetwork.GoogleSearch
};
// Geo targets
foreach (var geoId in payload.GeoTargetIds)
{
campaign.GeoModifiers.Add(new CriterionBidModifier
{
GeoTargetConstant = GeoTargetConstantName.Format(geoId.ToString())
});
}
// Ad group with keywords (cap at 20 for sanity)
var adGroup = new ForecastAdGroup();
foreach (var keyword in payload.Keywords.Take(20))
{
adGroup.BiddableKeywords.Add(new BiddableKeyword
{
MaxCpcBidMicros = 2_000_000, // $2.00 default bid for simulation
Keyword = new KeywordInfo
{
Text = keyword,
MatchType = KeywordMatchTypeEnum.Types.KeywordMatchType.Broad
}
});
}
campaign.AdGroups.Add(adGroup);
var request = new GenerateKeywordForecastMetricsRequest
{
Campaign = campaign,
ForecastPeriod = new DateRange
{
StartDate = DateTime.UtcNow.ToString("yyyy-MM-dd"),
EndDate = DateTime.UtcNow.AddDays(payload.ForecastDays).ToString("yyyy-MM-dd")
},
CustomerId = context.CustomerId
};
var response = await service.GenerateKeywordForecastMetricsAsync(request);
var m = response.CampaignForecastMetrics;
// V22 SDK returns non-nullable primitives (0 when no data)
var impressions = m.Impressions;
var clicks = m.Clicks;
var costMicros = m.CostMicros;
var conversions = m.Conversions;
var avgCpcMicros = m.AverageCpcMicros;
var cost = costMicros / 1_000_000m;
var avgCpc = avgCpcMicros / 1_000_000m;
var ctr = impressions > 0 ? clicks / impressions : 0;
var avgCpm = impressions > 0 ? (cost / (decimal)impressions) * 1000m : 0;
var cpa = conversions > 0 ? cost / (decimal)conversions : (decimal?)null;
_logger.LogInformation(
"[KeywordForecast] Real result | Imp={Imp} Clicks={Clicks} Cost={Cost}",
impressions, clicks, cost);
return new KeywordForecastResponse
{
Provider = "google",
Monthly = new ForecastEstimates
{
Impressions = impressions,
Clicks = clicks,
Cost = cost,
Conversions = conversions
},
Metrics = new ForecastMetrics
{
AvgCpc = avgCpc,
AvgCpm = avgCpm,
Ctr = ctr,
EstimatedCpa = cpa
},
Confidence = "medium",
DataSource = "keywordForecast"
};
}
// ================================================================
// Emulated: budget-proportional estimates with realistic variance
// ================================================================
private KeywordForecastResponse ForecastEmulated(KeywordForecastPayload payload)
{
_logger.LogInformation(
"[KeywordForecast] Emulated | Keywords={Count} Budget={Budget}",
payload.Keywords.Count, payload.MonthlyBudget);
// Realistic Search ranges for SMB tier
var keywordFactor = Math.Min(payload.Keywords.Count, 20) / 20.0;
var baseCpc = 2.50m - (decimal)(keywordFactor * 1.20); // $1.30 $2.50
var budget = payload.MonthlyBudget;
var clicks = budget > 0 ? (double)(budget / baseCpc) : 0;
var impressions = clicks / 0.045; // ~4.5% CTR
var conversions = clicks * 0.035; // ~3.5% conv rate
var ctr = impressions > 0 ? clicks / impressions : 0;
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
// Seeded variance (±15%) — same inputs → same output, but not canned-looking
var rng = new Random((int)(budget * 100) + payload.Keywords.Count);
var v = 0.85 + (rng.NextDouble() * 0.30);
return new KeywordForecastResponse
{
Provider = "google",
Monthly = new ForecastEstimates
{
Impressions = Math.Round(impressions * v),
Clicks = Math.Round(clicks * v),
Cost = Math.Round(budget * (decimal)v, 2),
Conversions = Math.Round(conversions * v, 1)
},
Metrics = new ForecastMetrics
{
AvgCpc = Math.Round(baseCpc, 2),
AvgCpm = Math.Round((decimal)cpm, 2),
Ctr = Math.Round(ctr, 4),
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null
},
Confidence = "low",
DataSource = "emulated"
};
}
private static KeywordForecastResponse EmptyForecast() => new()
{
Provider = "google",
Confidence = "none",
DataSource = "none"
};
}

View File

@@ -0,0 +1,382 @@
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
using Google.Ads.GoogleAds;
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Errors;
using Google.Ads.GoogleAds.V22.Services;
namespace GoogleApi.Services;
using GAdsServices = global::Google.Ads.GoogleAds.Services;
/// <summary>
/// Reporting service for pulling campaign performance metrics from Google Ads.
/// Supports both real API calls and emulated responses for development.
///
/// Operations:
/// - GetCampaignReport: Daily metrics for a specific campaign
/// - GetAccountReport: Daily metrics across all campaigns in an account
/// - GetCampaignList: Campaign status/budget summary (lightweight)
/// </summary>
public sealed class ReportingService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<ReportingService> _logger;
public ReportingService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<ReportingService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
/// <summary>
/// Get daily campaign performance report.
/// Returns impressions, clicks, spend, conversions, conversion value per day.
/// </summary>
public async Task<ProviderResponse> GetCampaignReportAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<ReportingPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd");
var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await GetCampaignReportRealAsync(payload.CampaignId, startDate, endDate, context, requestId, ct);
return GetCampaignReportEmulated(payload.CampaignId, startDate, endDate, requestId);
}
/// <summary>
/// Get daily account-level performance report across all campaigns.
/// Used for syncing metrics into tbPerformanceMetric.
/// </summary>
public async Task<ProviderResponse> GetAccountReportAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<ReportingPayload>();
var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd");
var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await GetAccountReportRealAsync(startDate, endDate, context, requestId, ct);
return GetAccountReportEmulated(startDate, endDate, requestId);
}
// ════════════════════════════════════════════════
// Real API Implementation
// ════════════════════════════════════════════════
private async Task<ProviderResponse> GetCampaignReportRealAsync(
string campaignId, string startDate, string endDate,
GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
var client = _clientFactory.CreateClient(context);
var gaService = client.GetService(GAdsServices.V22.GoogleAdsService);
// Extract numeric campaign ID from resource name if needed
var numericId = campaignId.Contains('/') ? campaignId.Split('/').Last() : campaignId;
var query = $@"
SELECT
campaign.id,
campaign.name,
campaign.status,
segments.date,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.all_conversions,
metrics.ctr,
metrics.average_cpc
FROM campaign
WHERE campaign.id = {numericId}
AND segments.date BETWEEN '{startDate}' AND '{endDate}'
ORDER BY segments.date";
var rows = new List<object>();
var searchRequest = new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
};
var response = gaService.Search(searchRequest);
foreach (var row in response)
{
rows.Add(new
{
date = row.Segments.Date,
campaignId = row.Campaign.Id,
campaignName = row.Campaign.Name,
campaignStatus = row.Campaign.Status.ToString(),
impressions = row.Metrics.Impressions,
clicks = row.Metrics.Clicks,
costMicros = row.Metrics.CostMicros,
spend = row.Metrics.CostMicros / 1_000_000.0,
conversions = row.Metrics.Conversions,
conversionValue = row.Metrics.ConversionsValue,
allConversions = row.Metrics.AllConversions,
ctr = row.Metrics.Ctr,
averageCpcMicros = row.Metrics.AverageCpc
});
}
_logger.LogInformation(
"[Reporting] Retrieved {Count} rows for campaign {CampaignId} | RequestId={RequestId}",
rows.Count, campaignId, requestId);
return ProviderResponse.Success(requestId, new
{
campaignId,
dateRange = new { start = startDate, end = endDate },
rows,
rowCount = rows.Count,
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "[Reporting] Google Ads error for campaign {CampaignId}", campaignId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Reporting] Error fetching campaign report");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
private async Task<ProviderResponse> GetAccountReportRealAsync(
string startDate, string endDate,
GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
var client = _clientFactory.CreateClient(context);
var gaService = client.GetService(GAdsServices.V22.GoogleAdsService);
var query = $@"
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
segments.date,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.all_conversions
FROM campaign
WHERE segments.date BETWEEN '{startDate}' AND '{endDate}'
AND campaign.status != 'REMOVED'
ORDER BY segments.date, campaign.id";
var rows = new List<object>();
var searchRequest = new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
};
var response = gaService.Search(searchRequest);
foreach (var row in response)
{
rows.Add(new
{
date = row.Segments.Date,
campaignId = row.Campaign.Id,
campaignName = row.Campaign.Name,
campaignStatus = row.Campaign.Status.ToString(),
channelType = row.Campaign.AdvertisingChannelType.ToString(),
impressions = row.Metrics.Impressions,
clicks = row.Metrics.Clicks,
costMicros = row.Metrics.CostMicros,
spend = row.Metrics.CostMicros / 1_000_000.0,
conversions = row.Metrics.Conversions,
conversionValue = row.Metrics.ConversionsValue,
allConversions = row.Metrics.AllConversions
});
}
_logger.LogInformation(
"[Reporting] Retrieved {Count} rows for account {CustomerId} | RequestId={RequestId}",
rows.Count, context.CustomerId, requestId);
return ProviderResponse.Success(requestId, new
{
customerId = context.CustomerId,
dateRange = new { start = startDate, end = endDate },
rows,
rowCount = rows.Count,
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "[Reporting] Google Ads error for account {CustomerId}", context.CustomerId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Reporting] Error fetching account report");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
// ════════════════════════════════════════════════
// Emulated Responses
// ════════════════════════════════════════════════
private ProviderResponse GetCampaignReportEmulated(
string campaignId, string startDate, string endDate, string requestId)
{
_logger.LogInformation("[Reporting] EMULATED: Campaign report for {CampaignId}", campaignId);
var rows = GenerateEmulatedDailyRows(startDate, endDate);
return ProviderResponse.Success(requestId, new
{
campaignId,
dateRange = new { start = startDate, end = endDate },
rows,
rowCount = rows.Count,
emulated = true
});
}
private ProviderResponse GetAccountReportEmulated(
string startDate, string endDate, string requestId)
{
_logger.LogInformation("[Reporting] EMULATED: Account report");
// Generate rows for 3 emulated campaigns
var allRows = new List<object>();
var campaigns = new[]
{
new { id = "emu_camp_001", name = "Search - Brand Terms", channel = "SEARCH" },
new { id = "emu_camp_002", name = "Display - Awareness", channel = "DISPLAY" },
new { id = "emu_camp_003", name = "PMax - Conversions", channel = "PERFORMANCE_MAX" }
};
foreach (var camp in campaigns)
{
var rows = GenerateEmulatedDailyRows(startDate, endDate);
foreach (var row in rows)
{
// Merge campaign info into each row
var dict = new Dictionary<string, object?>
{
["campaignId"] = camp.id,
["campaignName"] = camp.name,
["campaignStatus"] = "ENABLED",
["channelType"] = camp.channel
};
// Merge the row properties
var rowJson = System.Text.Json.JsonSerializer.Serialize(row);
var rowDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(rowJson);
if (rowDict != null)
foreach (var kv in rowDict) dict[kv.Key] = kv.Value;
allRows.Add(dict);
}
}
return ProviderResponse.Success(requestId, new
{
customerId = "emulated",
dateRange = new { start = startDate, end = endDate },
rows = allRows,
rowCount = allRows.Count,
emulated = true
});
}
private static List<object> GenerateEmulatedDailyRows(string startDate, string endDate)
{
var rows = new List<object>();
var rng = new Random(42); // deterministic seed for consistent emulation
if (!DateTime.TryParse(startDate, out var start)) start = DateTime.UtcNow.AddDays(-30);
if (!DateTime.TryParse(endDate, out var end)) end = DateTime.UtcNow.AddDays(-1);
for (var date = start; date <= end; date = date.AddDays(1))
{
var impressions = rng.Next(800, 3500);
var clicks = (int)(impressions * (0.015 + rng.NextDouble() * 0.04));
var costMicros = (long)(clicks * (450_000 + rng.Next(0, 300_000)));
var conversions = Math.Round(clicks * (0.03 + rng.NextDouble() * 0.05), 2);
var conversionValue = Math.Round(conversions * (25 + rng.NextDouble() * 75), 2);
rows.Add(new
{
date = date.ToString("yyyy-MM-dd"),
impressions,
clicks,
costMicros,
spend = Math.Round(costMicros / 1_000_000.0, 2),
conversions,
conversionValue,
allConversions = Math.Round(conversions * 1.15, 2),
ctr = impressions > 0 ? Math.Round((double)clicks / impressions, 4) : 0.0,
averageCpcMicros = clicks > 0 ? costMicros / clicks : 0L
});
}
return rows;
}
// ════════════════════════════════════════════════
// Error Handling
// ════════════════════════════════════════════════
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
});
}
}
/// <summary>
/// Payload for reporting operations.
/// </summary>
public sealed class ReportingPayload
{
public string? CampaignId { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
}