Initial import into Gitea
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user