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 ILogger _logger; // Operations that don't require a linked account (health checks, etc.) private static readonly HashSet AccountOptionalOperations = new(StringComparer.OrdinalIgnoreCase) { "Ping", "TestPing", "ListAccessibleCustomers" }; public ExecutionService( SqlService sql, IHttpClientFactory http, IConfiguration cfg, ClientContext client, ILogger logger) { _sql = sql; _http = http; _cfg = cfg; _client = client; _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"; // Legacy support: if "operation" is provided, use it as action string? operation = action; if (reqJson.TryGetProperty("operation", out var opProp) && opProp.ValueKind == JsonValueKind.String) operation = opProp.GetString(); // TenantId priority: 1) request body, 2) ClientContext, 3) null string? tenantId = null; if (reqJson.TryGetProperty("tenantId", out var tid) && tid.ValueKind == JsonValueKind.String) tenantId = tid.GetString(); tenantId ??= _client.TenantId; _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); // ================================================================ // AGENCY MODEL: Validate account and get loginCustomerId // ================================================================ 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); if (requiresAccount) { 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 (now includes clientId and routing info) int? logId = null; var startRqst = JsonSerializer.Serialize(new { action = "start", requestId, clientId, tenantId, provider, service, operation = action, 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(); } // Inject/override fields in request before forwarding to provider var enrichedRequest = EnrichRequest(reqJson, requestId, 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); 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}", requestId); 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); // Log finish (includes clientId and routing info for correlation) var finishRqst = JsonSerializer.Serialize(new { action = "finish", logId, requestId, clientId, provider, service, operation = action, providerStatus, elapsedMs = sw.ElapsedMilliseconds, resp = JsonDocument.Parse(providerResp).RootElement }); _ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct); // Wrap response with metadata var wrappedResponse = WrapResponse(providerResp, providerStatus, sw.ElapsedMilliseconds, requestId, clientId); return wrappedResponse; } /// /// 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"); } } /// /// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider. /// private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId) { using var doc = JsonDocument.Parse(original.GetRawText()); var dict = JsonSerializer.Deserialize>(doc.RootElement.GetRawText()) ?? new Dictionary(); // 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); } /// /// Wrap provider response with Gateway metadata. /// 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 }); } } /// /// Result of account validation. /// 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 }; } /// /// Get provider URL based on provider type. /// private string GetProviderUrl(string provider) { return provider.ToLowerInvariant() switch { "google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "", "meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "", "msads" => _cfg["MSADS_PROVIDER_URL"]?.TrimEnd('/') ?? "", _ => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "" }; } /// /// Get internal API key for provider. /// private string GetProviderKey(string provider) { return provider.ToLowerInvariant() switch { "google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "", "meta" => _cfg["META_INTERNAL_KEY"] ?? "", "msads" => _cfg["MSADS_INTERNAL_KEY"] ?? "", _ => _cfg["GOOGLE_INTERNAL_KEY"] ?? "" }; } }