Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -0,0 +1,229 @@
using System.Net.Http.Json;
using System.Text.Json;
using MetaApi.Configuration;
using Microsoft.Extensions.Options;
namespace MetaApi.Services;
/// <summary>
/// HTTP wrapper for Meta Graph API calls.
/// Handles authentication, API versioning, error parsing, and rate limiting.
///
/// Meta Graph API pattern:
/// GET https://graph.facebook.com/{version}/{node-id}?access_token=...&fields=...
/// POST https://graph.facebook.com/{version}/{node-id}/{edge}?access_token=...
/// </summary>
public sealed class MetaGraphClient
{
private readonly HttpClient _http;
private readonly MetaConfig _config;
private readonly ILogger<MetaGraphClient> _logger;
public MetaGraphClient(HttpClient http, IOptions<MetaConfig> config, ILogger<MetaGraphClient> logger)
{
_http = http;
_config = config.Value;
_logger = logger;
_http.BaseAddress = new Uri(_config.GraphApiBaseUrl.TrimEnd('/') + "/");
_http.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds);
}
/// <summary>
/// Whether real API calls are enabled.
/// </summary>
public bool IsRealApiEnabled => _config.EnableRealApi
&& !string.IsNullOrWhiteSpace(_config.SystemUserToken);
/// <summary>
/// GET a Graph API node with optional fields.
/// </summary>
public async Task<GraphApiResponse> GetAsync(string path, Dictionary<string, string>? queryParams = null, CancellationToken ct = default)
{
var url = BuildUrl(path, queryParams);
_logger.LogDebug("[GraphClient] GET {Url}", SanitizeUrl(url));
try
{
var response = await _http.GetAsync(url, ct);
return await ParseResponseAsync(response, ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
return GraphApiResponse.Error("TIMEOUT", "Meta API request timed out");
}
catch (Exception ex)
{
_logger.LogError(ex, "[GraphClient] GET failed for {Path}", path);
return GraphApiResponse.Error("HTTP_ERROR", ex.Message);
}
}
/// <summary>
/// POST to a Graph API edge with form-encoded or JSON body.
/// Meta's Marketing API generally uses form-encoded POST parameters.
/// </summary>
public async Task<GraphApiResponse> PostAsync(string path, Dictionary<string, string> formData, CancellationToken ct = default)
{
var url = $"{_config.ApiVersion}/{path.TrimStart('/')}";
// Add access token to form data
var data = new Dictionary<string, string>(formData)
{
["access_token"] = GetAccessToken()
};
_logger.LogDebug("[GraphClient] POST {Url} (fields: {Fields})", url, string.Join(", ", formData.Keys));
try
{
var response = await _http.PostAsync(url, new FormUrlEncodedContent(data), ct);
return await ParseResponseAsync(response, ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
return GraphApiResponse.Error("TIMEOUT", "Meta API request timed out");
}
catch (Exception ex)
{
_logger.LogError(ex, "[GraphClient] POST failed for {Path}", path);
return GraphApiResponse.Error("HTTP_ERROR", ex.Message);
}
}
/// <summary>
/// DELETE a Graph API node.
/// </summary>
public async Task<GraphApiResponse> DeleteAsync(string path, CancellationToken ct = default)
{
var url = BuildUrl(path);
_logger.LogDebug("[GraphClient] DELETE {Url}", SanitizeUrl(url));
try
{
var response = await _http.DeleteAsync(url, ct);
return await ParseResponseAsync(response, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "[GraphClient] DELETE failed for {Path}", path);
return GraphApiResponse.Error("HTTP_ERROR", ex.Message);
}
}
// ================================================================
// Internals
// ================================================================
private string GetAccessToken()
=> _config.SystemUserToken;
private string BuildUrl(string path, Dictionary<string, string>? queryParams = null)
{
var qs = new Dictionary<string, string>(queryParams ?? new())
{
["access_token"] = GetAccessToken()
};
var queryString = string.Join("&", qs.Select(kv =>
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
return $"{_config.ApiVersion}/{path.TrimStart('/')}?{queryString}";
}
/// <summary>Redact access token from URLs for logging.</summary>
private static string SanitizeUrl(string url)
=> System.Text.RegularExpressions.Regex.Replace(url, @"access_token=[^&]+", "access_token=***");
private async Task<GraphApiResponse> ParseResponseAsync(HttpResponseMessage response, CancellationToken ct)
{
var body = await response.Content.ReadAsStringAsync(ct);
var statusCode = (int)response.StatusCode;
if (string.IsNullOrWhiteSpace(body))
{
return new GraphApiResponse
{
IsSuccess = response.IsSuccessStatusCode,
StatusCode = statusCode,
RawJson = body
};
}
try
{
var json = JsonDocument.Parse(body);
var root = json.RootElement;
// Check for Meta error response format:
// { "error": { "message": "...", "type": "...", "code": 123, "error_subcode": 456 } }
if (root.TryGetProperty("error", out var errorProp) && errorProp.ValueKind == JsonValueKind.Object)
{
var errorMsg = errorProp.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown Meta API error";
var errorType = errorProp.TryGetProperty("type", out var typ) ? typ.GetString() : "OAuthException";
var errorCode = errorProp.TryGetProperty("code", out var cod) ? cod.GetInt32() : 0;
var errorSubcode = errorProp.TryGetProperty("error_subcode", out var sub) ? sub.GetInt32() : 0;
_logger.LogWarning(
"[GraphClient] API error | Status={Status} Code={Code} Subcode={Subcode} Type={Type} Message={Message}",
statusCode, errorCode, errorSubcode, errorType, errorMsg);
return new GraphApiResponse
{
IsSuccess = false,
StatusCode = statusCode,
ErrorMessage = errorMsg,
ErrorType = errorType,
ErrorCode = errorCode,
ErrorSubcode = errorSubcode,
RawJson = body
};
}
return new GraphApiResponse
{
IsSuccess = response.IsSuccessStatusCode,
StatusCode = statusCode,
Data = root,
RawJson = body
};
}
catch (JsonException)
{
_logger.LogWarning("[GraphClient] Non-JSON response from Meta API: {Body}", body[..Math.Min(body.Length, 200)]);
return new GraphApiResponse
{
IsSuccess = false,
StatusCode = statusCode,
ErrorMessage = "Non-JSON response from Meta API",
RawJson = body
};
}
}
}
/// <summary>
/// Parsed response from Meta's Graph API.
/// </summary>
public sealed class GraphApiResponse
{
public bool IsSuccess { get; set; }
public int StatusCode { get; set; }
public JsonElement? Data { get; set; }
public string? RawJson { get; set; }
// Error fields (populated when Meta returns error envelope)
public string? ErrorMessage { get; set; }
public string? ErrorType { get; set; }
public int ErrorCode { get; set; }
public int ErrorSubcode { get; set; }
public static GraphApiResponse Error(string code, string message)
=> new()
{
IsSuccess = false,
StatusCode = 0,
ErrorMessage = message,
ErrorType = code
};
}

View File

@@ -0,0 +1,528 @@
using MetaApi.Configuration;
using MetaApi.Models;
using Microsoft.Extensions.Options;
namespace MetaApi.Services;
/// <summary>
/// Core service for Meta Marketing API operations.
/// Follows the same dual-mode pattern as GoogleAdsService:
/// - When EnableRealApi=false: returns emulated responses
/// - When EnableRealApi=true: makes real Graph API calls
///
/// Meta campaign hierarchy: Campaign → Ad Set → Ad
/// Maps to platform model: Initiative → ChannelCampaign (meta) → provider entities
/// </summary>
public sealed class MetaMarketingService
{
private readonly MetaConfig _config;
private readonly MetaGraphClient _graphClient;
private readonly ILogger<MetaMarketingService> _logger;
public MetaMarketingService(
IOptions<MetaConfig> config,
MetaGraphClient graphClient,
ILogger<MetaMarketingService> logger)
{
_config = config.Value;
_graphClient = graphClient;
_logger = logger;
}
public async Task<ProviderResponse> ExecuteAsync(ProviderRequest request, CancellationToken ct)
{
var requestId = request.RequestId ?? Guid.NewGuid().ToString("N");
var operation = (request.Operation ?? string.Empty).Trim();
_logger.LogInformation(
"[MetaAds] Executing {Operation} | RequestId={RequestId} TenantId={TenantId} RealApi={RealApi}",
operation, requestId, request.TenantId, _graphClient.IsRealApiEnabled);
try
{
var context = new MetaApiContext
{
AdAccountId = NormalizeAdAccountId(request.TenantId ?? string.Empty),
BusinessManagerId = request.LoginCustomerId ?? _config.BusinessManagerId
};
var result = operation switch
{
"Ping" => Ping(requestId),
"TestPing" => Ping(requestId),
// Campaign operations
"CreateCampaign" => await CreateCampaignAsync(request, context, requestId, ct),
"GetCampaign" => await GetCampaignAsync(request, context, requestId, ct),
"UpdateCampaign" => await UpdateCampaignAsync(request, context, requestId, ct),
"ListCampaigns" => await ListCampaignsAsync(request, context, requestId, ct),
// Insights (reporting)
"GetCampaignInsights" => GetCampaignInsights(request, requestId),
"GetAccountInsights" => GetAccountInsights(request, requestId),
// Account management
"CreateAdAccount" => await CreateAdAccountAsync(request, context, requestId, ct),
"ListAdAccounts" => await ListAdAccountsAsync(context, requestId, ct),
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
};
_logger.LogInformation(
"[MetaAds] Completed {Operation} | RequestId={RequestId} Ok={Ok}",
operation, requestId, result.Ok);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "[MetaAds] Error in {Operation} | RequestId={RequestId}", operation, requestId);
return ProviderResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message);
}
}
// ================================================================
// Ping
// ================================================================
private ProviderResponse Ping(string requestId)
=> ProviderResponse.Success(requestId, new
{
message = "MetaApi provider is healthy",
service = "MetaApi",
realApiEnabled = _graphClient.IsRealApiEnabled,
apiVersion = _config.ApiVersion,
businessManagerId = _config.BusinessManagerId,
timestamp = DateTimeOffset.UtcNow
});
// ================================================================
// Campaign Operations
// ================================================================
private async Task<ProviderResponse> CreateCampaignAsync(
ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<CreateCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.Name))
return ProviderResponse.Fail(requestId, "VALIDATION", "Campaign name is required");
if (_graphClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdAccountId))
return await CreateCampaignRealAsync(payload, context, requestId, ct);
// Emulated response
var emulatedId = GenerateId().ToString();
return ProviderResponse.Success(requestId, new
{
campaignId = emulatedId,
name = payload.Name,
objective = MapObjectiveToApi(payload.Objective),
status = MapStatusToApi(payload.Status),
adAccountId = context.AdAccountId,
effectiveStatus = "PAUSED",
createdTime = DateTimeOffset.UtcNow.ToString("o"),
emulated = true
});
}
private async Task<ProviderResponse> CreateCampaignRealAsync(
CreateCampaignPayload payload, MetaApiContext context, string requestId, CancellationToken ct)
{
// POST /{ad-account-id}/campaigns
var formData = new Dictionary<string, string>
{
["name"] = payload.Name,
["objective"] = MapObjectiveToApi(payload.Objective),
["status"] = MapStatusToApi(payload.Status),
["special_ad_categories"] = payload.SpecialAdCategories.Count > 0
? $"[{string.Join(",", payload.SpecialAdCategories.Select(c => $"\"{c}\""))}]"
: "[]"
};
if (payload.SpendCapCents.HasValue)
formData["spend_cap"] = payload.SpendCapCents.Value.ToString();
var result = await _graphClient.PostAsync($"{context.AdAccountId}/campaigns", formData, ct);
if (!result.IsSuccess)
return ProviderResponse.Fail(requestId, "META_API_ERROR",
result.ErrorMessage ?? "Failed to create campaign",
new { metaErrorCode = result.ErrorCode, metaErrorSubcode = result.ErrorSubcode });
var campaignId = result.Data?.TryGetProperty("id", out var idProp) == true
? idProp.GetString() : null;
return ProviderResponse.Success(requestId, new
{
campaignId,
name = payload.Name,
objective = MapObjectiveToApi(payload.Objective),
status = MapStatusToApi(payload.Status),
adAccountId = context.AdAccountId,
emulated = false
});
}
private async Task<ProviderResponse> GetCampaignAsync(
ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<GetCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
if (_graphClient.IsRealApiEnabled)
return await GetCampaignRealAsync(payload.CampaignId, requestId, ct);
// Emulated
return ProviderResponse.Success(requestId, new
{
campaignId = payload.CampaignId,
name = $"Emulated Campaign {payload.CampaignId}",
objective = "OUTCOME_TRAFFIC",
status = "PAUSED",
effectiveStatus = "PAUSED",
dailyBudget = "5000",
lifetimeBudget = "0",
createdTime = DateTimeOffset.UtcNow.AddDays(-7).ToString("o"),
updatedTime = DateTimeOffset.UtcNow.ToString("o"),
emulated = true
});
}
private async Task<ProviderResponse> GetCampaignRealAsync(
string campaignId, string requestId, CancellationToken ct)
{
var fields = new Dictionary<string, string>
{
["fields"] = "id,name,objective,status,effective_status,daily_budget,lifetime_budget,created_time,updated_time,spend_cap,special_ad_categories"
};
var result = await _graphClient.GetAsync(campaignId, fields, ct);
if (!result.IsSuccess)
return ProviderResponse.Fail(requestId, "META_API_ERROR",
result.ErrorMessage ?? "Failed to get campaign");
return ProviderResponse.Success(requestId, new
{
raw = result.Data,
emulated = false
});
}
private async Task<ProviderResponse> UpdateCampaignAsync(
ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<UpdateCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
if (_graphClient.IsRealApiEnabled)
return await UpdateCampaignRealAsync(payload, requestId, ct);
// Emulated
return ProviderResponse.Success(requestId, new
{
campaignId = payload.CampaignId,
updated = true,
name = payload.Name,
status = payload.Status?.ToString()?.ToUpper() ?? "PAUSED",
emulated = true
});
}
private async Task<ProviderResponse> UpdateCampaignRealAsync(
UpdateCampaignPayload payload, string requestId, CancellationToken ct)
{
var formData = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(payload.Name))
formData["name"] = payload.Name;
if (payload.Status.HasValue)
formData["status"] = MapStatusToApi(payload.Status.Value);
if (payload.SpendCapCents.HasValue)
formData["spend_cap"] = payload.SpendCapCents.Value.ToString();
if (formData.Count == 0)
return ProviderResponse.Fail(requestId, "VALIDATION", "No fields to update");
var result = await _graphClient.PostAsync(payload.CampaignId, formData, ct);
if (!result.IsSuccess)
return ProviderResponse.Fail(requestId, "META_API_ERROR",
result.ErrorMessage ?? "Failed to update campaign");
return ProviderResponse.Success(requestId, new
{
campaignId = payload.CampaignId,
updated = true,
emulated = false
});
}
private async Task<ProviderResponse> ListCampaignsAsync(
ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<ListCampaignsPayload>();
if (_graphClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdAccountId))
return await ListCampaignsRealAsync(payload, context, requestId, ct);
// Emulated
var campaigns = Enumerable.Range(1, 3).Select(i => new
{
id = GenerateId().ToString(),
name = $"Emulated Campaign {i}",
objective = "OUTCOME_TRAFFIC",
status = i == 1 ? "ACTIVE" : "PAUSED",
effectiveStatus = i == 1 ? "ACTIVE" : "PAUSED",
dailyBudget = (5000 * i).ToString(),
createdTime = DateTimeOffset.UtcNow.AddDays(-i * 7).ToString("o")
});
return ProviderResponse.Success(requestId, new
{
campaigns,
adAccountId = context.AdAccountId,
emulated = true
});
}
private async Task<ProviderResponse> ListCampaignsRealAsync(
ListCampaignsPayload payload, MetaApiContext context, string requestId, CancellationToken ct)
{
var queryParams = new Dictionary<string, string>
{
["fields"] = "id,name,objective,status,effective_status,daily_budget,lifetime_budget,created_time,updated_time",
["limit"] = payload.Limit.ToString()
};
if (payload.StatusFilter.HasValue)
{
var apiStatus = MapStatusToApi(payload.StatusFilter.Value);
queryParams["filtering"] = $"[{{\"field\":\"effective_status\",\"operator\":\"IN\",\"value\":[\"{apiStatus}\"]}}]";
}
if (!string.IsNullOrWhiteSpace(payload.After))
queryParams["after"] = payload.After;
var result = await _graphClient.GetAsync($"{context.AdAccountId}/campaigns", queryParams, ct);
if (!result.IsSuccess)
return ProviderResponse.Fail(requestId, "META_API_ERROR",
result.ErrorMessage ?? "Failed to list campaigns");
return ProviderResponse.Success(requestId, new
{
raw = result.Data,
emulated = false
});
}
// ================================================================
// Insights (Reporting) - emulated only for now
// ================================================================
private ProviderResponse GetCampaignInsights(ProviderRequest request, string requestId)
{
var payload = request.GetPayload<CampaignInsightsPayload>();
var rng = new Random();
var days = Enumerable.Range(0, 7).Select(i =>
{
var date = DateTime.UtcNow.Date.AddDays(-i);
var impressions = rng.Next(1000, 50000);
var clicks = rng.Next(50, impressions / 10);
var spend = Math.Round(clicks * (rng.NextDouble() * 2 + 0.5), 2);
return new
{
dateStart = date.ToString("yyyy-MM-dd"),
dateStop = date.ToString("yyyy-MM-dd"),
impressions = impressions.ToString(),
clicks = clicks.ToString(),
spend = spend.ToString("F2"),
ctr = (clicks * 100.0 / impressions).ToString("F2"),
cpc = (spend / clicks).ToString("F2"),
cpm = (spend / impressions * 1000).ToString("F2")
};
}).Reverse();
return ProviderResponse.Success(requestId, new
{
campaignId = payload.CampaignId,
insights = days,
emulated = true
});
}
private ProviderResponse GetAccountInsights(ProviderRequest request, string requestId)
{
var payload = request.GetPayload<AccountInsightsPayload>();
return ProviderResponse.Success(requestId, new
{
totalSpend = "12450.00",
totalImpressions = "845230",
totalClicks = "23456",
totalConversions = "567",
ctr = "2.78",
cpc = "0.53",
dateRange = new { start = DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd"), end = DateTime.UtcNow.ToString("yyyy-MM-dd") },
emulated = true
});
}
// ================================================================
// Account Management
// ================================================================
private async Task<ProviderResponse> CreateAdAccountAsync(
ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<CreateAdAccountPayload>();
if (string.IsNullOrWhiteSpace(payload.Name))
return ProviderResponse.Fail(requestId, "VALIDATION", "Account name is required");
if (string.IsNullOrWhiteSpace(context.BusinessManagerId))
return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessManagerId is required");
if (_graphClient.IsRealApiEnabled)
return await CreateAdAccountRealAsync(payload, context, requestId, ct);
// Emulated
return ProviderResponse.Success(requestId, new
{
adAccountId = $"act_{GenerateId()}",
name = payload.Name,
currency = payload.Currency,
businessManagerId = context.BusinessManagerId,
status = 1, // Meta: 1=Active, 2=Disabled, 3=Unsettled, 7=Pending Review, etc.
emulated = true
});
}
private async Task<ProviderResponse> CreateAdAccountRealAsync(
CreateAdAccountPayload payload, MetaApiContext context, string requestId, CancellationToken ct)
{
// POST /{business-id}/adaccount
var formData = new Dictionary<string, string>
{
["name"] = payload.Name,
["currency"] = payload.Currency,
["timezone_id"] = payload.TimezoneId.ToString(),
["end_advertiser"] = payload.EndAdvertiser ?? _config.BusinessManagerId,
["media_agency"] = payload.MediaAgency ?? _config.BusinessManagerId,
["partner"] = payload.Partner ?? _config.BusinessManagerId
};
var result = await _graphClient.PostAsync($"{context.BusinessManagerId}/adaccount", formData, ct);
if (!result.IsSuccess)
return ProviderResponse.Fail(requestId, "META_API_ERROR",
result.ErrorMessage ?? "Failed to create ad account",
new { metaErrorCode = result.ErrorCode, metaErrorSubcode = result.ErrorSubcode });
var accountId = result.Data?.TryGetProperty("id", out var idProp) == true
? idProp.GetString() : null;
return ProviderResponse.Success(requestId, new
{
adAccountId = accountId,
name = payload.Name,
currency = payload.Currency,
businessManagerId = context.BusinessManagerId,
emulated = false
});
}
private async Task<ProviderResponse> ListAdAccountsAsync(
MetaApiContext context, string requestId, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(context.BusinessManagerId))
return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessManagerId is required");
if (_graphClient.IsRealApiEnabled)
{
var queryParams = new Dictionary<string, string>
{
["fields"] = "id,name,account_status,currency,timezone_name,amount_spent,balance",
["limit"] = "100"
};
var result = await _graphClient.GetAsync($"{context.BusinessManagerId}/owned_ad_accounts", queryParams, ct);
if (!result.IsSuccess)
return ProviderResponse.Fail(requestId, "META_API_ERROR",
result.ErrorMessage ?? "Failed to list ad accounts");
return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false });
}
// Emulated
var accounts = Enumerable.Range(1, 3).Select(i => new
{
id = $"act_{GenerateId()}",
name = $"Client Account {i}",
accountStatus = 1,
currency = "USD",
timezoneName = "America/Los_Angeles",
amountSpent = (i * 4500).ToString(),
balance = "0"
});
return ProviderResponse.Success(requestId, new
{
accounts,
businessManagerId = context.BusinessManagerId,
emulated = true
});
}
// ================================================================
// Helpers
// ================================================================
/// <summary>
/// Ensure ad account ID has "act_" prefix (Meta requirement).
/// </summary>
private static string NormalizeAdAccountId(string id)
{
if (string.IsNullOrWhiteSpace(id)) return string.Empty;
return id.StartsWith("act_", StringComparison.OrdinalIgnoreCase) ? id : $"act_{id}";
}
/// <summary>
/// Map platform objective enum to Meta API string.
/// Uses ODAX (Outcome-Driven Ad Experiences) objective names as of v18.0+.
/// </summary>
private static string MapObjectiveToApi(MetaObjective objective) => objective switch
{
MetaObjective.Awareness => "OUTCOME_AWARENESS",
MetaObjective.Traffic => "OUTCOME_TRAFFIC",
MetaObjective.Engagement => "OUTCOME_ENGAGEMENT",
MetaObjective.Leads => "OUTCOME_LEADS",
MetaObjective.AppPromotion => "OUTCOME_APP_PROMOTION",
MetaObjective.Conversions => "OUTCOME_SALES",
_ => "OUTCOME_TRAFFIC"
};
/// <summary>
/// Map platform status enum to Meta API string.
/// </summary>
private static string MapStatusToApi(MetaCampaignStatus status) => status switch
{
MetaCampaignStatus.Active => "ACTIVE",
MetaCampaignStatus.Paused => "PAUSED",
MetaCampaignStatus.Deleted => "DELETED",
MetaCampaignStatus.Archived => "ARCHIVED",
_ => "PAUSED"
};
private static long GenerateId() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000 + Random.Shared.Next(999);
}