Initial import into Gitea
This commit is contained in:
339
Gateway/Services/MetricSyncService.cs
Normal file
339
Gateway/Services/MetricSyncService.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates pulling campaign performance metrics from providers
|
||||
/// and writing them into the database via spPerformanceMetric.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Get active channel campaigns (from spChannelCampaign listByClient)
|
||||
/// 2. For each channel campaign with an external campaign ID:
|
||||
/// - Call the appropriate provider's reporting endpoint
|
||||
/// - Transform provider response into standard metric format
|
||||
/// - Upsert into tbPerformanceMetric via spPerformanceMetric.upsertBatch
|
||||
/// 3. After metrics are synced, trigger recommendation evaluation
|
||||
///
|
||||
/// Called by:
|
||||
/// - Admin endpoint (manual trigger)
|
||||
/// - Background polling (future: Azure Functions timer trigger)
|
||||
/// </summary>
|
||||
public sealed class MetricSyncService
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<MetricSyncService> _logger;
|
||||
|
||||
public MetricSyncService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ILogger<MetricSyncService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync metrics for a specific client's active campaigns.
|
||||
/// </summary>
|
||||
public async Task<SyncResult> SyncClientMetricsAsync(
|
||||
string clientId, string? startDate, string? endDate, CancellationToken ct)
|
||||
{
|
||||
var result = new SyncResult { ClientId = clientId };
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Get active channel campaigns for this client
|
||||
var listResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelCampaign, "listByClient",
|
||||
JsonSerializer.Serialize(new { clientId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(listResp))
|
||||
{
|
||||
result.Error = "Failed to retrieve channel campaigns";
|
||||
return result;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(listResp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
result.Error = "Channel campaign query returned error";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract campaigns array
|
||||
JsonElement campaigns;
|
||||
if (root.TryGetProperty("channelCampaigns", out campaigns) ||
|
||||
root.TryGetProperty("channels", out campaigns))
|
||||
{
|
||||
// ok
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Error = "No channel campaigns found in response";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (campaigns.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
result.Error = "Channel campaigns is not an array";
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Sync each active channel campaign
|
||||
foreach (var cc in campaigns.EnumerateArray())
|
||||
{
|
||||
var chcId = cc.TryGetProperty("channelCampaignId", out var chcIdProp) ? chcIdProp.GetInt64() :
|
||||
cc.TryGetProperty("chcId", out var chcProp) ? chcProp.GetInt64() : 0;
|
||||
var channelType = cc.TryGetProperty("channelType", out var ctProp) ? ctProp.GetString() :
|
||||
cc.TryGetProperty("chcChannelType", out var chcCtProp) ? chcCtProp.GetString() : null;
|
||||
var status = cc.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
cc.TryGetProperty("chcStatus", out var chcStProp) ? chcStProp.GetString() : null;
|
||||
|
||||
if (chcId == 0 || string.IsNullOrWhiteSpace(channelType)) continue;
|
||||
if (status != "active") continue;
|
||||
|
||||
result.CampaignsProcessed++;
|
||||
|
||||
try
|
||||
{
|
||||
var provider = MapChannelToProvider(channelType);
|
||||
var providerUrl = GetProviderUrl(provider);
|
||||
var providerKey = GetProviderKey(provider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] No URL for provider {Provider}, skipping chcId={ChcId}",
|
||||
provider, chcId);
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get external campaign ID
|
||||
// providerPayload from the channel campaign contains the external mapping
|
||||
var externalCampaignId = cc.TryGetProperty("externalCampaignId", out var extIdProp)
|
||||
? extIdProp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(externalCampaignId))
|
||||
{
|
||||
// Try to extract from providerPayload JSON
|
||||
if (cc.TryGetProperty("providerPayload", out var ppProp) &&
|
||||
ppProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ppDoc = JsonDocument.Parse(ppProp.GetString()!);
|
||||
externalCampaignId = ppDoc.RootElement.TryGetProperty("externalId", out var eidProp)
|
||||
? eidProp.GetString() : null;
|
||||
}
|
||||
catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(externalCampaignId))
|
||||
{
|
||||
_logger.LogDebug("[MetricSync] No externalCampaignId for chcId={ChcId}, skipping", chcId);
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Call provider reporting endpoint
|
||||
var reportPayload = new
|
||||
{
|
||||
operation = "GetCampaignReport",
|
||||
tenantId = GetTenantId(cc),
|
||||
requestId = Guid.NewGuid().ToString("N"),
|
||||
payload = new
|
||||
{
|
||||
campaignId = externalCampaignId,
|
||||
startDate = startDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd"),
|
||||
endDate = endDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd")
|
||||
}
|
||||
};
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", providerKey);
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(reportPayload),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var resp = await httpClient.SendAsync(msg, ct);
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] Provider returned {Status} for chcId={ChcId}",
|
||||
resp.StatusCode, chcId);
|
||||
result.Errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse provider response and extract daily rows
|
||||
using var respDoc = JsonDocument.Parse(respBody);
|
||||
var respRoot = respDoc.RootElement;
|
||||
|
||||
JsonElement data;
|
||||
if (respRoot.TryGetProperty("data", out data) ||
|
||||
respRoot.TryGetProperty("Data", out data))
|
||||
{
|
||||
// ok
|
||||
}
|
||||
else
|
||||
{
|
||||
data = respRoot;
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("rows", out var rowsEl) ||
|
||||
rowsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] No rows in provider response for chcId={ChcId}", chcId);
|
||||
result.Errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform rows into upsertBatch format
|
||||
var metrics = new List<object>();
|
||||
foreach (var row in rowsEl.EnumerateArray())
|
||||
{
|
||||
var metricDate = row.TryGetProperty("date", out var dProp) ? dProp.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(metricDate)) continue;
|
||||
|
||||
metrics.Add(new
|
||||
{
|
||||
channelCampaignId = chcId,
|
||||
metricDate,
|
||||
impressions = GetLong(row, "impressions"),
|
||||
clicks = GetLong(row, "clicks"),
|
||||
spend = GetDecimal(row, "spend") ?? (GetLong(row, "costMicros") / 1_000_000.0m),
|
||||
conversions = GetDecimal(row, "conversions") ?? 0,
|
||||
conversionValue = GetDecimal(row, "conversionValue") ?? 0,
|
||||
sourceAttribution = "provider"
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.Count == 0) continue;
|
||||
|
||||
// Upsert into database
|
||||
var upsertResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.PerformanceMetric, "upsertBatch",
|
||||
JsonSerializer.Serialize(new { metrics }), ct: ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[MetricSync] Synced {Count} rows for chcId={ChcId} channel={Channel}",
|
||||
metrics.Count, chcId, channelType);
|
||||
|
||||
result.MetricsWritten += metrics.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Error syncing chcId={ChcId}", chcId);
|
||||
result.Errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Trigger recommendation evaluation for this client
|
||||
if (result.MetricsWritten > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var evalResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Recommendation, "evaluate",
|
||||
JsonSerializer.Serialize(new { clientId }), ct: ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evalResp))
|
||||
{
|
||||
using var evalDoc = JsonDocument.Parse(evalResp);
|
||||
if (evalDoc.RootElement.TryGetProperty("generated", out var genProp))
|
||||
result.RecommendationsGenerated = genProp.GetInt32();
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[MetricSync] Evaluation complete for client {ClientId} | Recommendations={Recommendations}",
|
||||
clientId, result.RecommendationsGenerated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Evaluation failed for client {ClientId}", clientId);
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Sync failed for client {ClientId}", clientId);
|
||||
result.Error = ex.Message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static string MapChannelToProvider(string channelType) =>
|
||||
channelType.ToLowerInvariant() switch
|
||||
{
|
||||
"google_ads" or "google" => "google",
|
||||
"meta" or "facebook" => "meta",
|
||||
"tiktok" => "tiktok",
|
||||
_ => channelType
|
||||
};
|
||||
|
||||
private string GetProviderUrl(string provider) =>
|
||||
provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private string GetProviderKey(string provider) =>
|
||||
provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
|
||||
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private static string? GetTenantId(JsonElement cc)
|
||||
{
|
||||
if (cc.TryGetProperty("externalAccountId", out var eaProp)) return eaProp.GetString();
|
||||
if (cc.TryGetProperty("chcExternalAccountId", out var chcEaProp)) return chcEaProp.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long GetLong(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
|
||||
private static decimal? GetDecimal(JsonElement el, string prop)
|
||||
{
|
||||
if (!el.TryGetProperty(prop, out var p)) return null;
|
||||
return p.ValueKind == JsonValueKind.Number ? p.GetDecimal() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of a metric sync operation.</summary>
|
||||
public sealed class SyncResult
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public int CampaignsProcessed { get; set; }
|
||||
public int MetricsWritten { get; set; }
|
||||
public int RecommendationsGenerated { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
public int Errors { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user