using Gateway.Data; using Gateway.Security; using System.Diagnostics; using System.Text.Json; namespace Gateway.Services; public sealed class ExecutionService { private readonly SqlService _sql; private readonly IHttpClientFactory _http; private readonly IConfiguration _cfg; private readonly ClientContext _client; private readonly ImageStorageService _imageStorage; private readonly ILogger _logger; // Operations that don't require a linked account (health checks, etc.) private static readonly HashSet AccountOptionalOperations = new(StringComparer.OrdinalIgnoreCase) { "Ping", "TestPing", "ListAccessibleCustomers" }; // Providers that require Google Ads account validation private static readonly HashSet GoogleAccountProviders = new(StringComparer.OrdinalIgnoreCase) { "google" }; // Creative operations that return images and need blob storage processing private static readonly HashSet CreativeImageOperations = new(StringComparer.OrdinalIgnoreCase) { "CreateDraft", "GetImages" }; public ExecutionService( SqlService sql, IHttpClientFactory http, IConfiguration cfg, ClientContext client, ImageStorageService imageStorage, ILogger logger) { _sql = sql; _http = http; _cfg = cfg; _client = client; _imageStorage = imageStorage; _logger = logger; } public async Task ExecuteAsync(JsonElement reqJson, CancellationToken ct) { var requestId = Guid.NewGuid().ToString("N"); var started = DateTimeOffset.UtcNow; // Extract clientId from authenticated context var clientId = _client.ClientId; // Extract routing info: provider, service, action var provider = reqJson.TryGetProperty("provider", out var pv) ? pv.GetString() ?? "google" : "google"; var service = reqJson.TryGetProperty("service", out var sv) ? sv.GetString() ?? "system" : "system"; var action = reqJson.TryGetProperty("action", out var av) ? av.GetString() ?? "ping" : "ping"; // 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() ?? action; // 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} Operation={Operation} DevBypass={DevBypass}", requestId, clientId, tenantId, provider, service, operation, _client.IsDevBypass); // ================================================================ // 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 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 (requiresGoogleAccount) { var validation = await ValidateGoogleAccountAsync(tenantId!, ct); if (!validation.IsValid) { _logger.LogWarning( "[Execution] Account validation failed | RequestId={RequestId} TenantId={TenantId} Error={Error}", requestId, tenantId, validation.Error); // Return error response without calling provider return JsonSerializer.Serialize(new { ok = false, status = 400, elapsedMs = 0, requestId, clientId, error = new { code = validation.ErrorCode ?? "VALIDATION_ERROR", message = validation.Error } }); } loginCustomerId = validation.LoginCustomerId; validatedClientName = validation.ClientName; _logger.LogInformation( "[Execution] Account validated | RequestId={RequestId} TenantId={TenantId} LoginCustomerId={LoginCustomerId} Client={ClientName}", requestId, tenantId, loginCustomerId, validatedClientName); } // Log start (includes routing info) int? logId = null; var startRqst = JsonSerializer.Serialize(new { action = "start", requestId, clientId, tenantId, provider, service, operation, loginCustomerId, sessionId = _client.SessionId, userId = _client.UserId, isDevBypass = _client.IsDevBypass, req = reqJson }); var startResp = await _sql.ExecProcAsync("dbo.spAdpApiLog", "start", startRqst, ct: ct); using (var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(startResp) ? "{}" : startResp)) { if (doc.RootElement.TryGetProperty("logId", out var e) && e.ValueKind == JsonValueKind.Number) logId = e.GetInt32(); } // 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(); int providerStatus; string providerResp; try { 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); msg.Headers.Add("X-Request-Id", requestId); msg.Content = new StringContent(enrichedRequest, System.Text.Encoding.UTF8, "application/json"); using var resp = await httpClient.SendAsync(msg, ct); providerStatus = (int)resp.StatusCode; providerResp = await resp.Content.ReadAsStringAsync(ct); } catch (Exception ex) { _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} Provider={Provider} Status={Status} ElapsedMs={ElapsedMs}", requestId, clientId, provider, providerStatus, sw.ElapsedMilliseconds); // ================================================================ // 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", logId, requestId, clientId, provider, service, operation, providerStatus, elapsedMs = sw.ElapsedMilliseconds, resp = SafeParseJson(providerResp) }); _ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct); // Wrap response with metadata var wrappedResponse = WrapResponse(providerResp, providerStatus, sw.ElapsedMilliseconds, requestId, clientId); return wrappedResponse; } // ================================================================ // Provider request building // ================================================================ /// /// 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. /// private string BuildProviderRequest(JsonElement original, string requestId, string operation, string? tenantId, string? loginCustomerId) { var request = new Dictionary { ["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) // ================================================================ /// /// Validate that a Google Ads customer ID is linked in the database. /// Returns loginCustomerId if account is found. /// private async Task ValidateGoogleAccountAsync(string customerId, CancellationToken ct) { try { var rqst = JsonSerializer.Serialize(new { customerId }); var resp = await _sql.ExecProcAsync("dbo.spGoogleAccount", "validate", rqst, ct: ct); if (string.IsNullOrWhiteSpace(resp)) { return AccountValidation.Invalid("VALIDATION_ERROR", "Account validation failed"); } using var doc = JsonDocument.Parse(resp); var root = doc.RootElement; // Check if validation succeeded if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean()) { // Check account status var accountStatus = root.TryGetProperty("accountStatus", out var accStat) ? accStat.GetString() : null; var clientStatus = root.TryGetProperty("clientStatus", out var cltStat) ? cltStat.GetString() : null; if (accountStatus != "Active") { return AccountValidation.Invalid("ACCOUNT_INACTIVE", $"Google Ads account is {accountStatus?.ToLower() ?? "not active"}"); } if (clientStatus != "Active") { return AccountValidation.Invalid("CLIENT_INACTIVE", $"Client is {clientStatus?.ToLower() ?? "not active"}"); } // Extract loginCustomerId (manager account) var loginCustomerId = root.TryGetProperty("loginCustomerId", out var loginProp) && loginProp.ValueKind == JsonValueKind.String ? loginProp.GetString() : null; var clientName = root.TryGetProperty("clientName", out var nameProp) && nameProp.ValueKind == JsonValueKind.String ? nameProp.GetString() : null; return AccountValidation.Valid(loginCustomerId, clientName); } else { // Validation failed - account not found var errorCode = root.TryGetProperty("errorCode", out var ecProp) ? ecProp.GetString() : "ACCOUNT_NOT_FOUND"; var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Account not found"; return AccountValidation.Invalid(errorCode, error); } } catch (Exception ex) { _logger.LogError(ex, "Account validation error for customerId={CustomerId}", customerId); return AccountValidation.Invalid("VALIDATION_ERROR", "Account validation failed"); } } // ================================================================ // Response wrapping // ================================================================ /// /// Wrap provider response with Gateway metadata. /// 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 { var providerJson = JsonDocument.Parse(providerResp).RootElement; var wrapped = new { ok = status >= 200 && status < 300, status, elapsedMs, requestId, clientId, result = providerJson }; return JsonSerializer.Serialize(wrapped, new JsonSerializerOptions { WriteIndented = false }); } catch { // If provider response isn't valid JSON, wrap as string return JsonSerializer.Serialize(new { ok = false, status, elapsedMs, requestId, clientId, result = providerResp }); } } // ================================================================ // Validation result // ================================================================ private sealed class AccountValidation { public bool IsValid { get; init; } public string? ErrorCode { get; init; } public string? Error { get; init; } public string? LoginCustomerId { get; init; } public string? ClientName { get; init; } public static AccountValidation Valid(string? loginCustomerId, string? clientName) => new() { IsValid = true, LoginCustomerId = loginCustomerId, ClientName = clientName }; public static AccountValidation Invalid(string? errorCode, string? error) => new() { IsValid = false, ErrorCode = errorCode, Error = error }; } // ================================================================ // Provider routing // ================================================================ /// /// Get provider URL based on provider type. /// private string GetProviderUrl(string provider) { 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('/') ?? "", _ => "" // No default fallback ? unknown providers fail explicitly }; } /// /// Get internal API key for provider. /// private string GetProviderKey(string provider) { 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"] ?? "", _ => "" }; } }