300 lines
12 KiB
C#
300 lines
12 KiB
C#
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;
|
|
}
|
|
}
|