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

@@ -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; }
}