Initial import into Gitea
This commit is contained in:
299
Gateway/Services/ChannelConfigService.cs
Normal file
299
Gateway/Services/ChannelConfigService.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Loads channel provider configuration from the database (tbChannelConfig)
|
||||
/// instead of appsettings.json. Caches in memory and provides a populated
|
||||
/// MultiChannelConfig instance for DI consumers.
|
||||
///
|
||||
/// Why: Complex nested JSON in appsettings.json was causing startup crashes
|
||||
/// with the .NET configuration parser. Database-driven config is also easier
|
||||
/// to update without redeployment.
|
||||
///
|
||||
/// Usage:
|
||||
/// - Called once at startup to populate the singleton MultiChannelConfig
|
||||
/// - Admin endpoints can call RefreshAsync() to reload after DB changes
|
||||
/// </summary>
|
||||
public sealed class ChannelConfigService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<ChannelConfigService> _log;
|
||||
private MultiChannelConfig _cached;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public ChannelConfigService(
|
||||
IServiceProvider sp,
|
||||
IConfiguration cfg,
|
||||
ILogger<ChannelConfigService> log)
|
||||
{
|
||||
_sp = sp;
|
||||
_cfg = cfg;
|
||||
_log = log;
|
||||
_cached = BuildDefaults();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current in-memory config. Always non-null (falls back to defaults).
|
||||
/// </summary>
|
||||
public MultiChannelConfig Current
|
||||
{
|
||||
get { lock (_lock) { return _cached; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load channel config from the database.
|
||||
/// Call at startup and whenever admin updates channel config.
|
||||
/// </summary>
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var sql = scope.ServiceProvider.GetRequiredService<SqlService>();
|
||||
|
||||
var resp = await sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelConfig, "list", "{}", ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] DB returned empty — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] DB returned ok=false — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("channels", out var channelsEl) ||
|
||||
channelsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] No channels array in response — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
var channels = new Dictionary<string, ProviderConfig>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var ch in channelsEl.EnumerateArray())
|
||||
{
|
||||
var config = ParseChannel(ch);
|
||||
if (config != null)
|
||||
channels[config.ChannelType] = config;
|
||||
}
|
||||
|
||||
// Build new MultiChannelConfig with DB channels + appsettings allocation
|
||||
var newConfig = new MultiChannelConfig
|
||||
{
|
||||
Channels = channels,
|
||||
Allocation = LoadAllocationFromConfig()
|
||||
};
|
||||
|
||||
lock (_lock) { _cached = newConfig; }
|
||||
|
||||
_log.LogInformation(
|
||||
"[ChannelConfig] Loaded {Count} channels from DB: {Types}",
|
||||
channels.Count,
|
||||
string.Join(", ", channels.Keys));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[ChannelConfig] Failed to load from DB — using defaults");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reload config from DB (for admin refresh endpoints).</summary>
|
||||
public Task RefreshAsync(CancellationToken ct = default) => LoadAsync(ct);
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Parsing
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private static ProviderConfig? ParseChannel(JsonElement ch)
|
||||
{
|
||||
var channelType = Str(ch, "channelType");
|
||||
var displayName = Str(ch, "displayName");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(channelType) || string.IsNullOrWhiteSpace(displayName))
|
||||
return null;
|
||||
|
||||
return new ProviderConfig
|
||||
{
|
||||
ChannelType = channelType,
|
||||
DisplayName = displayName,
|
||||
Description = Str(ch, "description"),
|
||||
Icon = Str(ch, "icon"),
|
||||
Color = Str(ch, "color"),
|
||||
Enabled = Bool(ch, "enabled", true),
|
||||
IsStub = Bool(ch, "isStub", true),
|
||||
Endpoint = Str(ch, "endpoint"),
|
||||
InternalKey = Str(ch, "internalKey"),
|
||||
MinDailyBudget = Dec(ch, "minDailyBudget", 5m),
|
||||
MinMonthlyBudget = Dec(ch, "minMonthlyBudget", 150m),
|
||||
SupportedObjectives = StringList(ch, "supportedObjectives"),
|
||||
SupportedCreativeFormats = StringList(ch, "supportedCreativeFormats"),
|
||||
ApprovalEstimateHours = Int(ch, "approvalEstimateHours", 24),
|
||||
MetricsRefreshIntervalMinutes = Int(ch, "metricsRefreshIntervalMinutes", 60),
|
||||
AuthMethod = Str(ch, "authMethod"),
|
||||
KeyVaultSecretName = Str(ch, "keyVaultSecretName"),
|
||||
StatusMappings = StringDict(ch, "statusMappings")
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Allocation (stays in appsettings — simple scalars)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private AllocationSettings LoadAllocationFromConfig()
|
||||
{
|
||||
var section = _cfg.GetSection("MultiChannel:Allocation");
|
||||
if (!section.Exists())
|
||||
return new AllocationSettings();
|
||||
|
||||
return new AllocationSettings
|
||||
{
|
||||
MinMultiChannelMonthlyBudget = section.GetValue("MinMultiChannelMonthlyBudget", 500.00m),
|
||||
MaxChannelsPerInitiative = section.GetValue("MaxChannelsPerInitiative", 5),
|
||||
DefaultAllocationStrategy = section.GetValue("DefaultAllocationStrategy", "template") ?? "template",
|
||||
PerformanceEvalIntervalDays = section.GetValue("PerformanceEvalIntervalDays", 7),
|
||||
PerformanceLookbackDays = section.GetValue("PerformanceLookbackDays", 14),
|
||||
PerformanceLearningPeriodDays = section.GetValue("PerformanceLearningPeriodDays", 14),
|
||||
MaxAllocationShiftPct = section.GetValue("MaxAllocationShiftPct", 15.00m),
|
||||
MinChannelAllocationPct = section.GetValue("MinChannelAllocationPct", 10.00m),
|
||||
MaxChannelAllocationPct = section.GetValue("MaxChannelAllocationPct", 80.00m)
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Defaults (used until DB load completes or if DB is unavailable)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private MultiChannelConfig BuildDefaults()
|
||||
{
|
||||
return new MultiChannelConfig
|
||||
{
|
||||
Channels = new Dictionary<string, ProviderConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["google_ads"] = new()
|
||||
{
|
||||
ChannelType = "google_ads",
|
||||
DisplayName = "Google Ads",
|
||||
Description = "Search, Display, Shopping & Performance Max across Google properties",
|
||||
Icon = "google",
|
||||
Color = "#4285F4",
|
||||
Enabled = true,
|
||||
IsStub = false,
|
||||
MinDailyBudget = 10m,
|
||||
MinMonthlyBudget = 300m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "text", "image", "responsive", "video" },
|
||||
ApprovalEstimateHours = 24,
|
||||
AuthMethod = "mcc"
|
||||
},
|
||||
["meta"] = new()
|
||||
{
|
||||
ChannelType = "meta",
|
||||
DisplayName = "Meta Ads",
|
||||
Description = "Facebook, Instagram, Messenger & Threads advertising",
|
||||
Icon = "meta",
|
||||
Color = "#1877F2",
|
||||
Enabled = true,
|
||||
IsStub = true,
|
||||
MinDailyBudget = 5m,
|
||||
MinMonthlyBudget = 250m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "image", "video", "carousel", "stories" },
|
||||
ApprovalEstimateHours = 48,
|
||||
AuthMethod = "oauth2"
|
||||
},
|
||||
["tiktok"] = new()
|
||||
{
|
||||
ChannelType = "tiktok",
|
||||
DisplayName = "TikTok Ads",
|
||||
Description = "In-feed video ads across TikTok and partner apps",
|
||||
Icon = "tiktok",
|
||||
Color = "#000000",
|
||||
Enabled = true,
|
||||
IsStub = true,
|
||||
MinDailyBudget = 20m,
|
||||
MinMonthlyBudget = 200m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "video", "image", "spark_ads" },
|
||||
ApprovalEstimateHours = 24,
|
||||
AuthMethod = "oauth2"
|
||||
}
|
||||
},
|
||||
Allocation = new AllocationSettings()
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// JSON helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private static string? Str(JsonElement el, string prop)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String)
|
||||
return v.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Bool(JsonElement el, string prop, bool def = false)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v))
|
||||
{
|
||||
if (v.ValueKind == JsonValueKind.True) return true;
|
||||
if (v.ValueKind == JsonValueKind.False) return false;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
private static int Int(JsonElement el, string prop, int def = 0)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number)
|
||||
return v.GetInt32();
|
||||
return def;
|
||||
}
|
||||
|
||||
private static decimal Dec(JsonElement el, string prop, decimal def = 0m)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number)
|
||||
return v.GetDecimal();
|
||||
return def;
|
||||
}
|
||||
|
||||
private static List<string> StringList(JsonElement el, string prop)
|
||||
{
|
||||
var list = new List<string>();
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in v.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
list.Add(item.GetString()!);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> StringDict(JsonElement el, string prop)
|
||||
{
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var p in v.EnumerateObject())
|
||||
{
|
||||
if (p.Value.ValueKind == JsonValueKind.String)
|
||||
dict[p.Name] = p.Value.GetString()!;
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public sealed class ExecutionService
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ImageStorageService _imageStorage;
|
||||
private readonly ILogger<ExecutionService> _logger;
|
||||
|
||||
// Operations that don't require a linked account (health checks, etc.)
|
||||
@@ -19,17 +20,31 @@ public sealed class ExecutionService
|
||||
"Ping", "TestPing", "ListAccessibleCustomers"
|
||||
};
|
||||
|
||||
// Providers that require Google Ads account validation
|
||||
private static readonly HashSet<string> GoogleAccountProviders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"google"
|
||||
};
|
||||
|
||||
// Creative operations that return images and need blob storage processing
|
||||
private static readonly HashSet<string> CreativeImageOperations = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CreateDraft", "GetImages"
|
||||
};
|
||||
|
||||
public ExecutionService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ClientContext client,
|
||||
ImageStorageService imageStorage,
|
||||
ILogger<ExecutionService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_client = client;
|
||||
_imageStorage = imageStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,33 +61,55 @@ public sealed class ExecutionService
|
||||
var service = reqJson.TryGetProperty("service", out var sv) ? sv.GetString() ?? "system" : "system";
|
||||
var action = reqJson.TryGetProperty("action", out var av) ? av.GetString() ?? "ping" : "ping";
|
||||
|
||||
// Legacy support: if "operation" is provided, use it as action
|
||||
string? operation = action;
|
||||
// Operation: explicit "operation" field takes priority, then falls back to "action"
|
||||
string operation = action;
|
||||
if (reqJson.TryGetProperty("operation", out var opProp) && opProp.ValueKind == JsonValueKind.String)
|
||||
operation = opProp.GetString();
|
||||
operation = opProp.GetString() ?? action;
|
||||
|
||||
// TenantId priority: 1) request body, 2) ClientContext, 3) null
|
||||
// TenantId priority: 1) request body, 2) ClientContext (header), 3) default MCC, 4) null
|
||||
string? tenantId = null;
|
||||
if (reqJson.TryGetProperty("tenantId", out var tid) && tid.ValueKind == JsonValueKind.String)
|
||||
tenantId = tid.GetString();
|
||||
tenantId ??= _client.TenantId;
|
||||
|
||||
// Agency model fallback: use default MCC customer ID if no tenant specified
|
||||
// This ensures real API calls work even before per-client subaccounts exist
|
||||
bool tenantIsSystemDefault = false;
|
||||
if (string.IsNullOrWhiteSpace(tenantId) && GoogleAccountProviders.Contains(provider))
|
||||
{
|
||||
tenantId = _cfg["GoogleAds:DefaultLoginCustomerId"]
|
||||
?? _cfg["GOOGLE_DEFAULT_CUSTOMER_ID"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_DEFAULT_CUSTOMER_ID");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantIsSystemDefault = true;
|
||||
_logger.LogInformation("[Execution] Using default MCC customer ID as tenantId | RequestId={RequestId}", requestId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Action={Action} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, action, _client.IsDevBypass);
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Operation={Operation} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, operation, _client.IsDevBypass);
|
||||
|
||||
// ================================================================
|
||||
// AGENCY MODEL: Validate account and get loginCustomerId
|
||||
// AGENCY MODEL: Validate Google account (only for Google provider)
|
||||
// Skip validation if tenantId is the system-configured MCC default
|
||||
// (admin pre-configured, not user-supplied)
|
||||
// ================================================================
|
||||
string? loginCustomerId = null;
|
||||
string? validatedClientName = null;
|
||||
|
||||
// Only validate if operation requires a linked account
|
||||
bool requiresAccount = !string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId);
|
||||
// Only validate if provider requires it AND operation requires a linked account
|
||||
// AND tenantId is user-provided (not the system MCC default)
|
||||
bool requiresGoogleAccount =
|
||||
GoogleAccountProviders.Contains(provider) &&
|
||||
!string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId) &&
|
||||
!tenantIsSystemDefault;
|
||||
|
||||
if (requiresAccount)
|
||||
if (requiresGoogleAccount)
|
||||
{
|
||||
var validation = await ValidateGoogleAccountAsync(tenantId!, ct);
|
||||
|
||||
@@ -106,7 +143,7 @@ public sealed class ExecutionService
|
||||
requestId, tenantId, loginCustomerId, validatedClientName);
|
||||
}
|
||||
|
||||
// Log start (now includes clientId and routing info)
|
||||
// Log start (includes routing info)
|
||||
int? logId = null;
|
||||
var startRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
@@ -116,7 +153,7 @@ public sealed class ExecutionService
|
||||
tenantId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
operation,
|
||||
loginCustomerId,
|
||||
sessionId = _client.SessionId,
|
||||
userId = _client.UserId,
|
||||
@@ -131,8 +168,8 @@ public sealed class ExecutionService
|
||||
logId = e.GetInt32();
|
||||
}
|
||||
|
||||
// Inject/override fields in request before forwarding to provider
|
||||
var enrichedRequest = EnrichRequest(reqJson, requestId, tenantId, loginCustomerId);
|
||||
// Build enriched request for provider
|
||||
var enrichedRequest = BuildProviderRequest(reqJson, requestId, operation, tenantId, loginCustomerId);
|
||||
|
||||
// Forward to provider (URL based on provider type)
|
||||
var sw = Stopwatch.StartNew();
|
||||
@@ -143,6 +180,11 @@ public sealed class ExecutionService
|
||||
var providerUrl = GetProviderUrl(provider);
|
||||
var key = GetProviderKey(provider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
{
|
||||
throw new InvalidOperationException($"No provider URL configured for '{provider}'. Check environment variables.");
|
||||
}
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
@@ -155,17 +197,45 @@ public sealed class ExecutionService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId}", requestId);
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId} Provider={Provider}", requestId, provider);
|
||||
providerStatus = 500;
|
||||
providerResp = JsonSerializer.Serialize(new { ok = false, requestId, error = ex.Message });
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, providerStatus, sw.ElapsedMilliseconds);
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Provider={Provider} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, provider, providerStatus, sw.ElapsedMilliseconds);
|
||||
|
||||
// Log finish (includes clientId and routing info for correlation)
|
||||
// ================================================================
|
||||
// CREATIVE IMAGE PROCESSING: Store images in blob storage
|
||||
// ================================================================
|
||||
if (provider.Equals("creative", StringComparison.OrdinalIgnoreCase) &&
|
||||
CreativeImageOperations.Contains(operation) &&
|
||||
providerStatus >= 200 && providerStatus < 300 &&
|
||||
_imageStorage.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[Execution] Processing Creative images | RequestId={RequestId} ClientId={ClientId}",
|
||||
requestId, clientId);
|
||||
|
||||
providerResp = await _imageStorage.ProcessCreativeDraftAsync(
|
||||
clientId ?? "unknown",
|
||||
providerResp,
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"[Execution] Image storage failed, returning original response | RequestId={RequestId}",
|
||||
requestId);
|
||||
// Continue with original response - non-fatal error
|
||||
}
|
||||
}
|
||||
|
||||
// Log finish (includes routing info for correlation)
|
||||
var finishRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
action = "finish",
|
||||
@@ -174,10 +244,10 @@ public sealed class ExecutionService
|
||||
clientId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
operation,
|
||||
providerStatus,
|
||||
elapsedMs = sw.ElapsedMilliseconds,
|
||||
resp = JsonDocument.Parse(providerResp).RootElement
|
||||
resp = SafeParseJson(providerResp)
|
||||
});
|
||||
|
||||
_ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct);
|
||||
@@ -187,6 +257,52 @@ public sealed class ExecutionService
|
||||
return wrappedResponse;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Provider request building
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Build a clean request object for the provider container.
|
||||
/// Ensures "operation" is always set explicitly so providers can dispatch on it.
|
||||
/// Includes session context so providers know who initiated the request.
|
||||
/// </summary>
|
||||
private string BuildProviderRequest(JsonElement original, string requestId, string operation,
|
||||
string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
var request = new Dictionary<string, object?>
|
||||
{
|
||||
["requestId"] = requestId,
|
||||
["operation"] = operation,
|
||||
["tenantId"] = tenantId,
|
||||
["loginCustomerId"] = loginCustomerId,
|
||||
["session"] = new
|
||||
{
|
||||
sessionId = _client.SessionId,
|
||||
clientId = _client.ClientId,
|
||||
userId = _client.UserId,
|
||||
isDevBypass = _client.IsDevBypass
|
||||
}
|
||||
};
|
||||
|
||||
// Copy payload if present (provider-specific data)
|
||||
if (original.TryGetProperty("payload", out var payload))
|
||||
{
|
||||
request["payload"] = payload;
|
||||
}
|
||||
|
||||
// Copy service/action for providers that use them
|
||||
if (original.TryGetProperty("service", out var svc))
|
||||
request["service"] = svc.GetString();
|
||||
if (original.TryGetProperty("action", out var act))
|
||||
request["action"] = act.GetString();
|
||||
|
||||
return JsonSerializer.Serialize(request);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Account validation (Google-specific)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a Google Ads customer ID is linked in the database.
|
||||
/// Returns loginCustomerId if account is found.
|
||||
@@ -258,36 +374,19 @@ public sealed class ExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider.
|
||||
/// </summary>
|
||||
private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(original.GetRawText());
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(doc.RootElement.GetRawText())
|
||||
?? new Dictionary<string, JsonElement>();
|
||||
|
||||
// Add/override requestId
|
||||
dict["requestId"] = JsonDocument.Parse($"\"{requestId}\"").RootElement;
|
||||
|
||||
// Add tenantId if we have one
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
dict["tenantId"] = JsonDocument.Parse($"\"{tenantId}\"").RootElement;
|
||||
}
|
||||
|
||||
// Add loginCustomerId (manager account) if we have one
|
||||
if (!string.IsNullOrWhiteSpace(loginCustomerId))
|
||||
{
|
||||
dict["loginCustomerId"] = JsonDocument.Parse($"\"{loginCustomerId}\"").RootElement;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict);
|
||||
}
|
||||
// ================================================================
|
||||
// Response wrapping
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Wrap provider response with Gateway metadata.
|
||||
/// </summary>
|
||||
private static object SafeParseJson(string raw)
|
||||
{
|
||||
try { return JsonDocument.Parse(raw).RootElement; }
|
||||
catch { return raw[..Math.Min(raw.Length, 500)]; }
|
||||
}
|
||||
|
||||
private static string WrapResponse(string providerResp, int status, long elapsedMs, string requestId, string? clientId)
|
||||
{
|
||||
try
|
||||
@@ -319,9 +418,10 @@ public sealed class ExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of account validation.
|
||||
/// </summary>
|
||||
// ================================================================
|
||||
// Validation result
|
||||
// ================================================================
|
||||
|
||||
private sealed class AccountValidation
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
@@ -345,6 +445,10 @@ public sealed class ExecutionService
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Provider routing
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Get provider URL based on provider type.
|
||||
/// </summary>
|
||||
@@ -353,9 +457,12 @@ public sealed class ExecutionService
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"creative" => _cfg["CREATIVE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"intelligence" => _cfg["INTELLIGENCE_API_URL"]?.TrimEnd('/') ?? "",
|
||||
"msads" => _cfg["MSADS_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
_ => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? ""
|
||||
_ => "" // No default fallback ? unknown providers fail explicitly
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,9 +474,12 @@ public sealed class ExecutionService
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
|
||||
"creative" => _cfg["CREATIVE_INTERNAL_KEY"] ?? "",
|
||||
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "",
|
||||
"intelligence" => _cfg["INTELLIGENCE_INTERNAL_KEY"] ?? "",
|
||||
"msads" => _cfg["MSADS_INTERNAL_KEY"] ?? "",
|
||||
_ => _cfg["GOOGLE_INTERNAL_KEY"] ?? ""
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
478
Gateway/Services/ForecastService.cs
Normal file
478
Gateway/Services/ForecastService.cs
Normal file
@@ -0,0 +1,478 @@
|
||||
using Gateway.Models;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fans out to provider services for forecast estimates, normalizes the responses,
|
||||
/// scores them by objective, and derives recommended allocation percentages.
|
||||
///
|
||||
/// Called by ForecastController for the wizard budget step.
|
||||
/// Same capability can serve admin seed workflow later.
|
||||
/// </summary>
|
||||
public sealed class ForecastService
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<ForecastService> _logger;
|
||||
|
||||
private const int MIN_ALLOCATION = 15;
|
||||
private const int MAX_ALLOCATION = 85;
|
||||
|
||||
public ForecastService(IHttpClientFactory http, IConfiguration cfg, ILogger<ForecastService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate forecast estimates across requested channels and return
|
||||
/// normalized comparison with recommended allocation.
|
||||
/// </summary>
|
||||
public async Task<ChannelForecastResponse> ForecastAsync(ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var channels = request.Channels ?? new List<string> { "google_ads" };
|
||||
var weights = ObjectiveWeights.For(request.Objective);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Forecast] Starting | Objective={Obj} Budget={Budget} Channels={Ch}",
|
||||
request.Objective, request.MonthlyBudget, string.Join(",", channels));
|
||||
|
||||
// ── Fan out to providers in parallel ──
|
||||
var tasks = new Dictionary<string, Task<ProviderForecastResult>>();
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
tasks[channel] = channel switch
|
||||
{
|
||||
"google_ads" => FetchGoogleForecastAsync(request, ct),
|
||||
"meta" => FetchMetaForecastAsync(request, ct),
|
||||
"tiktok" => Task.FromResult(TemplateForecast("tiktok", request.MonthlyBudget, channels.Count)),
|
||||
_ => Task.FromResult(TemplateForecast(channel, request.MonthlyBudget, channels.Count))
|
||||
};
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks.Values);
|
||||
|
||||
// ── Collect results ──
|
||||
var results = new Dictionary<string, ProviderForecastResult>();
|
||||
foreach (var (channel, task) in tasks)
|
||||
{
|
||||
results[channel] = task.Result;
|
||||
}
|
||||
|
||||
// ── Score and derive allocation ──
|
||||
var scored = ScoreChannels(results, weights);
|
||||
var allocations = DeriveAllocations(scored);
|
||||
|
||||
// ── Build response ──
|
||||
var channelEstimates = new List<ChannelEstimate>();
|
||||
foreach (var (channel, result) in results)
|
||||
{
|
||||
var pct = allocations[channel];
|
||||
var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2);
|
||||
|
||||
channelEstimates.Add(new ChannelEstimate
|
||||
{
|
||||
Provider = channel,
|
||||
AllocationPercent = pct,
|
||||
AllocatedBudget = allocated,
|
||||
Estimates = new ChannelEstimateMetrics
|
||||
{
|
||||
Impressions = result.Impressions,
|
||||
Reach = result.Reach,
|
||||
Clicks = result.Clicks,
|
||||
Conversions = result.Conversions,
|
||||
AvgCpc = result.AvgCpc,
|
||||
AvgCpm = result.AvgCpm,
|
||||
EstimatedCpa = result.EstimatedCpa,
|
||||
Ctr = result.Ctr
|
||||
},
|
||||
EfficiencyScore = Math.Round(scored[channel], 3),
|
||||
StrengthLabel = GetStrengthLabel(channel, request.Objective),
|
||||
Confidence = result.Confidence,
|
||||
DataSource = result.DataSource
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by allocation descending
|
||||
channelEstimates.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent));
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation("[Forecast] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds);
|
||||
|
||||
return new ChannelForecastResponse
|
||||
{
|
||||
Ok = true,
|
||||
Objective = request.Objective,
|
||||
TotalBudget = request.MonthlyBudget,
|
||||
Channels = channelEstimates,
|
||||
Recommendation = BuildRecommendation(channelEstimates, request.Objective),
|
||||
Metadata = new ForecastMeta
|
||||
{
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
ForecastPeriod = "30 days"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Provider calls
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private async Task<ProviderForecastResult> FetchGoogleForecastAsync(
|
||||
ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerUrl = _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "";
|
||||
var key = _cfg["GOOGLE_INTERNAL_KEY"] ?? "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
return EmulatedGoogleFallback(request);
|
||||
|
||||
// Build provider request matching existing ProviderRequest pattern
|
||||
var payload = new
|
||||
{
|
||||
keywords = request.Keywords,
|
||||
geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List<long>(),
|
||||
monthlyBudget = request.MonthlyBudget,
|
||||
currencyCode = "USD",
|
||||
forecastDays = 30
|
||||
};
|
||||
|
||||
var providerRequest = new
|
||||
{
|
||||
operation = "KeywordForecast",
|
||||
requestId = Guid.NewGuid().ToString("N"),
|
||||
payload
|
||||
};
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(providerRequest, _jsonOpts),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var resp = await httpClient.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[Forecast] Google provider returned {Status}", (int)resp.StatusCode);
|
||||
return EmulatedGoogleFallback(request);
|
||||
}
|
||||
|
||||
// Parse provider response: { ok, data: { provider, monthly, metrics, ... } }
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
var data = root.TryGetProperty("data", out var d) ? d : root;
|
||||
|
||||
var monthly = data.GetProperty("monthly");
|
||||
var metrics = data.GetProperty("metrics");
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "google_ads",
|
||||
Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0,
|
||||
Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0,
|
||||
Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0,
|
||||
Reach = null, // Google Search doesn't provide reach
|
||||
AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0,
|
||||
AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0,
|
||||
Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0,
|
||||
EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null
|
||||
? cpa.GetDecimal() : null,
|
||||
Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low",
|
||||
DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Forecast] Google provider call failed");
|
||||
return EmulatedGoogleFallback(request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProviderForecastResult> FetchMetaForecastAsync(
|
||||
ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
// TODO Phase 2: Call MetaApi /internal/execute with DeliveryEstimate operation
|
||||
// For now, return realistic emulated Meta estimates
|
||||
await Task.CompletedTask;
|
||||
|
||||
var budget = request.MonthlyBudget;
|
||||
var rng = new Random((int)(budget * 77));
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
// Meta: strong reach/impressions, moderate clicks, lower CPC than Google
|
||||
var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0); // $12.50 – $20.50
|
||||
var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0;
|
||||
var reach = impressions * 0.42; // ~2.4 frequency
|
||||
var clickRate = 0.012 + (rng.NextDouble() * 0.008); // 1.2% – 2.0% CTR
|
||||
var clicks = impressions * clickRate;
|
||||
var convRate = 0.025 + (rng.NextDouble() * 0.015);
|
||||
var conversions = clicks * convRate;
|
||||
var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0;
|
||||
var cpa = conversions > 0 ? budget / (decimal)conversions : (decimal?)null;
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "meta",
|
||||
Impressions = Math.Round(impressions),
|
||||
Clicks = Math.Round(clicks),
|
||||
Conversions = Math.Round(conversions, 1),
|
||||
Reach = Math.Round(reach),
|
||||
AvgCpc = Math.Round(avgCpc, 2),
|
||||
AvgCpm = Math.Round(cpm, 2),
|
||||
Ctr = Math.Round(clickRate, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Template-only fallback for channels without API forecasting (e.g., TikTok)</summary>
|
||||
private static ProviderForecastResult TemplateForecast(string provider, decimal totalBudget, int channelCount)
|
||||
{
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = provider,
|
||||
Confidence = "none",
|
||||
DataSource = "template"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Client-side Google emulation when provider is unreachable</summary>
|
||||
private ProviderForecastResult EmulatedGoogleFallback(ChannelForecastRequest request)
|
||||
{
|
||||
var budget = request.MonthlyBudget;
|
||||
var kwCount = Math.Max(request.Keywords.Count, 1);
|
||||
var rng = new Random((int)(budget * 100) + kwCount);
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20);
|
||||
var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0;
|
||||
var impressions = clicks / 0.045;
|
||||
var conversions = clicks * 0.035;
|
||||
var ctr = impressions > 0 ? clicks / impressions : 0;
|
||||
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
|
||||
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "google_ads",
|
||||
Impressions = Math.Round(impressions),
|
||||
Clicks = Math.Round(clicks),
|
||||
Conversions = Math.Round(conversions, 1),
|
||||
AvgCpc = Math.Round(baseCpc, 2),
|
||||
AvgCpm = Math.Round((decimal)cpm, 2),
|
||||
Ctr = Math.Round(ctr, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Scoring: objective-weighted efficiency
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static Dictionary<string, double> ScoreChannels(
|
||||
Dictionary<string, ProviderForecastResult> results, MetricWeights w)
|
||||
{
|
||||
// Only score channels that have real estimates
|
||||
var scoreable = results
|
||||
.Where(r => r.Value.DataSource != "template")
|
||||
.ToDictionary(r => r.Key, r => r.Value);
|
||||
|
||||
if (scoreable.Count == 0)
|
||||
return results.ToDictionary(r => r.Key, _ => 1.0);
|
||||
|
||||
// For each "more is better" metric: normalize to 0–1 (best = 1.0)
|
||||
// For each "less is better" metric: invert (lowest = 1.0)
|
||||
//double Norm(Func<ProviderForecastResult, double> selector, bool invert = false)
|
||||
//{
|
||||
// Not used directly — we normalize per-channel below
|
||||
// return 0;
|
||||
// }
|
||||
|
||||
var scores = new Dictionary<string, double>();
|
||||
|
||||
// Find max/min across scoreable channels for normalization
|
||||
var maxImp = scoreable.Values.Max(r => r.Impressions);
|
||||
var maxReach = scoreable.Values.Max(r => r.Reach ?? 0);
|
||||
var maxClicks = scoreable.Values.Max(r => r.Clicks);
|
||||
var maxConv = scoreable.Values.Max(r => r.Conversions);
|
||||
var maxCtr = scoreable.Values.Max(r => r.Ctr);
|
||||
|
||||
var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min();
|
||||
var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min();
|
||||
var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0).Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min();
|
||||
|
||||
foreach (var (channel, r) in scoreable)
|
||||
{
|
||||
double score = 0;
|
||||
|
||||
// "More is better" — value / max
|
||||
score += w.Impressions * SafeDiv(r.Impressions, maxImp);
|
||||
score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1);
|
||||
score += w.Clicks * SafeDiv(r.Clicks, maxClicks);
|
||||
score += w.Conversions * SafeDiv(r.Conversions, maxConv);
|
||||
score += w.Ctr * SafeDiv(r.Ctr, maxCtr);
|
||||
|
||||
// "Less is better" — min / value
|
||||
score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0);
|
||||
score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0);
|
||||
score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0);
|
||||
|
||||
scores[channel] = score;
|
||||
}
|
||||
|
||||
// Template-only channels get the average score
|
||||
var avgScore = scores.Values.Average();
|
||||
foreach (var channel in results.Keys.Except(scoreable.Keys))
|
||||
{
|
||||
scores[channel] = avgScore * 0.5; // Slight penalty for no data
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> DeriveAllocations(Dictionary<string, double> scores)
|
||||
{
|
||||
var total = scores.Values.Sum();
|
||||
if (total == 0)
|
||||
{
|
||||
// Even split
|
||||
var even = 100 / scores.Count;
|
||||
return scores.ToDictionary(s => s.Key, _ => even);
|
||||
}
|
||||
|
||||
// Proportional split
|
||||
var raw = scores.ToDictionary(s => s.Key, s => (int)Math.Round(s.Value / total * 100));
|
||||
|
||||
// Apply floor/ceiling constraints
|
||||
foreach (var key in raw.Keys.ToList())
|
||||
{
|
||||
raw[key] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[key]));
|
||||
}
|
||||
|
||||
// Normalize to exactly 100%
|
||||
var sum = raw.Values.Sum();
|
||||
if (sum != 100 && raw.Count > 0)
|
||||
{
|
||||
var diff = 100 - sum;
|
||||
// Add/subtract difference from the highest-scored channel
|
||||
var topChannel = raw.OrderByDescending(r => r.Value).First().Key;
|
||||
raw[topChannel] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[topChannel] + diff));
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static double SafeDiv(double numerator, double denominator)
|
||||
=> denominator > 0 ? numerator / denominator : 0;
|
||||
|
||||
private static string GetStrengthLabel(string channel, string objective) => channel switch
|
||||
{
|
||||
"google_ads" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for search visibility",
|
||||
"traffic" => "Strong for search intent clicks",
|
||||
"leads" => "Strong for high-intent leads",
|
||||
"sales" => "Strong for purchase intent",
|
||||
_ => "Search & intent targeting"
|
||||
},
|
||||
"meta" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for reach & discovery",
|
||||
"traffic" => "Strong for social traffic",
|
||||
"leads" => "Strong for lead gen forms",
|
||||
"sales" => "Strong for retargeting & social proof",
|
||||
_ => "Social reach & engagement"
|
||||
},
|
||||
"tiktok" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for viral reach",
|
||||
_ => "Video-first engagement"
|
||||
},
|
||||
_ => "Advertising channel"
|
||||
};
|
||||
|
||||
private static ForecastRecommendation BuildRecommendation(
|
||||
List<ChannelEstimate> channels, string objective)
|
||||
{
|
||||
if (channels.Count < 2)
|
||||
return new ForecastRecommendation
|
||||
{
|
||||
Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.",
|
||||
Highlights = new List<string>()
|
||||
};
|
||||
|
||||
var top = channels[0];
|
||||
var second = channels[1];
|
||||
var highlights = new List<string>();
|
||||
|
||||
// Compare key metrics
|
||||
if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0)
|
||||
{
|
||||
var clickRatio = top.Estimates.Clicks / second.Estimates.Clicks;
|
||||
if (clickRatio > 1.3)
|
||||
highlights.Add($"{ChannelDisplayName(top.Provider)}: ~{clickRatio:F0}x more clicks per dollar");
|
||||
}
|
||||
|
||||
if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0)
|
||||
{
|
||||
var impRatio = second.Estimates.Impressions / top.Estimates.Impressions;
|
||||
if (impRatio > 1.5)
|
||||
highlights.Add($"{ChannelDisplayName(second.Provider)}: ~{impRatio:F0}x more impressions per dollar");
|
||||
}
|
||||
|
||||
if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0)
|
||||
{
|
||||
highlights.Add($"CPA range: ${Math.Min(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0}–${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels");
|
||||
}
|
||||
|
||||
return new ForecastRecommendation
|
||||
{
|
||||
Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " +
|
||||
$"between {ChannelDisplayName(top.Provider)} and {ChannelDisplayName(second.Provider)}, " +
|
||||
$"optimized for {objective}.",
|
||||
Highlights = highlights
|
||||
};
|
||||
}
|
||||
|
||||
private static string ChannelDisplayName(string provider) => provider switch
|
||||
{
|
||||
"google_ads" => "Google",
|
||||
"meta" => "Meta",
|
||||
"tiktok" => "TikTok",
|
||||
_ => provider
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Internal result from a single provider call</summary>
|
||||
private sealed class ProviderForecastResult
|
||||
{
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public double Impressions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "none";
|
||||
}
|
||||
}
|
||||
353
Gateway/Services/ImageStorageService.cs
Normal file
353
Gateway/Services/ImageStorageService.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles downloading images from source URLs and storing them in Azure Blob Storage.
|
||||
/// Used when processing Creative drafts to ensure all image URLs are permanent.
|
||||
///
|
||||
/// Blob structure: {clientId}/drafts/{draftId}/{orientation}.{ext}
|
||||
/// Example: client-42/drafts/a1b2c3d4e5f6/landscape.jpg
|
||||
///
|
||||
/// This structure enables:
|
||||
/// - Client isolation (easy to list/delete all client assets)
|
||||
/// - Draft organization (images grouped per draft)
|
||||
/// - Future expansion (campaigns, versions, etc.)
|
||||
/// - Per-client access control via SAS tokens if needed
|
||||
/// </summary>
|
||||
public class ImageStorageService
|
||||
{
|
||||
private readonly BlobServiceClient _blobClient;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<ImageStorageService> _logger;
|
||||
private readonly string _containerName;
|
||||
private readonly string _blobBaseUrl;
|
||||
private readonly bool _isConfigured;
|
||||
|
||||
public ImageStorageService(
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<ImageStorageService> logger,
|
||||
IConfiguration config,
|
||||
BlobServiceClient blobClient)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
_blobClient = blobClient;
|
||||
_containerName = config["BlobStorage:ContainerName"] ?? "creative-images";
|
||||
_blobBaseUrl = config["BlobStorage:BaseUrl"] ?? "https://usimadpcreatives.blob.core.windows.net";
|
||||
_isConfigured = blobClient != null;
|
||||
|
||||
if (!_isConfigured)
|
||||
{
|
||||
_logger.LogWarning("[ImageStorage] Blob storage not configured - images will use source URLs");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("[ImageStorage] Blob storage configured: {BaseUrl}/{Container}", _blobBaseUrl, _containerName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether blob storage is configured and available.
|
||||
/// </summary>
|
||||
public bool IsConfigured => _isConfigured;
|
||||
|
||||
/// <summary>
|
||||
/// Process a Creative draft response, downloading and storing images in blob storage.
|
||||
/// Returns the modified JSON with blob URLs replacing source URLs.
|
||||
/// </summary>
|
||||
public async Task<string> ProcessCreativeDraftAsync(
|
||||
string clientId,
|
||||
string providerResponseJson,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured)
|
||||
{
|
||||
_logger.LogDebug("[ImageStorage] Skipping image processing - not configured");
|
||||
return providerResponseJson;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(providerResponseJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check if this is a successful response with data
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
return providerResponseJson;
|
||||
|
||||
if (!root.TryGetProperty("data", out var dataProp))
|
||||
return providerResponseJson;
|
||||
|
||||
// Check if data has images array
|
||||
if (!dataProp.TryGetProperty("images", out var imagesProp) ||
|
||||
imagesProp.ValueKind != JsonValueKind.Array ||
|
||||
imagesProp.GetArrayLength() == 0)
|
||||
{
|
||||
_logger.LogDebug("[ImageStorage] No images in draft response");
|
||||
return providerResponseJson;
|
||||
}
|
||||
|
||||
// Get draftId
|
||||
var draftId = dataProp.TryGetProperty("draftId", out var draftIdProp)
|
||||
? draftIdProp.GetString() ?? Guid.NewGuid().ToString("N")[..12]
|
||||
: Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
_logger.LogInformation(
|
||||
"[ImageStorage] Processing {Count} images for client {ClientId} draft {DraftId}",
|
||||
imagesProp.GetArrayLength(), clientId, draftId);
|
||||
|
||||
// Ensure container exists
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: ct);
|
||||
|
||||
// Process each image and collect results
|
||||
var processedImages = new List<Dictionary<string, object?>>();
|
||||
|
||||
foreach (var image in imagesProp.EnumerateArray())
|
||||
{
|
||||
var processedImage = await ProcessSingleImageAsync(
|
||||
containerClient, clientId, draftId, image, ct);
|
||||
processedImages.Add(processedImage);
|
||||
}
|
||||
|
||||
// Rebuild the response with updated image URLs
|
||||
return RebuildResponseWithProcessedImages(doc, processedImages);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[ImageStorage] Failed to process draft images, returning original response");
|
||||
return providerResponseJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a single image: download and upload to blob storage.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, object?>> ProcessSingleImageAsync(
|
||||
BlobContainerClient container,
|
||||
string clientId,
|
||||
string draftId,
|
||||
JsonElement image,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Extract image properties
|
||||
var imageId = image.TryGetProperty("imageId", out var idProp) ? idProp.GetString() : null;
|
||||
var sourceUrl = image.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null;
|
||||
var downloadUrl = image.TryGetProperty("downloadUrl", out var dlProp) ? dlProp.GetString() : null;
|
||||
var orientation = image.TryGetProperty("orientation", out var orProp) ? orProp.GetString() ?? "unknown" : "unknown";
|
||||
var source = image.TryGetProperty("source", out var srcProp) ? srcProp.GetString() : "unknown";
|
||||
var width = image.TryGetProperty("width", out var wProp) ? wProp.GetInt32() : 0;
|
||||
var height = image.TryGetProperty("height", out var hProp) ? hProp.GetInt32() : 0;
|
||||
var altText = image.TryGetProperty("altText", out var altProp) ? altProp.GetString() : null;
|
||||
var attribution = image.TryGetProperty("attribution", out var attrProp) ? attrProp.GetString() : null;
|
||||
|
||||
// Build result with original properties
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["imageId"] = imageId,
|
||||
["url"] = sourceUrl, // Will be replaced with blob URL on success
|
||||
["source"] = source,
|
||||
["orientation"] = orientation,
|
||||
["width"] = width,
|
||||
["height"] = height,
|
||||
["altText"] = altText,
|
||||
["attribution"] = attribution,
|
||||
["downloadUrl"] = downloadUrl,
|
||||
["blobStored"] = false // Track whether we stored it
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(sourceUrl))
|
||||
{
|
||||
_logger.LogWarning("[ImageStorage] Image has no URL, skipping");
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Download image bytes (prefer download URL for higher quality)
|
||||
var fetchUrl = !string.IsNullOrEmpty(downloadUrl) ? downloadUrl : sourceUrl;
|
||||
|
||||
var httpClient = _httpFactory.CreateClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
_logger.LogDebug("[ImageStorage] Downloading from {Url}", fetchUrl);
|
||||
|
||||
using var response = await httpClient.GetAsync(fetchUrl, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/jpeg";
|
||||
var extension = GetExtensionFromContentType(contentType);
|
||||
|
||||
// Build blob path: {clientId}/drafts/{draftId}/{orientation}.{ext}
|
||||
var blobName = $"{clientId}/drafts/{draftId}/{orientation}.{extension}";
|
||||
var blobClient = container.GetBlobClient(blobName);
|
||||
|
||||
// Upload with proper content type and caching headers
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
|
||||
var uploadOptions = new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders
|
||||
{
|
||||
ContentType = contentType,
|
||||
CacheControl = "public, max-age=31536000" // 1 year cache
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = source ?? "unknown",
|
||||
["originalUrl"] = sourceUrl,
|
||||
["orientation"] = orientation,
|
||||
["width"] = width.ToString(),
|
||||
["height"] = height.ToString(),
|
||||
["clientId"] = clientId,
|
||||
["draftId"] = draftId
|
||||
}
|
||||
};
|
||||
|
||||
await blobClient.UploadAsync(stream, uploadOptions, ct);
|
||||
|
||||
// Build permanent blob URL
|
||||
var blobUrl = $"{_blobBaseUrl}/{_containerName}/{blobName}";
|
||||
|
||||
result["url"] = blobUrl;
|
||||
result["blobStored"] = true;
|
||||
result["originalUrl"] = sourceUrl; // Keep original for reference
|
||||
|
||||
_logger.LogInformation("[ImageStorage] Stored {Orientation} image: {BlobUrl}", orientation, blobUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[ImageStorage] Failed to store {Orientation} image, keeping original URL", orientation);
|
||||
// Keep original URL as fallback
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild the provider response JSON with processed image data.
|
||||
/// </summary>
|
||||
private static string RebuildResponseWithProcessedImages(
|
||||
JsonDocument original,
|
||||
List<Dictionary<string, object?>> processedImages)
|
||||
{
|
||||
var root = original.RootElement;
|
||||
|
||||
// Build new response maintaining structure
|
||||
var response = new Dictionary<string, object?>
|
||||
{
|
||||
["ok"] = root.GetProperty("ok").GetBoolean(),
|
||||
["requestId"] = root.TryGetProperty("requestId", out var rid) ? rid.GetString() : null
|
||||
};
|
||||
|
||||
// Rebuild data object with processed images
|
||||
if (root.TryGetProperty("data", out var dataProp))
|
||||
{
|
||||
var data = new Dictionary<string, object?>();
|
||||
|
||||
// Copy all data properties except images
|
||||
foreach (var prop in dataProp.EnumerateObject())
|
||||
{
|
||||
if (prop.Name == "images")
|
||||
continue;
|
||||
|
||||
data[prop.Name] = JsonElementToObject(prop.Value);
|
||||
}
|
||||
|
||||
// Add processed images
|
||||
data["images"] = processedImages;
|
||||
|
||||
response["data"] = data;
|
||||
}
|
||||
|
||||
// Copy error if present
|
||||
if (root.TryGetProperty("error", out var errorProp))
|
||||
{
|
||||
response["error"] = JsonElementToObject(errorProp);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert JsonElement to object for serialization.
|
||||
/// </summary>
|
||||
private static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => element.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
|
||||
JsonValueKind.Array => element.EnumerateArray()
|
||||
.Select(JsonElementToObject).ToList(),
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all images for a specific draft.
|
||||
/// </summary>
|
||||
public async Task DeleteDraftImagesAsync(string clientId, string draftId, CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured) return;
|
||||
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
|
||||
var prefix = $"{clientId}/drafts/{draftId}/";
|
||||
await foreach (var blob in containerClient.GetBlobsAsync(
|
||||
traits: BlobTraits.None,
|
||||
states: BlobStates.None,
|
||||
prefix: prefix,
|
||||
cancellationToken: ct))
|
||||
{
|
||||
await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct);
|
||||
_logger.LogInformation("[ImageStorage] Deleted blob {Name}", blob.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all images for a client.
|
||||
/// </summary>
|
||||
public async Task DeleteClientImagesAsync(string clientId, CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured) return;
|
||||
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
|
||||
var prefix = $"{clientId}/";
|
||||
var count = 0;
|
||||
|
||||
await foreach (var blob in containerClient.GetBlobsAsync(
|
||||
traits: BlobTraits.None,
|
||||
states: BlobStates.None,
|
||||
prefix: prefix,
|
||||
cancellationToken: ct))
|
||||
{
|
||||
await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct);
|
||||
count++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("[ImageStorage] Deleted {Count} blobs for client {ClientId}", count, clientId);
|
||||
}
|
||||
|
||||
private static string GetExtensionFromContentType(string contentType) => contentType switch
|
||||
{
|
||||
"image/png" => "png",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
"image/svg+xml" => "svg",
|
||||
_ => "jpg"
|
||||
};
|
||||
}
|
||||
553
Gateway/Services/InitiativeLaunchService.cs
Normal file
553
Gateway/Services/InitiativeLaunchService.cs
Normal 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; }
|
||||
}
|
||||
214
Gateway/Services/IntelligenceApiClient.cs
Normal file
214
Gateway/Services/IntelligenceApiClient.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using Gateway.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for IntelligenceApi — the spend distribution engine container.
|
||||
///
|
||||
/// The Gateway injects clientCategory from ClientContext and provider config
|
||||
/// before forwarding requests. The client portal never calls IntelligenceApi
|
||||
/// directly; all routing goes through the Gateway.
|
||||
///
|
||||
/// FALLBACK: If IntelligenceApi is unreachable, ForecastController falls back
|
||||
/// to the local ForecastService (identical to the General engine output).
|
||||
/// This means a container restart or deployment never breaks the wizard.
|
||||
/// </summary>
|
||||
public sealed class IntelligenceApiClient
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<IntelligenceApiClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts =
|
||||
new(JsonSerializerDefaults.Web);
|
||||
|
||||
public IntelligenceApiClient(
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ILogger<IntelligenceApiClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward raw census data for a ZCTA to the Intelligence container for
|
||||
/// market analysis derivation (age chips, income tiers, insight strings).
|
||||
/// Returns the raw JSON response string, or null if the container is
|
||||
/// unreachable — caller falls back to returning raw census data.
|
||||
/// </summary>
|
||||
public async Task<string?> GetDemographicAnalysisAsync(
|
||||
string zcta,
|
||||
JsonElement censusData,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = _cfg["INTELLIGENCE_API_URL"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping demographics analysis");
|
||||
return null;
|
||||
}
|
||||
|
||||
var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
zcta,
|
||||
census = censusData
|
||||
}, _jsonOpts);
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"{baseUrl.TrimEnd('/')}/api/demographics/analyze");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(internalKey))
|
||||
msg.Headers.Add("X-Internal-Key", internalKey);
|
||||
|
||||
msg.Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
_logger.LogInformation("[IntelligenceApiClient] Demographics analysis | ZCTA={Zcta}", zcta);
|
||||
|
||||
using var resp = await client.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Demographics non-success {Status}", (int)resp.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Demographics analysis timed out | ZCTA={Zcta}", zcta);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[IntelligenceApiClient] Demographics analysis failed | ZCTA={Zcta}", zcta);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward a channel forecast request to IntelligenceApi with
|
||||
/// clientCategory injected. Returns null if the service is unreachable
|
||||
/// or returns an error — caller should fall back to ForecastService.
|
||||
/// </summary>
|
||||
public async Task<ChannelForecastResponse?> GetSpendDistributionAsync(
|
||||
ChannelForecastRequest request,
|
||||
string? clientCategory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = _cfg["INTELLIGENCE_API_URL"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
||||
|
||||
// Build the IntelligenceApi request — forward everything from the
|
||||
// original wizard request, plus inject clientCategory and provider config
|
||||
var intelligenceRequest = new
|
||||
{
|
||||
clientCategory = clientCategory ?? "General",
|
||||
objective = request.Objective,
|
||||
businessCategory = request.BusinessCategory,
|
||||
keywords = request.Keywords,
|
||||
geoTargeting = request.GeoTargeting,
|
||||
audience = request.Audience,
|
||||
monthlyBudget = request.MonthlyBudget,
|
||||
channels = request.Channels,
|
||||
|
||||
// Forward provider URLs so the engine can call providers directly
|
||||
providerUrls = new Dictionary<string, string>
|
||||
{
|
||||
["google_ads"] = _cfg["GOOGLE_PROVIDER_URL"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL") ?? ""
|
||||
},
|
||||
internalKeys = new Dictionary<string, string>
|
||||
{
|
||||
["google_ads"] = _cfg["GOOGLE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY") ?? ""
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"{baseUrl.TrimEnd('/')}/api/spend-distribution");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(internalKey))
|
||||
msg.Headers.Add("X-Internal-Key", internalKey);
|
||||
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(intelligenceRequest, _jsonOpts),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
_logger.LogInformation(
|
||||
"[IntelligenceApiClient] Calling engine | Category={Category} Budget={Budget}",
|
||||
clientCategory, request.MonthlyBudget);
|
||||
|
||||
using var resp = await client.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[IntelligenceApiClient] Non-success {Status}: {Body}",
|
||||
(int)resp.StatusCode, body[..Math.Min(body.Length, 200)]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map IntelligenceApi response shape → Gateway ChannelForecastResponse
|
||||
// The shapes are intentionally aligned so this is a straight deserialize.
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Engine returned ok=false");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-serialize then deserialize into the Gateway model
|
||||
// (avoids a hard dependency on IntelligenceApi model types in Gateway)
|
||||
var result = JsonSerializer.Deserialize<ChannelForecastResponse>(body, _jsonOpts);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[IntelligenceApiClient] OK | Engine={Engine} Channels={N}",
|
||||
root.TryGetProperty("metadata", out var meta)
|
||||
&& meta.TryGetProperty("engine", out var eng)
|
||||
? eng.GetString() : "?",
|
||||
result?.Channels?.Count ?? 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Request timed out");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[IntelligenceApiClient] Request failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
339
Gateway/Services/MetricSyncService.cs
Normal file
339
Gateway/Services/MetricSyncService.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
public sealed class MetricSyncService
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<MetricSyncService> _logger;
|
||||
|
||||
public MetricSyncService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ILogger<MetricSyncService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync metrics for a specific client's active campaigns.
|
||||
/// </summary>
|
||||
public async Task<SyncResult> 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<object>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of a metric sync operation.</summary>
|
||||
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; }
|
||||
}
|
||||
160
Gateway/Services/ProviderStatusNormalizer.cs
Normal file
160
Gateway/Services/ProviderStatusNormalizer.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using Gateway.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes provider-specific campaign statuses into platform-standard statuses.
|
||||
///
|
||||
/// Each advertising channel (Google Ads, Meta, TikTok, etc.) reports campaign state
|
||||
/// using its own vocabulary. This service translates those into the platform's
|
||||
/// unified status set: draft, staged, pending, active, paused, completed, cancelled, error.
|
||||
///
|
||||
/// Mapping priority:
|
||||
/// 1. Channel-specific mapping from config (e.g. google_ads → ENABLED → active)
|
||||
/// 2. Common/internal mappings (e.g. submitted → active, pending_review → pending)
|
||||
/// 3. Pass-through if the raw status is already a valid platform status
|
||||
/// 4. "error" fallback with a warning log for truly unknown statuses
|
||||
/// </summary>
|
||||
public sealed class ProviderStatusNormalizer
|
||||
{
|
||||
private readonly MultiChannelConfig _config;
|
||||
private readonly ILogger<ProviderStatusNormalizer> _log;
|
||||
|
||||
/// <summary>The canonical set of platform-level statuses.</summary>
|
||||
private static readonly HashSet<string> PlatformStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"draft", "staged", "pending", "active", "paused", "completed", "cancelled", "error"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Internal/transitional statuses used during launch orchestration.
|
||||
/// These are not provider-specific but arise from the platform's own workflow.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> CommonMappings = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Launch service assigns these during dispatch
|
||||
["submitted"] = "active",
|
||||
["pending_review"] = "pending",
|
||||
["stub_provider"] = "pending",
|
||||
|
||||
// Webhook / callback transitional states
|
||||
["approved"] = "active",
|
||||
["rejected"] = "error",
|
||||
["suspended"] = "paused",
|
||||
["budget_depleted"] = "paused",
|
||||
["expired"] = "completed",
|
||||
["archived"] = "completed",
|
||||
["deleted"] = "cancelled",
|
||||
["in_process"] = "pending",
|
||||
["in_review"] = "pending",
|
||||
["learning"] = "active", // Meta "learning phase"
|
||||
["limited"] = "active", // Google "limited by budget" etc.
|
||||
};
|
||||
|
||||
public ProviderStatusNormalizer(
|
||||
IOptions<MultiChannelConfig> config,
|
||||
ILogger<ProviderStatusNormalizer> log)
|
||||
{
|
||||
_config = config.Value;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize a raw provider status into a platform status.
|
||||
/// </summary>
|
||||
/// <param name="channelType">Channel identifier (e.g. "google_ads", "meta", "tiktok").</param>
|
||||
/// <param name="rawProviderStatus">The status string as returned by the provider.</param>
|
||||
/// <returns>A valid platform status string.</returns>
|
||||
public string Normalize(string? channelType, string? rawProviderStatus)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawProviderStatus))
|
||||
return "error";
|
||||
|
||||
var raw = rawProviderStatus.Trim();
|
||||
|
||||
// 1. Try channel-specific mapping from config
|
||||
if (!string.IsNullOrWhiteSpace(channelType))
|
||||
{
|
||||
var provider = _config.GetChannel(channelType);
|
||||
if (provider?.StatusMappings != null &&
|
||||
provider.StatusMappings.TryGetValue(raw, out var mapped))
|
||||
{
|
||||
_log.LogDebug("[StatusNorm] {Channel}/{RawStatus} → {PlatformStatus} (config)",
|
||||
channelType, raw, mapped);
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try common/internal mappings
|
||||
if (CommonMappings.TryGetValue(raw, out var common))
|
||||
{
|
||||
_log.LogDebug("[StatusNorm] {RawStatus} → {PlatformStatus} (common)",
|
||||
raw, common);
|
||||
return common;
|
||||
}
|
||||
|
||||
// 3. If the raw value is already a valid platform status, pass through
|
||||
if (PlatformStatuses.Contains(raw))
|
||||
{
|
||||
_log.LogDebug("[StatusNorm] {RawStatus} → pass-through (already platform status)", raw);
|
||||
return raw.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// 4. Unknown — log warning and return "error"
|
||||
_log.LogWarning(
|
||||
"[StatusNorm] Unknown provider status: channel={Channel}, raw={RawStatus}. Defaulting to 'error'. " +
|
||||
"Add a mapping in MultiChannel.Channels[].StatusMappings or CommonMappings.",
|
||||
channelType ?? "(none)", raw);
|
||||
|
||||
return "error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the platform status for a sync operation.
|
||||
/// If an explicit platform status is provided, validate and use it.
|
||||
/// Otherwise, normalize the provider status.
|
||||
/// </summary>
|
||||
/// <param name="channelType">Channel identifier.</param>
|
||||
/// <param name="explicitStatus">Explicitly provided platform status (optional).</param>
|
||||
/// <param name="rawProviderStatus">Raw provider status (optional).</param>
|
||||
/// <returns>A valid platform status string.</returns>
|
||||
public string Resolve(string? channelType, string? explicitStatus, string? rawProviderStatus)
|
||||
{
|
||||
// If an explicit platform status was given, validate it
|
||||
if (!string.IsNullOrWhiteSpace(explicitStatus))
|
||||
{
|
||||
if (PlatformStatuses.Contains(explicitStatus))
|
||||
return explicitStatus.ToLowerInvariant();
|
||||
|
||||
_log.LogWarning("[StatusNorm] Invalid explicit status '{Status}', normalizing as provider status instead.",
|
||||
explicitStatus);
|
||||
// Fall through to normalization
|
||||
}
|
||||
|
||||
return Normalize(channelType, rawProviderStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all configured mappings for a channel (for diagnostics / admin display).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GetMappings(string channelType)
|
||||
{
|
||||
var result = new Dictionary<string, string>(CommonMappings, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var provider = _config.GetChannel(channelType);
|
||||
if (provider?.StatusMappings != null)
|
||||
{
|
||||
foreach (var kv in provider.StatusMappings)
|
||||
result[kv.Key] = kv.Value; // Channel-specific overrides common
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether a string is a valid platform status.
|
||||
/// </summary>
|
||||
public static bool IsValidPlatformStatus(string? status) =>
|
||||
!string.IsNullOrWhiteSpace(status) && PlatformStatuses.Contains(status);
|
||||
}
|
||||
Reference in New Issue
Block a user