Files
AdPlatform-Server/Gateway/Services/ChannelConfigService.cs
2026-03-14 13:50:09 -07:00

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;
}
}