using Gateway.Data; using Gateway.Models; using System.Text.Json; namespace Gateway.Services; /// /// 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 /// public sealed class ChannelConfigService { private readonly IServiceProvider _sp; private readonly IConfiguration _cfg; private readonly ILogger _log; private MultiChannelConfig _cached; private readonly object _lock = new(); public ChannelConfigService( IServiceProvider sp, IConfiguration cfg, ILogger log) { _sp = sp; _cfg = cfg; _log = log; _cached = BuildDefaults(); } /// /// The current in-memory config. Always non-null (falls back to defaults). /// public MultiChannelConfig Current { get { lock (_lock) { return _cached; } } } /// /// Load channel config from the database. /// Call at startup and whenever admin updates channel config. /// public async Task LoadAsync(CancellationToken ct = default) { try { using var scope = _sp.CreateScope(); var sql = scope.ServiceProvider.GetRequiredService(); 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(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"); } } /// Reload config from DB (for admin refresh endpoints). 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(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 StringList(JsonElement el, string prop) { var list = new List(); 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 StringDict(JsonElement el, string prop) { var dict = new Dictionary(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; } }