Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -0,0 +1,553 @@
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; }
}