340 lines
14 KiB
C#
340 lines
14 KiB
C#
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; }
|
|
}
|