Files
AdPlatform-Server/Gateway/Services/ProviderStatusNormalizer.cs
2026-03-14 13:50:09 -07:00

161 lines
6.3 KiB
C#

using Gateway.Models;
using Microsoft.Extensions.Options;
namespace Gateway.Services;
/// <summary>
/// Normalizes provider-specific campaign statuses into platform-standard statuses.
///
/// Each advertising channel (Google Ads, Meta, TikTok, etc.) reports campaign state
/// using its own vocabulary. This service translates those into the platform's
/// unified status set: draft, staged, pending, active, paused, completed, cancelled, error.
///
/// Mapping priority:
/// 1. Channel-specific mapping from config (e.g. google_ads → ENABLED → active)
/// 2. Common/internal mappings (e.g. submitted → active, pending_review → pending)
/// 3. Pass-through if the raw status is already a valid platform status
/// 4. "error" fallback with a warning log for truly unknown statuses
/// </summary>
public sealed class ProviderStatusNormalizer
{
private readonly MultiChannelConfig _config;
private readonly ILogger<ProviderStatusNormalizer> _log;
/// <summary>The canonical set of platform-level statuses.</summary>
private static readonly HashSet<string> PlatformStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"draft", "staged", "pending", "active", "paused", "completed", "cancelled", "error"
};
/// <summary>
/// Internal/transitional statuses used during launch orchestration.
/// These are not provider-specific but arise from the platform's own workflow.
/// </summary>
private static readonly Dictionary<string, string> CommonMappings = new(StringComparer.OrdinalIgnoreCase)
{
// Launch service assigns these during dispatch
["submitted"] = "active",
["pending_review"] = "pending",
["stub_provider"] = "pending",
// Webhook / callback transitional states
["approved"] = "active",
["rejected"] = "error",
["suspended"] = "paused",
["budget_depleted"] = "paused",
["expired"] = "completed",
["archived"] = "completed",
["deleted"] = "cancelled",
["in_process"] = "pending",
["in_review"] = "pending",
["learning"] = "active", // Meta "learning phase"
["limited"] = "active", // Google "limited by budget" etc.
};
public ProviderStatusNormalizer(
IOptions<MultiChannelConfig> config,
ILogger<ProviderStatusNormalizer> log)
{
_config = config.Value;
_log = log;
}
/// <summary>
/// Normalize a raw provider status into a platform status.
/// </summary>
/// <param name="channelType">Channel identifier (e.g. "google_ads", "meta", "tiktok").</param>
/// <param name="rawProviderStatus">The status string as returned by the provider.</param>
/// <returns>A valid platform status string.</returns>
public string Normalize(string? channelType, string? rawProviderStatus)
{
if (string.IsNullOrWhiteSpace(rawProviderStatus))
return "error";
var raw = rawProviderStatus.Trim();
// 1. Try channel-specific mapping from config
if (!string.IsNullOrWhiteSpace(channelType))
{
var provider = _config.GetChannel(channelType);
if (provider?.StatusMappings != null &&
provider.StatusMappings.TryGetValue(raw, out var mapped))
{
_log.LogDebug("[StatusNorm] {Channel}/{RawStatus} → {PlatformStatus} (config)",
channelType, raw, mapped);
return mapped;
}
}
// 2. Try common/internal mappings
if (CommonMappings.TryGetValue(raw, out var common))
{
_log.LogDebug("[StatusNorm] {RawStatus} → {PlatformStatus} (common)",
raw, common);
return common;
}
// 3. If the raw value is already a valid platform status, pass through
if (PlatformStatuses.Contains(raw))
{
_log.LogDebug("[StatusNorm] {RawStatus} → pass-through (already platform status)", raw);
return raw.ToLowerInvariant();
}
// 4. Unknown — log warning and return "error"
_log.LogWarning(
"[StatusNorm] Unknown provider status: channel={Channel}, raw={RawStatus}. Defaulting to 'error'. " +
"Add a mapping in MultiChannel.Channels[].StatusMappings or CommonMappings.",
channelType ?? "(none)", raw);
return "error";
}
/// <summary>
/// Resolve the platform status for a sync operation.
/// If an explicit platform status is provided, validate and use it.
/// Otherwise, normalize the provider status.
/// </summary>
/// <param name="channelType">Channel identifier.</param>
/// <param name="explicitStatus">Explicitly provided platform status (optional).</param>
/// <param name="rawProviderStatus">Raw provider status (optional).</param>
/// <returns>A valid platform status string.</returns>
public string Resolve(string? channelType, string? explicitStatus, string? rawProviderStatus)
{
// If an explicit platform status was given, validate it
if (!string.IsNullOrWhiteSpace(explicitStatus))
{
if (PlatformStatuses.Contains(explicitStatus))
return explicitStatus.ToLowerInvariant();
_log.LogWarning("[StatusNorm] Invalid explicit status '{Status}', normalizing as provider status instead.",
explicitStatus);
// Fall through to normalization
}
return Normalize(channelType, rawProviderStatus);
}
/// <summary>
/// Get all configured mappings for a channel (for diagnostics / admin display).
/// </summary>
public Dictionary<string, string> GetMappings(string channelType)
{
var result = new Dictionary<string, string>(CommonMappings, StringComparer.OrdinalIgnoreCase);
var provider = _config.GetChannel(channelType);
if (provider?.StatusMappings != null)
{
foreach (var kv in provider.StatusMappings)
result[kv.Key] = kv.Value; // Channel-specific overrides common
}
return result;
}
/// <summary>
/// Check whether a string is a valid platform status.
/// </summary>
public static bool IsValidPlatformStatus(string? status) =>
!string.IsNullOrWhiteSpace(status) && PlatformStatuses.Contains(status);
}