Initial import into Gitea
This commit is contained in:
90
MetaApi/Configuration/MetaConfig.cs
Normal file
90
MetaApi/Configuration/MetaConfig.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
namespace MetaApi.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for Meta Marketing API integration.
|
||||
/// Bind to the "Meta" section in appsettings.json or environment variables.
|
||||
///
|
||||
/// Meta uses System User tokens for server-to-server auth (no interactive OAuth flow).
|
||||
/// These tokens don't expire if the System User remains active in Business Manager.
|
||||
/// </summary>
|
||||
public sealed class MetaConfig
|
||||
{
|
||||
public const string SectionName = "Meta";
|
||||
|
||||
/// <summary>
|
||||
/// Enable/disable real API calls. When false, the provider returns emulated responses.
|
||||
/// Override via: Meta__EnableRealApi=true
|
||||
/// </summary>
|
||||
public bool EnableRealApi { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Graph API version (e.g. "v21.0").
|
||||
/// Meta requires explicit versioning in all API URLs.
|
||||
/// </summary>
|
||||
public string ApiVersion { get; set; } = "v21.0";
|
||||
|
||||
/// <summary>
|
||||
/// Meta App ID from the Developer Portal.
|
||||
/// </summary>
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Meta App Secret from the Developer Portal.
|
||||
/// Store in Key Vault; inject via environment variable in prod.
|
||||
/// </summary>
|
||||
public string AppSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// System User Access Token for server-to-server API calls.
|
||||
/// Generated in Business Manager → System Users → Generate Token.
|
||||
/// Unlike OAuth tokens, these don't expire unless revoked.
|
||||
/// Store in Key Vault; inject via environment variable in prod.
|
||||
/// </summary>
|
||||
public string SystemUserToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// USIM's Business Manager ID.
|
||||
/// All client ad accounts are created under this BM.
|
||||
/// Format: numeric string (e.g. "123456789012345")
|
||||
/// </summary>
|
||||
public string BusinessManagerId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Default ad account ID for testing/sandbox.
|
||||
/// Format: act_XXXXXXXXXXXXXXX (with "act_" prefix)
|
||||
/// </summary>
|
||||
public string? DefaultAdAccountId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout in seconds for Graph API calls.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Graph API base URL. Override for sandbox/testing if needed.
|
||||
/// </summary>
|
||||
public string GraphApiBaseUrl { get; set; } = "https://graph.facebook.com";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-request Meta API context, populated from request and/or database.
|
||||
/// </summary>
|
||||
public sealed class MetaApiContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Target Meta ad account ID for this request.
|
||||
/// Format: act_XXXXXXXXXXXXXXX (with "act_" prefix)
|
||||
/// </summary>
|
||||
public string AdAccountId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Business Manager ID that owns this ad account.
|
||||
/// </summary>
|
||||
public string? BusinessManagerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override access token for a specific account.
|
||||
/// If null, the platform System User token from config is used.
|
||||
/// </summary>
|
||||
public string? AccessToken { get; set; }
|
||||
}
|
||||
83
MetaApi/Controllers/InternalController.cs
Normal file
83
MetaApi/Controllers/InternalController.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MetaApi.Models;
|
||||
using MetaApi.Security;
|
||||
using MetaApi.Services;
|
||||
|
||||
namespace MetaApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Internal API endpoint called by Gateway.
|
||||
/// Protected by X-Internal-Key header validation.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal")]
|
||||
public sealed class InternalController : ControllerBase
|
||||
{
|
||||
private readonly MetaMarketingService _metaAds;
|
||||
private readonly ILogger<InternalController> _logger;
|
||||
|
||||
public InternalController(MetaMarketingService metaAds, ILogger<InternalController> logger)
|
||||
{
|
||||
_metaAds = metaAds;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check - no auth required.
|
||||
/// </summary>
|
||||
[HttpGet("health")]
|
||||
public IActionResult Health()
|
||||
{
|
||||
_logger.LogDebug("[InternalController] Health check");
|
||||
return Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "MetaApi",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main execution endpoint - Gateway calls this.
|
||||
/// Protected by InternalAuthFilter.
|
||||
/// </summary>
|
||||
[ServiceFilter(typeof(InternalAuthFilter))]
|
||||
[HttpPost("execute")]
|
||||
public async Task<IActionResult> Execute([FromBody] ProviderRequest request, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[InternalController] Execute called | Operation={Operation} RequestId={RequestId}",
|
||||
request?.Operation, request?.RequestId);
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return BadRequest(ProviderResponse.Fail(null, "VALIDATION", "Request body is required"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Operation))
|
||||
{
|
||||
return BadRequest(ProviderResponse.Fail(request.RequestId, "VALIDATION", "Operation is required"));
|
||||
}
|
||||
|
||||
var result = await _metaAds.ExecuteAsync(request, ct);
|
||||
|
||||
if (result.Ok)
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
var statusCode = result.Error?.Code switch
|
||||
{
|
||||
"VALIDATION" => 400,
|
||||
"NOT_FOUND" => 404,
|
||||
"UNAUTHORIZED" => 401,
|
||||
"FORBIDDEN" => 403,
|
||||
"RATE_LIMITED" => 429,
|
||||
_ => 400
|
||||
};
|
||||
|
||||
return StatusCode(statusCode, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
179
MetaApi/GATEWAY_INTEGRATION.md
Normal file
179
MetaApi/GATEWAY_INTEGRATION.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Gateway Integration: Meta & TikTok Provider Routing
|
||||
|
||||
## Overview
|
||||
|
||||
The Gateway's `ExecutionService` already routes `provider="google"` to the GoogleApi container.
|
||||
Adding Meta and TikTok follows the same pattern.
|
||||
|
||||
---
|
||||
|
||||
## 1. Gateway ExecutionService Changes
|
||||
|
||||
### GetProviderUrl() — add meta + tiktok routing
|
||||
|
||||
```csharp
|
||||
private string GetProviderUrl(string provider)
|
||||
{
|
||||
return provider.ToLower() switch
|
||||
{
|
||||
"google" => Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL")
|
||||
?? _config["Providers:Google:Url"]
|
||||
?? "http://localhost:5200",
|
||||
|
||||
"meta" => Environment.GetEnvironmentVariable("META_PROVIDER_URL")
|
||||
?? _config["Providers:Meta:Url"]
|
||||
?? "http://localhost:5300",
|
||||
|
||||
"tiktok" => Environment.GetEnvironmentVariable("TIKTOK_PROVIDER_URL")
|
||||
?? _config["Providers:TikTok:Url"]
|
||||
?? "http://localhost:5400",
|
||||
|
||||
_ => throw new ArgumentException($"Unknown provider: {provider}")
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### GetProviderKey() — add meta + tiktok internal keys
|
||||
|
||||
```csharp
|
||||
private string GetProviderKey(string provider)
|
||||
{
|
||||
return provider.ToLower() switch
|
||||
{
|
||||
"google" => Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY")
|
||||
?? _config["Providers:Google:InternalKey"] ?? "",
|
||||
|
||||
"meta" => Environment.GetEnvironmentVariable("META_INTERNAL_KEY")
|
||||
?? _config["Providers:Meta:InternalKey"] ?? "",
|
||||
|
||||
"tiktok" => Environment.GetEnvironmentVariable("TIKTOK_INTERNAL_KEY")
|
||||
?? _config["Providers:TikTok:InternalKey"] ?? "",
|
||||
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Gateway Environment Variables (Azure Container Apps)
|
||||
|
||||
Add to the Gateway container's env vars:
|
||||
|
||||
```bash
|
||||
# Meta
|
||||
META_PROVIDER_URL=https://usim-adp-metaapi.internal.<env>.azurecontainerapps.io
|
||||
META_INTERNAL_KEY=<shared-secret>
|
||||
|
||||
# TikTok
|
||||
TIKTOK_PROVIDER_URL=https://usim-adp-tiktokapi.internal.<env>.azurecontainerapps.io
|
||||
TIKTOK_INTERNAL_KEY=<shared-secret>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Gateway appsettings.json — MultiChannel StatusMappings
|
||||
|
||||
The Gateway already has a MultiChannel config section for status mapping.
|
||||
Add/verify meta and tiktok entries:
|
||||
|
||||
```json
|
||||
{
|
||||
"MultiChannel": {
|
||||
"google": {
|
||||
"StatusMappings": {
|
||||
"ENABLED": "active",
|
||||
"PAUSED": "paused",
|
||||
"REMOVED": "cancelled"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"StatusMappings": {
|
||||
"ACTIVE": "active",
|
||||
"PAUSED": "paused",
|
||||
"DELETED": "cancelled",
|
||||
"ARCHIVED": "archived"
|
||||
}
|
||||
},
|
||||
"tiktok": {
|
||||
"StatusMappings": {
|
||||
"ENABLE": "active",
|
||||
"DISABLE": "paused",
|
||||
"DELETE": "cancelled"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Account Validation (Optional — implement when ready)
|
||||
|
||||
Currently `ValidateGoogleAccountAsync` checks Google-specific account setup.
|
||||
When ready, add equivalent methods:
|
||||
|
||||
```csharp
|
||||
// In ExecutionService or a dedicated AccountValidationService:
|
||||
private async Task ValidateMetaAccountAsync(string adAccountId) { ... }
|
||||
private async Task ValidateTikTokAccountAsync(string advertiserId) { ... }
|
||||
```
|
||||
|
||||
These would verify the external account ID exists and is accessible
|
||||
before forwarding operations to the provider containers.
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Views & Stored Procedures
|
||||
|
||||
### Meta
|
||||
```sql
|
||||
-- vwMetaAccount: mirrors vwGoogleAccount for accNetwork='meta'
|
||||
CREATE VIEW vwMetaAccount AS
|
||||
SELECT accId, accClientId, accExternalAccountId, accLoginAccountId, ...
|
||||
FROM tbAdAccount WHERE accNetwork = 'meta';
|
||||
|
||||
-- spMetaAccount: account linking/validation
|
||||
-- spMetaCredential: token storage (System User token doesn't expire, but store for reference)
|
||||
```
|
||||
|
||||
### TikTok
|
||||
```sql
|
||||
-- vwTikTokAccount: mirrors vwGoogleAccount for accNetwork='tiktok'
|
||||
CREATE VIEW vwTikTokAccount AS
|
||||
SELECT accId, accClientId, accExternalAccountId, accLoginAccountId, ...
|
||||
FROM tbAdAccount WHERE accNetwork = 'tiktok';
|
||||
|
||||
-- spTikTokAccount: account linking/validation
|
||||
-- spTikTokCredential: access token storage (doesn't expire unless revoked)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Port Map (All Services)
|
||||
|
||||
| Service | Port | Container Name |
|
||||
|---------|------|----------------|
|
||||
| Gateway | 5000 | usim-adp-gateway |
|
||||
| Creative | 5100 | usim-adp-creative |
|
||||
| GoogleApi | 5200 | usim-adp-googleapi |
|
||||
| MetaApi | 5300 | usim-adp-metaapi |
|
||||
| TikTokApi | 5400 | usim-adp-tiktokapi |
|
||||
| Management | 5500 | usim-adp-management |
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Checklist
|
||||
|
||||
For each new provider (meta, tiktok):
|
||||
|
||||
- [ ] Container builds and starts locally
|
||||
- [ ] `GET /` returns service info
|
||||
- [ ] `GET /internal/health` returns ok
|
||||
- [ ] `POST /internal/execute` with Ping operation works (no auth needed for Ping)
|
||||
- [ ] `POST /internal/execute` rejects without X-Internal-Key
|
||||
- [ ] `POST /internal/execute` with CreateCampaign returns emulated response
|
||||
- [ ] Gateway routes `provider="meta"` / `provider="tiktok"` correctly
|
||||
- [ ] Gateway passes X-Internal-Key header
|
||||
- [ ] End-to-end: client app → Gateway → provider container → emulated response
|
||||
- [ ] Swagger UI accessible at `/swagger`
|
||||
23
MetaApi/MetaApi.csproj
Normal file
23
MetaApi/MetaApi.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<!-- Container Settings -->
|
||||
<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>
|
||||
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
|
||||
<ContainerRepository>metaapi</ContainerRepository>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<!-- No Meta SDK needed - all calls via HttpClient to graph.facebook.com -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ContainerPort Include="8080" Type="tcp" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
MetaApi/MetaApi.http
Normal file
6
MetaApi/MetaApi.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@MetaApi_HostAddress = http://localhost:5064
|
||||
|
||||
GET {{MetaApi_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
205
MetaApi/Models/OperationPayloads.cs
Normal file
205
MetaApi/Models/OperationPayloads.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MetaApi.Models;
|
||||
|
||||
#region Campaign Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Create a Meta campaign.
|
||||
/// Meta hierarchy: Campaign → Ad Set → Ad
|
||||
/// With Advantage+ (v25.0+), this uses the unified campaign API.
|
||||
/// </summary>
|
||||
public sealed class CreateCampaignPayload
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public MetaObjective Objective { get; set; } = MetaObjective.Conversions;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign spending limit in account currency (in cents).
|
||||
/// Null = no campaign spending limit.
|
||||
/// </summary>
|
||||
public long? SpendCapCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Special ad categories required by Meta for housing, employment, credit, etc.
|
||||
/// Empty = none (most campaigns).
|
||||
/// </summary>
|
||||
public List<string> SpecialAdCategories { get; set; } = new();
|
||||
|
||||
public MetaCampaignStatus Status { get; set; } = MetaCampaignStatus.Paused;
|
||||
}
|
||||
|
||||
public sealed class GetCampaignPayload
|
||||
{
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateCampaignPayload
|
||||
{
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public MetaCampaignStatus? Status { get; set; }
|
||||
public long? SpendCapCents { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ListCampaignsPayload
|
||||
{
|
||||
public MetaCampaignStatus? StatusFilter { get; set; }
|
||||
public int Limit { get; set; } = 50;
|
||||
public string? After { get; set; } // Cursor-based pagination
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ad Set Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Create a Meta Ad Set (equivalent to Google Ad Group).
|
||||
/// Ad Sets define targeting, budget, schedule, and bid strategy.
|
||||
/// </summary>
|
||||
public sealed class CreateAdSetPayload
|
||||
{
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Daily budget in account currency cents.</summary>
|
||||
public long DailyBudgetCents { get; set; }
|
||||
|
||||
/// <summary>Lifetime budget in cents (alternative to daily). Null = use daily.</summary>
|
||||
public long? LifetimeBudgetCents { get; set; }
|
||||
|
||||
public string? StartTime { get; set; } // ISO 8601
|
||||
public string? EndTime { get; set; } // ISO 8601
|
||||
|
||||
public MetaBillingEvent BillingEvent { get; set; } = MetaBillingEvent.Impressions;
|
||||
public MetaOptimizationGoal OptimizationGoal { get; set; } = MetaOptimizationGoal.LinkClicks;
|
||||
|
||||
/// <summary>Target CPA bid amount in cents (for CPA bidding).</summary>
|
||||
public long? BidAmountCents { get; set; }
|
||||
|
||||
public MetaCampaignStatus Status { get; set; } = MetaCampaignStatus.Paused;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ad Account Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Create a new ad account under USIM's Business Manager.
|
||||
/// Requires Advanced Access to business_management permission.
|
||||
/// </summary>
|
||||
public sealed class CreateAdAccountPayload
|
||||
{
|
||||
/// <summary>Display name for the new ad account.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Currency code (e.g. "USD"). ISO 4217.</summary>
|
||||
public string Currency { get; set; } = "USD";
|
||||
|
||||
/// <summary>Timezone (IANA format, e.g. "America/Los_Angeles"). Numeric TZ ID also accepted.</summary>
|
||||
public int TimezoneId { get; set; } = 1; // Default: America/Los_Angeles
|
||||
|
||||
/// <summary>End advertiser name/business for the account.</summary>
|
||||
public string? EndAdvertiser { get; set; }
|
||||
|
||||
/// <summary>Media agency managing the account.</summary>
|
||||
public string? MediaAgency { get; set; }
|
||||
|
||||
/// <summary>Partner (USIM).</summary>
|
||||
public string? Partner { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Insights Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve campaign performance metrics (Meta Insights API).
|
||||
/// </summary>
|
||||
public sealed class CampaignInsightsPayload
|
||||
{
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
public string? DatePreset { get; set; } // e.g., "last_7d", "last_30d", "this_month"
|
||||
public string? StartDate { get; set; } // YYYY-MM-DD (time_range)
|
||||
public string? EndDate { get; set; } // YYYY-MM-DD (time_range)
|
||||
public string Level { get; set; } = "campaign"; // campaign, adset, ad
|
||||
}
|
||||
|
||||
public sealed class AccountInsightsPayload
|
||||
{
|
||||
public string? DatePreset { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enums
|
||||
|
||||
/// <summary>
|
||||
/// Meta campaign objectives.
|
||||
/// As of API v25.0, Meta is consolidating to Outcome-Driven Ad Experiences (ODAX).
|
||||
/// These map to the Advantage+ unified campaign objectives.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum MetaObjective
|
||||
{
|
||||
/// <summary>OUTCOME_AWARENESS - Brand awareness and reach</summary>
|
||||
Awareness = 0,
|
||||
|
||||
/// <summary>OUTCOME_TRAFFIC - Drive traffic to a destination</summary>
|
||||
Traffic = 1,
|
||||
|
||||
/// <summary>OUTCOME_ENGAGEMENT - Get more engagement (likes, comments, shares)</summary>
|
||||
Engagement = 2,
|
||||
|
||||
/// <summary>OUTCOME_LEADS - Generate leads</summary>
|
||||
Leads = 3,
|
||||
|
||||
/// <summary>OUTCOME_APP_PROMOTION - Drive app installs</summary>
|
||||
AppPromotion = 4,
|
||||
|
||||
/// <summary>OUTCOME_SALES - Drive conversions/sales</summary>
|
||||
Conversions = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meta campaign/ad set status values.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum MetaCampaignStatus
|
||||
{
|
||||
Active = 0,
|
||||
Paused = 1,
|
||||
Deleted = 2,
|
||||
Archived = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meta billing events for ad sets.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum MetaBillingEvent
|
||||
{
|
||||
Impressions = 0,
|
||||
LinkClicks = 1,
|
||||
ThruPlay = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meta optimization goals for ad sets.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum MetaOptimizationGoal
|
||||
{
|
||||
None = 0,
|
||||
LinkClicks = 1,
|
||||
Impressions = 2,
|
||||
Reach = 3,
|
||||
LandingPageViews = 4,
|
||||
LeadGeneration = 5,
|
||||
Conversions = 6,
|
||||
Value = 7
|
||||
}
|
||||
|
||||
#endregion
|
||||
96
MetaApi/Models/ProviderModels.cs
Normal file
96
MetaApi/Models/ProviderModels.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MetaApi.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request from Gateway to MetaApi.
|
||||
/// Identical contract to GoogleApi.Models.ProviderRequest for Gateway compatibility.
|
||||
/// </summary>
|
||||
public sealed class ProviderRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Operation to execute (e.g., "Ping", "CreateCampaign", "GetCampaignInsights")
|
||||
/// </summary>
|
||||
public string Operation { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant/account ID - maps to Meta ad account ID (act_XXXXXXX).
|
||||
/// Populated by Gateway from tbAdAccount.accExternalAccountId where accNetwork='meta'.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Login customer ID - maps to Meta Business Manager ID.
|
||||
/// In Meta's agency model, the BM owns and manages client ad accounts.
|
||||
/// Populated by Gateway from tbAdAccount.accLoginAccountId.
|
||||
/// </summary>
|
||||
public string? LoginCustomerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for request tracing
|
||||
/// </summary>
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Operation-specific payload
|
||||
/// </summary>
|
||||
public JsonElement? Payload { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize payload to strongly-typed object
|
||||
/// </summary>
|
||||
public T GetPayload<T>() where T : new()
|
||||
{
|
||||
if (Payload == null || Payload.Value.ValueKind == JsonValueKind.Null || Payload.Value.ValueKind == JsonValueKind.Undefined)
|
||||
return new T();
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(Payload.Value.GetRawText(), JsonOptions.Default) ?? new T();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from MetaApi to Gateway.
|
||||
/// Identical contract to GoogleApi.Models.ProviderResponse.
|
||||
/// </summary>
|
||||
public sealed class ProviderResponse
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public string? RequestId { get; set; }
|
||||
public object? Data { get; set; }
|
||||
public ProviderError? Error { get; set; }
|
||||
|
||||
public static ProviderResponse Success(string? requestId, object? data = null)
|
||||
=> new() { Ok = true, RequestId = requestId, Data = data };
|
||||
|
||||
public static ProviderResponse Fail(string? requestId, string code, string message, object? detail = null)
|
||||
=> new()
|
||||
{
|
||||
Ok = false,
|
||||
RequestId = requestId,
|
||||
Error = new ProviderError { Code = code, Message = message, Detail = detail }
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class ProviderError
|
||||
{
|
||||
public string Code { get; set; } = "ERROR";
|
||||
public string Message { get; set; } = "Unknown error";
|
||||
public object? Detail { get; set; }
|
||||
}
|
||||
|
||||
internal static class JsonOptions
|
||||
{
|
||||
public static readonly JsonSerializerOptions Default = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
65
MetaApi/Program.cs
Normal file
65
MetaApi/Program.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using MetaApi.Configuration;
|
||||
using MetaApi.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Port binding (same pattern as GoogleApi)
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? "5300";
|
||||
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
|
||||
|
||||
// Configuration
|
||||
builder.Services.Configure<MetaConfig>(options =>
|
||||
{
|
||||
// Bind from appsettings Meta section
|
||||
builder.Configuration.GetSection("Meta").Bind(options);
|
||||
|
||||
// Environment variable overrides (Azure Container Apps pattern)
|
||||
options.AppId = Environment.GetEnvironmentVariable("Meta__AppId") ?? options.AppId;
|
||||
options.AppSecret = Environment.GetEnvironmentVariable("Meta__AppSecret") ?? options.AppSecret;
|
||||
options.SystemUserToken = Environment.GetEnvironmentVariable("Meta__SystemUserToken") ?? options.SystemUserToken;
|
||||
options.BusinessManagerId = Environment.GetEnvironmentVariable("Meta__BusinessManagerId") ?? options.BusinessManagerId;
|
||||
options.ApiVersion = Environment.GetEnvironmentVariable("Meta__ApiVersion") ?? options.ApiVersion;
|
||||
|
||||
var enableReal = Environment.GetEnvironmentVariable("Meta__EnableRealApi");
|
||||
if (bool.TryParse(enableReal, out var realApi))
|
||||
options.EnableRealApi = realApi;
|
||||
});
|
||||
|
||||
// HTTP client for Graph API
|
||||
builder.Services.AddHttpClient<MetaGraphClient>(client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Services
|
||||
builder.Services.AddScoped<MetaMarketingService>();
|
||||
builder.Services.AddScoped<MetaApi.Security.InternalAuthFilter>();
|
||||
|
||||
// Controllers + Swagger
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "MetaApi", Version = "v1" });
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Swagger (all environments - same as GoogleApi)
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
service = "MetaApi",
|
||||
status = "running",
|
||||
version = "1.0.0",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
app.Logger.LogInformation("MetaApi starting on port {Port}", port);
|
||||
|
||||
app.Run();
|
||||
52
MetaApi/Properties/launchSettings.json
Normal file
52
MetaApi/Properties/launchSettings.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:5064"
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://localhost:7162;http://localhost:5064"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Container (.NET SDK)": {
|
||||
"commandName": "SdkContainer",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_HTTPS_PORTS": "8081",
|
||||
"ASPNETCORE_HTTP_PORTS": "8080"
|
||||
},
|
||||
"publishAllPorts": true,
|
||||
"useSSL": true
|
||||
}
|
||||
},
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:42518",
|
||||
"sslPort": 44383
|
||||
}
|
||||
}
|
||||
}
|
||||
102
MetaApi/README.md
Normal file
102
MetaApi/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# MetaApi - Meta Marketing API Provider Service
|
||||
|
||||
Standalone microservice for Meta (Facebook/Instagram) advertising integration. Mirrors the GoogleApi architecture pattern — the Gateway routes `provider="meta"` requests here via internal HTTP.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Gateway ──(POST /internal/execute)──► MetaApi ──(Graph API)──► Meta Marketing API
|
||||
X-Internal-Key auth │
|
||||
├── Emulated mode (default)
|
||||
└── Real API mode (Meta__EnableRealApi=true)
|
||||
```
|
||||
|
||||
## Key Differences from GoogleApi
|
||||
|
||||
| Aspect | GoogleApi | MetaApi |
|
||||
|--------|-----------|---------|
|
||||
| Auth | OAuth refresh tokens | System User token (no expiry) |
|
||||
| SDK | Google.Ads.GoogleAds NuGet | HttpClient → graph.facebook.com |
|
||||
| Account format | Numeric customer ID | `act_XXXXXXXXXXXXXXX` |
|
||||
| Hierarchy | Campaign → Ad Group → Ad | Campaign → Ad Set → Ad |
|
||||
| API versioning | SDK version | URL path (`/v21.0/`) |
|
||||
|
||||
## Operations
|
||||
|
||||
| Operation | Description | Real API |
|
||||
|-----------|-------------|----------|
|
||||
| Ping / TestPing | Health check | N/A |
|
||||
| CreateCampaign | Create Meta campaign | ✅ |
|
||||
| GetCampaign | Retrieve campaign details | ✅ |
|
||||
| UpdateCampaign | Update name/status/budget | ✅ |
|
||||
| ListCampaigns | List campaigns with filters | ✅ |
|
||||
| GetCampaignInsights | Campaign performance metrics | Emulated only |
|
||||
| GetAccountInsights | Account-level metrics | Emulated only |
|
||||
| CreateAdAccount | Create ad account under BM | ✅ |
|
||||
| ListAdAccounts | List BM-owned ad accounts | ✅ |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `META_INTERNAL_KEY` | Yes | Gateway→MetaApi shared auth key |
|
||||
| `Meta__EnableRealApi` | No | `false` (default) = emulated responses |
|
||||
| `Meta__AppId` | For real API | Meta App ID from Developer Portal |
|
||||
| `Meta__AppSecret` | For real API | Meta App Secret |
|
||||
| `Meta__SystemUserToken` | For real API | System User token from Business Manager |
|
||||
| `Meta__BusinessManagerId` | For real API | USIM's Business Manager numeric ID |
|
||||
| `Meta__ApiVersion` | No | Graph API version (default: `v21.0`) |
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
dotnet run --project MetaApi
|
||||
# → http://localhost:5300
|
||||
# → Swagger: http://localhost:5300/swagger
|
||||
```
|
||||
|
||||
## Deploy to Azure Container Apps
|
||||
|
||||
```bash
|
||||
az containerapp create \
|
||||
--name usim-adp-metaapi \
|
||||
--resource-group USIM-AdPlatform \
|
||||
--environment usim-adp-env \
|
||||
--image <registry>/usim-adp-metaapi:latest \
|
||||
--target-port 5300 \
|
||||
--ingress internal \
|
||||
--env-vars \
|
||||
META_INTERNAL_KEY=secretref:meta-internal-key \
|
||||
Meta__EnableRealApi=false
|
||||
```
|
||||
|
||||
## Gateway Integration
|
||||
|
||||
Add to Gateway environment:
|
||||
```
|
||||
META_PROVIDER_URL=https://usim-adp-metaapi.internal.<env>.azurecontainerapps.io
|
||||
META_INTERNAL_KEY=<matching-key>
|
||||
```
|
||||
|
||||
Gateway's `ExecutionService.GetProviderUrl()` already routes `provider="meta"` to `META_PROVIDER_URL`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
MetaApi/
|
||||
├── Configuration/
|
||||
│ └── MetaConfig.cs # Config + MetaApiContext
|
||||
├── Controllers/
|
||||
│ └── InternalController.cs # /internal/execute + /internal/health
|
||||
├── Models/
|
||||
│ ├── OperationPayloads.cs # Meta-specific request payloads + enums
|
||||
│ └── ProviderModels.cs # ProviderRequest/Response (Gateway contract)
|
||||
├── Security/
|
||||
│ └── InternalAuthFilter.cs # X-Internal-Key validation
|
||||
├── Services/
|
||||
│ ├── MetaGraphClient.cs # HTTP wrapper for graph.facebook.com
|
||||
│ └── MetaMarketingService.cs # Operation dispatcher (emulated/real)
|
||||
├── Program.cs
|
||||
├── appsettings.json
|
||||
└── MetaApi.csproj
|
||||
```
|
||||
58
MetaApi/Security/InternalAuthFilter.cs
Normal file
58
MetaApi/Security/InternalAuthFilter.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace MetaApi.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the X-Internal-Key header for internal service-to-service calls.
|
||||
/// Gateway must provide the correct key to call MetaApi endpoints.
|
||||
/// </summary>
|
||||
public sealed class InternalAuthFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<InternalAuthFilter> _logger;
|
||||
|
||||
public InternalAuthFilter(IConfiguration config, ILogger<InternalAuthFilter> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var headerName = _config["InternalAuth:HeaderName"] ?? "X-Internal-Key";
|
||||
|
||||
// Try multiple sources for the key
|
||||
var expectedKey = _config["InternalAuth:Key"]
|
||||
?? _config["META_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("META_INTERNAL_KEY");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expectedKey))
|
||||
{
|
||||
_logger.LogError("[InternalAuth] No internal key configured - check META_INTERNAL_KEY env var");
|
||||
context.Result = new ObjectResult(new { error = "Internal auth key not configured" })
|
||||
{
|
||||
StatusCode = 500
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue(headerName, out var providedKey) ||
|
||||
string.IsNullOrWhiteSpace(providedKey))
|
||||
{
|
||||
_logger.LogWarning("[InternalAuth] Missing {HeaderName} header", headerName);
|
||||
context.Result = new UnauthorizedObjectResult(new { error = $"Missing {headerName} header" });
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!string.Equals(providedKey.ToString(), expectedKey, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("[InternalAuth] Invalid key provided");
|
||||
context.Result = new UnauthorizedObjectResult(new { error = "Invalid internal auth key" });
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_logger.LogDebug("[InternalAuth] Request authorized");
|
||||
return next();
|
||||
}
|
||||
}
|
||||
229
MetaApi/Services/MetaGraphClient.cs
Normal file
229
MetaApi/Services/MetaGraphClient.cs
Normal 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
|
||||
};
|
||||
}
|
||||
528
MetaApi/Services/MetaMarketingService.cs
Normal file
528
MetaApi/Services/MetaMarketingService.cs
Normal 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);
|
||||
}
|
||||
8
MetaApi/appsettings.Development.json
Normal file
8
MetaApi/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
MetaApi/appsettings.json
Normal file
19
MetaApi/appsettings.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Meta": {
|
||||
"AppId": "",
|
||||
"AppSecret": "",
|
||||
"SystemUserToken": "",
|
||||
"BusinessManagerId": "",
|
||||
"ApiVersion": "v21.0",
|
||||
"EnableRealApi": false,
|
||||
"GraphApiBaseUrl": "https://graph.facebook.com",
|
||||
"TimeoutSeconds": 30
|
||||
},
|
||||
"InternalKey": "dev-meta-internal-key-change-in-production"
|
||||
}
|
||||
Reference in New Issue
Block a user