485 lines
19 KiB
C#
485 lines
19 KiB
C#
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<ExecutionService> _logger;
|
|
|
|
// Operations that don't require a linked account (health checks, etc.)
|
|
private static readonly HashSet<string> AccountOptionalOperations = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"Ping", "TestPing", "ListAccessibleCustomers"
|
|
};
|
|
|
|
// Providers that require Google Ads account validation
|
|
private static readonly HashSet<string> GoogleAccountProviders = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"google"
|
|
};
|
|
|
|
// Creative operations that return images and need blob storage processing
|
|
private static readonly HashSet<string> CreativeImageOperations = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"CreateDraft", "GetImages"
|
|
};
|
|
|
|
public ExecutionService(
|
|
SqlService sql,
|
|
IHttpClientFactory http,
|
|
IConfiguration cfg,
|
|
ClientContext client,
|
|
ImageStorageService imageStorage,
|
|
ILogger<ExecutionService> logger)
|
|
{
|
|
_sql = sql;
|
|
_http = http;
|
|
_cfg = cfg;
|
|
_client = client;
|
|
_imageStorage = imageStorage;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<string> 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
|
|
// ================================================================
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private string BuildProviderRequest(JsonElement original, string requestId, string operation,
|
|
string? tenantId, string? loginCustomerId)
|
|
{
|
|
var request = new Dictionary<string, object?>
|
|
{
|
|
["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)
|
|
// ================================================================
|
|
|
|
/// <summary>
|
|
/// Validate that a Google Ads customer ID is linked in the database.
|
|
/// Returns loginCustomerId if account is found.
|
|
/// </summary>
|
|
private async Task<AccountValidation> 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
|
|
// ================================================================
|
|
|
|
/// <summary>
|
|
/// Wrap provider response with Gateway metadata.
|
|
/// </summary>
|
|
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
|
|
// ================================================================
|
|
|
|
/// <summary>
|
|
/// Get provider URL based on provider type.
|
|
/// </summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get internal API key for provider.
|
|
/// </summary>
|
|
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"] ?? "",
|
|
_ => ""
|
|
};
|
|
}
|
|
} |