using Gateway.Data;
using Gateway.Security;
using System.Text.Json;
namespace Gateway.Services;
///
/// 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)
///
public sealed class MetricSyncService
{
private readonly SqlService _sql;
private readonly IHttpClientFactory _http;
private readonly IConfiguration _cfg;
private readonly ILogger _logger;
public MetricSyncService(
SqlService sql,
IHttpClientFactory http,
IConfiguration cfg,
ILogger logger)
{
_sql = sql;
_http = http;
_cfg = cfg;
_logger = logger;
}
///
/// Sync metrics for a specific client's active campaigns.
///
public async Task 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