Initial import into Gitea
This commit is contained in:
553
Gateway/Services/InitiativeLaunchService.cs
Normal file
553
Gateway/Services/InitiativeLaunchService.cs
Normal file
@@ -0,0 +1,553 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates launching an initiative by dispatching each channel campaign
|
||||
/// to its provider service (GoogleApi, Meta, TikTok, etc.).
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Load initiative + channel campaigns from DB (single call, channels nested)
|
||||
/// 2. Validate initiative belongs to requesting client
|
||||
/// 3. For each channel in "pending" status:
|
||||
/// a. Resolve provider config (endpoint, stub status)
|
||||
/// b. If real provider → dispatch via ExecutionService
|
||||
/// c. If stub/unconfigured → simulate "pending_review"
|
||||
/// d. Sync result back to DB via spChannelCampaign
|
||||
/// 4. Update initiative status based on aggregate results
|
||||
/// </summary>
|
||||
public sealed class InitiativeLaunchService
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ExecutionService _execution;
|
||||
private readonly MultiChannelConfig _config;
|
||||
private readonly ProviderStatusNormalizer _statusNorm;
|
||||
private readonly IConfiguration _appConfig;
|
||||
private readonly ILogger<InitiativeLaunchService> _log;
|
||||
|
||||
public InitiativeLaunchService(
|
||||
SqlService sql,
|
||||
ExecutionService execution,
|
||||
IOptions<MultiChannelConfig> config,
|
||||
ProviderStatusNormalizer statusNorm,
|
||||
IConfiguration appConfig,
|
||||
ILogger<InitiativeLaunchService> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_execution = execution;
|
||||
_config = config.Value;
|
||||
_statusNorm = statusNorm;
|
||||
_appConfig = appConfig;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launch all pending channel campaigns for an initiative.
|
||||
/// Returns a per-channel result summary.
|
||||
/// </summary>
|
||||
public async Task<LaunchResult> LaunchAsync(
|
||||
long initiativeId,
|
||||
string clientId,
|
||||
string? userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_log.LogInformation("[Launch] Starting initiative {InitiativeId} for client {ClientId}",
|
||||
initiativeId, clientId);
|
||||
|
||||
var result = new LaunchResult { InitiativeId = initiativeId };
|
||||
|
||||
// 1. Get initiative + nested channels in a single call
|
||||
// Pass clientId for ownership validation
|
||||
var initResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Initiative, "get",
|
||||
JsonSerializer.Serialize(new { initiativeId, clientId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(initResp))
|
||||
{
|
||||
result.Error = "Initiative not found";
|
||||
return result;
|
||||
}
|
||||
|
||||
using var initDoc = JsonDocument.Parse(initResp);
|
||||
var initRoot = initDoc.RootElement;
|
||||
|
||||
if (initRoot.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
result.Error = initRoot.TryGetProperty("error", out var errProp)
|
||||
? errProp.GetString() ?? "Initiative not found"
|
||||
: "Initiative not found";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract initiative fields we need for dispatch (check both clean and prefixed shapes)
|
||||
var initiative = initRoot.TryGetProperty("initiative", out var initEl) ? initEl : initRoot;
|
||||
var initiativeName = TryStr(initiative, "name", "iniName") ?? "Campaign";
|
||||
var objective = TryStr(initiative, "objective", "iniObjective") ?? "traffic";
|
||||
var totalBudget = TryDec(initiative, "totalBudget", "iniBudget");
|
||||
var budgetPeriod = TryStr(initiative, "budgetPeriod", "iniBudgetPeriod") ?? "monthly";
|
||||
var businessCategory = TryStr(initiative, "businessCategory", "iniBusinessCategory");
|
||||
|
||||
// 2. Extract channels from the initiative response (already nested by spInitiative 'get')
|
||||
// No separate DB call needed — channels come back with clean field names
|
||||
JsonElement campaignsArray;
|
||||
if (initiative.TryGetProperty("channels", out var channelsEl) && channelsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
campaignsArray = channelsEl;
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.LogWarning("[Launch] No channels array in initiative response. Keys: {Keys}",
|
||||
string.Join(", ", EnumerateKeys(initiative)));
|
||||
result.Error = "No channel campaigns found for this initiative";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (campaignsArray.GetArrayLength() == 0)
|
||||
{
|
||||
result.Error = "Initiative has no channel campaigns to launch";
|
||||
return result;
|
||||
}
|
||||
|
||||
_log.LogInformation("[Launch] Found {Count} channel campaigns for initiative {InitiativeId}",
|
||||
campaignsArray.GetArrayLength(), initiativeId);
|
||||
|
||||
// 3. Dispatch each channel campaign
|
||||
foreach (var camp in campaignsArray.EnumerateArray())
|
||||
{
|
||||
var channelResult = new ChannelLaunchResult();
|
||||
|
||||
// Fields come back with clean names from spInitiative 'get':
|
||||
// channelCampaignId, channelType, allocatedBudget, allocationPct,
|
||||
// externalCampaignId, externalAccountId, providerPayload, status, providerStatus
|
||||
var ccId = TryLong(camp, "channelCampaignId", "chcId");
|
||||
var channelType = TryStr(camp, "channelType", "chcChannelType") ?? "unknown";
|
||||
var status = TryStr(camp, "status", "chcStatus") ?? "pending";
|
||||
var allocationPct = TryDec(camp, "allocationPct", "chcAllocationPct");
|
||||
if (allocationPct == 0m) allocationPct = 100m;
|
||||
|
||||
channelResult.ChannelCampaignId = ccId;
|
||||
channelResult.ChannelType = channelType ?? "unknown";
|
||||
|
||||
// Skip already-launched channels
|
||||
if (status != "pending" && status != "draft" && status != "staged")
|
||||
{
|
||||
channelResult.Status = status ?? "unknown";
|
||||
channelResult.Message = "Already dispatched";
|
||||
channelResult.Skipped = true;
|
||||
result.Channels.Add(channelResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate this channel's budget
|
||||
var channelBudget = totalBudget * allocationPct / 100m;
|
||||
|
||||
// Look up provider config
|
||||
var providerConfig = _config.GetChannel(channelType ?? "");
|
||||
|
||||
if (providerConfig == null || !providerConfig.Enabled)
|
||||
{
|
||||
channelResult.Status = "error";
|
||||
channelResult.Message = $"Channel '{channelType}' is not enabled";
|
||||
result.Channels.Add(channelResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Determine if a real provider is available:
|
||||
// Check both MultiChannel config Endpoint AND the PROVIDER_URL env var
|
||||
// that ExecutionService uses for routing.
|
||||
var hasRealProvider = !providerConfig.IsStub && IsProviderUrlConfigured(channelType!);
|
||||
|
||||
if (!hasRealProvider)
|
||||
{
|
||||
// Stub provider - simulate submission
|
||||
channelResult = await DispatchStubAsync(ccId, channelType!, providerConfig, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Real provider - dispatch through ExecutionService
|
||||
channelResult = await DispatchRealAsync(
|
||||
ccId, channelType!, providerConfig,
|
||||
initiativeName, objective, channelBudget, budgetPeriod,
|
||||
businessCategory, clientId, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Launch] Dispatch failed for channel {Channel} on initiative {InitiativeId}",
|
||||
channelType, initiativeId);
|
||||
channelResult.Status = "error";
|
||||
channelResult.Message = $"Dispatch error: {ex.Message}";
|
||||
}
|
||||
|
||||
result.Channels.Add(channelResult);
|
||||
}
|
||||
|
||||
// 4. Update initiative status based on results
|
||||
var anySuccess = result.Channels.Any(c =>
|
||||
c.Status == "active" || c.Status == "pending" || c.Status == "submitted");
|
||||
var allFailed = result.Channels.All(c => c.Status == "error");
|
||||
|
||||
if (anySuccess)
|
||||
{
|
||||
await UpdateInitiativeStatus(initiativeId, "active", ct);
|
||||
result.InitiativeStatus = "active";
|
||||
}
|
||||
else if (allFailed)
|
||||
{
|
||||
result.InitiativeStatus = "error";
|
||||
result.Error = "All channel dispatches failed";
|
||||
}
|
||||
|
||||
result.Ok = anySuccess;
|
||||
|
||||
_log.LogInformation(
|
||||
"[Launch] Completed initiative {InitiativeId} | Channels={ChannelCount} Success={SuccessCount} Failed={FailCount}",
|
||||
initiativeId, result.Channels.Count,
|
||||
result.Channels.Count(c => c.Status != "error"),
|
||||
result.Channels.Count(c => c.Status == "error"));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch to a real provider service via ExecutionService.
|
||||
/// Builds a GoogleApi-compatible request with proper payload structure.
|
||||
/// </summary>
|
||||
private async Task<ChannelLaunchResult> DispatchRealAsync(
|
||||
long channelCampaignId,
|
||||
string channelType,
|
||||
ProviderConfig config,
|
||||
string? campaignName,
|
||||
string? objective,
|
||||
decimal budget,
|
||||
string? budgetPeriod,
|
||||
string? businessCategory,
|
||||
string clientId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = new ChannelLaunchResult
|
||||
{
|
||||
ChannelCampaignId = channelCampaignId,
|
||||
ChannelType = channelType,
|
||||
};
|
||||
|
||||
// Convert budget: initiative stores dollars, GoogleApi expects daily micros
|
||||
var dailyBudget = ConvertToDailyBudget(budget, budgetPeriod);
|
||||
var budgetMicros = (long)(dailyBudget * 1_000_000m);
|
||||
|
||||
// Map objective to campaign type
|
||||
var campaignType = MapObjectiveToCampaignType(objective);
|
||||
|
||||
// Build execution request with proper payload structure
|
||||
// ExecutionService.BuildProviderRequest copies the "payload" field through
|
||||
var providerName = MapChannelToProvider(channelType);
|
||||
var execRequest = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = providerName,
|
||||
service = "campaign",
|
||||
action = "create",
|
||||
operation = "CreateCampaign",
|
||||
payload = new
|
||||
{
|
||||
name = campaignName ?? "Campaign",
|
||||
type = campaignType,
|
||||
budgetMicros = budgetMicros,
|
||||
biddingStrategy = MapObjectiveToBiddingStrategy(objective),
|
||||
}
|
||||
});
|
||||
|
||||
_log.LogInformation(
|
||||
"[Launch] Dispatching {Channel} to provider {Provider} | Budget=${Budget}/mo → {DailyBudget}/day → {BudgetMicros} micros | Type={CampaignType}",
|
||||
channelType, providerName, budget, dailyBudget, budgetMicros, campaignType);
|
||||
|
||||
// Call ExecutionService (handles routing, auth, logging)
|
||||
var execDoc = JsonDocument.Parse(execRequest);
|
||||
var respJson = await _execution.ExecuteAsync(execDoc.RootElement, ct);
|
||||
|
||||
// Parse wrapped response:
|
||||
// { ok, status, result: { ok, data: { campaignResourceName, externalId, ... } } }
|
||||
using var respDoc = JsonDocument.Parse(respJson);
|
||||
var respRoot = respDoc.RootElement;
|
||||
|
||||
// Check wrapper ok
|
||||
var wrapperOk = respRoot.TryGetProperty("ok", out var wrapOkEl) && wrapOkEl.GetBoolean();
|
||||
|
||||
// Navigate into result.data for the actual response
|
||||
string? externalId = null;
|
||||
bool providerOk = false;
|
||||
|
||||
if (respRoot.TryGetProperty("result", out var resultEl))
|
||||
{
|
||||
providerOk = resultEl.TryGetProperty("ok", out var provOkEl) && provOkEl.GetBoolean();
|
||||
|
||||
if (resultEl.TryGetProperty("data", out var dataEl))
|
||||
{
|
||||
// Real API returns campaignResourceName
|
||||
if (dataEl.TryGetProperty("campaignResourceName", out var crnEl))
|
||||
externalId = crnEl.GetString();
|
||||
// Emulated returns externalId
|
||||
else if (dataEl.TryGetProperty("externalId", out var extEl))
|
||||
externalId = extEl.GetString();
|
||||
}
|
||||
|
||||
// Also check for flat error at result level
|
||||
if (!providerOk && resultEl.TryGetProperty("error", out var errEl))
|
||||
{
|
||||
var errorMsg = errEl.ValueKind == JsonValueKind.Object
|
||||
? (errEl.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : errEl.GetRawText())
|
||||
: errEl.GetString();
|
||||
result.Status = "error";
|
||||
result.Message = $"Provider error: {errorMsg}";
|
||||
await SyncChannelCampaign(channelCampaignId, null, "error", errorMsg, ct);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapperOk && providerOk)
|
||||
{
|
||||
var platformStatus = _statusNorm.Normalize(channelType, "submitted");
|
||||
result.Status = platformStatus;
|
||||
result.ExternalCampaignId = externalId;
|
||||
result.Message = $"Successfully dispatched to {config.DisplayName}";
|
||||
|
||||
_log.LogInformation(
|
||||
"[Launch] {Channel} dispatched successfully | ExternalId={ExternalId} PlatformStatus={Status}",
|
||||
channelType, externalId, platformStatus);
|
||||
|
||||
// Sync back to DB
|
||||
await SyncChannelCampaign(channelCampaignId, externalId, platformStatus, "submitted", ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = "Provider returned error";
|
||||
// Try to extract error message
|
||||
if (respRoot.TryGetProperty("result", out var resEl) &&
|
||||
resEl.TryGetProperty("error", out var errObj))
|
||||
{
|
||||
error = errObj.ValueKind == JsonValueKind.Object
|
||||
? (errObj.TryGetProperty("message", out var m) ? m.GetString() : errObj.GetRawText())
|
||||
: errObj.GetString();
|
||||
}
|
||||
|
||||
result.Status = "error";
|
||||
result.Message = $"Provider error: {error}";
|
||||
|
||||
_log.LogWarning("[Launch] {Channel} dispatch failed | Error={Error}", channelType, error);
|
||||
|
||||
await SyncChannelCampaign(channelCampaignId, null, "error", error, ct);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulate dispatch for stub/unconfigured providers.
|
||||
/// Marks the channel as "pending_review" since there's no real provider to call.
|
||||
/// </summary>
|
||||
private async Task<ChannelLaunchResult> DispatchStubAsync(
|
||||
long channelCampaignId,
|
||||
string channelType,
|
||||
ProviderConfig config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_log.LogInformation("[Launch] Stub dispatch for {Channel} (no real provider)", channelType);
|
||||
|
||||
// Simulate a short delay for realism
|
||||
await Task.Delay(100, ct);
|
||||
|
||||
var result = new ChannelLaunchResult
|
||||
{
|
||||
ChannelCampaignId = channelCampaignId,
|
||||
ChannelType = channelType,
|
||||
Status = _statusNorm.Normalize(channelType, "pending_review"),
|
||||
Message = $"{config.DisplayName} campaign queued for review (provider coming soon)",
|
||||
IsStub = true,
|
||||
};
|
||||
|
||||
// Sync to DB: chcStatus = normalized, chcProviderStatus = raw
|
||||
await SyncChannelCampaign(channelCampaignId, null, result.Status, "stub_provider", ct);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Update channel campaign status in DB.</summary>
|
||||
private async Task SyncChannelCampaign(
|
||||
long channelCampaignId,
|
||||
string? externalCampaignId,
|
||||
string status,
|
||||
string? providerStatus,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelCampaign, "sync",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
channelCampaignId,
|
||||
externalCampaignId = externalCampaignId,
|
||||
status = status,
|
||||
providerStatus = providerStatus,
|
||||
}), ct: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Launch] Failed to sync channel campaign {Id}", channelCampaignId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Update initiative status in DB.</summary>
|
||||
private async Task UpdateInitiativeStatus(long initiativeId, string status, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Initiative, "updateStatus",
|
||||
JsonSerializer.Serialize(new { initiativeId, status }), ct: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Launch] Failed to update initiative status {Id}", initiativeId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Map channel type to execution provider name.</summary>
|
||||
private static string MapChannelToProvider(string channelType)
|
||||
{
|
||||
return channelType switch
|
||||
{
|
||||
"google_ads" => "google",
|
||||
"meta" => "meta",
|
||||
"tiktok" => "tiktok",
|
||||
_ => channelType
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a real provider URL is configured for this channel type.
|
||||
/// Uses the same env var pattern as ExecutionService for routing.
|
||||
/// </summary>
|
||||
private bool IsProviderUrlConfigured(string channelType)
|
||||
{
|
||||
var envVarName = channelType switch
|
||||
{
|
||||
"google_ads" => "GOOGLE_PROVIDER_URL",
|
||||
"meta" => "META_PROVIDER_URL",
|
||||
"tiktok" => "TIKTOK_PROVIDER_URL",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (envVarName == null) return false;
|
||||
|
||||
var url = _appConfig[envVarName];
|
||||
return !string.IsNullOrWhiteSpace(url);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert initiative budget (dollars per period) to daily budget.
|
||||
/// Google Ads API operates on daily budgets.
|
||||
/// </summary>
|
||||
private static decimal ConvertToDailyBudget(decimal budget, string? budgetPeriod)
|
||||
{
|
||||
return (budgetPeriod?.ToLowerInvariant()) switch
|
||||
{
|
||||
"daily" => budget,
|
||||
"weekly" => budget / 7m,
|
||||
"monthly" => budget / 30.4m, // Google's standard month divisor
|
||||
_ => budget / 30.4m // Default to monthly
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map platform objective to Google Ads campaign type.
|
||||
/// This determines the advertising channel (Search, Display, etc.)
|
||||
/// </summary>
|
||||
private static string MapObjectiveToCampaignType(string? objective)
|
||||
{
|
||||
return (objective?.ToLowerInvariant()) switch
|
||||
{
|
||||
"awareness" => "Display", // Brand awareness → Display network
|
||||
"traffic" => "Search", // Website traffic → Search ads
|
||||
"leads" => "Search", // Lead generation → Search ads
|
||||
"conversions" => "Search", // Conversions → Search ads
|
||||
"sales" => "PerformanceMax", // Sales → Performance Max
|
||||
"engagement" => "Display", // Engagement → Display network
|
||||
_ => "Search" // Default to Search
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map platform objective to a bidding strategy.
|
||||
/// </summary>
|
||||
private static string MapObjectiveToBiddingStrategy(string? objective)
|
||||
{
|
||||
return (objective?.ToLowerInvariant()) switch
|
||||
{
|
||||
"awareness" => "MaximizeClicks", // Broad reach
|
||||
"traffic" => "MaximizeClicks", // Drive traffic
|
||||
"leads" => "MaximizeConversions", // Optimize for leads
|
||||
"conversions" => "MaximizeConversions", // Optimize for conversions
|
||||
"sales" => "MaximizeConversions", // Optimize for sales
|
||||
_ => "MaximizeClicks" // Safe default
|
||||
};
|
||||
}
|
||||
|
||||
// ── JSON field helpers (try both clean and prefixed names) ──
|
||||
|
||||
private static string? TryStr(JsonElement el, string key1, string key2)
|
||||
{
|
||||
if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.String) return p1.GetString();
|
||||
if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.String) return p2.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static decimal TryDec(JsonElement el, string key1, string key2)
|
||||
{
|
||||
if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.Number) return p1.GetDecimal();
|
||||
if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.Number) return p2.GetDecimal();
|
||||
return 0m;
|
||||
}
|
||||
|
||||
private static long TryLong(JsonElement el, string key1, string key2)
|
||||
{
|
||||
if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.Number) return p1.GetInt64();
|
||||
if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.Number) return p2.GetInt64();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateKeys(JsonElement el)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Object)
|
||||
foreach (var prop in el.EnumerateObject())
|
||||
yield return prop.Name;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Result DTOs
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
public sealed class LaunchResult
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public long InitiativeId { get; set; }
|
||||
public string? InitiativeStatus { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public List<ChannelLaunchResult> Channels { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ChannelLaunchResult
|
||||
{
|
||||
public long ChannelCampaignId { get; set; }
|
||||
public string ChannelType { get; set; } = "";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? Message { get; set; }
|
||||
public string? ExternalCampaignId { get; set; }
|
||||
public bool IsStub { get; set; }
|
||||
public bool Skipped { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user