Add project files.

This commit is contained in:
Grae Jones
2026-02-03 15:04:37 -08:00
parent a4838b594d
commit 8e7e03702e
65 changed files with 6227 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
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<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"
};
public ExecutionService(
SqlService sql,
IHttpClientFactory http,
IConfiguration cfg,
ClientContext client,
ILogger<ExecutionService> logger)
{
_sql = sql;
_http = http;
_cfg = cfg;
_client = client;
_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";
// 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;
}
/// <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");
}
}
/// <summary>
/// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider.
/// </summary>
private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId)
{
using var doc = JsonDocument.Parse(original.GetRawText());
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(doc.RootElement.GetRawText())
?? new Dictionary<string, JsonElement>();
// 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);
}
/// <summary>
/// Wrap provider response with Gateway metadata.
/// </summary>
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
});
}
}
/// <summary>
/// Result of account validation.
/// </summary>
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
};
}
/// <summary>
/// Get provider URL based on provider type.
/// </summary>
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('/') ?? ""
};
}
/// <summary>
/// Get internal API key for provider.
/// </summary>
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"] ?? ""
};
}
}