using Gateway.Data; using Gateway.Models; using Microsoft.Extensions.Options; using System.Text.Json; namespace Gateway.Services; /// /// Orchestrates launching an initiative by dispatching each channel campaign /// to its provider service (GoogleApi, Meta, TikTok, etc.). /// /// Flow: /// 1. Load initiative + channel campaigns from DB (single call, channels nested) /// 2. Validate initiative belongs to requesting client /// 3. For each channel in "pending" status: /// a. Resolve provider config (endpoint, stub status) /// b. If real provider → dispatch via ExecutionService /// c. If stub/unconfigured → simulate "pending_review" /// d. Sync result back to DB via spChannelCampaign /// 4. Update initiative status based on aggregate results /// public sealed class InitiativeLaunchService { private readonly SqlService _sql; private readonly ExecutionService _execution; private readonly MultiChannelConfig _config; private readonly ProviderStatusNormalizer _statusNorm; private readonly IConfiguration _appConfig; private readonly ILogger _log; public InitiativeLaunchService( SqlService sql, ExecutionService execution, IOptions config, ProviderStatusNormalizer statusNorm, IConfiguration appConfig, ILogger log) { _sql = sql; _execution = execution; _config = config.Value; _statusNorm = statusNorm; _appConfig = appConfig; _log = log; } /// /// Launch all pending channel campaigns for an initiative. /// Returns a per-channel result summary. /// public async Task LaunchAsync( long initiativeId, string clientId, string? userId, CancellationToken ct) { _log.LogInformation("[Launch] Starting initiative {InitiativeId} for client {ClientId}", initiativeId, clientId); var result = new LaunchResult { InitiativeId = initiativeId }; // 1. Get initiative + nested channels in a single call // Pass clientId for ownership validation var initResp = await _sql.ExecProcAsync( SqlNames.Procs.Initiative, "get", JsonSerializer.Serialize(new { initiativeId, clientId }), ct: ct); if (string.IsNullOrWhiteSpace(initResp)) { result.Error = "Initiative not found"; return result; } using var initDoc = JsonDocument.Parse(initResp); var initRoot = initDoc.RootElement; if (initRoot.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) { result.Error = initRoot.TryGetProperty("error", out var errProp) ? errProp.GetString() ?? "Initiative not found" : "Initiative not found"; return result; } // Extract initiative fields we need for dispatch (check both clean and prefixed shapes) var initiative = initRoot.TryGetProperty("initiative", out var initEl) ? initEl : initRoot; var initiativeName = TryStr(initiative, "name", "iniName") ?? "Campaign"; var objective = TryStr(initiative, "objective", "iniObjective") ?? "traffic"; var totalBudget = TryDec(initiative, "totalBudget", "iniBudget"); var budgetPeriod = TryStr(initiative, "budgetPeriod", "iniBudgetPeriod") ?? "monthly"; var businessCategory = TryStr(initiative, "businessCategory", "iniBusinessCategory"); // 2. Extract channels from the initiative response (already nested by spInitiative 'get') // No separate DB call needed — channels come back with clean field names JsonElement campaignsArray; if (initiative.TryGetProperty("channels", out var channelsEl) && channelsEl.ValueKind == JsonValueKind.Array) { campaignsArray = channelsEl; } else { _log.LogWarning("[Launch] No channels array in initiative response. Keys: {Keys}", string.Join(", ", EnumerateKeys(initiative))); result.Error = "No channel campaigns found for this initiative"; return result; } if (campaignsArray.GetArrayLength() == 0) { result.Error = "Initiative has no channel campaigns to launch"; return result; } _log.LogInformation("[Launch] Found {Count} channel campaigns for initiative {InitiativeId}", campaignsArray.GetArrayLength(), initiativeId); // 3. Dispatch each channel campaign foreach (var camp in campaignsArray.EnumerateArray()) { var channelResult = new ChannelLaunchResult(); // Fields come back with clean names from spInitiative 'get': // channelCampaignId, channelType, allocatedBudget, allocationPct, // externalCampaignId, externalAccountId, providerPayload, status, providerStatus var ccId = TryLong(camp, "channelCampaignId", "chcId"); var channelType = TryStr(camp, "channelType", "chcChannelType") ?? "unknown"; var status = TryStr(camp, "status", "chcStatus") ?? "pending"; var allocationPct = TryDec(camp, "allocationPct", "chcAllocationPct"); if (allocationPct == 0m) allocationPct = 100m; channelResult.ChannelCampaignId = ccId; channelResult.ChannelType = channelType ?? "unknown"; // Skip already-launched channels if (status != "pending" && status != "draft" && status != "staged") { channelResult.Status = status ?? "unknown"; channelResult.Message = "Already dispatched"; channelResult.Skipped = true; result.Channels.Add(channelResult); continue; } // Calculate this channel's budget var channelBudget = totalBudget * allocationPct / 100m; // Look up provider config var providerConfig = _config.GetChannel(channelType ?? ""); if (providerConfig == null || !providerConfig.Enabled) { channelResult.Status = "error"; channelResult.Message = $"Channel '{channelType}' is not enabled"; result.Channels.Add(channelResult); continue; } try { // Determine if a real provider is available: // Check both MultiChannel config Endpoint AND the PROVIDER_URL env var // that ExecutionService uses for routing. var hasRealProvider = !providerConfig.IsStub && IsProviderUrlConfigured(channelType!); if (!hasRealProvider) { // Stub provider - simulate submission channelResult = await DispatchStubAsync(ccId, channelType!, providerConfig, ct); } else { // Real provider - dispatch through ExecutionService channelResult = await DispatchRealAsync( ccId, channelType!, providerConfig, initiativeName, objective, channelBudget, budgetPeriod, businessCategory, clientId, ct); } } catch (Exception ex) { _log.LogError(ex, "[Launch] Dispatch failed for channel {Channel} on initiative {InitiativeId}", channelType, initiativeId); channelResult.Status = "error"; channelResult.Message = $"Dispatch error: {ex.Message}"; } result.Channels.Add(channelResult); } // 4. Update initiative status based on results var anySuccess = result.Channels.Any(c => c.Status == "active" || c.Status == "pending" || c.Status == "submitted"); var allFailed = result.Channels.All(c => c.Status == "error"); if (anySuccess) { await UpdateInitiativeStatus(initiativeId, "active", ct); result.InitiativeStatus = "active"; } else if (allFailed) { result.InitiativeStatus = "error"; result.Error = "All channel dispatches failed"; } result.Ok = anySuccess; _log.LogInformation( "[Launch] Completed initiative {InitiativeId} | Channels={ChannelCount} Success={SuccessCount} Failed={FailCount}", initiativeId, result.Channels.Count, result.Channels.Count(c => c.Status != "error"), result.Channels.Count(c => c.Status == "error")); return result; } /// /// Dispatch to a real provider service via ExecutionService. /// Builds a GoogleApi-compatible request with proper payload structure. /// private async Task DispatchRealAsync( long channelCampaignId, string channelType, ProviderConfig config, string? campaignName, string? objective, decimal budget, string? budgetPeriod, string? businessCategory, string clientId, CancellationToken ct) { var result = new ChannelLaunchResult { ChannelCampaignId = channelCampaignId, ChannelType = channelType, }; // Convert budget: initiative stores dollars, GoogleApi expects daily micros var dailyBudget = ConvertToDailyBudget(budget, budgetPeriod); var budgetMicros = (long)(dailyBudget * 1_000_000m); // Map objective to campaign type var campaignType = MapObjectiveToCampaignType(objective); // Build execution request with proper payload structure // ExecutionService.BuildProviderRequest copies the "payload" field through var providerName = MapChannelToProvider(channelType); var execRequest = JsonSerializer.Serialize(new { provider = providerName, service = "campaign", action = "create", operation = "CreateCampaign", payload = new { name = campaignName ?? "Campaign", type = campaignType, budgetMicros = budgetMicros, biddingStrategy = MapObjectiveToBiddingStrategy(objective), } }); _log.LogInformation( "[Launch] Dispatching {Channel} to provider {Provider} | Budget=${Budget}/mo → {DailyBudget}/day → {BudgetMicros} micros | Type={CampaignType}", channelType, providerName, budget, dailyBudget, budgetMicros, campaignType); // Call ExecutionService (handles routing, auth, logging) var execDoc = JsonDocument.Parse(execRequest); var respJson = await _execution.ExecuteAsync(execDoc.RootElement, ct); // Parse wrapped response: // { ok, status, result: { ok, data: { campaignResourceName, externalId, ... } } } using var respDoc = JsonDocument.Parse(respJson); var respRoot = respDoc.RootElement; // Check wrapper ok var wrapperOk = respRoot.TryGetProperty("ok", out var wrapOkEl) && wrapOkEl.GetBoolean(); // Navigate into result.data for the actual response string? externalId = null; bool providerOk = false; if (respRoot.TryGetProperty("result", out var resultEl)) { providerOk = resultEl.TryGetProperty("ok", out var provOkEl) && provOkEl.GetBoolean(); if (resultEl.TryGetProperty("data", out var dataEl)) { // Real API returns campaignResourceName if (dataEl.TryGetProperty("campaignResourceName", out var crnEl)) externalId = crnEl.GetString(); // Emulated returns externalId else if (dataEl.TryGetProperty("externalId", out var extEl)) externalId = extEl.GetString(); } // Also check for flat error at result level if (!providerOk && resultEl.TryGetProperty("error", out var errEl)) { var errorMsg = errEl.ValueKind == JsonValueKind.Object ? (errEl.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : errEl.GetRawText()) : errEl.GetString(); result.Status = "error"; result.Message = $"Provider error: {errorMsg}"; await SyncChannelCampaign(channelCampaignId, null, "error", errorMsg, ct); return result; } } if (wrapperOk && providerOk) { var platformStatus = _statusNorm.Normalize(channelType, "submitted"); result.Status = platformStatus; result.ExternalCampaignId = externalId; result.Message = $"Successfully dispatched to {config.DisplayName}"; _log.LogInformation( "[Launch] {Channel} dispatched successfully | ExternalId={ExternalId} PlatformStatus={Status}", channelType, externalId, platformStatus); // Sync back to DB await SyncChannelCampaign(channelCampaignId, externalId, platformStatus, "submitted", ct); } else { var error = "Provider returned error"; // Try to extract error message if (respRoot.TryGetProperty("result", out var resEl) && resEl.TryGetProperty("error", out var errObj)) { error = errObj.ValueKind == JsonValueKind.Object ? (errObj.TryGetProperty("message", out var m) ? m.GetString() : errObj.GetRawText()) : errObj.GetString(); } result.Status = "error"; result.Message = $"Provider error: {error}"; _log.LogWarning("[Launch] {Channel} dispatch failed | Error={Error}", channelType, error); await SyncChannelCampaign(channelCampaignId, null, "error", error, ct); } return result; } /// /// Simulate dispatch for stub/unconfigured providers. /// Marks the channel as "pending_review" since there's no real provider to call. /// private async Task DispatchStubAsync( long channelCampaignId, string channelType, ProviderConfig config, CancellationToken ct) { _log.LogInformation("[Launch] Stub dispatch for {Channel} (no real provider)", channelType); // Simulate a short delay for realism await Task.Delay(100, ct); var result = new ChannelLaunchResult { ChannelCampaignId = channelCampaignId, ChannelType = channelType, Status = _statusNorm.Normalize(channelType, "pending_review"), Message = $"{config.DisplayName} campaign queued for review (provider coming soon)", IsStub = true, }; // Sync to DB: chcStatus = normalized, chcProviderStatus = raw await SyncChannelCampaign(channelCampaignId, null, result.Status, "stub_provider", ct); return result; } /// Update channel campaign status in DB. private async Task SyncChannelCampaign( long channelCampaignId, string? externalCampaignId, string status, string? providerStatus, CancellationToken ct) { try { await _sql.ExecProcAsync( SqlNames.Procs.ChannelCampaign, "sync", JsonSerializer.Serialize(new { channelCampaignId, externalCampaignId = externalCampaignId, status = status, providerStatus = providerStatus, }), ct: ct); } catch (Exception ex) { _log.LogError(ex, "[Launch] Failed to sync channel campaign {Id}", channelCampaignId); } } /// Update initiative status in DB. private async Task UpdateInitiativeStatus(long initiativeId, string status, CancellationToken ct) { try { await _sql.ExecProcAsync( SqlNames.Procs.Initiative, "updateStatus", JsonSerializer.Serialize(new { initiativeId, status }), ct: ct); } catch (Exception ex) { _log.LogError(ex, "[Launch] Failed to update initiative status {Id}", initiativeId); } } /// Map channel type to execution provider name. private static string MapChannelToProvider(string channelType) { return channelType switch { "google_ads" => "google", "meta" => "meta", "tiktok" => "tiktok", _ => channelType }; } /// /// Check if a real provider URL is configured for this channel type. /// Uses the same env var pattern as ExecutionService for routing. /// private bool IsProviderUrlConfigured(string channelType) { var envVarName = channelType switch { "google_ads" => "GOOGLE_PROVIDER_URL", "meta" => "META_PROVIDER_URL", "tiktok" => "TIKTOK_PROVIDER_URL", _ => null }; if (envVarName == null) return false; var url = _appConfig[envVarName]; return !string.IsNullOrWhiteSpace(url); } /// /// Convert initiative budget (dollars per period) to daily budget. /// Google Ads API operates on daily budgets. /// private static decimal ConvertToDailyBudget(decimal budget, string? budgetPeriod) { return (budgetPeriod?.ToLowerInvariant()) switch { "daily" => budget, "weekly" => budget / 7m, "monthly" => budget / 30.4m, // Google's standard month divisor _ => budget / 30.4m // Default to monthly }; } /// /// Map platform objective to Google Ads campaign type. /// This determines the advertising channel (Search, Display, etc.) /// private static string MapObjectiveToCampaignType(string? objective) { return (objective?.ToLowerInvariant()) switch { "awareness" => "Display", // Brand awareness → Display network "traffic" => "Search", // Website traffic → Search ads "leads" => "Search", // Lead generation → Search ads "conversions" => "Search", // Conversions → Search ads "sales" => "PerformanceMax", // Sales → Performance Max "engagement" => "Display", // Engagement → Display network _ => "Search" // Default to Search }; } /// /// Map platform objective to a bidding strategy. /// private static string MapObjectiveToBiddingStrategy(string? objective) { return (objective?.ToLowerInvariant()) switch { "awareness" => "MaximizeClicks", // Broad reach "traffic" => "MaximizeClicks", // Drive traffic "leads" => "MaximizeConversions", // Optimize for leads "conversions" => "MaximizeConversions", // Optimize for conversions "sales" => "MaximizeConversions", // Optimize for sales _ => "MaximizeClicks" // Safe default }; } // ── JSON field helpers (try both clean and prefixed names) ── private static string? TryStr(JsonElement el, string key1, string key2) { if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.String) return p1.GetString(); if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.String) return p2.GetString(); return null; } private static decimal TryDec(JsonElement el, string key1, string key2) { if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.Number) return p1.GetDecimal(); if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.Number) return p2.GetDecimal(); return 0m; } private static long TryLong(JsonElement el, string key1, string key2) { if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.Number) return p1.GetInt64(); if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.Number) return p2.GetInt64(); return 0; } private static IEnumerable EnumerateKeys(JsonElement el) { if (el.ValueKind == JsonValueKind.Object) foreach (var prop in el.EnumerateObject()) yield return prop.Name; } } // ──────────────────────────────────────────────── // Result DTOs // ──────────────────────────────────────────────── public sealed class LaunchResult { public bool Ok { get; set; } public long InitiativeId { get; set; } public string? InitiativeStatus { get; set; } public string? Error { get; set; } public List Channels { get; set; } = new(); } public sealed class ChannelLaunchResult { public long ChannelCampaignId { get; set; } public string ChannelType { get; set; } = ""; public string Status { get; set; } = "pending"; public string? Message { get; set; } public string? ExternalCampaignId { get; set; } public bool IsStub { get; set; } public bool Skipped { get; set; } }