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(); 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; } } /// Result of a metric sync operation. 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; } }