553 lines
22 KiB
C#
553 lines
22 KiB
C#
using Gateway.Data;
|
|
using Gateway.Models;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Text.Json;
|
|
|
|
namespace Gateway.Services;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
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<InitiativeLaunchService> _log;
|
|
|
|
public InitiativeLaunchService(
|
|
SqlService sql,
|
|
ExecutionService execution,
|
|
IOptions<MultiChannelConfig> config,
|
|
ProviderStatusNormalizer statusNorm,
|
|
IConfiguration appConfig,
|
|
ILogger<InitiativeLaunchService> log)
|
|
{
|
|
_sql = sql;
|
|
_execution = execution;
|
|
_config = config.Value;
|
|
_statusNorm = statusNorm;
|
|
_appConfig = appConfig;
|
|
_log = log;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Launch all pending channel campaigns for an initiative.
|
|
/// Returns a per-channel result summary.
|
|
/// </summary>
|
|
public async Task<LaunchResult> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dispatch to a real provider service via ExecutionService.
|
|
/// Builds a GoogleApi-compatible request with proper payload structure.
|
|
/// </summary>
|
|
private async Task<ChannelLaunchResult> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simulate dispatch for stub/unconfigured providers.
|
|
/// Marks the channel as "pending_review" since there's no real provider to call.
|
|
/// </summary>
|
|
private async Task<ChannelLaunchResult> 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;
|
|
}
|
|
|
|
/// <summary>Update channel campaign status in DB.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Update initiative status in DB.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Map channel type to execution provider name.</summary>
|
|
private static string MapChannelToProvider(string channelType)
|
|
{
|
|
return channelType switch
|
|
{
|
|
"google_ads" => "google",
|
|
"meta" => "meta",
|
|
"tiktok" => "tiktok",
|
|
_ => channelType
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if a real provider URL is configured for this channel type.
|
|
/// Uses the same env var pattern as ExecutionService for routing.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert initiative budget (dollars per period) to daily budget.
|
|
/// Google Ads API operates on daily budgets.
|
|
/// </summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Map platform objective to Google Ads campaign type.
|
|
/// This determines the advertising channel (Search, Display, etc.)
|
|
/// </summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Map platform objective to a bidding strategy.
|
|
/// </summary>
|
|
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<string> 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<ChannelLaunchResult> 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; }
|
|
} |