Initial import into Gitea
This commit is contained in:
@@ -9,6 +9,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoogleApi", "GoogleApi\Goog
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Management", "Management\Management.csproj", "{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Creative", "Creative\Creative.csproj", "{6F7D9A25-A555-4355-8417-255908767870}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Registration", "Registration\Registration.csproj", "{F2855523-594F-4C86-A2E8-2CAF6E6FA175}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaApi", "MetaApi\MetaApi.csproj", "{92B2C2C6-7D47-48AD-A8BB-7EE02141B950}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TikTokApi", "TikTokApi\TikTokApi.csproj", "{90100339-E52D-4E6B-9F14-B034192508E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntelligenceAPI", "IntelligenceAPI\IntelligenceAPI.csproj", "{1971AA11-806A-4482-BFA5-8C9479E6EDF3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -27,6 +37,26 @@ Global
|
||||
{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6F7D9A25-A555-4355-8417-255908767870}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6F7D9A25-A555-4355-8417-255908767870}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6F7D9A25-A555-4355-8417-255908767870}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6F7D9A25-A555-4355-8417-255908767870}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F2855523-594F-4C86-A2E8-2CAF6E6FA175}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F2855523-594F-4C86-A2E8-2CAF6E6FA175}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F2855523-594F-4C86-A2E8-2CAF6E6FA175}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F2855523-594F-4C86-A2E8-2CAF6E6FA175}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{92B2C2C6-7D47-48AD-A8BB-7EE02141B950}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{92B2C2C6-7D47-48AD-A8BB-7EE02141B950}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{92B2C2C6-7D47-48AD-A8BB-7EE02141B950}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{92B2C2C6-7D47-48AD-A8BB-7EE02141B950}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{90100339-E52D-4E6B-9F14-B034192508E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{90100339-E52D-4E6B-9F14-B034192508E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{90100339-E52D-4E6B-9F14-B034192508E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{90100339-E52D-4E6B-9F14-B034192508E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1971AA11-806A-4482-BFA5-8C9479E6EDF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1971AA11-806A-4482-BFA5-8C9479E6EDF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1971AA11-806A-4482-BFA5-8C9479E6EDF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1971AA11-806A-4482-BFA5-8C9479E6EDF3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
74
Creative/Configuration/CreativeConfig.cs
Normal file
74
Creative/Configuration/CreativeConfig.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
namespace Creative.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Creative service.
|
||||
/// Bound from appsettings.json section "Creative".
|
||||
/// Override via environment variables: Creative__OpenAiApiKey, etc.
|
||||
/// </summary>
|
||||
public class CreativeConfig
|
||||
{
|
||||
public const string SectionName = "Creative";
|
||||
|
||||
/// <summary>
|
||||
/// When false, returns emulated/mock creative assets.
|
||||
/// When true, calls OpenAI and performs real URL scraping.
|
||||
/// </summary>
|
||||
public bool EnableRealApi { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI API key for copy generation.
|
||||
/// </summary>
|
||||
public string? OpenAiApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI model to use. Default: gpt-4o-mini.
|
||||
/// </summary>
|
||||
public string OpenAiModel { get; set; } = "gpt-4o-mini";
|
||||
|
||||
/// <summary>
|
||||
/// Max tokens for OpenAI responses.
|
||||
/// </summary>
|
||||
public int OpenAiMaxTokens { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for URL scraping.
|
||||
/// </summary>
|
||||
public int ScrapeTimeoutSeconds { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for OpenAI API calls.
|
||||
/// </summary>
|
||||
public int OpenAiTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
// ── Image Provider ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Image provider: "emulated" | "unsplash" | "dalle".
|
||||
/// Default: emulated (placeholder images).
|
||||
/// </summary>
|
||||
public string ImageProvider { get; set; } = "emulated";
|
||||
|
||||
/// <summary>
|
||||
/// Unsplash Access Key (optional - basic search works without it,
|
||||
/// but rate limits are generous with a free key from unsplash.com/developers).
|
||||
/// </summary>
|
||||
public string? UnsplashAccessKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of images to return per draft. Default: 3
|
||||
/// (landscape, square, portrait for responsive display ads).
|
||||
/// </summary>
|
||||
public int ImageCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// DALL-E model to use when ImageProvider=dalle.
|
||||
/// Default: dall-e-3.
|
||||
/// </summary>
|
||||
public string DalleModel { get; set; } = "dall-e-3";
|
||||
|
||||
/// <summary>
|
||||
/// DALL-E image size. Default: 1024x1024.
|
||||
/// Options: 1024x1024, 1792x1024, 1024x1792.
|
||||
/// </summary>
|
||||
public string DalleSize { get; set; } = "1024x1024";
|
||||
}
|
||||
48
Creative/Controllers/InternalController.cs
Normal file
48
Creative/Controllers/InternalController.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Creative.Models;
|
||||
using Creative.Security;
|
||||
using Creative.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Creative.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Internal endpoint called by Gateway.
|
||||
/// Single dispatch point: POST /internal/execute
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal")]
|
||||
public class InternalController : ControllerBase
|
||||
{
|
||||
private readonly CreativeService _service;
|
||||
private readonly ILogger<InternalController> _logger;
|
||||
|
||||
public InternalController(CreativeService service, ILogger<InternalController> logger)
|
||||
{
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a creative operation.
|
||||
/// Called by Gateway with X-Internal-Key header.
|
||||
/// </summary>
|
||||
[HttpPost("execute")]
|
||||
[ServiceFilter(typeof(InternalAuthFilter))]
|
||||
public async Task<IActionResult> Execute(
|
||||
[FromBody] CreativeRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var requestId = Request.Headers["X-Request-Id"].FirstOrDefault()
|
||||
?? request.RequestId
|
||||
?? Guid.NewGuid().ToString("N");
|
||||
|
||||
request.RequestId = requestId;
|
||||
|
||||
_logger.LogInformation("[Internal] {Operation} | RequestId={RequestId}",
|
||||
request.Operation, requestId);
|
||||
|
||||
var result = await _service.ExecuteAsync(request, ct);
|
||||
|
||||
return result.Ok ? Ok(result) : BadRequest(result);
|
||||
}
|
||||
}
|
||||
23
Creative/Creative.csproj
Normal file
23
Creative/Creative.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>creative</ContainerRepository>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ContainerPort Include="8080" Type="tcp" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
BIN
Creative/Creative.zip
Normal file
BIN
Creative/Creative.zip
Normal file
Binary file not shown.
206
Creative/Models/CreativeModels.cs
Normal file
206
Creative/Models/CreativeModels.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Creative.Models;
|
||||
|
||||
// ============================================================
|
||||
// Request / Response envelope (matches ProviderModels pattern)
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Inbound request from Gateway via /internal/execute.
|
||||
/// </summary>
|
||||
public class CreativeRequest
|
||||
{
|
||||
[JsonPropertyName("operation")]
|
||||
public string? Operation { get; set; }
|
||||
|
||||
[JsonPropertyName("requestId")]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public Dictionary<string, object>? Payload { get; set; }
|
||||
|
||||
// Session context forwarded by Gateway
|
||||
[JsonPropertyName("session")]
|
||||
public SessionContext? Session { get; set; }
|
||||
}
|
||||
|
||||
public class SessionContext
|
||||
{
|
||||
[JsonPropertyName("sessionId")]
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
[JsonPropertyName("clientId")]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[JsonPropertyName("clientName")]
|
||||
public string? ClientName { get; set; }
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("userEmail")]
|
||||
public string? UserEmail { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard response envelope.
|
||||
/// </summary>
|
||||
public class CreativeResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok { get; set; }
|
||||
|
||||
[JsonPropertyName("requestId")]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
public object? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public object? Error { get; set; }
|
||||
|
||||
public static CreativeResponse Success(string requestId, object? data = null) => new()
|
||||
{
|
||||
Ok = true,
|
||||
RequestId = requestId,
|
||||
Data = data
|
||||
};
|
||||
|
||||
public static CreativeResponse Fail(string requestId, string code, string message, object? details = null) => new()
|
||||
{
|
||||
Ok = false,
|
||||
RequestId = requestId,
|
||||
Error = new { code, message, details }
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Domain models - scraped content and generated assets
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Result of scraping and analyzing a URL.
|
||||
/// </summary>
|
||||
public class UrlAnalysis
|
||||
{
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("metaDescription")]
|
||||
public string? MetaDescription { get; set; }
|
||||
|
||||
[JsonPropertyName("headings")]
|
||||
public List<string> Headings { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("bodySnippet")]
|
||||
public string? BodySnippet { get; set; }
|
||||
|
||||
[JsonPropertyName("inferredCategory")]
|
||||
public string? InferredCategory { get; set; }
|
||||
|
||||
[JsonPropertyName("scrapedAt")]
|
||||
public DateTimeOffset ScrapedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single text asset (headline or description) for Google Ads.
|
||||
/// </summary>
|
||||
public class TextAsset
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = ""; // "headline" or "description"
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("charCount")]
|
||||
public int CharCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An image asset sourced for the campaign.
|
||||
/// </summary>
|
||||
public class ImageAsset
|
||||
{
|
||||
[JsonPropertyName("imageId")]
|
||||
public string ImageId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Where the image came from: "emulated" | "unsplash" | "dalle"
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = "emulated";
|
||||
|
||||
/// <summary>
|
||||
/// Orientation/aspect: "landscape" | "square" | "portrait"
|
||||
/// </summary>
|
||||
[JsonPropertyName("orientation")]
|
||||
public string Orientation { get; set; } = "landscape";
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
|
||||
[JsonPropertyName("altText")]
|
||||
public string? AltText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Attribution line (required by Unsplash TOS, informational for others).
|
||||
/// </summary>
|
||||
[JsonPropertyName("attribution")]
|
||||
public string? Attribution { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Direct download/full-res URL if different from display URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete set of generated assets for a campaign draft.
|
||||
/// </summary>
|
||||
public class CampaignDraft
|
||||
{
|
||||
[JsonPropertyName("draftId")]
|
||||
public string DraftId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("analysis")]
|
||||
public UrlAnalysis? Analysis { get; set; }
|
||||
|
||||
[JsonPropertyName("headlines")]
|
||||
public List<TextAsset> Headlines { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("descriptions")]
|
||||
public List<TextAsset> Descriptions { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public List<ImageAsset> Images { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Copy source: "emulated" | "openai"
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = "emulated";
|
||||
|
||||
/// <summary>
|
||||
/// Image source: "emulated" | "unsplash" | "dalle"
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageSource")]
|
||||
public string ImageSource { get; set; } = "emulated";
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
96
Creative/Program.cs
Normal file
96
Creative/Program.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Creative.Configuration;
|
||||
using Creative.Security;
|
||||
using Creative.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --------------------
|
||||
// Container-friendly HTTP binding
|
||||
// --------------------
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
|
||||
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
|
||||
|
||||
// --------------------
|
||||
// Configuration
|
||||
// --------------------
|
||||
builder.Services.Configure<CreativeConfig>(
|
||||
builder.Configuration.GetSection(CreativeConfig.SectionName));
|
||||
|
||||
var creativeConfig = builder.Configuration
|
||||
.GetSection(CreativeConfig.SectionName)
|
||||
.Get<CreativeConfig>();
|
||||
|
||||
Console.WriteLine("===========================================");
|
||||
Console.WriteLine("[Creative] Starting service...");
|
||||
Console.WriteLine($"[Creative] Emulated Mode: {!(creativeConfig?.EnableRealApi ?? false)}");
|
||||
Console.WriteLine($"[Creative] OpenAI Key Set: {!string.IsNullOrEmpty(creativeConfig?.OpenAiApiKey)}");
|
||||
Console.WriteLine($"[Creative] Image Provider: {creativeConfig?.ImageProvider ?? "emulated"}");
|
||||
Console.WriteLine($"[Creative] Unsplash Key Set: {!string.IsNullOrEmpty(creativeConfig?.UnsplashAccessKey)}");
|
||||
Console.WriteLine($"[Creative] Internal Key Set: {!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CREATIVE_INTERNAL_KEY"))}");
|
||||
Console.WriteLine("===========================================");
|
||||
|
||||
// --------------------
|
||||
// Services
|
||||
// --------------------
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "Creative Service", Version = "v1" });
|
||||
});
|
||||
|
||||
// Core services
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<ScraperService>();
|
||||
builder.Services.AddSingleton<CopyGeneratorService>();
|
||||
builder.Services.AddSingleton<ImageGeneratorService>();
|
||||
builder.Services.AddScoped<CreativeService>();
|
||||
|
||||
// Auth filter for internal calls from Gateway
|
||||
builder.Services.AddScoped<InternalAuthFilter>();
|
||||
|
||||
// --------------------
|
||||
// Build & Configure
|
||||
// --------------------
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.UseRouting();
|
||||
app.MapControllers();
|
||||
|
||||
// Root endpoint
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
service = "Creative",
|
||||
status = "healthy",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
// Health check with config status
|
||||
app.MapGet("/health", (IConfiguration config) =>
|
||||
{
|
||||
var settings = config.GetSection(CreativeConfig.SectionName).Get<CreativeConfig>();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
service = "Creative",
|
||||
status = "healthy",
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
config = new
|
||||
{
|
||||
realApiEnabled = settings?.EnableRealApi ?? false,
|
||||
openAiConfigured = !string.IsNullOrEmpty(settings?.OpenAiApiKey),
|
||||
model = settings?.OpenAiModel ?? "(default)",
|
||||
imageProvider = settings?.ImageProvider ?? "emulated",
|
||||
unsplashConfigured = !string.IsNullOrEmpty(settings?.UnsplashAccessKey),
|
||||
imageCount = settings?.ImageCount ?? 3
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Console.WriteLine("[Creative] Pipeline configured, starting listener...");
|
||||
Console.WriteLine($"[Creative] Listening on http://0.0.0.0:{port}");
|
||||
|
||||
app.Run();
|
||||
15
Creative/Properties/launchSettings.json
Normal file
15
Creative/Properties/launchSettings.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5200",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Creative/Security/InternalAuthFilter.cs
Normal file
50
Creative/Security/InternalAuthFilter.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Creative.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates X-Internal-Key header on internal endpoints.
|
||||
/// Gateway sends this key when forwarding requests.
|
||||
/// </summary>
|
||||
public class InternalAuthFilter : IActionFilter
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<InternalAuthFilter> _logger;
|
||||
|
||||
public InternalAuthFilter(IConfiguration config, ILogger<InternalAuthFilter> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
// Get expected key from config or environment
|
||||
var expectedKey = _config["InternalKey"]
|
||||
?? Environment.GetEnvironmentVariable("CREATIVE_INTERNAL_KEY")
|
||||
?? "";
|
||||
|
||||
// If no key configured, allow all (dev mode)
|
||||
if (string.IsNullOrWhiteSpace(expectedKey))
|
||||
{
|
||||
_logger.LogWarning("[InternalAuth] No internal key configured - allowing all requests");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate header
|
||||
var providedKey = context.HttpContext.Request.Headers["X-Internal-Key"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providedKey) || providedKey != expectedKey)
|
||||
{
|
||||
_logger.LogWarning("[InternalAuth] Invalid or missing X-Internal-Key");
|
||||
context.Result = new UnauthorizedObjectResult(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Unauthorized: invalid internal key"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void OnActionExecuted(ActionExecutedContext context) { }
|
||||
}
|
||||
233
Creative/Services/CopyGeneratorService.cs
Normal file
233
Creative/Services/CopyGeneratorService.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Creative.Configuration;
|
||||
using Creative.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Creative.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates Google Ads text assets (headlines + descriptions).
|
||||
/// Uses OpenAI API when enabled, emulated data otherwise.
|
||||
///
|
||||
/// Google Ads specs:
|
||||
/// Headlines: max 30 characters, up to 15 per RSA
|
||||
/// Descriptions: max 90 characters, up to 4 per RSA
|
||||
/// </summary>
|
||||
public class CopyGeneratorService
|
||||
{
|
||||
private const int MaxHeadlineChars = 30;
|
||||
private const int MaxDescriptionChars = 90;
|
||||
|
||||
private readonly CreativeConfig _config;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<CopyGeneratorService> _logger;
|
||||
|
||||
public CopyGeneratorService(
|
||||
IOptions<CreativeConfig> config,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<CopyGeneratorService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate text assets from analyzed URL content.
|
||||
/// Returns validated headlines and descriptions.
|
||||
/// </summary>
|
||||
public async Task<(List<TextAsset> Headlines, List<TextAsset> Descriptions, string Source)>
|
||||
GenerateAsync(UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
if (!_config.EnableRealApi || string.IsNullOrWhiteSpace(_config.OpenAiApiKey))
|
||||
return EmulateGeneration(analysis);
|
||||
|
||||
return await GenerateRealAsync(analysis, ct);
|
||||
}
|
||||
|
||||
#region Real Implementation (OpenAI)
|
||||
|
||||
private async Task<(List<TextAsset>, List<TextAsset>, string)> GenerateRealAsync(
|
||||
UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("[CopyGen] Calling OpenAI for {Url}", analysis.Url);
|
||||
|
||||
var prompt = BuildPrompt(analysis);
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(_config.OpenAiTimeoutSeconds);
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.OpenAiApiKey}");
|
||||
|
||||
var requestBody = new
|
||||
{
|
||||
model = _config.OpenAiModel,
|
||||
max_tokens = _config.OpenAiMaxTokens,
|
||||
messages = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
role = "system",
|
||||
content = "You are an expert Google Ads copywriter. " +
|
||||
"Return ONLY valid JSON with no markdown formatting. " +
|
||||
"Follow character limits exactly."
|
||||
},
|
||||
new { role = "user", content = prompt }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync("https://api.openai.com/v1/chat/completions", content, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("[CopyGen] OpenAI returned {Status}: {Body}", response.StatusCode, errorBody);
|
||||
throw new InvalidOperationException($"OpenAI API returned {response.StatusCode}");
|
||||
}
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync(ct);
|
||||
return ParseOpenAiResponse(responseJson);
|
||||
}
|
||||
|
||||
private static string BuildPrompt(UrlAnalysis analysis)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Generate Google Ads copy for this business.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"URL: {analysis.Url}");
|
||||
sb.AppendLine($"Title: {analysis.Title}");
|
||||
sb.AppendLine($"Description: {analysis.MetaDescription}");
|
||||
|
||||
if (analysis.Headings.Count > 0)
|
||||
sb.AppendLine($"Headings: {string.Join(", ", analysis.Headings)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(analysis.BodySnippet))
|
||||
sb.AppendLine($"Content: {analysis.BodySnippet}");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Requirements:");
|
||||
sb.AppendLine("- 10 headlines, each MAXIMUM 30 characters");
|
||||
sb.AppendLine("- 4 descriptions, each MAXIMUM 90 characters");
|
||||
sb.AppendLine("- Headlines should be punchy and action-oriented");
|
||||
sb.AppendLine("- Descriptions should expand on value and include a call to action");
|
||||
sb.AppendLine("- Do NOT use excessive punctuation or ALL CAPS");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Return JSON only, no markdown:");
|
||||
sb.AppendLine("""{"headlines":["..."],"descriptions":["..."]}""");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private (List<TextAsset>, List<TextAsset>, string) ParseOpenAiResponse(string responseJson)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseJson);
|
||||
|
||||
// Extract the content from OpenAI's response structure
|
||||
var messageContent = doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString() ?? "{}";
|
||||
|
||||
// Strip markdown fences if present
|
||||
messageContent = messageContent
|
||||
.Replace("```json", "")
|
||||
.Replace("```", "")
|
||||
.Trim();
|
||||
|
||||
using var parsed = JsonDocument.Parse(messageContent);
|
||||
|
||||
var headlines = new List<TextAsset>();
|
||||
var descriptions = new List<TextAsset>();
|
||||
|
||||
if (parsed.RootElement.TryGetProperty("headlines", out var hArray))
|
||||
{
|
||||
foreach (var h in hArray.EnumerateArray())
|
||||
{
|
||||
var text = h.GetString() ?? "";
|
||||
headlines.Add(ValidateAsset("headline", text, MaxHeadlineChars));
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.RootElement.TryGetProperty("descriptions", out var dArray))
|
||||
{
|
||||
foreach (var d in dArray.EnumerateArray())
|
||||
{
|
||||
var text = d.GetString() ?? "";
|
||||
descriptions.Add(ValidateAsset("description", text, MaxDescriptionChars));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[CopyGen] OpenAI returned {H} headlines, {D} descriptions",
|
||||
headlines.Count, descriptions.Count);
|
||||
|
||||
return (headlines, descriptions, "openai");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Emulated
|
||||
|
||||
private (List<TextAsset>, List<TextAsset>, string) EmulateGeneration(UrlAnalysis analysis)
|
||||
{
|
||||
_logger.LogInformation("[CopyGen] Emulated generation for {Url}", analysis.Url);
|
||||
|
||||
var businessName = analysis.Title?.Split('-', '|', '–')[0].Trim() ?? "Our Business";
|
||||
if (businessName.Length > 20) businessName = businessName[..20].Trim();
|
||||
|
||||
var headlines = new List<string>
|
||||
{
|
||||
$"{businessName} Near You",
|
||||
$"Visit {businessName} Today",
|
||||
"Quality You Can Trust",
|
||||
"Get Started Today",
|
||||
"See Our Services",
|
||||
$"Discover {businessName}",
|
||||
"Book an Appointment",
|
||||
"Free Consultation",
|
||||
"Top-Rated Service",
|
||||
"Limited Time Offer"
|
||||
}
|
||||
.Select(h => ValidateAsset("headline", h, MaxHeadlineChars))
|
||||
.ToList();
|
||||
|
||||
var descriptions = new List<string>
|
||||
{
|
||||
$"{businessName} delivers quality products and services. Visit us today and see the difference.",
|
||||
"Trusted by thousands of customers. Get a free quote and experience our commitment to excellence.",
|
||||
"Looking for reliable service? We offer competitive pricing and a satisfaction guarantee.",
|
||||
"Join our happy customers today. Professional service, fair prices, and results that speak."
|
||||
}
|
||||
.Select(d => ValidateAsset("description", d, MaxDescriptionChars))
|
||||
.ToList();
|
||||
|
||||
return (headlines, descriptions, "emulated");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation
|
||||
|
||||
/// <summary>
|
||||
/// Validate and truncate asset text to meet Google Ads character limits.
|
||||
/// </summary>
|
||||
private static TextAsset ValidateAsset(string type, string text, int maxChars)
|
||||
{
|
||||
text = text.Trim();
|
||||
|
||||
// Truncate if over limit (shouldn't happen often with good prompts)
|
||||
if (text.Length > maxChars)
|
||||
text = text[..(maxChars - 1)].TrimEnd() + "…";
|
||||
|
||||
return new TextAsset
|
||||
{
|
||||
Type = type,
|
||||
Text = text,
|
||||
CharCount = text.Length
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
230
Creative/Services/CreativeService.cs
Normal file
230
Creative/Services/CreativeService.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System.Text.Json;
|
||||
using Creative.Models;
|
||||
|
||||
namespace Creative.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Main creative service - dispatches operations to appropriate handlers.
|
||||
/// Stateless: returns JSON, Gateway handles persistence.
|
||||
/// </summary>
|
||||
public class CreativeService
|
||||
{
|
||||
private readonly ScraperService _scraper;
|
||||
private readonly CopyGeneratorService _copyGen;
|
||||
private readonly ImageGeneratorService _imageGen;
|
||||
private readonly ILogger<CreativeService> _logger;
|
||||
|
||||
public CreativeService(
|
||||
ScraperService scraper,
|
||||
CopyGeneratorService copyGen,
|
||||
ImageGeneratorService imageGen,
|
||||
ILogger<CreativeService> logger)
|
||||
{
|
||||
_scraper = scraper;
|
||||
_copyGen = copyGen;
|
||||
_imageGen = imageGen;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main dispatch method - routes to appropriate operation handler.
|
||||
/// </summary>
|
||||
public async Task<CreativeResponse> ExecuteAsync(CreativeRequest request, CancellationToken ct)
|
||||
{
|
||||
var requestId = request.RequestId ?? Guid.NewGuid().ToString("N");
|
||||
var operation = (request.Operation ?? "").Trim();
|
||||
|
||||
_logger.LogInformation("[Creative] Executing {Operation} | RequestId={RequestId}",
|
||||
operation, requestId);
|
||||
|
||||
try
|
||||
{
|
||||
return operation switch
|
||||
{
|
||||
"Ping" => Ping(requestId),
|
||||
"AnalyzeUrl" => await AnalyzeUrlAsync(request, requestId, ct),
|
||||
"GenerateAssets" => await GenerateAssetsAsync(request, requestId, ct),
|
||||
"GetImages" => await GetImagesAsync(request, requestId, ct),
|
||||
"CreateDraft" => await CreateDraftAsync(request, requestId, ct),
|
||||
_ => CreativeResponse.Fail(requestId, "UNKNOWN_OPERATION",
|
||||
$"Unknown operation: {operation}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Creative] {Operation} failed | RequestId={RequestId}",
|
||||
operation, requestId);
|
||||
return CreativeResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Operations
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Health check.
|
||||
/// </summary>
|
||||
private static CreativeResponse Ping(string requestId)
|
||||
{
|
||||
return CreativeResponse.Success(requestId, new
|
||||
{
|
||||
pong = true,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrape and analyze a URL. Returns structured content.
|
||||
/// Payload: { "url": "https://..." }
|
||||
/// </summary>
|
||||
private async Task<CreativeResponse> AnalyzeUrlAsync(
|
||||
CreativeRequest request, string requestId, CancellationToken ct)
|
||||
{
|
||||
var url = GetPayloadString(request, "url");
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return CreativeResponse.Fail(requestId, "MISSING_URL", "payload.url is required");
|
||||
|
||||
url = NormalizeUrl(url);
|
||||
var analysis = await _scraper.AnalyzeUrlAsync(url, ct);
|
||||
|
||||
return CreativeResponse.Success(requestId, analysis);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate text assets from previously analyzed content.
|
||||
/// Payload: { "analysis": { ... } } (UrlAnalysis object)
|
||||
/// </summary>
|
||||
private async Task<CreativeResponse> GenerateAssetsAsync(
|
||||
CreativeRequest request, string requestId, CancellationToken ct)
|
||||
{
|
||||
var analysisJson = GetPayloadObject(request, "analysis");
|
||||
if (analysisJson == null)
|
||||
return CreativeResponse.Fail(requestId, "MISSING_ANALYSIS",
|
||||
"payload.analysis is required (pass result from AnalyzeUrl)");
|
||||
|
||||
var analysis = JsonSerializer.Deserialize<UrlAnalysis>(analysisJson.Value.GetRawText());
|
||||
if (analysis == null)
|
||||
return CreativeResponse.Fail(requestId, "INVALID_ANALYSIS",
|
||||
"Could not deserialize analysis object");
|
||||
|
||||
var (headlines, descriptions, source) = await _copyGen.GenerateAsync(analysis, ct);
|
||||
|
||||
return CreativeResponse.Success(requestId, new
|
||||
{
|
||||
headlines,
|
||||
descriptions,
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get images matching previously analyzed content.
|
||||
/// Payload: { "analysis": { ... } } (UrlAnalysis object)
|
||||
/// </summary>
|
||||
private async Task<CreativeResponse> GetImagesAsync(
|
||||
CreativeRequest request, string requestId, CancellationToken ct)
|
||||
{
|
||||
var analysisJson = GetPayloadObject(request, "analysis");
|
||||
if (analysisJson == null)
|
||||
return CreativeResponse.Fail(requestId, "MISSING_ANALYSIS",
|
||||
"payload.analysis is required (pass result from AnalyzeUrl)");
|
||||
|
||||
var analysis = JsonSerializer.Deserialize<UrlAnalysis>(analysisJson.Value.GetRawText());
|
||||
if (analysis == null)
|
||||
return CreativeResponse.Fail(requestId, "INVALID_ANALYSIS",
|
||||
"Could not deserialize analysis object");
|
||||
|
||||
var (images, source) = await _imageGen.GenerateAsync(analysis, ct);
|
||||
|
||||
return CreativeResponse.Success(requestId, new
|
||||
{
|
||||
images,
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full pipeline: scrape URL → generate copy → source images → return campaign draft.
|
||||
/// Payload: { "url": "https://..." }
|
||||
/// Gateway persists the returned draft to tbCreativeDraft.
|
||||
/// </summary>
|
||||
private async Task<CreativeResponse> CreateDraftAsync(
|
||||
CreativeRequest request, string requestId, CancellationToken ct)
|
||||
{
|
||||
var url = GetPayloadString(request, "url");
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return CreativeResponse.Fail(requestId, "MISSING_URL", "payload.url is required");
|
||||
|
||||
url = NormalizeUrl(url);
|
||||
|
||||
// Step 1: Scrape
|
||||
_logger.LogInformation("[Creative] CreateDraft step 1/3: scraping {Url}", url);
|
||||
var analysis = await _scraper.AnalyzeUrlAsync(url, ct);
|
||||
|
||||
// Step 2: Generate copy
|
||||
_logger.LogInformation("[Creative] CreateDraft step 2/3: generating copy");
|
||||
var (headlines, descriptions, copySource) = await _copyGen.GenerateAsync(analysis, ct);
|
||||
|
||||
// Step 3: Source images
|
||||
_logger.LogInformation("[Creative] CreateDraft step 3/3: sourcing images");
|
||||
var (images, imageSource) = await _imageGen.GenerateAsync(analysis, ct);
|
||||
|
||||
// Assemble draft - Gateway will persist this
|
||||
var draftId = Guid.NewGuid().ToString("N")[..12];
|
||||
var draft = new CampaignDraft
|
||||
{
|
||||
DraftId = draftId,
|
||||
Url = url,
|
||||
Analysis = analysis,
|
||||
Headlines = headlines,
|
||||
Descriptions = descriptions,
|
||||
Images = images,
|
||||
Source = copySource,
|
||||
ImageSource = imageSource,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Creative] Draft assembled | DraftId={DraftId} Headlines={H} Descriptions={D} Images={I} CopySource={CS} ImageSource={IS}",
|
||||
draftId, headlines.Count, descriptions.Count, images.Count, copySource, imageSource);
|
||||
|
||||
return CreativeResponse.Success(requestId, draft);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
private static string? GetPayloadString(CreativeRequest request, string key)
|
||||
{
|
||||
if (request.Payload == null) return null;
|
||||
if (!request.Payload.TryGetValue(key, out var value)) return null;
|
||||
|
||||
return value switch
|
||||
{
|
||||
string s => s,
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
||||
_ => value?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement? GetPayloadObject(CreativeRequest request, string key)
|
||||
{
|
||||
if (request.Payload == null) return null;
|
||||
if (!request.Payload.TryGetValue(key, out var value)) return null;
|
||||
|
||||
if (value is JsonElement je && je.ValueKind == JsonValueKind.Object)
|
||||
return je;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
url = url.Trim();
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
414
Creative/Services/ImageGeneratorService.cs
Normal file
414
Creative/Services/ImageGeneratorService.cs
Normal file
@@ -0,0 +1,414 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Creative.Configuration;
|
||||
using Creative.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Creative.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sources images for campaign drafts.
|
||||
/// Three providers:
|
||||
/// - emulated: placeholder images (no network calls)
|
||||
/// - unsplash: free stock photos via Unsplash API
|
||||
/// - dalle: AI-generated images via OpenAI DALL-E (requires OpenAI key)
|
||||
///
|
||||
/// Google Ads Responsive Display Ad image specs:
|
||||
/// Landscape (1.91:1): 1200×628 recommended
|
||||
/// Square (1:1): 1200×1200 recommended
|
||||
/// Portrait (4:5): 960×1200 recommended (optional)
|
||||
/// </summary>
|
||||
public class ImageGeneratorService
|
||||
{
|
||||
private readonly CreativeConfig _config;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<ImageGeneratorService> _logger;
|
||||
|
||||
public ImageGeneratorService(
|
||||
IOptions<CreativeConfig> config,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<ImageGeneratorService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get images matching the analyzed content.
|
||||
/// Returns a list of ImageAssets and the provider name used.
|
||||
/// </summary>
|
||||
public async Task<(List<ImageAsset> Images, string Source)>
|
||||
GenerateAsync(UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
var provider = (_config.ImageProvider ?? "emulated").ToLowerInvariant();
|
||||
|
||||
_logger.LogInformation("[ImageGen] Provider={Provider} for {Url}", provider, analysis.Url);
|
||||
|
||||
return provider switch
|
||||
{
|
||||
"unsplash" => await GenerateUnsplashAsync(analysis, ct),
|
||||
"dalle" => await GenerateDalleAsync(analysis, ct),
|
||||
_ => EmulateGeneration(analysis)
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Emulated Provider
|
||||
// ============================================================
|
||||
|
||||
private (List<ImageAsset>, string) EmulateGeneration(UrlAnalysis analysis)
|
||||
{
|
||||
_logger.LogInformation("[ImageGen] Emulated images for {Url}", analysis.Url);
|
||||
|
||||
var keyword = ExtractSearchKeyword(analysis);
|
||||
var images = new List<ImageAsset>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ImageId = $"emu-landscape-{Guid.NewGuid():N}"[..20],
|
||||
Url = $"https://placehold.co/1200x628/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
|
||||
Source = "emulated",
|
||||
Orientation = "landscape",
|
||||
Width = 1200,
|
||||
Height = 628,
|
||||
AltText = $"{keyword} - landscape",
|
||||
Attribution = "Placeholder image"
|
||||
},
|
||||
new()
|
||||
{
|
||||
ImageId = $"emu-square-{Guid.NewGuid():N}"[..20],
|
||||
Url = $"https://placehold.co/1200x1200/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
|
||||
Source = "emulated",
|
||||
Orientation = "square",
|
||||
Width = 1200,
|
||||
Height = 1200,
|
||||
AltText = $"{keyword} - square",
|
||||
Attribution = "Placeholder image"
|
||||
},
|
||||
new()
|
||||
{
|
||||
ImageId = $"emu-portrait-{Guid.NewGuid():N}"[..20],
|
||||
Url = $"https://placehold.co/960x1200/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
|
||||
Source = "emulated",
|
||||
Orientation = "portrait",
|
||||
Width = 960,
|
||||
Height = 1200,
|
||||
AltText = $"{keyword} - portrait",
|
||||
Attribution = "Placeholder image"
|
||||
}
|
||||
};
|
||||
|
||||
return (images.Take(_config.ImageCount).ToList(), "emulated");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Unsplash Provider
|
||||
// ============================================================
|
||||
|
||||
private async Task<(List<ImageAsset>, string)> GenerateUnsplashAsync(
|
||||
UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
var keyword = ExtractSearchKeyword(analysis);
|
||||
_logger.LogInformation("[ImageGen] Unsplash search: '{Keyword}'", keyword);
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
// Unsplash supports unauthenticated requests at lower rate limits.
|
||||
// With an access key you get 50 req/hour (free tier).
|
||||
var hasKey = !string.IsNullOrWhiteSpace(_config.UnsplashAccessKey);
|
||||
if (hasKey)
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Client-ID {_config.UnsplashAccessKey}");
|
||||
|
||||
var images = new List<ImageAsset>();
|
||||
var orientations = new[] { "landscape", "squarish", "portrait" };
|
||||
|
||||
foreach (var orientation in orientations.Take(_config.ImageCount))
|
||||
{
|
||||
try
|
||||
{
|
||||
var queryUrl = hasKey
|
||||
? $"https://api.unsplash.com/search/photos?query={Uri.EscapeDataString(keyword)}&orientation={orientation}&per_page=1"
|
||||
: $"https://api.unsplash.com/search/photos?query={Uri.EscapeDataString(keyword)}&orientation={orientation}&per_page=1";
|
||||
|
||||
var response = await client.GetAsync(queryUrl, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[ImageGen] Unsplash returned {Status} for orientation={Orientation}",
|
||||
response.StatusCode, orientation);
|
||||
|
||||
// If unauthenticated and rate-limited, fall back to source.unsplash.com
|
||||
images.Add(BuildUnsplashFallback(keyword, orientation));
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
var results = parsed.RootElement.GetProperty("results");
|
||||
|
||||
if (results.GetArrayLength() == 0)
|
||||
{
|
||||
_logger.LogInformation("[ImageGen] No Unsplash results for '{Keyword}' {Orientation}",
|
||||
keyword, orientation);
|
||||
images.Add(BuildUnsplashFallback(keyword, orientation));
|
||||
continue;
|
||||
}
|
||||
|
||||
var photo = results[0];
|
||||
var mappedOrientation = orientation == "squarish" ? "square" : orientation;
|
||||
|
||||
images.Add(new ImageAsset
|
||||
{
|
||||
ImageId = photo.GetProperty("id").GetString() ?? $"unsplash-{Guid.NewGuid():N}"[..16],
|
||||
Url = photo.GetProperty("urls").GetProperty("regular").GetString() ?? "",
|
||||
DownloadUrl = photo.GetProperty("urls").GetProperty("full").GetString(),
|
||||
Source = "unsplash",
|
||||
Orientation = mappedOrientation,
|
||||
Width = photo.GetProperty("width").GetInt32(),
|
||||
Height = photo.GetProperty("height").GetInt32(),
|
||||
AltText = photo.GetProperty("alt_description").ValueKind == JsonValueKind.Null
|
||||
? keyword
|
||||
: photo.GetProperty("alt_description").GetString(),
|
||||
Attribution = BuildUnsplashAttribution(photo)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[ImageGen] Unsplash error for {Orientation}, using fallback", orientation);
|
||||
images.Add(BuildUnsplashFallback(keyword, orientation));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[ImageGen] Unsplash returned {Count} images", images.Count);
|
||||
return (images, "unsplash");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback using source.unsplash.com redirect (no API key needed, no rate limit).
|
||||
/// Returns a random photo matching the keyword at the requested dimensions.
|
||||
/// </summary>
|
||||
private static ImageAsset BuildUnsplashFallback(string keyword, string orientation)
|
||||
{
|
||||
var (w, h, mapped) = orientation switch
|
||||
{
|
||||
"landscape" => (1200, 628, "landscape"),
|
||||
"squarish" => (1200, 1200, "square"),
|
||||
"portrait" => (960, 1200, "portrait"),
|
||||
_ => (1200, 628, "landscape")
|
||||
};
|
||||
|
||||
return new ImageAsset
|
||||
{
|
||||
ImageId = $"unsplash-fallback-{Guid.NewGuid():N}"[..24],
|
||||
Url = $"https://source.unsplash.com/{w}x{h}/?{Uri.EscapeDataString(keyword)}",
|
||||
Source = "unsplash",
|
||||
Orientation = mapped,
|
||||
Width = w,
|
||||
Height = h,
|
||||
AltText = keyword,
|
||||
Attribution = "Photo from Unsplash"
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildUnsplashAttribution(JsonElement photo)
|
||||
{
|
||||
var userName = "Unknown";
|
||||
var userLink = "";
|
||||
|
||||
if (photo.TryGetProperty("user", out var user))
|
||||
{
|
||||
userName = user.TryGetProperty("name", out var name)
|
||||
? name.GetString() ?? "Unknown"
|
||||
: "Unknown";
|
||||
|
||||
if (user.TryGetProperty("links", out var links) &&
|
||||
links.TryGetProperty("html", out var html))
|
||||
{
|
||||
userLink = html.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// Unsplash TOS requires photographer attribution
|
||||
return string.IsNullOrEmpty(userLink)
|
||||
? $"Photo by {userName} on Unsplash"
|
||||
: $"Photo by {userName} on Unsplash ({userLink})";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DALL-E Provider (stubbed — ready for OpenAI key)
|
||||
// ============================================================
|
||||
|
||||
private async Task<(List<ImageAsset>, string)> GenerateDalleAsync(
|
||||
UrlAnalysis analysis, CancellationToken ct)
|
||||
{
|
||||
// Guard: DALL-E requires the OpenAI key
|
||||
if (string.IsNullOrWhiteSpace(_config.OpenAiApiKey))
|
||||
{
|
||||
_logger.LogWarning("[ImageGen] DALL-E requested but no OpenAI key configured, falling back to emulated");
|
||||
return EmulateGeneration(analysis);
|
||||
}
|
||||
|
||||
var keyword = ExtractSearchKeyword(analysis);
|
||||
var prompt = BuildDallePrompt(analysis, keyword);
|
||||
|
||||
_logger.LogInformation("[ImageGen] DALL-E generation: '{Prompt}'", prompt[..Math.Min(80, prompt.Length)]);
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(_config.OpenAiTimeoutSeconds);
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.OpenAiApiKey}");
|
||||
|
||||
var images = new List<ImageAsset>();
|
||||
|
||||
// DALL-E 3 supports: 1024x1024, 1792x1024 (landscape), 1024x1792 (portrait)
|
||||
var dalleVariants = new[]
|
||||
{
|
||||
(size: "1792x1024", orientation: "landscape", w: 1792, h: 1024),
|
||||
(size: "1024x1024", orientation: "square", w: 1024, h: 1024),
|
||||
(size: "1024x1792", orientation: "portrait", w: 1024, h: 1792)
|
||||
};
|
||||
|
||||
foreach (var variant in dalleVariants.Take(_config.ImageCount))
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = _config.DalleModel,
|
||||
prompt = prompt,
|
||||
n = 1,
|
||||
size = variant.size,
|
||||
quality = "standard",
|
||||
response_format = "url"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync(
|
||||
"https://api.openai.com/v1/images/generations", content, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("[ImageGen] DALL-E returned {Status}: {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
|
||||
// Fall back to emulated for this orientation
|
||||
images.Add(BuildEmulatedFallback(keyword, variant.orientation,
|
||||
variant.w, variant.h));
|
||||
continue;
|
||||
}
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync(ct);
|
||||
var parsed = JsonDocument.Parse(responseJson);
|
||||
var data = parsed.RootElement.GetProperty("data")[0];
|
||||
|
||||
var imageUrl = data.GetProperty("url").GetString() ?? "";
|
||||
var revisedPrompt = data.TryGetProperty("revised_prompt", out var rp)
|
||||
? rp.GetString() : null;
|
||||
|
||||
images.Add(new ImageAsset
|
||||
{
|
||||
ImageId = $"dalle-{variant.orientation}-{Guid.NewGuid():N}"[..24],
|
||||
Url = imageUrl,
|
||||
Source = "dalle",
|
||||
Orientation = variant.orientation,
|
||||
Width = variant.w,
|
||||
Height = variant.h,
|
||||
AltText = revisedPrompt ?? keyword,
|
||||
Attribution = $"AI-generated image via {_config.DalleModel}"
|
||||
});
|
||||
|
||||
_logger.LogInformation("[ImageGen] DALL-E generated {Orientation} image", variant.orientation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[ImageGen] DALL-E error for {Orientation}, using fallback",
|
||||
variant.orientation);
|
||||
images.Add(BuildEmulatedFallback(keyword, variant.orientation,
|
||||
variant.w, variant.h));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[ImageGen] DALL-E returned {Count} images", images.Count);
|
||||
return (images, "dalle");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a DALL-E prompt from the analysis. Aims for clean,
|
||||
/// professional ad imagery — not artistic or abstract.
|
||||
/// </summary>
|
||||
private static string BuildDallePrompt(UrlAnalysis analysis, string keyword)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("Professional advertising photograph for a ");
|
||||
sb.Append(analysis.InferredCategory ?? "business");
|
||||
sb.Append(" business. ");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(analysis.Title))
|
||||
{
|
||||
var businessName = analysis.Title.Split('-', '|', '–')[0].Trim();
|
||||
sb.Append($"Business: {businessName}. ");
|
||||
}
|
||||
|
||||
sb.Append($"Theme: {keyword}. ");
|
||||
sb.Append("Clean, well-lit, commercial style. ");
|
||||
sb.Append("No text or watermarks. Suitable for Google Ads display.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static ImageAsset BuildEmulatedFallback(string keyword, string orientation, int w, int h)
|
||||
{
|
||||
return new ImageAsset
|
||||
{
|
||||
ImageId = $"fallback-{orientation}-{Guid.NewGuid():N}"[..24],
|
||||
Url = $"https://placehold.co/{w}x{h}/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
|
||||
Source = "emulated",
|
||||
Orientation = orientation,
|
||||
Width = w,
|
||||
Height = h,
|
||||
AltText = $"{keyword} - {orientation}",
|
||||
Attribution = "Placeholder image (provider fallback)"
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Shared Helpers
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Extract a concise search keyword from the analysis.
|
||||
/// Uses category first, then title, then domain.
|
||||
/// </summary>
|
||||
private static string ExtractSearchKeyword(UrlAnalysis analysis)
|
||||
{
|
||||
// Prefer inferred category (e.g., "Pizza", "Soccer")
|
||||
if (!string.IsNullOrWhiteSpace(analysis.InferredCategory))
|
||||
return analysis.InferredCategory;
|
||||
|
||||
// Fall back to first meaningful heading
|
||||
var heading = analysis.Headings?.FirstOrDefault(h => h.Length > 3 && h.Length < 40);
|
||||
if (!string.IsNullOrWhiteSpace(heading))
|
||||
return heading;
|
||||
|
||||
// Fall back to title (cleaned)
|
||||
if (!string.IsNullOrWhiteSpace(analysis.Title))
|
||||
{
|
||||
var title = analysis.Title.Split('-', '|', '–')[0].Trim();
|
||||
return title.Length > 30 ? title[..30] : title;
|
||||
}
|
||||
|
||||
// Last resort: domain name
|
||||
try
|
||||
{
|
||||
var uri = new Uri(analysis.Url.StartsWith("http") ? analysis.Url : $"https://{analysis.Url}");
|
||||
return uri.Host.Replace("www.", "").Split('.')[0];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "business";
|
||||
}
|
||||
}
|
||||
}
|
||||
161
Creative/Services/ScraperService.cs
Normal file
161
Creative/Services/ScraperService.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using Creative.Configuration;
|
||||
using Creative.Models;
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Creative.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes a URL and extracts structured business data.
|
||||
/// Supports emulated mode for development without network calls.
|
||||
/// </summary>
|
||||
public class ScraperService
|
||||
{
|
||||
private readonly CreativeConfig _config;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<ScraperService> _logger;
|
||||
|
||||
public ScraperService(
|
||||
IOptions<CreativeConfig> config,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<ScraperService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyze a URL - scrape and extract structured content.
|
||||
/// </summary>
|
||||
public async Task<UrlAnalysis> AnalyzeUrlAsync(string url, CancellationToken ct)
|
||||
{
|
||||
if (!_config.EnableRealApi)
|
||||
return EmulateAnalysis(url);
|
||||
|
||||
return await ScrapeRealAsync(url, ct);
|
||||
}
|
||||
|
||||
#region Real Implementation
|
||||
|
||||
private async Task<UrlAnalysis> ScrapeRealAsync(string url, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("[Scraper] Fetching {Url}", url);
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(_config.ScrapeTimeoutSeconds);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (compatible; AdPlatformBot/1.0)");
|
||||
|
||||
var html = await client.GetStringAsync(url, ct);
|
||||
|
||||
var doc = new HtmlDocument();
|
||||
doc.LoadHtml(html);
|
||||
|
||||
// Extract title
|
||||
var title = doc.DocumentNode
|
||||
.SelectSingleNode("//title")?.InnerText?.Trim();
|
||||
|
||||
// Extract meta description
|
||||
var metaDesc = doc.DocumentNode
|
||||
.SelectSingleNode("//meta[@name='description']")?
|
||||
.GetAttributeValue("content", null)?.Trim();
|
||||
|
||||
// Extract H1-H3 headings
|
||||
var headings = new List<string>();
|
||||
foreach (var tag in new[] { "h1", "h2", "h3" })
|
||||
{
|
||||
var nodes = doc.DocumentNode.SelectNodes($"//{tag}");
|
||||
if (nodes != null)
|
||||
{
|
||||
foreach (var node in nodes.Take(5))
|
||||
{
|
||||
var text = HtmlEntity.DeEntitize(node.InnerText).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
headings.Add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract body text snippet (first meaningful paragraphs)
|
||||
var bodySnippet = ExtractBodySnippet(doc);
|
||||
|
||||
_logger.LogInformation("[Scraper] Extracted: title={Title} headings={Count}",
|
||||
title?.Length > 40 ? title[..40] + "..." : title, headings.Count);
|
||||
|
||||
return new UrlAnalysis
|
||||
{
|
||||
Url = url,
|
||||
Title = title,
|
||||
MetaDescription = metaDesc,
|
||||
Headings = headings,
|
||||
BodySnippet = bodySnippet,
|
||||
InferredCategory = null, // Category inference handled by CopyGenerator
|
||||
ScrapedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractBodySnippet(HtmlDocument doc)
|
||||
{
|
||||
// Remove script/style nodes
|
||||
var removeNodes = doc.DocumentNode.SelectNodes("//script|//style|//nav|//footer|//header");
|
||||
if (removeNodes != null)
|
||||
{
|
||||
foreach (var node in removeNodes)
|
||||
node.Remove();
|
||||
}
|
||||
|
||||
var paragraphs = doc.DocumentNode.SelectNodes("//p");
|
||||
if (paragraphs == null) return null;
|
||||
|
||||
var texts = paragraphs
|
||||
.Select(p => HtmlEntity.DeEntitize(p.InnerText).Trim())
|
||||
.Where(t => t.Length > 30)
|
||||
.Take(3);
|
||||
|
||||
var snippet = string.Join(" ", texts);
|
||||
return snippet.Length > 500 ? snippet[..500] : snippet;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Emulated
|
||||
|
||||
private UrlAnalysis EmulateAnalysis(string url)
|
||||
{
|
||||
_logger.LogInformation("[Scraper] Emulated analysis for {Url}", url);
|
||||
|
||||
// Parse domain for realistic emulated data
|
||||
var domain = "example.com";
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url.StartsWith("http") ? url : $"https://{url}");
|
||||
domain = uri.Host.Replace("www.", "");
|
||||
}
|
||||
catch { /* use default */ }
|
||||
|
||||
var businessName = domain.Split('.')[0];
|
||||
var titleCase = char.ToUpper(businessName[0]) + businessName[1..];
|
||||
|
||||
return new UrlAnalysis
|
||||
{
|
||||
Url = url,
|
||||
Title = $"{titleCase} - Quality Products & Services",
|
||||
MetaDescription = $"{titleCase} offers premium products and services. Visit us today for the best experience.",
|
||||
Headings = new List<string>
|
||||
{
|
||||
$"Welcome to {titleCase}",
|
||||
"Our Services",
|
||||
"Why Choose Us",
|
||||
"Contact Us Today"
|
||||
},
|
||||
BodySnippet = $"{titleCase} has been serving customers with dedication and quality. " +
|
||||
"We offer a wide range of products and services designed to meet your needs. " +
|
||||
"Our team is committed to providing exceptional value and customer satisfaction.",
|
||||
InferredCategory = "Business Services",
|
||||
ScrapedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
8
Creative/appsettings.Development.json
Normal file
8
Creative/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Creative/appsettings.json
Normal file
25
Creative/appsettings.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"InternalKey": "",
|
||||
|
||||
"Creative": {
|
||||
"EnableRealApi": false,
|
||||
"OpenAiApiKey": "",
|
||||
"OpenAiModel": "gpt-4o-mini",
|
||||
"OpenAiMaxTokens": 1000,
|
||||
"ScrapeTimeoutSeconds": 15,
|
||||
"OpenAiTimeoutSeconds": 30,
|
||||
"ImageProvider": "emulated",
|
||||
"UnsplashAccessKey": "",
|
||||
"ImageCount": 3,
|
||||
"DalleModel": "dall-e-3",
|
||||
"DalleSize": "1024x1024"
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,18 @@ public sealed class AuthController : ControllerBase
|
||||
_log.LogWarning("[Session] Authenticated: ClientId={ClientId}, Email={Email}",
|
||||
_client.ClientId, _client.Email);
|
||||
|
||||
// Gateway handles CIAM client sessions only.
|
||||
// Staff apps authenticate directly to Management API via JWT Bearer — never via Gateway.
|
||||
if (_client.IsStaff)
|
||||
{
|
||||
_log.LogWarning("[Session] Staff token rejected — use JWT Bearer directly to Management API");
|
||||
return StatusCode(403, new { ok = false, error = "Staff authentication does not use Gateway sessions" });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = _client.AuthProvider ?? "EntraExternalId",
|
||||
subject = _client.ClientId,
|
||||
subject = _client.ClientId,
|
||||
email = _client.Email,
|
||||
displayName = _client.ClientName,
|
||||
clientId = request?.PreferredClientId,
|
||||
@@ -56,13 +64,15 @@ public sealed class AuthController : ControllerBase
|
||||
sessionDurationHours = request?.SessionDurationHours ?? 24
|
||||
});
|
||||
|
||||
_log.LogWarning("[Session] Calling spSession with: {Rqst}", rqst);
|
||||
_log.LogWarning("[Session] Calling proc with: {Rqst}", rqst);
|
||||
|
||||
_log.LogWarning("[Session] Using proc=dbo.spClientSession");
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "createFromIdentity", rqst, ct: ct);
|
||||
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "createFromIdentity", rqst, ct: ct);
|
||||
|
||||
_log.LogWarning("[Session] spSession response: {Resp}", resp ?? "(null)");
|
||||
_log.LogWarning("[Session] Proc response: {Resp}", resp ?? "(null)");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -118,7 +128,7 @@ public sealed class AuthController : ControllerBase
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = _client.AuthProvider ?? "EntraExternalId",
|
||||
subject = _client.ClientId,
|
||||
subject = _client.ClientId,
|
||||
email = _client.Email,
|
||||
displayName = _client.ClientName,
|
||||
companyName = request.CompanyName,
|
||||
@@ -173,10 +183,11 @@ public sealed class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var signoffProc = "dbo.spClientSession"; // Gateway handles client sessions only
|
||||
|
||||
try
|
||||
{
|
||||
await _sql.ExecProcAsync("dbo.spSession", "signoff", rqst, ct: ct);
|
||||
await _sql.ExecProcAsync(signoffProc, "signoff", rqst, ct: ct);
|
||||
return Ok(new { ok = true, message = "Signed out successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -204,10 +215,11 @@ public sealed class AuthController : ControllerBase
|
||||
sessionToken = token,
|
||||
sessionDurationHours = request?.SessionDurationHours ?? 24
|
||||
});
|
||||
var refreshProc = "dbo.spClientSession"; // Gateway handles client sessions only
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "refresh", rqst, ct: ct);
|
||||
var resp = await _sql.ExecProcAsync(refreshProc, "refresh", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -251,7 +263,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: ct);
|
||||
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -304,7 +316,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "switchClient", rqst, ct: ct);
|
||||
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "switchClient", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -338,11 +350,18 @@ public sealed class AuthController : ControllerBase
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
return token;
|
||||
|
||||
// Check Authorization header (for session tokens, not JWTs)
|
||||
// Check Authorization header — accept both "Session <token>" and "Bearer <token>".
|
||||
// NOTE: Bearer here is a session token (not an Entra JWT) because the middleware
|
||||
// only routes to these controller actions after session validation succeeds.
|
||||
// The JWT-only endpoint (/api/auth/session) never calls ExtractSessionToken().
|
||||
var auth = Request.Headers.Authorization.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.IsNullOrWhiteSpace(auth))
|
||||
{
|
||||
return auth.Substring(8).Trim();
|
||||
if (auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase))
|
||||
return auth.Substring(8).Trim();
|
||||
|
||||
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return auth.Substring(7).Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
185
Gateway/Controllers/CampaignIntelligenceController.cs
Normal file
185
Gateway/Controllers/CampaignIntelligenceController.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign intelligence endpoints: health overview, budget pacing,
|
||||
/// and post-campaign analysis.
|
||||
///
|
||||
/// SECURITY MODEL:
|
||||
/// - Every endpoint requires authenticated session (via middleware)
|
||||
/// - Initiative endpoints verify ownership before data access
|
||||
/// - Client-level endpoints scoped via injected ClientContext
|
||||
/// - ClientId is always injected server-side, never from request body
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/intelligence")]
|
||||
public sealed class CampaignIntelligenceController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<CampaignIntelligenceController> _log;
|
||||
|
||||
public CampaignIntelligenceController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<CampaignIntelligenceController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Campaign Health Overview
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Get health overview for all active initiatives.
|
||||
/// Returns green/yellow/red status per channel campaign based on active recommendations.
|
||||
/// </summary>
|
||||
[HttpGet("health")]
|
||||
public async Task<IActionResult> Health(CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "health",
|
||||
JsonSerializer.Serialize(new { clientId = _client.ClientId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Budget Pacing
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Get budget pacing analysis for an initiative.
|
||||
/// Shows actual vs expected spend velocity with projections.
|
||||
/// </summary>
|
||||
[HttpGet("{initiativeId:long}/pacing")]
|
||||
public async Task<IActionResult> Pacing(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "pacing",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Post-Campaign Report
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive post-campaign analysis.
|
||||
/// Cross-platform comparison with daily trends, efficiency metrics,
|
||||
/// and recommendation history.
|
||||
/// </summary>
|
||||
[HttpGet("{initiativeId:long}/report")]
|
||||
public async Task<IActionResult> PostCampaignReport(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "postCampaign",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Metric Snapshots (internal / polling service)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Record an intraday metric snapshot for pacing analysis.
|
||||
/// Called by the background polling service between daily aggregations.
|
||||
/// Admin-only endpoint.
|
||||
/// </summary>
|
||||
[HttpPost("snapshot")]
|
||||
public async Task<IActionResult> Snapshot([FromBody] SnapshotRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireServiceKey();
|
||||
if (!ok) return StatusCode(403, new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "snapshot",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
channelCampaignId = request.ChannelCampaignId,
|
||||
date = request.Date,
|
||||
impressions = request.Impressions,
|
||||
clicks = request.Clicks,
|
||||
spend = request.Spend,
|
||||
conversions = request.Conversions
|
||||
}), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch insert intraday snapshots.
|
||||
/// Admin-only endpoint.
|
||||
/// </summary>
|
||||
[HttpPost("snapshot/batch")]
|
||||
public async Task<IActionResult> SnapshotBatch([FromBody] SnapshotBatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireServiceKey();
|
||||
if (!ok) return StatusCode(403, new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "snapshotBatch",
|
||||
JsonSerializer.Serialize(new { snapshots = request.Snapshots }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return NotFound(JsonSerializer.Deserialize<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "CampaignIntelligence {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class SnapshotRequest
|
||||
{
|
||||
public long? ChannelCampaignId { get; set; }
|
||||
public string? Date { get; set; }
|
||||
public long? Impressions { get; set; }
|
||||
public long? Clicks { get; set; }
|
||||
public decimal? Spend { get; set; }
|
||||
public decimal? Conversions { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SnapshotBatchRequest
|
||||
{
|
||||
public object[]? Snapshots { get; set; }
|
||||
}
|
||||
212
Gateway/Controllers/ClientDocumentController.cs
Normal file
212
Gateway/Controllers/ClientDocumentController.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Client-facing document endpoints.
|
||||
/// All operations are scoped to the authenticated client — clientId is always
|
||||
/// injected from ClientContext, never trusted from the request body.
|
||||
///
|
||||
/// POST /api/documents/list - List client's own documents
|
||||
/// POST /api/documents - Upload a document (multipart)
|
||||
/// GET /api/documents/{id}/download - Download (enforces client ownership)
|
||||
/// DELETE /api/documents/{id} - Soft delete (enforces client ownership)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/documents")]
|
||||
public sealed class ClientDocumentController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<ClientDocumentController> _log;
|
||||
|
||||
public ClientDocumentController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
IConfiguration config,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<ClientDocumentController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_config = config;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ── POST /api/documents/list ─────────────────────────────────────────────
|
||||
[HttpPost("list")]
|
||||
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
scope = "client",
|
||||
clientId = _client.ClientId // always from session, never from body
|
||||
});
|
||||
|
||||
var result = await _sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Client document list failed");
|
||||
return StatusCode(500, new { ok = false, error = "Document service error" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/documents ──────────────────────────────────────────────────
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(52_428_800)]
|
||||
public async Task<IActionResult> Upload(
|
||||
IFormFile file,
|
||||
[FromForm] string category,
|
||||
[FromForm] string? description = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { ok = false, error = "No file provided" });
|
||||
|
||||
try
|
||||
{
|
||||
byte[] fileBytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
await file.CopyToAsync(ms, ct);
|
||||
fileBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
docFileName = file.FileName,
|
||||
docMimeType = file.ContentType,
|
||||
docFileSize = file.Length,
|
||||
docCategory = category,
|
||||
docDescription = description,
|
||||
docUploadedBy = _client.Email,
|
||||
docScope = "client",
|
||||
docCltId = _client.ClientId // injected from session
|
||||
});
|
||||
|
||||
_log.LogInformation("[ClientDocs] Upload {FileName} | Client={ClientId}",
|
||||
file.FileName, _client.ClientId);
|
||||
|
||||
var result = await ExecUploadAsync(rqst, fileBytes, ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Client document upload failed: {FileName}", file?.FileName);
|
||||
return StatusCode(500, new { ok = false, error = "Upload failed" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/documents/{id}/download ─────────────────────────────────────
|
||||
[HttpGet("{id:long}/download")]
|
||||
public async Task<IActionResult> Download(long id, CancellationToken ct = default)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql")
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
|
||||
await using var conn = new SqlConnection(cs);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = 60
|
||||
};
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value =
|
||||
JsonSerializer.Serialize(new { docId = id, clientId = _client.ClientId }) });
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return NotFound(new { ok = false, error = "Document not found" });
|
||||
|
||||
var fileName = reader.GetString(reader.GetOrdinal("docFileName"));
|
||||
var mimeType = reader.GetString(reader.GetOrdinal("docMimeType"));
|
||||
var content = (byte[])reader["docContent"];
|
||||
|
||||
return File(content, mimeType, fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Client document download failed: docId={DocId}", id);
|
||||
return StatusCode(500, new { ok = false, error = "Download failed" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /api/documents/{id} ───────────────────────────────────────────
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<IActionResult> Delete(long id, CancellationToken ct = default)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
_log.LogInformation("[ClientDocs] Delete docId={DocId} | Client={ClientId}", id, _client.ClientId);
|
||||
|
||||
// Pass clientId so the SP enforces ownership before deleting
|
||||
var rqst = JsonSerializer.Serialize(new { docId = id, clientId = _client.ClientId });
|
||||
var result = await _sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Client document delete failed: docId={DocId}", id);
|
||||
return StatusCode(500, new { ok = false, error = "Delete failed" });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Upload helper: binary passed separately from JSON rqst ──────────────
|
||||
private async Task<string> ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct)
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql")
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
|
||||
await using var conn = new SqlConnection(cs);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = 60
|
||||
};
|
||||
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst });
|
||||
cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent });
|
||||
|
||||
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
cmd.Parameters.Add(pResp);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
return pResp.Value as string
|
||||
?? JsonSerializer.Serialize(new { ok = false, error = "No response from database" });
|
||||
}
|
||||
}
|
||||
179
Gateway/Controllers/DemographicsController.cs
Normal file
179
Gateway/Controllers/DemographicsController.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Census demographic data endpoints for the campaign wizard.
|
||||
///
|
||||
/// GET /api/demographics/{zcta} — fetch census data from DB, forward to
|
||||
/// Intelligence container for derived
|
||||
/// recommendations. Falls back to raw census
|
||||
/// data if Intelligence is unreachable.
|
||||
/// POST /api/demographics/list — multiple ZCTAs (raw data only)
|
||||
/// POST /api/demographics/search — find ZCTAs by criteria (raw data only)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/demographics")]
|
||||
public sealed class DemographicsController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly IntelligenceApiClient _intelligence;
|
||||
private readonly ILogger<DemographicsController> _log;
|
||||
|
||||
public DemographicsController(
|
||||
SqlService sql,
|
||||
AuthorizationGuard guard,
|
||||
IntelligenceApiClient intelligence,
|
||||
ILogger<DemographicsController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_guard = guard;
|
||||
_intelligence = intelligence;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch raw census data for a ZCTA, then forward to Intelligence container
|
||||
/// for derived audience recommendations (age chips, income tiers, insights).
|
||||
/// Falls back to raw census data only if Intelligence is unreachable.
|
||||
/// </summary>
|
||||
[HttpGet("{zcta}")]
|
||||
public async Task<IActionResult> Get(string zcta, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(zcta) || zcta.Length != 5 || !zcta.All(char.IsDigit))
|
||||
return BadRequest(new { ok = false, error = "Valid 5-digit ZIP code is required" });
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Fetch raw census data from DB
|
||||
var censusJson = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Demographics, "get",
|
||||
JsonSerializer.Serialize(new { zcta }),
|
||||
ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(censusJson))
|
||||
return NotFound(new { ok = false, error = "ZIP code not found" });
|
||||
|
||||
using var doc = JsonDocument.Parse(censusJson);
|
||||
var censusRoot = doc.RootElement;
|
||||
|
||||
if (censusRoot.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return NotFound(new { ok = false, error = "ZIP code not found" });
|
||||
|
||||
// 2. Forward to Intelligence container for market analysis derivation
|
||||
var analysis = await _intelligence.GetDemographicAnalysisAsync(zcta, censusRoot, ct);
|
||||
|
||||
if (analysis != null)
|
||||
{
|
||||
_log.LogInformation("[Demographics] Analysis by Intelligence container | ZCTA={Zcta}", zcta);
|
||||
return Content(analysis, "application/json");
|
||||
}
|
||||
|
||||
// 3. Fallback: return raw census data
|
||||
_log.LogInformation("[Demographics] Intelligence unavailable — raw census | ZCTA={Zcta}", zcta);
|
||||
return Content(censusJson, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Demographics get error for ZCTA {Zcta}", zcta);
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Get demographics for multiple ZCTAs (raw census data).</summary>
|
||||
[HttpPost("list")]
|
||||
public async Task<IActionResult> List([FromBody] ZctaListRequest? request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (request?.Zctas == null || request.Zctas.Length == 0)
|
||||
return BadRequest(new { ok = false, error = "zctas array is required" });
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
zctas = request.Zctas,
|
||||
page = request.Page ?? 1,
|
||||
pageSize = request.PageSize ?? 50
|
||||
});
|
||||
|
||||
var resp = await _sql.ExecProcAsync(SqlNames.Procs.Demographics, "list", rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Demographics list error");
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Search ZCTAs by demographic criteria (raw census data).</summary>
|
||||
[HttpPost("search")]
|
||||
public async Task<IActionResult> Search([FromBody] DemographicSearchRequest? request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
zctaPrefix = request?.ZctaPrefix,
|
||||
minIncome = request?.MinIncome,
|
||||
maxIncome = request?.MaxIncome,
|
||||
minPopulation = request?.MinPopulation,
|
||||
minBachelorPct = request?.MinBachelorPct,
|
||||
minAge25to34Pct = request?.MinAge25to34Pct,
|
||||
minHomeValue = request?.MinHomeValue,
|
||||
page = request?.Page ?? 1,
|
||||
pageSize = request?.PageSize ?? 50
|
||||
});
|
||||
|
||||
var resp = await _sql.ExecProcAsync(SqlNames.Procs.Demographics, "search", rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Demographics search error");
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class ZctaListRequest
|
||||
{
|
||||
public string[]? Zctas { get; set; }
|
||||
public int? Page { get; set; }
|
||||
public int? PageSize { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DemographicSearchRequest
|
||||
{
|
||||
public string? ZctaPrefix { get; set; }
|
||||
public int? MinIncome { get; set; }
|
||||
public int? MaxIncome { get; set; }
|
||||
public int? MinPopulation { get; set; }
|
||||
public decimal? MinBachelorPct { get; set; }
|
||||
public decimal? MinAge25to34Pct { get; set; }
|
||||
public int? MinHomeValue { get; set; }
|
||||
public int? Page { get; set; }
|
||||
public int? PageSize { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
@@ -9,17 +10,25 @@ namespace Gateway.Controllers;
|
||||
public sealed class ExecutionController : ControllerBase
|
||||
{
|
||||
private readonly ExecutionService _svc;
|
||||
public ExecutionController(ExecutionService svc) => _svc = svc;
|
||||
private readonly ClientContext _client;
|
||||
|
||||
public ExecutionController(ExecutionService svc, ClientContext client)
|
||||
{
|
||||
_svc = svc;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
[HttpPost("request")]
|
||||
public async Task<IActionResult> Execute([FromBody] JsonElement body)
|
||||
{
|
||||
// SECURITY: Require authenticated session
|
||||
if (!_client.IsAuthenticated)
|
||||
return Unauthorized(new { ok = false, error = "Authentication required" });
|
||||
|
||||
if (body.ValueKind == JsonValueKind.Undefined || body.ValueKind == JsonValueKind.Null)
|
||||
return BadRequest(new { ok = false, error = "Missing request body" });
|
||||
|
||||
var resp = await _svc.ExecuteAsync(body, HttpContext.RequestAborted);
|
||||
|
||||
// resp is JsonElement / JsonDocument / string json — you decide.
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
96
Gateway/Controllers/ForecastController.cs
Normal file
96
Gateway/Controllers/ForecastController.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Gateway.Models;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Channel forecast endpoint for the campaign wizard.
|
||||
///
|
||||
/// Routes to IntelligenceApi (category-aware engine container) when configured.
|
||||
/// Falls back to local ForecastService (General/rules-based) if IntelligenceApi
|
||||
/// is unreachable — ensuring the wizard never breaks during deployments.
|
||||
///
|
||||
/// ROUTING LOGIC:
|
||||
/// 1. Try IntelligenceApi — passes clientCategory so the engine router
|
||||
/// can select the correct model (General, Franchisee, Franchisor, etc.)
|
||||
/// 2. If unreachable / error → fall back to local ForecastService
|
||||
///
|
||||
/// SECURITY: Requires authenticated client session.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/forecast")]
|
||||
public sealed class ForecastController : ControllerBase
|
||||
{
|
||||
private readonly ForecastService _forecastService;
|
||||
private readonly IntelligenceApiClient _intelligenceClient;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<ForecastController> _log;
|
||||
|
||||
public ForecastController(
|
||||
ForecastService forecastService,
|
||||
IntelligenceApiClient intelligenceClient,
|
||||
ClientContext client,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<ForecastController> log)
|
||||
{
|
||||
_forecastService = forecastService;
|
||||
_intelligenceClient = intelligenceClient;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate channel performance estimates for given targeting + budget.
|
||||
/// Called by the wizard AllocationStep when budget changes.
|
||||
///
|
||||
/// POST /api/forecast/channel-estimate
|
||||
/// </summary>
|
||||
[HttpPost("channel-estimate")]
|
||||
public async Task<IActionResult> ChannelEstimate(
|
||||
[FromBody] ChannelForecastRequest? request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (request == null)
|
||||
return BadRequest(new { ok = false, error = "Request body is required" });
|
||||
|
||||
if (request.MonthlyBudget <= 0)
|
||||
return BadRequest(new { ok = false, error = "monthlyBudget must be greater than zero" });
|
||||
|
||||
if (request.Keywords.Count == 0)
|
||||
return BadRequest(new { ok = false, error = "At least one keyword is required" });
|
||||
|
||||
_log.LogInformation(
|
||||
"[Forecast] Request | Category={Category} Budget={Budget} Objective={Obj}",
|
||||
_client.ClientCategory, request.MonthlyBudget, request.Objective);
|
||||
|
||||
try
|
||||
{
|
||||
// ── 1. Try IntelligenceApi (category-aware) ──
|
||||
var result = await _intelligenceClient.GetSpendDistributionAsync(
|
||||
request, _client.ClientCategory, ct);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
_log.LogInformation("[Forecast] Served by IntelligenceApi");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── 2. Fallback: local ForecastService (General engine equivalent) ──
|
||||
_log.LogInformation("[Forecast] IntelligenceApi unavailable — using local ForecastService");
|
||||
var fallback = await _forecastService.ForecastAsync(request, ct);
|
||||
return Ok(fallback);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Forecast] Error");
|
||||
return StatusCode(500, new { ok = false, error = "Forecast service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
502
Gateway/Controllers/InitiativeController.cs
Normal file
502
Gateway/Controllers/InitiativeController.cs
Normal file
@@ -0,0 +1,502 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-channel initiative endpoints.
|
||||
///
|
||||
/// SECURITY MODEL:
|
||||
/// - Every endpoint requires authenticated session (via middleware)
|
||||
/// - Every resource-specific endpoint validates ownership (initiative → client)
|
||||
/// - Status changes are restricted to valid client-initiated transitions
|
||||
/// - Sync endpoint is restricted to admin role
|
||||
/// - Budget values are validated server-side against channel minimums
|
||||
/// - ClientId is injected server-side, never trusted from request body
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/initiative")]
|
||||
public sealed class InitiativeController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly MultiChannelConfig _config;
|
||||
private readonly InitiativeLaunchService _launch;
|
||||
private readonly ProviderStatusNormalizer _statusNorm;
|
||||
private readonly ILogger<InitiativeController> _log;
|
||||
|
||||
public InitiativeController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
AuthorizationGuard guard,
|
||||
IOptions<MultiChannelConfig> config,
|
||||
InitiativeLaunchService launch,
|
||||
ProviderStatusNormalizer statusNorm,
|
||||
ILogger<InitiativeController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_config = config.Value;
|
||||
_launch = launch;
|
||||
_statusNorm = statusNorm;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Initiative CRUD
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Create a new initiative with channel allocations.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInitiativeRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (request.TotalBudget.HasValue)
|
||||
{
|
||||
var (budgetOk, budgetErr) = _guard.ValidateBudget(
|
||||
request.TotalBudget.Value, request.BudgetPeriod, _config);
|
||||
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId,
|
||||
userId = _client.UserId,
|
||||
name = request.Name,
|
||||
objective = request.Objective,
|
||||
totalBudget = request.TotalBudget,
|
||||
budgetPeriod = request.BudgetPeriod ?? "monthly",
|
||||
startDate = request.StartDate,
|
||||
endDate = request.EndDate,
|
||||
allocationStrategy = request.AllocationStrategy ?? "manual",
|
||||
businessCategory = request.BusinessCategory,
|
||||
wizardId = request.WizardId,
|
||||
channels = request.Channels
|
||||
});
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "create", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stage an initiative for confirmation with server-calculated billing.
|
||||
/// </summary>
|
||||
[HttpPost("stage")]
|
||||
public async Task<IActionResult> Stage([FromBody] CreateInitiativeRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (request.InitiativeId.HasValue && request.InitiativeId > 0)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(request.InitiativeId.Value, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
}
|
||||
|
||||
if (request.TotalBudget.HasValue)
|
||||
{
|
||||
var (budgetOk, budgetErr) = _guard.ValidateBudget(
|
||||
request.TotalBudget.Value, request.BudgetPeriod, _config);
|
||||
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId,
|
||||
userId = _client.UserId,
|
||||
name = request.Name,
|
||||
objective = request.Objective,
|
||||
totalBudget = request.TotalBudget,
|
||||
budgetPeriod = request.BudgetPeriod ?? "monthly",
|
||||
startDate = request.StartDate,
|
||||
endDate = request.EndDate,
|
||||
allocationStrategy = request.AllocationStrategy ?? "manual",
|
||||
businessCategory = request.BusinessCategory,
|
||||
wizardId = request.WizardId,
|
||||
initiativeId = request.InitiativeId,
|
||||
channels = request.Channels
|
||||
});
|
||||
|
||||
return await Exec(SqlNames.Procs.InitiativeStage, "stage", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Get billing for a staged initiative.</summary>
|
||||
[HttpGet("{initiativeId:long}/billing")]
|
||||
public async Task<IActionResult> GetBilling(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
return await Exec(SqlNames.Procs.InitiativeStage, "getBilling",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
/// <summary>Get initiative by ID (ownership verified).</summary>
|
||||
[HttpGet("{initiativeId:long}")]
|
||||
public async Task<IActionResult> Get(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ownership.EntityJson))
|
||||
return Content(ownership.EntityJson, "application/json");
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "get",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
/// <summary>List initiatives for current client (always scoped).</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] string? status, [FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "list",
|
||||
JsonSerializer.Serialize(new { clientId = _client.ClientId, status, page, pageSize }), ct);
|
||||
}
|
||||
|
||||
/// <summary>Update initiative metadata (ownership verified, status stripped).</summary>
|
||||
[HttpPut("{initiativeId:long}")]
|
||||
public async Task<IActionResult> Update(long initiativeId, [FromBody] UpdateInitiativeRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
if (request.TotalBudget.HasValue)
|
||||
{
|
||||
var (budgetOk, budgetErr) = _guard.ValidateBudget(request.TotalBudget.Value, null, _config);
|
||||
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
|
||||
}
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "update",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
initiativeId,
|
||||
clientId = _client.ClientId,
|
||||
name = request.Name,
|
||||
totalBudget = request.TotalBudget,
|
||||
startDate = request.StartDate,
|
||||
endDate = request.EndDate,
|
||||
businessCategory = request.BusinessCategory
|
||||
}), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update status with transition enforcement.
|
||||
/// Clients: active↔paused, *→cancelled only. Admins: any transition.
|
||||
/// </summary>
|
||||
[HttpPatch("{initiativeId:long}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(long initiativeId, [FromBody] UpdateInitiativeStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Status))
|
||||
return BadRequest(new { ok = false, error = "status is required" });
|
||||
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var isAdmin = string.Equals(_client.Role, "admin", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isAdmin)
|
||||
{
|
||||
var (transOk, transErr) = _guard.ValidateClientStatusTransition(
|
||||
ownership.CurrentStatus, request.Status, "initiative");
|
||||
if (!transOk) return BadRequest(new { ok = false, error = transErr });
|
||||
}
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "updateStatus",
|
||||
JsonSerializer.Serialize(new { initiativeId, clientId = _client.ClientId, status = request.Status }), ct);
|
||||
}
|
||||
|
||||
/// <summary>Soft-delete (cannot delete active — cancel first).</summary>
|
||||
[HttpDelete("{initiativeId:long}")]
|
||||
public async Task<IActionResult> Delete(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
if (ownership.CurrentStatus == "active")
|
||||
return BadRequest(new { ok = false, error = "Cannot delete an active initiative. Cancel it first." });
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "delete",
|
||||
JsonSerializer.Serialize(new { initiativeId, clientId = _client.ClientId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Launch / Dispatch
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Launch a staged initiative (ownership + status verified).</summary>
|
||||
[HttpPost("{initiativeId:long}/launch")]
|
||||
public async Task<IActionResult> Launch(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
if (ownership.CurrentStatus != "staged")
|
||||
return BadRequest(new { ok = false, error = $"Initiative must be staged before launching (current: {ownership.CurrentStatus})" });
|
||||
|
||||
_log.LogInformation("[Initiative] Launch {InitiativeId} by {UserId}", initiativeId, _client.UserId);
|
||||
|
||||
var result = await _launch.LaunchAsync(initiativeId, _client.ClientId ?? "", _client.UserId, ct);
|
||||
|
||||
if (!result.Ok && result.Error != null)
|
||||
{
|
||||
_log.LogWarning("[Initiative] Launch failed {InitiativeId}: {Error}", initiativeId, result.Error);
|
||||
return BadRequest(result);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Channel Campaigns (all ownership-verified)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{initiativeId:long}/channels")]
|
||||
public async Task<IActionResult> ListChannels(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.ChannelCampaign, "list", JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
[HttpGet("channel/{channelCampaignId:long}")]
|
||||
public async Task<IActionResult> GetChannel(long channelCampaignId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyChannelOwnerAsync(channelCampaignId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.ChannelCampaign, "get", JsonSerializer.Serialize(new { channelCampaignId }), ct);
|
||||
}
|
||||
|
||||
/// <summary>Sync channel status — called by provider containers.</summary>
|
||||
[HttpPatch("channel/{channelCampaignId:long}/sync")]
|
||||
public async Task<IActionResult> SyncChannel(long channelCampaignId, [FromBody] SyncChannelRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireServiceKey();
|
||||
if (!ok) return StatusCode(403, new { ok = false, error = err });
|
||||
|
||||
var normalizedStatus = _statusNorm.Resolve(request.ChannelType, request.Status, request.ProviderStatus);
|
||||
|
||||
_log.LogInformation("[Sync] Channel {Id} | {Provider} → {Status} | By={User}",
|
||||
channelCampaignId, request.ProviderStatus, normalizedStatus, _client.UserId);
|
||||
|
||||
return await Exec(SqlNames.Procs.ChannelCampaign, "sync",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
channelCampaignId,
|
||||
externalCampaignId = request.ExternalCampaignId,
|
||||
externalAccountId = request.ExternalAccountId,
|
||||
status = normalizedStatus,
|
||||
providerStatus = request.ProviderStatus
|
||||
}), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Budget Allocation (all ownership-verified)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{initiativeId:long}/allocation")]
|
||||
public async Task<IActionResult> GetAllocation(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "get", JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
[HttpPut("{initiativeId:long}/allocation")]
|
||||
public async Task<IActionResult> UpdateAllocation(long initiativeId, [FromBody] UpdateAllocationRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "update",
|
||||
JsonSerializer.Serialize(new { initiativeId, userId = _client.UserId, allocations = request.Allocations, reason = request.Reason }), ct);
|
||||
}
|
||||
|
||||
[HttpGet("{initiativeId:long}/allocation/recommend")]
|
||||
public async Task<IActionResult> GetRecommendation(long initiativeId, [FromQuery] string? businessCategory, [FromQuery] string? objective, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "recommend", JsonSerializer.Serialize(new { initiativeId, businessCategory, objective }), ct);
|
||||
}
|
||||
|
||||
[HttpPost("{initiativeId:long}/allocation/apply")]
|
||||
public async Task<IActionResult> ApplyAllocation(long initiativeId, [FromBody] ApplyAllocationRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "apply",
|
||||
JsonSerializer.Serialize(new { initiativeId, source = request.Source, allocations = request.Allocations, reason = request.Reason }), ct);
|
||||
}
|
||||
|
||||
[HttpGet("{initiativeId:long}/allocation/history")]
|
||||
public async Task<IActionResult> GetAllocationHistory(long initiativeId, [FromQuery] int? limit, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "history", JsonSerializer.Serialize(new { initiativeId, limit }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Channel Config (read-only)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("channels/available")]
|
||||
public async Task<IActionResult> GetAvailableChannels(CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
var mappingsResp = await _sql.ExecProcAsync(SqlNames.Procs.ObjectiveMapping, "list", "{}", ct: ct);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ok = true,
|
||||
channels = _config.EnabledChannels.Select(c => new
|
||||
{
|
||||
c.ChannelType, c.DisplayName, c.Description, c.Icon, c.Color,
|
||||
c.MinDailyBudget, c.MinMonthlyBudget, c.SupportedObjectives,
|
||||
c.SupportedCreativeFormats, c.ApprovalEstimateHours, c.IsStub
|
||||
}),
|
||||
allocation = new
|
||||
{
|
||||
_config.Allocation.MinMultiChannelMonthlyBudget,
|
||||
_config.Allocation.MaxChannelsPerInitiative,
|
||||
_config.Allocation.DefaultAllocationStrategy,
|
||||
_config.Allocation.MinChannelAllocationPct,
|
||||
_config.Allocation.MaxChannelAllocationPct
|
||||
},
|
||||
objectiveMappings = JsonSerializer.Deserialize<object>(mappingsResp ?? "{}")
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Status mappings — available to authenticated clients.</summary>
|
||||
[HttpGet("channels/status-mappings")]
|
||||
public IActionResult GetStatusMappings([FromQuery] string? channelType)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelType))
|
||||
return Ok(new { ok = true, channelType, mappings = _statusNorm.GetMappings(channelType) });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ok = true,
|
||||
channels = _config.Channels.Values.Select(c => new { c.ChannelType, c.DisplayName, c.Enabled, mappings = _statusNorm.GetMappings(c.ChannelType) })
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("templates")]
|
||||
public async Task<IActionResult> GetTemplates([FromQuery] string? businessCategory, [FromQuery] string? objective, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
return await Exec(SqlNames.Procs.Allocation, "getTemplates", JsonSerializer.Serialize(new { businessCategory, objective }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Performance Metrics (ownership-verified)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{initiativeId:long}/metrics")]
|
||||
public async Task<IActionResult> MetricsSummary(long initiativeId, [FromQuery] string? fromDate, [FromQuery] string? toDate, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.PerformanceMetric, "summary", JsonSerializer.Serialize(new { initiativeId, fromDate, toDate }), ct);
|
||||
}
|
||||
|
||||
[HttpGet("{initiativeId:long}/metrics/compare")]
|
||||
public async Task<IActionResult> MetricsCompare(long initiativeId, [FromQuery] int? lookbackDays, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.PerformanceMetric, "compare", JsonSerializer.Serialize(new { initiativeId, lookbackDays }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return NotFound(JsonSerializer.Deserialize<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Initiative {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class CreateInitiativeRequest
|
||||
{
|
||||
public long? InitiativeId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public decimal? TotalBudget { get; set; }
|
||||
public string? BudgetPeriod { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public string? AllocationStrategy { get; set; }
|
||||
public string? BusinessCategory { get; set; }
|
||||
public string? WizardId { get; set; }
|
||||
public object[]? Channels { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateInitiativeRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public decimal? TotalBudget { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public string? BusinessCategory { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateInitiativeStatusRequest { public string? Status { get; set; } }
|
||||
public sealed class SyncChannelRequest
|
||||
{
|
||||
public string? ExternalCampaignId { get; set; }
|
||||
public string? ExternalAccountId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? ProviderStatus { get; set; }
|
||||
public string? ChannelType { get; set; }
|
||||
}
|
||||
public sealed class UpdateAllocationRequest { public object[]? Allocations { get; set; } public string? Reason { get; set; } }
|
||||
public sealed class ApplyAllocationRequest { public string? Source { get; set; } public object[]? Allocations { get; set; } public string? Reason { get; set; } }
|
||||
62
Gateway/Controllers/MetricSyncController.cs
Normal file
62
Gateway/Controllers/MetricSyncController.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Metric sync trigger — called by Management API or Azure Functions timer.
|
||||
/// Pulls campaign performance data from provider containers and writes
|
||||
/// it into the database, then triggers recommendation evaluation.
|
||||
/// Secured by internal service key (X-Service-Key header).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/sync")]
|
||||
public sealed class MetricSyncController : ControllerBase
|
||||
{
|
||||
private readonly MetricSyncService _sync;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<MetricSyncController> _log;
|
||||
|
||||
public MetricSyncController(
|
||||
MetricSyncService sync,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<MetricSyncController> log)
|
||||
{
|
||||
_sync = sync;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync metrics for a specific client.
|
||||
/// Pulls from all active channel campaign providers, writes to DB,
|
||||
/// then triggers recommendation evaluation.
|
||||
/// </summary>
|
||||
[HttpPost("metrics/{clientId}")]
|
||||
public async Task<IActionResult> SyncClient(
|
||||
string clientId,
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireServiceKey();
|
||||
if (!ok) return StatusCode(403, new { ok = false, error = err });
|
||||
|
||||
_log.LogInformation("[MetricSync] Manual sync triggered for client {ClientId}", clientId);
|
||||
|
||||
var result = await _sync.SyncClientMetricsAsync(clientId, startDate, endDate, ct);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ok = result.Success,
|
||||
clientId = result.ClientId,
|
||||
campaignsProcessed = result.CampaignsProcessed,
|
||||
metricsWritten = result.MetricsWritten,
|
||||
recommendationsGenerated = result.RecommendationsGenerated,
|
||||
skipped = result.Skipped,
|
||||
errors = result.Errors,
|
||||
error = result.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
153
Gateway/Controllers/RecommendationController.cs
Normal file
153
Gateway/Controllers/RecommendationController.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Client-facing recommendation endpoints.
|
||||
///
|
||||
/// Clients can view, dismiss, and resolve recommendations for their
|
||||
/// own campaigns. All endpoints are scoped to the authenticated client.
|
||||
///
|
||||
/// Admin operations (rule CRUD, evaluate, cleanup) live in the
|
||||
/// Management API at /api/admin/recommendations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/recommendations")]
|
||||
public sealed class RecommendationController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<RecommendationController> _log;
|
||||
|
||||
public RecommendationController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<RecommendationController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Client-Facing: List Recommendations
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Get active recommendations for the authenticated client's dashboard.
|
||||
/// Returns recommendations sorted by severity (critical first).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListByClient(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.Recommendation, "listByClient",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId,
|
||||
status = status ?? "active",
|
||||
limit = limit ?? 50
|
||||
}), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get recommendations for a specific initiative (ownership verified).
|
||||
/// </summary>
|
||||
[HttpGet("initiative/{initiativeId:long}")]
|
||||
public async Task<IActionResult> ListByInitiative(
|
||||
long initiativeId,
|
||||
[FromQuery] string? status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
return await Exec(SqlNames.Procs.Recommendation, "listByInitiative",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
initiativeId,
|
||||
status = status ?? "active"
|
||||
}), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Client-Facing: Manage Recommendations
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Dismiss a recommendation (user explicitly ignores it).
|
||||
/// </summary>
|
||||
[HttpPost("{recommendationId:long}/dismiss")]
|
||||
public async Task<IActionResult> Dismiss(long recommendationId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
// Ownership check: verify the recommendation belongs to this client
|
||||
// The SP itself filters by recId, but we pass userId for audit trail
|
||||
return await Exec(SqlNames.Procs.Recommendation, "dismiss",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
recommendationId,
|
||||
userId = _client.UserId
|
||||
}), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a recommendation (action was taken to address it).
|
||||
/// </summary>
|
||||
[HttpPost("{recommendationId:long}/resolve")]
|
||||
public async Task<IActionResult> Resolve(long recommendationId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.Recommendation, "resolve",
|
||||
JsonSerializer.Serialize(new { recommendationId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return NotFound(JsonSerializer.Deserialize<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Recommendation {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
301
Gateway/Controllers/WizardController.cs
Normal file
301
Gateway/Controllers/WizardController.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign wizard endpoints.
|
||||
///
|
||||
/// SECURITY: Every wizard operation validates ownership (wizard → client).
|
||||
/// ClientId is always injected server-side.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/wizard")]
|
||||
public sealed class WizardController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<WizardController> _log;
|
||||
|
||||
public WizardController(SqlService sql, ClientContext client, AuthorizationGuard guard, ILogger<WizardController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get active categories + objectives for wizard Step 1.
|
||||
/// Client-authenticated (not admin). Read-only.
|
||||
/// Calls spAdminTemplateConfig with action 'public.config'.
|
||||
/// </summary>
|
||||
[HttpGet("config")]
|
||||
public async Task<IActionResult> GetConfig(CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.TemplateConfig,
|
||||
"public.config",
|
||||
"{}",
|
||||
ct: ct
|
||||
);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Config service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Wizard config error");
|
||||
return StatusCode(500, new { ok = false, error = "Config service error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Create a new wizard (no ownership check — creates for current client).</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateWizardRequest? request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId, // ← SERVER-SIDE
|
||||
userId = _client.UserId,
|
||||
name = request?.Name,
|
||||
url = request?.Url
|
||||
});
|
||||
|
||||
return await ExecAndReturn("create", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Get wizard by ID (ownership verified).</summary>
|
||||
[HttpGet("{wizardId}")]
|
||||
public async Task<IActionResult> Get(string wizardId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
// Return already-fetched entity
|
||||
if (!string.IsNullOrWhiteSpace(ownership.EntityJson))
|
||||
return Content(ownership.EntityJson, "application/json");
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId });
|
||||
return await ExecAndReturn("get", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>List wizards for current client (always scoped).</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] string? status, [FromQuery] int? limit, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId, // ← scoped to authenticated client
|
||||
status,
|
||||
limit
|
||||
});
|
||||
|
||||
return await ExecAndReturn("listByClient", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Update step data (ownership verified, steps 1-4 only).</summary>
|
||||
[HttpPut("{wizardId}/step/{step:int}")]
|
||||
public async Task<IActionResult> UpdateStep(string wizardId, int step, [FromBody] UpdateStepRequest? request, CancellationToken ct)
|
||||
{
|
||||
if (step < 1 || step > 5)
|
||||
return BadRequest(new { ok = false, error = "Step must be 1-5" });
|
||||
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
wizardId,
|
||||
step,
|
||||
data = request?.Data,
|
||||
name = request?.Name
|
||||
});
|
||||
|
||||
return await ExecAndReturn("updateStep", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Navigate to step (ownership verified).</summary>
|
||||
[HttpPatch("{wizardId}/step/{step:int}")]
|
||||
public async Task<IActionResult> SetStep(string wizardId, int step, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId, step });
|
||||
return await ExecAndReturn("setStep", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Get wizard summary for review (ownership verified).</summary>
|
||||
[HttpGet("{wizardId}/summary")]
|
||||
public async Task<IActionResult> GetSummary(string wizardId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId });
|
||||
return await ExecAndReturn("getSummary", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Submit wizard (ownership verified).</summary>
|
||||
[HttpPost("{wizardId}/submit")]
|
||||
public async Task<IActionResult> Submit(string wizardId, [FromBody] SubmitWizardRequest? request, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
wizardId,
|
||||
campaignId = (string?)null,
|
||||
network = request?.Network ?? "google"
|
||||
});
|
||||
|
||||
return await ExecAndReturn("submit", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Update wizard status (ownership verified, transition rules applied).</summary>
|
||||
[HttpPatch("{wizardId}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(string wizardId, [FromBody] UpdateStatusRequest? request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Status))
|
||||
return BadRequest(new { ok = false, error = "status is required" });
|
||||
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
// Wizard status transitions: only allow cancel from draft
|
||||
var current = ownership.CurrentStatus ?? "draft";
|
||||
var requested = request.Status.ToLowerInvariant();
|
||||
var isAdmin = string.Equals(_client.Role, "admin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isAdmin && requested != "cancelled")
|
||||
return BadRequest(new { ok = false, error = $"Cannot change wizard status to '{requested}'" });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId, status = request.Status });
|
||||
return await ExecAndReturn("updateStatus", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Delete wizard (ownership verified).</summary>
|
||||
[HttpDelete("{wizardId}")]
|
||||
public async Task<IActionResult> Delete(string wizardId, [FromQuery] bool force = false, CancellationToken ct = default)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId, force });
|
||||
return await ExecAndReturn("delete", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get audience-adjusted channel mix recommendation.
|
||||
/// Calls spAllocationRecommend with audience factors.
|
||||
/// </summary>
|
||||
[HttpPost("recommend")]
|
||||
public async Task<IActionResult> Recommend([FromBody] RecommendRequest? request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request?.BusinessCategory) || string.IsNullOrWhiteSpace(request?.Objective))
|
||||
return BadRequest(new { ok = false, error = "businessCategory and objective are required" });
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
businessCategory = request.BusinessCategory,
|
||||
objective = request.Objective,
|
||||
ageSkew = request.AgeSkew,
|
||||
marketScope = request.MarketScope
|
||||
});
|
||||
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.AllocationRecommend,
|
||||
"recommend",
|
||||
rqst,
|
||||
ct: ct
|
||||
);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Recommendation service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Recommend error");
|
||||
return StatusCode(500, new { ok = false, error = "Recommendation service error" });
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Helper
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> ExecAndReturn(string action, string rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spCampaignWizard", action, rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Wizard service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return NotFound(JsonSerializer.Deserialize<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Wizard {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Wizard service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class CreateWizardRequest { public string? Name { get; set; } public string? Url { get; set; } }
|
||||
public sealed class UpdateStepRequest { public object? Data { get; set; } public string? Name { get; set; } }
|
||||
public sealed class SubmitWizardRequest { public string? Network { get; set; } }
|
||||
public sealed class UpdateStatusRequest { public string? Status { get; set; } }
|
||||
public sealed class RecommendRequest
|
||||
{
|
||||
public string? BusinessCategory { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public string? AgeSkew { get; set; }
|
||||
public string? MarketScope { get; set; }
|
||||
}
|
||||
@@ -1,14 +1,36 @@
|
||||
namespace Gateway.Data;
|
||||
namespace Gateway.Data;
|
||||
|
||||
public static class SqlNames
|
||||
{
|
||||
public static class Procs
|
||||
{
|
||||
public const string Client = "dbo.spClient";
|
||||
public const string User = "dbo.spUser";
|
||||
// ── Existing ──
|
||||
public const string Client = "dbo.spClient";
|
||||
public const string User = "dbo.spUser";
|
||||
public const string UserClientRole = "dbo.spUserClientRole";
|
||||
public const string AdAccount = "dbo.spAdAccount";
|
||||
public const string AdCampaign = "dbo.spAdCampaign";
|
||||
public const string Invoice = "dbo.spInvoice";
|
||||
public const string AdAccount = "dbo.spAdAccount";
|
||||
public const string AdCampaign = "dbo.spAdCampaign";
|
||||
public const string Invoice = "dbo.spInvoice";
|
||||
|
||||
// ── Multi-Channel ──
|
||||
public const string Initiative = "dbo.spInitiative";
|
||||
public const string ChannelCampaign = "dbo.spChannelCampaign";
|
||||
public const string ChannelConfig = "dbo.spChannelConfig";
|
||||
public const string InitiativeStage = "dbo.spInitiativeStage";
|
||||
public const string Allocation = "dbo.spAllocation";
|
||||
public const string ObjectiveMapping = "dbo.spObjectiveMapping";
|
||||
public const string PerformanceMetric = "dbo.spPerformanceMetric";
|
||||
|
||||
// ── Campaign Wizard ──
|
||||
public const string CampaignWizard = "dbo.spCampaignWizard";
|
||||
public const string TemplateConfig = "dbo.spAdminTemplateConfig";
|
||||
public const string AllocationRecommend = "dbo.spAllocationRecommend";
|
||||
|
||||
// ── Campaign Intelligence ──
|
||||
public const string CampaignIntelligence = "dbo.spCampaignIntelligence";
|
||||
public const string Recommendation = "dbo.spRecommendation";
|
||||
|
||||
// ── Census Demographics ──
|
||||
public const string Demographics = "dbo.spDemographics";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.4.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.16.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
321
Gateway/Migrations/001_ChannelConfig.sql
Normal file
321
Gateway/Migrations/001_ChannelConfig.sql
Normal file
@@ -0,0 +1,321 @@
|
||||
-- ============================================================
|
||||
-- 001_ChannelConfig.sql
|
||||
-- Move channel provider configuration from appsettings.json
|
||||
-- into database-driven configuration.
|
||||
-- ============================================================
|
||||
|
||||
-- ── Table ──────────────────────────────────────────────────
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'tbChannelConfig')
|
||||
BEGIN
|
||||
CREATE TABLE dbo.tbChannelConfig (
|
||||
chcChannelType VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||
chcDisplayName NVARCHAR(100) NOT NULL,
|
||||
chcDescription NVARCHAR(500) NULL,
|
||||
chcIcon VARCHAR(50) NULL,
|
||||
chcColor VARCHAR(20) NULL,
|
||||
chcEnabled BIT NOT NULL DEFAULT 1,
|
||||
chcIsStub BIT NOT NULL DEFAULT 1,
|
||||
chcEndpoint VARCHAR(500) NULL,
|
||||
chcInternalKey VARCHAR(500) NULL,
|
||||
chcMinDailyBudget DECIMAL(10,2) NOT NULL DEFAULT 5.00,
|
||||
chcMinMonthlyBudget DECIMAL(10,2) NOT NULL DEFAULT 150.00,
|
||||
chcSupportedObjectives NVARCHAR(500) NULL, -- JSON array: ["sales","leads","traffic"]
|
||||
chcSupportedCreativeFormats NVARCHAR(500) NULL, -- JSON array: ["text","image","video"]
|
||||
chcApprovalEstimateHours INT NOT NULL DEFAULT 24,
|
||||
chcMetricsRefreshIntervalMinutes INT NOT NULL DEFAULT 60,
|
||||
chcAuthMethod VARCHAR(50) NULL,
|
||||
chcKeyVaultSecretName VARCHAR(200) NULL,
|
||||
chcStatusMappings NVARCHAR(MAX) NULL, -- JSON object: {"ENABLED":"active",...}
|
||||
chcSortOrder INT NOT NULL DEFAULT 0,
|
||||
chcCreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||
chcUpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||
);
|
||||
PRINT 'Created table tbChannelConfig';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ── Stored Procedure ───────────────────────────────────────
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.spChannelConfig
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX) = '{}',
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
-- ── list: return all enabled channels ──
|
||||
IF @action = 'list'
|
||||
BEGIN
|
||||
SET @resp = (
|
||||
SELECT
|
||||
chcChannelType AS channelType,
|
||||
chcDisplayName AS displayName,
|
||||
chcDescription AS [description],
|
||||
chcIcon AS icon,
|
||||
chcColor AS color,
|
||||
chcEnabled AS [enabled],
|
||||
chcIsStub AS isStub,
|
||||
chcEndpoint AS endpoint,
|
||||
chcInternalKey AS internalKey,
|
||||
chcMinDailyBudget AS minDailyBudget,
|
||||
chcMinMonthlyBudget AS minMonthlyBudget,
|
||||
JSON_QUERY(chcSupportedObjectives) AS supportedObjectives,
|
||||
JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats,
|
||||
chcApprovalEstimateHours AS approvalEstimateHours,
|
||||
chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes,
|
||||
chcAuthMethod AS authMethod,
|
||||
chcKeyVaultSecretName AS keyVaultSecretName,
|
||||
JSON_QUERY(chcStatusMappings) AS statusMappings,
|
||||
chcSortOrder AS sortOrder
|
||||
FROM dbo.tbChannelConfig
|
||||
ORDER BY chcSortOrder, chcChannelType
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
IF @resp IS NULL SET @resp = '[]';
|
||||
SET @resp = '{"ok":true,"channels":' + @resp + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── listAll: return all channels including disabled (for admin) ──
|
||||
IF @action = 'listAll'
|
||||
BEGIN
|
||||
SET @resp = (
|
||||
SELECT
|
||||
chcChannelType AS channelType,
|
||||
chcDisplayName AS displayName,
|
||||
chcDescription AS [description],
|
||||
chcIcon AS icon,
|
||||
chcColor AS color,
|
||||
chcEnabled AS [enabled],
|
||||
chcIsStub AS isStub,
|
||||
chcEndpoint AS endpoint,
|
||||
chcMinDailyBudget AS minDailyBudget,
|
||||
chcMinMonthlyBudget AS minMonthlyBudget,
|
||||
JSON_QUERY(chcSupportedObjectives) AS supportedObjectives,
|
||||
JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats,
|
||||
chcApprovalEstimateHours AS approvalEstimateHours,
|
||||
chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes,
|
||||
chcAuthMethod AS authMethod,
|
||||
chcKeyVaultSecretName AS keyVaultSecretName,
|
||||
JSON_QUERY(chcStatusMappings) AS statusMappings,
|
||||
chcSortOrder AS sortOrder,
|
||||
chcCreatedAt AS createdAt,
|
||||
chcUpdatedAt AS updatedAt
|
||||
FROM dbo.tbChannelConfig
|
||||
ORDER BY chcSortOrder, chcChannelType
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
IF @resp IS NULL SET @resp = '[]';
|
||||
SET @resp = '{"ok":true,"channels":' + @resp + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── get: return single channel by type ──
|
||||
IF @action = 'get'
|
||||
BEGIN
|
||||
DECLARE @channelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = @channelType)
|
||||
BEGIN
|
||||
SET @resp = '{"ok":false,"error":"Channel not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
chcChannelType AS channelType,
|
||||
chcDisplayName AS displayName,
|
||||
chcDescription AS [description],
|
||||
chcIcon AS icon,
|
||||
chcColor AS color,
|
||||
chcEnabled AS [enabled],
|
||||
chcIsStub AS isStub,
|
||||
chcEndpoint AS endpoint,
|
||||
chcMinDailyBudget AS minDailyBudget,
|
||||
chcMinMonthlyBudget AS minMonthlyBudget,
|
||||
JSON_QUERY(chcSupportedObjectives) AS supportedObjectives,
|
||||
JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats,
|
||||
chcApprovalEstimateHours AS approvalEstimateHours,
|
||||
chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes,
|
||||
chcAuthMethod AS authMethod,
|
||||
chcKeyVaultSecretName AS keyVaultSecretName,
|
||||
JSON_QUERY(chcStatusMappings) AS statusMappings
|
||||
FROM dbo.tbChannelConfig
|
||||
WHERE chcChannelType = @channelType
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
|
||||
SET @resp = '{"ok":true,"channel":' + @resp + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── upsert: create or update a channel (admin) ──
|
||||
IF @action = 'upsert'
|
||||
BEGIN
|
||||
DECLARE @uChannelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType');
|
||||
|
||||
IF @uChannelType IS NULL
|
||||
BEGIN
|
||||
SET @resp = '{"ok":false,"error":"channelType is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
MERGE dbo.tbChannelConfig AS tgt
|
||||
USING (SELECT @uChannelType AS chcChannelType) AS src
|
||||
ON tgt.chcChannelType = src.chcChannelType
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET
|
||||
chcDisplayName = ISNULL(JSON_VALUE(@rqst, '$.displayName'), tgt.chcDisplayName),
|
||||
chcDescription = ISNULL(JSON_VALUE(@rqst, '$.description'), tgt.chcDescription),
|
||||
chcIcon = ISNULL(JSON_VALUE(@rqst, '$.icon'), tgt.chcIcon),
|
||||
chcColor = ISNULL(JSON_VALUE(@rqst, '$.color'), tgt.chcColor),
|
||||
chcEnabled = ISNULL(CAST(JSON_VALUE(@rqst, '$.enabled') AS BIT), tgt.chcEnabled),
|
||||
chcIsStub = ISNULL(CAST(JSON_VALUE(@rqst, '$.isStub') AS BIT), tgt.chcIsStub),
|
||||
chcEndpoint = CASE WHEN JSON_VALUE(@rqst, '$.endpoint') IS NOT NULL
|
||||
THEN JSON_VALUE(@rqst, '$.endpoint')
|
||||
ELSE tgt.chcEndpoint END,
|
||||
chcInternalKey = CASE WHEN JSON_VALUE(@rqst, '$.internalKey') IS NOT NULL
|
||||
THEN JSON_VALUE(@rqst, '$.internalKey')
|
||||
ELSE tgt.chcInternalKey END,
|
||||
chcMinDailyBudget = ISNULL(CAST(JSON_VALUE(@rqst, '$.minDailyBudget') AS DECIMAL(10,2)), tgt.chcMinDailyBudget),
|
||||
chcMinMonthlyBudget = ISNULL(CAST(JSON_VALUE(@rqst, '$.minMonthlyBudget') AS DECIMAL(10,2)), tgt.chcMinMonthlyBudget),
|
||||
chcSupportedObjectives = CASE WHEN JSON_QUERY(@rqst, '$.supportedObjectives') IS NOT NULL
|
||||
THEN JSON_QUERY(@rqst, '$.supportedObjectives')
|
||||
ELSE tgt.chcSupportedObjectives END,
|
||||
chcSupportedCreativeFormats = CASE WHEN JSON_QUERY(@rqst, '$.supportedCreativeFormats') IS NOT NULL
|
||||
THEN JSON_QUERY(@rqst, '$.supportedCreativeFormats')
|
||||
ELSE tgt.chcSupportedCreativeFormats END,
|
||||
chcApprovalEstimateHours = ISNULL(CAST(JSON_VALUE(@rqst, '$.approvalEstimateHours') AS INT), tgt.chcApprovalEstimateHours),
|
||||
chcMetricsRefreshIntervalMinutes = ISNULL(CAST(JSON_VALUE(@rqst, '$.metricsRefreshIntervalMinutes') AS INT), tgt.chcMetricsRefreshIntervalMinutes),
|
||||
chcAuthMethod = ISNULL(JSON_VALUE(@rqst, '$.authMethod'), tgt.chcAuthMethod),
|
||||
chcKeyVaultSecretName = ISNULL(JSON_VALUE(@rqst, '$.keyVaultSecretName'), tgt.chcKeyVaultSecretName),
|
||||
chcStatusMappings = CASE WHEN JSON_QUERY(@rqst, '$.statusMappings') IS NOT NULL
|
||||
THEN JSON_QUERY(@rqst, '$.statusMappings')
|
||||
ELSE tgt.chcStatusMappings END,
|
||||
chcSortOrder = ISNULL(CAST(JSON_VALUE(@rqst, '$.sortOrder') AS INT), tgt.chcSortOrder),
|
||||
chcUpdatedAt = GETUTCDATE()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor,
|
||||
chcEnabled, chcIsStub, chcEndpoint, chcInternalKey,
|
||||
chcMinDailyBudget, chcMinMonthlyBudget,
|
||||
chcSupportedObjectives, chcSupportedCreativeFormats,
|
||||
chcApprovalEstimateHours, chcMetricsRefreshIntervalMinutes,
|
||||
chcAuthMethod, chcKeyVaultSecretName, chcStatusMappings, chcSortOrder)
|
||||
VALUES (
|
||||
@uChannelType,
|
||||
JSON_VALUE(@rqst, '$.displayName'),
|
||||
JSON_VALUE(@rqst, '$.description'),
|
||||
JSON_VALUE(@rqst, '$.icon'),
|
||||
JSON_VALUE(@rqst, '$.color'),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.enabled') AS BIT), 1),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.isStub') AS BIT), 1),
|
||||
JSON_VALUE(@rqst, '$.endpoint'),
|
||||
JSON_VALUE(@rqst, '$.internalKey'),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.minDailyBudget') AS DECIMAL(10,2)), 5.00),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.minMonthlyBudget') AS DECIMAL(10,2)), 150.00),
|
||||
JSON_QUERY(@rqst, '$.supportedObjectives'),
|
||||
JSON_QUERY(@rqst, '$.supportedCreativeFormats'),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.approvalEstimateHours') AS INT), 24),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.metricsRefreshIntervalMinutes') AS INT), 60),
|
||||
JSON_VALUE(@rqst, '$.authMethod'),
|
||||
JSON_VALUE(@rqst, '$.keyVaultSecretName'),
|
||||
JSON_QUERY(@rqst, '$.statusMappings'),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.sortOrder') AS INT), 0)
|
||||
);
|
||||
|
||||
SET @resp = '{"ok":true}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── delete: remove a channel (admin) ──
|
||||
IF @action = 'delete'
|
||||
BEGIN
|
||||
DECLARE @dChannelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType');
|
||||
|
||||
DELETE FROM dbo.tbChannelConfig WHERE chcChannelType = @dChannelType;
|
||||
|
||||
SET @resp = '{"ok":true,"deleted":' + CAST(@@ROWCOUNT AS VARCHAR) + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = '{"ok":false,"error":"Unknown action: ' + @action + '"}';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ── Seed Data ──────────────────────────────────────────────
|
||||
|
||||
-- Google Ads
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'google_ads')
|
||||
INSERT INTO dbo.tbChannelConfig (
|
||||
chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor,
|
||||
chcEnabled, chcIsStub, chcEndpoint,
|
||||
chcMinDailyBudget, chcMinMonthlyBudget,
|
||||
chcSupportedObjectives, chcSupportedCreativeFormats,
|
||||
chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName,
|
||||
chcStatusMappings, chcSortOrder
|
||||
) VALUES (
|
||||
'google_ads',
|
||||
'Google Ads',
|
||||
'Search, Display, Shopping & Performance Max across Google properties',
|
||||
'google', '#4285F4',
|
||||
1, 0, NULL,
|
||||
10.00, 300.00,
|
||||
'["awareness","traffic","conversions","leads","sales"]',
|
||||
'["text","image","responsive","video"]',
|
||||
24, 'mcc', 'google-ads-refresh-token',
|
||||
'{"ENABLED":"active","Enabled":"active","PAUSED":"paused","Paused":"paused","REMOVED":"cancelled","Removed":"cancelled","UNKNOWN":"error","UNSPECIFIED":"error"}',
|
||||
1
|
||||
);
|
||||
|
||||
-- Meta
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'meta')
|
||||
INSERT INTO dbo.tbChannelConfig (
|
||||
chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor,
|
||||
chcEnabled, chcIsStub, chcEndpoint,
|
||||
chcMinDailyBudget, chcMinMonthlyBudget,
|
||||
chcSupportedObjectives, chcSupportedCreativeFormats,
|
||||
chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName,
|
||||
chcStatusMappings, chcSortOrder
|
||||
) VALUES (
|
||||
'meta',
|
||||
'Meta Ads',
|
||||
'Facebook, Instagram, Messenger & Threads advertising',
|
||||
'meta', '#1877F2',
|
||||
1, 1, NULL,
|
||||
5.00, 250.00,
|
||||
'["awareness","traffic","conversions","leads","sales"]',
|
||||
'["image","video","carousel","stories"]',
|
||||
48, 'oauth2', 'meta-access-token',
|
||||
'{"ACTIVE":"active","PAUSED":"paused","DELETED":"cancelled","ARCHIVED":"completed","IN_PROCESS":"pending","WITH_ISSUES":"error","CAMPAIGN_PAUSED":"paused","ADSET_PAUSED":"paused","DISAPPROVED":"error","PREAPPROVED":"pending","PENDING_REVIEW":"pending","PENDING_BILLING_INFO":"error"}',
|
||||
2
|
||||
);
|
||||
|
||||
-- TikTok
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'tiktok')
|
||||
INSERT INTO dbo.tbChannelConfig (
|
||||
chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor,
|
||||
chcEnabled, chcIsStub, chcEndpoint,
|
||||
chcMinDailyBudget, chcMinMonthlyBudget,
|
||||
chcSupportedObjectives, chcSupportedCreativeFormats,
|
||||
chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName,
|
||||
chcStatusMappings, chcSortOrder
|
||||
) VALUES (
|
||||
'tiktok',
|
||||
'TikTok Ads',
|
||||
'In-feed video ads across TikTok and partner apps',
|
||||
'tiktok', '#000000',
|
||||
1, 1, NULL,
|
||||
20.00, 200.00,
|
||||
'["awareness","traffic","conversions","leads","sales"]',
|
||||
'["video","image","spark_ads"]',
|
||||
24, 'oauth2', 'tiktok-access-token',
|
||||
'{"ENABLE":"active","CAMPAIGN_STATUS_ENABLE":"active","DISABLE":"paused","CAMPAIGN_STATUS_DISABLE":"paused","DELETE":"cancelled","CAMPAIGN_STATUS_DELETE":"cancelled","BUDGET_EXCEED":"paused","CAMPAIGN_STATUS_BUDGET_EXCEED":"paused","ADVERTISER_AUDIT_DENY":"error","CAMPAIGN_STATUS_ADVERTISER_AUDIT_DENY":"error","NOT_DELETE":"active","ADVERTISER_AUDIT":"pending","CAMPAIGN_STATUS_ADVERTISER_AUDIT":"pending","REAUDIT":"pending","ALL":"active"}',
|
||||
3
|
||||
);
|
||||
|
||||
PRINT 'Channel config seeded successfully';
|
||||
GO
|
||||
257
Gateway/Migrations/007_ProviderStatusMap.sql
Normal file
257
Gateway/Migrations/007_ProviderStatusMap.sql
Normal file
@@ -0,0 +1,257 @@
|
||||
-- ============================================================
|
||||
-- Provider Status Mapping Reference Table
|
||||
-- ============================================================
|
||||
-- Maps provider-specific campaign statuses to platform statuses.
|
||||
-- Runtime normalization is config-driven (appsettings.json),
|
||||
-- but this table serves as:
|
||||
-- 1. Canonical reference / documentation
|
||||
-- 2. Admin-editable override (future phase)
|
||||
-- 3. Audit trail for mapping changes
|
||||
--
|
||||
-- Platform statuses: draft, staged, pending, active, paused,
|
||||
-- completed, cancelled, error
|
||||
-- ============================================================
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'tbProviderStatusMap')
|
||||
BEGIN
|
||||
CREATE TABLE dbo.tbProviderStatusMap (
|
||||
psmId INT IDENTITY(1,1) PRIMARY KEY,
|
||||
psmChannelType VARCHAR(50) NOT NULL, -- google_ads, meta, tiktok
|
||||
psmProviderStatus VARCHAR(100) NOT NULL, -- raw provider value (ENABLED, DELIVERY_OK, etc.)
|
||||
psmPlatformStatus VARCHAR(20) NOT NULL, -- normalized platform value
|
||||
psmDescription NVARCHAR(200) NULL, -- human-readable explanation
|
||||
psmIsActive BIT NOT NULL DEFAULT 1,
|
||||
psmCreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
|
||||
psmUpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
|
||||
|
||||
CONSTRAINT UQ_ProviderStatusMap_Channel_Status
|
||||
UNIQUE (psmChannelType, psmProviderStatus),
|
||||
|
||||
CONSTRAINT CK_ProviderStatusMap_PlatformStatus
|
||||
CHECK (psmPlatformStatus IN ('draft','staged','pending','active','paused','completed','cancelled','error'))
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_ProviderStatusMap_Channel
|
||||
ON dbo.tbProviderStatusMap (psmChannelType)
|
||||
INCLUDE (psmProviderStatus, psmPlatformStatus)
|
||||
WHERE psmIsActive = 1;
|
||||
END
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: Google Ads
|
||||
-- ============================================================
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (VALUES
|
||||
('google_ads', 'ENABLED', 'active', 'Campaign is serving ads'),
|
||||
('google_ads', 'Enabled', 'active', 'Campaign is serving ads (camelCase variant)'),
|
||||
('google_ads', 'PAUSED', 'paused', 'Campaign is paused by advertiser'),
|
||||
('google_ads', 'Paused', 'paused', 'Campaign is paused (camelCase variant)'),
|
||||
('google_ads', 'REMOVED', 'cancelled', 'Campaign has been removed'),
|
||||
('google_ads', 'Removed', 'cancelled', 'Campaign has been removed (camelCase variant)'),
|
||||
('google_ads', 'UNKNOWN', 'error', 'Unknown status from Google Ads API'),
|
||||
('google_ads', 'UNSPECIFIED', 'error', 'Unspecified status from Google Ads API')
|
||||
) AS src (channelType, providerStatus, platformStatus, description)
|
||||
ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description);
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: Meta (Facebook / Instagram)
|
||||
-- ============================================================
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (VALUES
|
||||
('meta', 'ACTIVE', 'active', 'Campaign is delivering'),
|
||||
('meta', 'PAUSED', 'paused', 'Campaign paused by advertiser'),
|
||||
('meta', 'DELETED', 'cancelled', 'Campaign deleted'),
|
||||
('meta', 'ARCHIVED', 'completed', 'Campaign archived after completion'),
|
||||
('meta', 'IN_PROCESS', 'pending', 'Campaign is being processed'),
|
||||
('meta', 'WITH_ISSUES', 'error', 'Campaign has delivery issues'),
|
||||
('meta', 'CAMPAIGN_PAUSED', 'paused', 'Parent campaign is paused'),
|
||||
('meta', 'ADSET_PAUSED', 'paused', 'Ad set level pause'),
|
||||
('meta', 'DISAPPROVED', 'error', 'Ad/campaign disapproved by review'),
|
||||
('meta', 'PREAPPROVED', 'pending', 'Preapproved, awaiting final review'),
|
||||
('meta', 'PENDING_REVIEW', 'pending', 'Awaiting Meta ad review'),
|
||||
('meta', 'PENDING_BILLING_INFO','error', 'Billing information required')
|
||||
) AS src (channelType, providerStatus, platformStatus, description)
|
||||
ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description);
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: TikTok
|
||||
-- ============================================================
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (VALUES
|
||||
('tiktok', 'ENABLE', 'active', 'Campaign is active and delivering'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_ENABLE', 'active', 'Campaign enabled (prefixed variant)'),
|
||||
('tiktok', 'DISABLE', 'paused', 'Campaign disabled by advertiser'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_DISABLE', 'paused', 'Campaign disabled (prefixed variant)'),
|
||||
('tiktok', 'DELETE', 'cancelled', 'Campaign deleted'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_DELETE', 'cancelled', 'Campaign deleted (prefixed variant)'),
|
||||
('tiktok', 'BUDGET_EXCEED', 'paused', 'Budget limit exceeded'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_BUDGET_EXCEED', 'paused', 'Budget exceeded (prefixed variant)'),
|
||||
('tiktok', 'ADVERTISER_AUDIT_DENY', 'error', 'Advertiser account audit denied'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_ADVERTISER_AUDIT_DENY','error', 'Audit denied (prefixed variant)'),
|
||||
('tiktok', 'NOT_DELETE', 'active', 'Campaign exists and is not deleted'),
|
||||
('tiktok', 'ADVERTISER_AUDIT', 'pending', 'Advertiser account under audit'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_ADVERTISER_AUDIT', 'pending', 'Under audit (prefixed variant)'),
|
||||
('tiktok', 'REAUDIT', 'pending', 'Campaign under re-audit'),
|
||||
('tiktok', 'ALL', 'active', 'TikTok ALL filter status (treat as active)')
|
||||
) AS src (channelType, providerStatus, platformStatus, description)
|
||||
ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description);
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: Common / Internal (platform-generated statuses)
|
||||
-- ============================================================
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (VALUES
|
||||
('_common', 'submitted', 'active', 'Successfully dispatched to provider'),
|
||||
('_common', 'pending_review', 'pending', 'Awaiting provider review'),
|
||||
('_common', 'stub_provider', 'pending', 'Stub provider — no real dispatch yet'),
|
||||
('_common', 'approved', 'active', 'Provider approved the campaign'),
|
||||
('_common', 'rejected', 'error', 'Provider rejected the campaign'),
|
||||
('_common', 'suspended', 'paused', 'Campaign suspended by provider'),
|
||||
('_common', 'budget_depleted', 'paused', 'Budget fully consumed'),
|
||||
('_common', 'expired', 'completed', 'Campaign reached its end date'),
|
||||
('_common', 'archived', 'completed', 'Campaign archived'),
|
||||
('_common', 'deleted', 'cancelled', 'Campaign deleted'),
|
||||
('_common', 'in_process', 'pending', 'Campaign is being processed'),
|
||||
('_common', 'in_review', 'pending', 'Campaign is under review'),
|
||||
('_common', 'learning', 'active', 'Campaign in learning/optimization phase'),
|
||||
('_common', 'limited', 'active', 'Campaign serving but limited (budget, targeting)')
|
||||
) AS src (channelType, providerStatus, platformStatus, description)
|
||||
ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description);
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Stored Procedure: spProviderStatusMap
|
||||
-- ============================================================
|
||||
-- Actions: list, get, upsert, delete
|
||||
-- Follows standard JSON request/response pattern.
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE dbo.spProviderStatusMap
|
||||
@Action VARCHAR(20),
|
||||
@Rqst NVARCHAR(MAX) = '{}',
|
||||
@Resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
-- ── LIST ──
|
||||
IF @Action = 'list'
|
||||
BEGIN
|
||||
DECLARE @filterChannel VARCHAR(50) = JSON_VALUE(@Rqst, '$.channelType');
|
||||
|
||||
SET @Resp = (
|
||||
SELECT
|
||||
psmId AS id,
|
||||
psmChannelType AS channelType,
|
||||
psmProviderStatus AS providerStatus,
|
||||
psmPlatformStatus AS platformStatus,
|
||||
psmDescription AS [description],
|
||||
psmIsActive AS isActive
|
||||
FROM dbo.tbProviderStatusMap
|
||||
WHERE psmIsActive = 1
|
||||
AND (@filterChannel IS NULL OR psmChannelType = @filterChannel)
|
||||
ORDER BY psmChannelType, psmProviderStatus
|
||||
FOR JSON PATH, ROOT('data')
|
||||
);
|
||||
|
||||
IF @Resp IS NULL SET @Resp = '{"data":[]}';
|
||||
SET @Resp = '{"ok":true,' + SUBSTRING(@Resp, 2, LEN(@Resp));
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── GET ──
|
||||
IF @Action = 'get'
|
||||
BEGIN
|
||||
DECLARE @getId INT = JSON_VALUE(@Rqst, '$.id');
|
||||
|
||||
SET @Resp = (
|
||||
SELECT
|
||||
psmId AS id,
|
||||
psmChannelType AS channelType,
|
||||
psmProviderStatus AS providerStatus,
|
||||
psmPlatformStatus AS platformStatus,
|
||||
psmDescription AS [description],
|
||||
psmIsActive AS isActive
|
||||
FROM dbo.tbProviderStatusMap
|
||||
WHERE psmId = @getId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
|
||||
IF @Resp IS NULL
|
||||
BEGIN
|
||||
SET @Resp = '{"ok":false,"error":"Mapping not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @Resp = '{"ok":true,"data":' + @Resp + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── UPSERT ──
|
||||
IF @Action = 'upsert'
|
||||
BEGIN
|
||||
DECLARE @uChannelType VARCHAR(50) = JSON_VALUE(@Rqst, '$.channelType');
|
||||
DECLARE @uProviderStatus VARCHAR(100) = JSON_VALUE(@Rqst, '$.providerStatus');
|
||||
DECLARE @uPlatformStatus VARCHAR(20) = JSON_VALUE(@Rqst, '$.platformStatus');
|
||||
DECLARE @uDescription NVARCHAR(200) = JSON_VALUE(@Rqst, '$.description');
|
||||
|
||||
IF @uChannelType IS NULL OR @uProviderStatus IS NULL OR @uPlatformStatus IS NULL
|
||||
BEGIN
|
||||
SET @Resp = '{"ok":false,"error":"channelType, providerStatus, and platformStatus are required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF @uPlatformStatus NOT IN ('draft','staged','pending','active','paused','completed','cancelled','error')
|
||||
BEGIN
|
||||
SET @Resp = '{"ok":false,"error":"Invalid platformStatus. Must be: draft, staged, pending, active, paused, completed, cancelled, error"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (SELECT @uChannelType, @uProviderStatus) AS src (ct, ps)
|
||||
ON tgt.psmChannelType = src.ct AND tgt.psmProviderStatus = src.ps
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET
|
||||
psmPlatformStatus = @uPlatformStatus,
|
||||
psmDescription = COALESCE(@uDescription, psmDescription),
|
||||
psmIsActive = 1,
|
||||
psmUpdatedAt = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (@uChannelType, @uProviderStatus, @uPlatformStatus, @uDescription);
|
||||
|
||||
SET @Resp = '{"ok":true,"message":"Mapping saved"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── DELETE (soft) ──
|
||||
IF @Action = 'delete'
|
||||
BEGIN
|
||||
DECLARE @dId INT = JSON_VALUE(@Rqst, '$.id');
|
||||
|
||||
UPDATE dbo.tbProviderStatusMap
|
||||
SET psmIsActive = 0, psmUpdatedAt = SYSUTCDATETIME()
|
||||
WHERE psmId = @dId;
|
||||
|
||||
SET @Resp = '{"ok":true,"message":"Mapping deactivated"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @Resp = '{"ok":false,"error":"Unknown action: ' + ISNULL(@Action,'null') + '"}';
|
||||
END
|
||||
GO
|
||||
174
Gateway/Migrations/SecurityHardening.sql
Normal file
174
Gateway/Migrations/SecurityHardening.sql
Normal file
@@ -0,0 +1,174 @@
|
||||
-- ════════════════════════════════════════════════════════════════
|
||||
-- SECURITY HARDENING: Stored Procedure Ownership Enforcement
|
||||
-- ════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- PURPOSE: Add WHERE clientId checks to all stored procedures
|
||||
-- that accept initiativeId, channelCampaignId, or wizardId.
|
||||
--
|
||||
-- The Gateway now passes clientId in all JSON requests.
|
||||
-- These proc changes enforce ownership at the database level
|
||||
-- as a SECOND layer of defense (the Gateway guard is the first).
|
||||
--
|
||||
-- APPLY: Run against your AdPlatform SQL Server database.
|
||||
-- TEST FIRST in dev/staging before production.
|
||||
-- ════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- PATTERN: Inside each proc's @Action handler, add:
|
||||
--
|
||||
-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId');
|
||||
--
|
||||
-- -- Then in every SELECT/UPDATE/DELETE that references an initiative:
|
||||
-- WHERE i.initiativeId = @initiativeId
|
||||
-- AND i.clientId = @clientId -- ← ADD THIS
|
||||
--
|
||||
-- -- If the WHERE filters out the row, return "not found"
|
||||
-- -- (same response as non-existent ID — prevents enumeration)
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT '=== Security Hardening Migration ==='
|
||||
PRINT ''
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 1. spInitiative — get, update, updateStatus, delete
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Hardening spInitiative...'
|
||||
|
||||
-- Example pattern for the "get" action:
|
||||
-- (Apply this pattern to get, update, updateStatus, delete actions)
|
||||
--
|
||||
-- Current:
|
||||
-- SELECT ... FROM tbInitiative WHERE initiativeId = @initiativeId
|
||||
--
|
||||
-- Hardened:
|
||||
-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId')
|
||||
-- SELECT ... FROM tbInitiative
|
||||
-- WHERE initiativeId = @initiativeId
|
||||
-- AND (@clientId IS NULL OR clientId = @clientId)
|
||||
--
|
||||
-- The @clientId IS NULL fallback allows internal/system calls
|
||||
-- (like InitiativeLaunchService) that don't pass clientId to still work.
|
||||
|
||||
-- IMPORTANT: Apply to each action in spInitiative:
|
||||
-- 'get' → WHERE initiativeId = @id AND (@clientId IS NULL OR clientId = @clientId)
|
||||
-- 'update' → same
|
||||
-- 'updateStatus' → same
|
||||
-- 'delete' → same
|
||||
-- 'list' → already scoped by clientId (verify it uses = not LIKE)
|
||||
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 2. spChannelCampaign — get, sync
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Hardening spChannelCampaign...'
|
||||
|
||||
-- Channel campaigns link to initiatives, so ownership requires a JOIN:
|
||||
--
|
||||
-- Current:
|
||||
-- SELECT cc.* FROM tbChannelCampaign cc WHERE cc.channelCampaignId = @id
|
||||
--
|
||||
-- Hardened:
|
||||
-- SELECT cc.*
|
||||
-- FROM tbChannelCampaign cc
|
||||
-- JOIN tbInitiative i ON cc.initiativeId = i.initiativeId
|
||||
-- WHERE cc.channelCampaignId = @id
|
||||
-- AND (@clientId IS NULL OR i.clientId = @clientId)
|
||||
--
|
||||
-- For 'sync' action: This is now admin-only in the Gateway,
|
||||
-- but add the JOIN anyway for defense in depth.
|
||||
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 3. spCampaignWizard — get, updateStep, setStep, submit, updateStatus, delete
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Hardening spCampaignWizard...'
|
||||
|
||||
-- Current:
|
||||
-- SELECT ... FROM tbCampaignWizard WHERE wizardId = @wizardId
|
||||
--
|
||||
-- Hardened:
|
||||
-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId')
|
||||
-- SELECT ... FROM tbCampaignWizard
|
||||
-- WHERE wizardId = @wizardId
|
||||
-- AND (@clientId IS NULL OR clientId = @clientId)
|
||||
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 4. spAllocation — all actions
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Hardening spAllocation...'
|
||||
|
||||
-- Allocations link to initiatives:
|
||||
--
|
||||
-- Hardened:
|
||||
-- JOIN tbInitiative i ON a.initiativeId = i.initiativeId
|
||||
-- WHERE a.initiativeId = @initiativeId
|
||||
-- AND (@clientId IS NULL OR i.clientId = @clientId)
|
||||
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 5. Status transition validation at DB level (optional extra layer)
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Adding status transition function...'
|
||||
|
||||
-- Create a function the procs can call to validate transitions:
|
||||
IF OBJECT_ID('dbo.fnIsValidStatusTransition', 'FN') IS NOT NULL
|
||||
DROP FUNCTION dbo.fnIsValidStatusTransition
|
||||
GO
|
||||
|
||||
CREATE FUNCTION dbo.fnIsValidStatusTransition(
|
||||
@currentStatus VARCHAR(20),
|
||||
@requestedStatus VARCHAR(20),
|
||||
@isSystem BIT = 0 -- 1 = system/admin (broader transitions allowed)
|
||||
)
|
||||
RETURNS BIT
|
||||
AS
|
||||
BEGIN
|
||||
-- System can do anything
|
||||
IF @isSystem = 1 RETURN 1
|
||||
|
||||
-- Client-allowed transitions
|
||||
IF @currentStatus = 'active' AND @requestedStatus = 'paused' RETURN 1
|
||||
IF @currentStatus = 'paused' AND @requestedStatus = 'active' RETURN 1
|
||||
IF @currentStatus IN ('draft','staged','pending','active','paused')
|
||||
AND @requestedStatus = 'cancelled' RETURN 1
|
||||
|
||||
RETURN 0
|
||||
END
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 6. spGoogleAccount — validate
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Verifying spGoogleAccount...'
|
||||
|
||||
-- The validate action should verify that the customerId
|
||||
-- belongs to the requesting client. Current implementation
|
||||
-- may not check this — verify and add:
|
||||
--
|
||||
-- WHERE a.customerId = @customerId
|
||||
-- AND a.clientId = @clientId
|
||||
|
||||
GO
|
||||
|
||||
PRINT ''
|
||||
PRINT '=== Migration complete ==='
|
||||
PRINT 'NOTE: This is a TEMPLATE. Review each stored procedure and apply'
|
||||
PRINT 'the ownership WHERE clauses to match your exact table/column names.'
|
||||
PRINT ''
|
||||
PRINT 'After applying, test:'
|
||||
PRINT ' 1. Normal user can only see their own initiatives/wizards'
|
||||
PRINT ' 2. User A cannot access User B resources by guessing IDs'
|
||||
PRINT ' 3. LaunchService (no clientId) can still read initiatives'
|
||||
PRINT ' 4. Admin role can sync channel status'
|
||||
GO
|
||||
137
Gateway/Models/ForecastModels.cs
Normal file
137
Gateway/Models/ForecastModels.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
namespace Gateway.Models;
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Request: Client → Gateway
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class ChannelForecastRequest
|
||||
{
|
||||
/// <summary>Advertising objective: awareness, traffic, leads, sales</summary>
|
||||
public string Objective { get; set; } = "traffic";
|
||||
|
||||
/// <summary>Business category from wizard Step 1</summary>
|
||||
public string? BusinessCategory { get; set; }
|
||||
|
||||
/// <summary>Keywords from URL analysis (Step 1)</summary>
|
||||
public List<string> Keywords { get; set; } = new();
|
||||
|
||||
/// <summary>Geo targeting from audience step</summary>
|
||||
public ForecastGeoTargeting? GeoTargeting { get; set; }
|
||||
|
||||
/// <summary>Audience parameters from Step 2</summary>
|
||||
public ForecastAudience? Audience { get; set; }
|
||||
|
||||
/// <summary>Monthly budget in whole dollars</summary>
|
||||
public decimal MonthlyBudget { get; set; }
|
||||
|
||||
/// <summary>Channels to estimate (defaults to all selected)</summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForecastGeoTargeting
|
||||
{
|
||||
public List<string>? ZipCodes { get; set; }
|
||||
public double? RadiusMiles { get; set; }
|
||||
public List<long>? GeoTargetIds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForecastAudience
|
||||
{
|
||||
public int? AgeMin { get; set; }
|
||||
public int? AgeMax { get; set; }
|
||||
public List<string>? Genders { get; set; }
|
||||
public List<string>? Interests { get; set; }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Response: Gateway → Client (normalized)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class ChannelForecastResponse
|
||||
{
|
||||
public bool Ok { get; set; } = true;
|
||||
public string Objective { get; set; } = string.Empty;
|
||||
public decimal TotalBudget { get; set; }
|
||||
public List<ChannelEstimate> Channels { get; set; } = new();
|
||||
public ForecastRecommendation? Recommendation { get; set; }
|
||||
public ForecastMeta Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ChannelEstimate
|
||||
{
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public int AllocationPercent { get; set; }
|
||||
public decimal AllocatedBudget { get; set; }
|
||||
public ChannelEstimateMetrics Estimates { get; set; } = new();
|
||||
public double EfficiencyScore { get; set; }
|
||||
public string StrengthLabel { get; set; } = string.Empty;
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "none";
|
||||
}
|
||||
|
||||
public sealed class ChannelEstimateMetrics
|
||||
{
|
||||
public double Impressions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForecastRecommendation
|
||||
{
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public List<string> Highlights { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ForecastMeta
|
||||
{
|
||||
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string ForecastPeriod { get; set; } = "30 days";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Objective-weighted scoring
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class MetricWeights
|
||||
{
|
||||
public double Reach { get; }
|
||||
public double Impressions { get; }
|
||||
public double Cpm { get; }
|
||||
public double Clicks { get; }
|
||||
public double Cpc { get; }
|
||||
public double Ctr { get; }
|
||||
public double Conversions { get; }
|
||||
public double Cpa { get; }
|
||||
|
||||
public MetricWeights(double reach, double impressions, double cpm,
|
||||
double clicks, double cpc, double ctr, double conversions, double cpa)
|
||||
{
|
||||
Reach = reach; Impressions = impressions; Cpm = cpm;
|
||||
Clicks = clicks; Cpc = cpc; Ctr = ctr;
|
||||
Conversions = conversions; Cpa = cpa;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ObjectiveWeights
|
||||
{
|
||||
public static readonly Dictionary<string, MetricWeights> Weights = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// reach imp cpm clicks cpc ctr conv cpa
|
||||
["awareness"] = new MetricWeights(0.35, 0.25, 0.20, 0.05, 0.05, 0.05, 0.00, 0.00),
|
||||
["traffic"] = new MetricWeights(0.05, 0.10, 0.10, 0.30, 0.30, 0.15, 0.00, 0.00),
|
||||
["leads"] = new MetricWeights(0.05, 0.05, 0.05, 0.15, 0.15, 0.10, 0.25, 0.20),
|
||||
["sales"] = new MetricWeights(0.05, 0.05, 0.05, 0.10, 0.10, 0.10, 0.30, 0.25),
|
||||
};
|
||||
|
||||
/// <summary>Fallback: balanced weights if objective not recognized</summary>
|
||||
public static readonly MetricWeights Default =
|
||||
new(0.10, 0.10, 0.10, 0.20, 0.20, 0.10, 0.10, 0.10);
|
||||
|
||||
public static MetricWeights For(string objective)
|
||||
=> Weights.TryGetValue(objective, out var w) ? w : Default;
|
||||
}
|
||||
109
Gateway/Models/MultiChannelConfig.cs
Normal file
109
Gateway/Models/MultiChannelConfig.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Gateway.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a single advertising channel provider.
|
||||
/// Populated from database (tbChannelConfig) via ChannelConfigService.
|
||||
/// Drives Gateway routing, wizard behavior, and validation.
|
||||
/// </summary>
|
||||
public sealed class ProviderConfig
|
||||
{
|
||||
/// <summary>Channel type key (e.g., "google_ads", "meta", "tiktok").</summary>
|
||||
public string ChannelType { get; set; } = "";
|
||||
|
||||
/// <summary>Display name for UI.</summary>
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
/// <summary>Short description shown in channel selection.</summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Whether this channel is currently available for new campaigns.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Provider service endpoint URL (null = stub/disabled).</summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>Internal API key for provider service authentication.</summary>
|
||||
public string? InternalKey { get; set; }
|
||||
|
||||
/// <summary>Whether this is a stub container (test mode).</summary>
|
||||
public bool IsStub { get; set; }
|
||||
|
||||
/// <summary>Minimum daily budget in USD for this channel.</summary>
|
||||
public decimal MinDailyBudget { get; set; }
|
||||
|
||||
/// <summary>Minimum monthly budget in USD for this channel.</summary>
|
||||
public decimal MinMonthlyBudget { get; set; }
|
||||
|
||||
/// <summary>Supported unified objectives (awareness, traffic, conversions, leads, sales).</summary>
|
||||
public List<string> SupportedObjectives { get; set; } = new();
|
||||
|
||||
/// <summary>Supported creative formats (text, image, video, carousel, etc.).</summary>
|
||||
public List<string> SupportedCreativeFormats { get; set; } = new();
|
||||
|
||||
/// <summary>Estimated approval time in hours.</summary>
|
||||
public int ApprovalEstimateHours { get; set; }
|
||||
|
||||
/// <summary>How often to refresh metrics from this provider (minutes).</summary>
|
||||
public int MetricsRefreshIntervalMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>Icon identifier for UI (e.g., "google", "meta", "tiktok").</summary>
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>Brand color hex for UI (e.g., "#4285F4").</summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>Auth method used by provider (oauth2, api_key, mcc).</summary>
|
||||
public string? AuthMethod { get; set; }
|
||||
|
||||
/// <summary>Key Vault secret name for provider credentials.</summary>
|
||||
public string? KeyVaultSecretName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps provider-specific status strings to platform statuses.
|
||||
/// Keys are raw provider values (case-insensitive), values are platform statuses
|
||||
/// (draft, staged, pending, active, paused, completed, cancelled, error).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> StatusMappings { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global allocation and multi-channel settings.
|
||||
/// Loaded from appsettings.json "MultiChannel:Allocation" section (simple scalars only).
|
||||
/// </summary>
|
||||
public sealed class AllocationSettings
|
||||
{
|
||||
public decimal MinMultiChannelMonthlyBudget { get; set; } = 500.00m;
|
||||
public int MaxChannelsPerInitiative { get; set; } = 5;
|
||||
public string DefaultAllocationStrategy { get; set; } = "template";
|
||||
public int PerformanceEvalIntervalDays { get; set; } = 7;
|
||||
public int PerformanceLookbackDays { get; set; } = 14;
|
||||
public int PerformanceLearningPeriodDays { get; set; } = 14;
|
||||
public decimal MaxAllocationShiftPct { get; set; } = 15.00m;
|
||||
public decimal MinChannelAllocationPct { get; set; } = 10.00m;
|
||||
public decimal MaxChannelAllocationPct { get; set; } = 80.00m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root multi-channel configuration.
|
||||
/// Channels are populated from DB via ChannelConfigService at startup.
|
||||
/// Allocation settings loaded from appsettings.json (simple scalars).
|
||||
/// </summary>
|
||||
public sealed class MultiChannelConfig
|
||||
{
|
||||
/// <summary>Per-provider configurations, keyed by channel type.</summary>
|
||||
public Dictionary<string, ProviderConfig> Channels { get; set; } = new();
|
||||
|
||||
/// <summary>Global allocation settings.</summary>
|
||||
public AllocationSettings Allocation { get; set; } = new();
|
||||
|
||||
/// <summary>Get only enabled providers.</summary>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<ProviderConfig> EnabledChannels =>
|
||||
Channels.Values.Where(c => c.Enabled);
|
||||
|
||||
/// <summary>Look up a provider config by channel type.</summary>
|
||||
public ProviderConfig? GetChannel(string channelType) =>
|
||||
Channels.TryGetValue(channelType, out var config) ? config : null;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Azure.Storage.Blobs;
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Gateway.ProviderClients;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
@@ -20,7 +22,18 @@ builder.Services.AddSwaggerGen();
|
||||
|
||||
// Data & business services
|
||||
builder.Services.AddScoped<SqlService>();
|
||||
builder.Services.AddScoped<ExecutionService>();
|
||||
|
||||
// Channel configuration (loaded from DB at startup, not appsettings)
|
||||
builder.Services.AddSingleton<ChannelConfigService>();
|
||||
|
||||
// For consumers injecting MultiChannelConfig directly (e.g. ExecutionService)
|
||||
builder.Services.AddScoped<MultiChannelConfig>(sp =>
|
||||
sp.GetRequiredService<ChannelConfigService>().Current);
|
||||
|
||||
// For consumers injecting IOptions<MultiChannelConfig> (e.g. InitiativeController, InitiativeLaunchService)
|
||||
builder.Services.AddSingleton<Microsoft.Extensions.Options.IOptions<MultiChannelConfig>>(sp =>
|
||||
Microsoft.Extensions.Options.Options.Create(
|
||||
sp.GetRequiredService<ChannelConfigService>().Current));
|
||||
|
||||
// Authentication context (scoped - one per request)
|
||||
builder.Services.AddScoped<ClientContext>();
|
||||
@@ -38,8 +51,86 @@ builder.Services.AddHttpClient<GoogleProviderClient>(client =>
|
||||
// HTTP client factory for ExecutionService
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// --------------------
|
||||
// Blob Storage (for Creative images)
|
||||
// --------------------
|
||||
var blobConnectionString = builder.Configuration["BlobStorage:ConnectionString"]
|
||||
?? Environment.GetEnvironmentVariable("BLOB_STORAGE_CONNECTION_STRING");
|
||||
|
||||
if (!string.IsNullOrEmpty(blobConnectionString))
|
||||
{
|
||||
builder.Services.AddSingleton(new BlobServiceClient(blobConnectionString));
|
||||
Console.WriteLine("[Gateway] Blob storage configured");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Register null so DI can resolve ImageStorageService
|
||||
builder.Services.AddSingleton<BlobServiceClient>(sp => null!);
|
||||
Console.WriteLine("[Gateway] Blob storage not configured - Creative images will use source URLs");
|
||||
}
|
||||
|
||||
// ImageStorageService (works with or without blob storage configured)
|
||||
builder.Services.AddScoped<ImageStorageService>();
|
||||
|
||||
// ExecutionService (depends on ImageStorageService)
|
||||
builder.Services.AddScoped<ExecutionService>();
|
||||
|
||||
// Metric sync orchestration (pulls from providers, writes to DB, triggers evaluation)
|
||||
builder.Services.AddScoped<MetricSyncService>();
|
||||
|
||||
// Initiative launch orchestration service
|
||||
builder.Services.AddScoped<InitiativeLaunchService>();
|
||||
|
||||
// Authorization guard (ownership, roles, status transitions)
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<AuthorizationGuard>();
|
||||
|
||||
// Provider status normalization
|
||||
builder.Services.AddSingleton<ProviderStatusNormalizer>();
|
||||
|
||||
// Forecast service for channel performance estimates (local fallback)
|
||||
builder.Services.AddSingleton<ForecastService>();
|
||||
|
||||
// IntelligenceApi client — routes forecast requests to the category-aware engine container
|
||||
// Falls back to ForecastService if INTELLIGENCE_API_URL is not configured
|
||||
builder.Services.AddSingleton<IntelligenceApiClient>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Load channel config from database (before serving requests)
|
||||
// ────────────────────────────────────────────────
|
||||
try
|
||||
{
|
||||
var channelConfigSvc = app.Services.GetRequiredService<ChannelConfigService>();
|
||||
await channelConfigSvc.LoadAsync();
|
||||
Console.WriteLine("[Gateway] Channel config loaded from database");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Gateway] ⚠️ Channel config DB load failed — using defaults: {ex.Message}");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// SECURITY: Startup environment checks
|
||||
// ────────────────────────────────────────────────
|
||||
var env = app.Environment;
|
||||
var allowDevBypass = builder.Configuration.GetValue<bool>("Auth:AllowDevBypass");
|
||||
|
||||
if (allowDevBypass && !env.IsDevelopment())
|
||||
{
|
||||
Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ ⚠️ WARNING: Auth:AllowDevBypass=true in NON-DEV env! ║");
|
||||
Console.WriteLine("║ This allows X-Dev-ClientId header to bypass auth. ║");
|
||||
Console.WriteLine("║ Remove this setting in production! ║");
|
||||
Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
|
||||
}
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
Console.WriteLine("[Gateway] ⚠️ Development mode — dev bypass headers accepted");
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// Middleware pipeline
|
||||
// --------------------
|
||||
@@ -49,12 +140,31 @@ app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// Health check endpoint (before auth & logging)
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
app.MapGet("/health", (ChannelConfigService channelSvc, IConfiguration config) =>
|
||||
{
|
||||
ok = true,
|
||||
service = "Gateway",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
var blobConfigured = !string.IsNullOrEmpty(config["BlobStorage:ConnectionString"]) ||
|
||||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BLOB_STORAGE_CONNECTION_STRING"));
|
||||
|
||||
var mcConfig = channelSvc.Current;
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "Gateway",
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
config = new
|
||||
{
|
||||
blobStorageConfigured = blobConfigured,
|
||||
blobContainer = config["BlobStorage:ContainerName"] ?? "creative-images",
|
||||
enabledChannels = mcConfig.EnabledChannels.Select(c => new
|
||||
{
|
||||
c.ChannelType,
|
||||
c.DisplayName,
|
||||
c.IsStub
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Root endpoint
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
|
||||
396
Gateway/Security/AuthorizationGuard.cs
Normal file
396
Gateway/Security/AuthorizationGuard.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized authorization guard for resource ownership, role checks,
|
||||
/// and status transition enforcement.
|
||||
///
|
||||
/// DEFENSE IN DEPTH:
|
||||
/// Layer 1: Middleware authenticates session → populates ClientContext
|
||||
/// Layer 2: This guard validates resource ownership before operations
|
||||
/// Layer 3: Stored procs SHOULD also have WHERE clientId = @clientId
|
||||
///
|
||||
/// All public methods return (bool Allowed, string? Error) to keep
|
||||
/// controller code clean and consistent.
|
||||
/// </summary>
|
||||
public sealed class AuthorizationGuard
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ILogger<AuthorizationGuard> _log;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public AuthorizationGuard(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
ILogger<AuthorizationGuard> log,
|
||||
IConfiguration config,
|
||||
IHttpContextAccessor http)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_log = log;
|
||||
_config = config;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// SERVICE KEY CHECK
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate an internal service-to-service call via X-Service-Key header.
|
||||
/// Used by provider containers and background services that cannot carry
|
||||
/// a CIAM session token. Configure via INTERNAL_SERVICE_KEY env var.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) RequireServiceKey()
|
||||
{
|
||||
var expected = _config["INTERNAL_SERVICE_KEY"];
|
||||
if (string.IsNullOrWhiteSpace(expected))
|
||||
{
|
||||
_log.LogWarning("[AuthZ] INTERNAL_SERVICE_KEY not configured — service key check denied");
|
||||
return (false, "Service key not configured");
|
||||
}
|
||||
|
||||
var provided = _http.HttpContext?.Request.Headers["X-Service-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(provided) || provided != expected)
|
||||
{
|
||||
_log.LogWarning("[AuthZ] Invalid or missing X-Service-Key");
|
||||
return (false, "Valid service key required");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// BASIC AUTH CHECKS
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>Require authenticated session with a valid ClientId.</summary>
|
||||
public (bool Ok, string? Error) RequireAuth()
|
||||
{
|
||||
if (!_client.IsAuthenticated)
|
||||
return (false, "Authentication required");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>Require specific role(s). Case-insensitive.</summary>
|
||||
public (bool Ok, string? Error) RequireRole(params string[] allowedRoles)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return (ok, err);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_client.Role))
|
||||
return (false, "No role assigned");
|
||||
|
||||
if (!allowedRoles.Any(r => string.Equals(_client.Role, r, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Role denied | ClientId={ClientId} Role={Role} Required={Required}",
|
||||
_client.ClientId, _client.Role, string.Join(",", allowedRoles));
|
||||
return (false, "Insufficient permissions");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>Require admin role.</summary>
|
||||
public (bool Ok, string? Error) RequireAdmin()
|
||||
=> RequireRole("admin");
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// INITIATIVE OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify initiative belongs to the authenticated client.
|
||||
/// Returns the initiative JSON on success (avoids double-fetch).
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyInitiativeOwnerAsync(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Initiative, "get",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Initiative not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Initiative not found");
|
||||
|
||||
// Extract clientId from response — check both clean and prefixed shapes
|
||||
var initiative = root.TryGetProperty("initiative", out var initEl) ? initEl : root;
|
||||
var ownerClientId =
|
||||
initiative.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() :
|
||||
initiative.TryGetProperty("iniClientId", out var iniCidProp) ? iniCidProp.GetString() :
|
||||
null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerClientId))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Initiative {Id} has no clientId — ownership check inconclusive, denying",
|
||||
initiativeId);
|
||||
return OwnershipResult.Denied("Initiative ownership could not be verified");
|
||||
}
|
||||
|
||||
if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] IDOR attempt | InitiativeId={InitiativeId} Owner={Owner} Requester={Requester}",
|
||||
initiativeId, ownerClientId, _client.ClientId);
|
||||
return OwnershipResult.Denied("Initiative not found"); // Don't reveal existence
|
||||
}
|
||||
|
||||
// Extract current status for transition validation — check both shapes
|
||||
var status =
|
||||
initiative.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
initiative.TryGetProperty("iniStatus", out var iniStProp) ? iniStProp.GetString() :
|
||||
null;
|
||||
|
||||
return OwnershipResult.Allowed(resp, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Ownership check failed for initiative {Id}", initiativeId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// CHANNEL CAMPAIGN OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify channel campaign belongs to the authenticated client.
|
||||
/// Follows channelCampaign → initiative → client ownership chain.
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyChannelOwnerAsync(long channelCampaignId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelCampaign, "get",
|
||||
JsonSerializer.Serialize(new { channelCampaignId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
// Get initiativeId, then check initiative ownership
|
||||
var campaign = root.TryGetProperty("channelCampaign", out var ccEl) ? ccEl : root;
|
||||
var initiativeId =
|
||||
campaign.TryGetProperty("initiativeId", out var initIdProp) ? initIdProp.GetInt64() :
|
||||
campaign.TryGetProperty("chcInitiativeId", out var chcInitProp) ? chcInitProp.GetInt64() :
|
||||
0;
|
||||
|
||||
if (initiativeId <= 0)
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
// Delegate to initiative ownership check
|
||||
return await VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Channel ownership check failed for {Id}", channelCampaignId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// WIZARD OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify wizard belongs to the authenticated client.
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyWizardOwnerAsync(string wizardId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
"dbo.spCampaignWizard", "get",
|
||||
JsonSerializer.Serialize(new { wizardId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
|
||||
var wizard = root.TryGetProperty("wizard", out var wzEl) ? wzEl : root;
|
||||
var ownerClientId =
|
||||
wizard.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() :
|
||||
wizard.TryGetProperty("wizClientId", out var wzCidProp) ? wzCidProp.GetString() :
|
||||
null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerClientId))
|
||||
{
|
||||
_log.LogWarning("[AuthZ] Wizard {Id} has no clientId", wizardId);
|
||||
return OwnershipResult.Denied("Wizard ownership could not be verified");
|
||||
}
|
||||
|
||||
if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] IDOR attempt | WizardId={WizardId} Owner={Owner} Requester={Requester}",
|
||||
wizardId, ownerClientId, _client.ClientId);
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
}
|
||||
|
||||
var status =
|
||||
wizard.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
wizard.TryGetProperty("wizStatus", out var wzStProp) ? wzStProp.GetString() :
|
||||
null;
|
||||
return OwnershipResult.Allowed(resp, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Wizard ownership check failed for {Id}", wizardId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// STATUS TRANSITION VALIDATION
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a status transition is allowed for client-initiated actions.
|
||||
/// Internal/system transitions (from launch service, provider callbacks) bypass this.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) ValidateClientStatusTransition(
|
||||
string? currentStatus, string requestedStatus, string resourceType = "initiative")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requestedStatus))
|
||||
return (false, "Status is required");
|
||||
|
||||
// Normalize
|
||||
var from = (currentStatus ?? "").ToLowerInvariant();
|
||||
var to = requestedStatus.ToLowerInvariant();
|
||||
|
||||
// Client-allowed transitions (restrictive)
|
||||
var allowed = IsClientTransitionAllowed(from, to);
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Invalid status transition | {ResourceType} {From} → {To} by ClientId={ClientId}",
|
||||
resourceType, from, to, _client.ClientId);
|
||||
return (false, $"Cannot change {resourceType} from '{from}' to '{to}'");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whitelist of client-allowed transitions.
|
||||
/// Everything else requires admin or system action.
|
||||
/// </summary>
|
||||
private static bool IsClientTransitionAllowed(string from, string to)
|
||||
{
|
||||
return (from, to) switch
|
||||
{
|
||||
// Pausing: only active campaigns can be paused
|
||||
("active", "paused") => true,
|
||||
|
||||
// Resuming: only paused campaigns can be resumed
|
||||
("paused", "active") => true,
|
||||
|
||||
// Cancelling: clients can cancel from most pre-completion states
|
||||
("draft", "cancelled") => true,
|
||||
("staged", "cancelled") => true,
|
||||
("pending", "cancelled") => true,
|
||||
("active", "cancelled") => true,
|
||||
("paused", "cancelled") => true,
|
||||
|
||||
// Everything else is denied at the client level
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// BUDGET VALIDATION
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate budget against channel minimums.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) ValidateBudget(
|
||||
decimal totalBudget, string? budgetPeriod, MultiChannelConfig config)
|
||||
{
|
||||
if (totalBudget <= 0)
|
||||
return (false, "Budget must be greater than zero");
|
||||
|
||||
// Convert to monthly for comparison
|
||||
var monthlyBudget = (budgetPeriod?.ToLowerInvariant()) switch
|
||||
{
|
||||
"daily" => totalBudget * 30.4m,
|
||||
"weekly" => totalBudget * 4.33m,
|
||||
_ => totalBudget
|
||||
};
|
||||
|
||||
// Check against lowest channel minimum
|
||||
var minBudget = config.EnabledChannels
|
||||
.Select(c => c.MinMonthlyBudget)
|
||||
.DefaultIfEmpty(150m)
|
||||
.Min();
|
||||
|
||||
if (monthlyBudget < minBudget)
|
||||
return (false, $"Monthly budget must be at least ${minBudget:F0}");
|
||||
|
||||
// Cap at reasonable maximum (safety valve)
|
||||
if (monthlyBudget > 1_000_000m)
|
||||
return (false, "Budget exceeds maximum allowed. Contact support for high-spend campaigns.");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Result type
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class OwnershipResult
|
||||
{
|
||||
public bool IsAllowed { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>The raw JSON response from the ownership lookup (avoids re-fetching).</summary>
|
||||
public string? EntityJson { get; init; }
|
||||
|
||||
/// <summary>Current status of the entity (for transition validation).</summary>
|
||||
public string? CurrentStatus { get; init; }
|
||||
|
||||
public static OwnershipResult Allowed(string? entityJson = null, string? status = null)
|
||||
=> new() { IsAllowed = true, EntityJson = entityJson, CurrentStatus = status };
|
||||
|
||||
public static OwnershipResult Denied(string error)
|
||||
=> new() { IsAllowed = false, Error = error };
|
||||
}
|
||||
@@ -223,7 +223,7 @@ public sealed class ClientAuthMiddleware
|
||||
_logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId);
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
|
||||
var resp = await sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -239,12 +239,13 @@ public sealed class ClientAuthMiddleware
|
||||
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
|
||||
|
||||
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
|
||||
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
|
||||
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
|
||||
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
clientContext.IsDevBypass = false;
|
||||
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
|
||||
clientContext.ClientCategory = data.TryGetProperty("clientCategory", out var ccat) ? ccat.GetString() : null;
|
||||
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
|
||||
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
clientContext.IsDevBypass = false;
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
@@ -1,60 +1,37 @@
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Holds authenticated client information for the current request.
|
||||
/// Populated by ClientAuthMiddleware.
|
||||
/// Holds authenticated identity information for the current request.
|
||||
/// Populated by MultiProviderAuthMiddleware.
|
||||
/// </summary>
|
||||
public sealed class ClientContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Session ID from session-based auth.
|
||||
/// </summary>
|
||||
public string? SessionId { get; set; }
|
||||
public string? SessionId { get; set; }
|
||||
public string? ClientId { get; set; } // OID (JWT) or platform client ID (session)
|
||||
public string? TenantId { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public string? ClientCategory { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public bool IsDevBypass { get; set; }
|
||||
public string? AuthProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authenticated client ID (from session, JWT sub claim, or dev header).
|
||||
/// This identifies the client/organization in our platform.
|
||||
/// Raw Entra Object ID (oid claim) — always set for Microsoft tokens.
|
||||
/// Used for identity and activity logging. Distinct from ClientId which may fall
|
||||
/// back to sub for tokens where oid isn't surfaced as a named claim.
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
public string? EntraOid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant ID for the ad platform (e.g., Google Ads customer ID).
|
||||
/// May be derived from ClientId mapping or passed in request.
|
||||
/// True when the token was issued by the standard Entra (staff) tenant.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
public bool IsStaff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name from token or session (if available).
|
||||
/// </summary>
|
||||
public string? ClientName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User ID from session (if using session auth).
|
||||
/// </summary>
|
||||
public string? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Email from token or session (if available).
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User role from session (admin, user, readonly).
|
||||
/// </summary>
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this request was authenticated via dev bypass (vs real auth).
|
||||
/// </summary>
|
||||
public bool IsDevBypass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authentication provider used (microsoft, google, etc.)
|
||||
/// </summary>
|
||||
public string? AuthProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if we have a valid ClientId.
|
||||
/// </summary>
|
||||
/// <summary>True if we have a valid ClientId.</summary>
|
||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
|
||||
|
||||
/// <summary>True if this is an admin session (IsStaff + Role set).</summary>
|
||||
public bool IsAdmin => IsStaff && !string.IsNullOrWhiteSpace(Role);
|
||||
}
|
||||
|
||||
@@ -248,7 +248,22 @@ public sealed class MultiProviderAuthMiddleware
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Entra ID
|
||||
// Standard Entra ID — could be CIAM tenant or Staff tenant (Tech, Admin)
|
||||
// Detect by comparing issuer against configured Staff tenant ID
|
||||
var staffTenantId = _config["Auth:Microsoft:StaffTenantId"];
|
||||
var staffClientId = _config["Auth:Microsoft:StaffClientId"];
|
||||
|
||||
var isStaff = !string.IsNullOrWhiteSpace(staffTenantId) &&
|
||||
jwt.Issuer.Contains(staffTenantId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isStaff)
|
||||
{
|
||||
tenantId = staffTenantId!;
|
||||
clientId = staffClientId ?? clientId;
|
||||
_logger.LogWarning("[Auth] Staff Entra token detected | tenant={Tenant} | Corr={Corr}", tenantId, corrId);
|
||||
clientContext.IsStaff = true;
|
||||
}
|
||||
|
||||
authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||
metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
validIssuers = new[]
|
||||
@@ -342,9 +357,17 @@ public sealed class MultiProviderAuthMiddleware
|
||||
/// </summary>
|
||||
private static void ExtractClaims(ClaimsPrincipal principal, ClientContext clientContext)
|
||||
{
|
||||
// Always extract oid explicitly — used for activity logging and identity.
|
||||
// For standard Entra access tokens oid may be under the full claim URI.
|
||||
var oid = principal.FindFirstValue("oid")
|
||||
?? principal.FindFirstValue("http://schemas.microsoft.com/identity/claims/objectidentifier");
|
||||
|
||||
clientContext.EntraOid = oid;
|
||||
|
||||
// ClientId: prefer oid, fall back to sub
|
||||
clientContext.ClientId =
|
||||
principal.FindFirstValue("oid") ?? // Microsoft object ID
|
||||
principal.FindFirstValue("sub") ?? // Standard subject
|
||||
oid ??
|
||||
principal.FindFirstValue("sub") ??
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
clientContext.Email =
|
||||
@@ -389,7 +412,9 @@ public sealed class MultiProviderAuthMiddleware
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
var sessionProc = "dbo.spClientSession"; // Gateway handles CIAM client sessions only
|
||||
var resp = await sql.ExecProcAsync(sessionProc, "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -412,8 +437,22 @@ public sealed class MultiProviderAuthMiddleware
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
clientContext.IsDevBypass = false;
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
// TenantId: session data first, then X-Tenant-Id header fallback
|
||||
// (In agency model, this is the client's Google Ads customer ID)
|
||||
clientContext.TenantId =
|
||||
data.TryGetProperty("tenantId", out var tenId) ? tenId.GetString() :
|
||||
data.TryGetProperty("googleCustomerId", out var gcid) ? gcid.GetString() :
|
||||
null;
|
||||
|
||||
// Fall back to X-Tenant-Id header if not in session data
|
||||
if (string.IsNullOrWhiteSpace(clientContext.TenantId) &&
|
||||
context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader))
|
||||
{
|
||||
clientContext.TenantId = tenantHeader.FirstOrDefault();
|
||||
}
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} IsAdmin={IsAdmin} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, clientContext.IsAdmin, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
|
||||
299
Gateway/Services/ChannelConfigService.cs
Normal file
299
Gateway/Services/ChannelConfigService.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Loads channel provider configuration from the database (tbChannelConfig)
|
||||
/// instead of appsettings.json. Caches in memory and provides a populated
|
||||
/// MultiChannelConfig instance for DI consumers.
|
||||
///
|
||||
/// Why: Complex nested JSON in appsettings.json was causing startup crashes
|
||||
/// with the .NET configuration parser. Database-driven config is also easier
|
||||
/// to update without redeployment.
|
||||
///
|
||||
/// Usage:
|
||||
/// - Called once at startup to populate the singleton MultiChannelConfig
|
||||
/// - Admin endpoints can call RefreshAsync() to reload after DB changes
|
||||
/// </summary>
|
||||
public sealed class ChannelConfigService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<ChannelConfigService> _log;
|
||||
private MultiChannelConfig _cached;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public ChannelConfigService(
|
||||
IServiceProvider sp,
|
||||
IConfiguration cfg,
|
||||
ILogger<ChannelConfigService> log)
|
||||
{
|
||||
_sp = sp;
|
||||
_cfg = cfg;
|
||||
_log = log;
|
||||
_cached = BuildDefaults();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current in-memory config. Always non-null (falls back to defaults).
|
||||
/// </summary>
|
||||
public MultiChannelConfig Current
|
||||
{
|
||||
get { lock (_lock) { return _cached; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load channel config from the database.
|
||||
/// Call at startup and whenever admin updates channel config.
|
||||
/// </summary>
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var sql = scope.ServiceProvider.GetRequiredService<SqlService>();
|
||||
|
||||
var resp = await sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelConfig, "list", "{}", ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] DB returned empty — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] DB returned ok=false — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("channels", out var channelsEl) ||
|
||||
channelsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] No channels array in response — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
var channels = new Dictionary<string, ProviderConfig>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var ch in channelsEl.EnumerateArray())
|
||||
{
|
||||
var config = ParseChannel(ch);
|
||||
if (config != null)
|
||||
channels[config.ChannelType] = config;
|
||||
}
|
||||
|
||||
// Build new MultiChannelConfig with DB channels + appsettings allocation
|
||||
var newConfig = new MultiChannelConfig
|
||||
{
|
||||
Channels = channels,
|
||||
Allocation = LoadAllocationFromConfig()
|
||||
};
|
||||
|
||||
lock (_lock) { _cached = newConfig; }
|
||||
|
||||
_log.LogInformation(
|
||||
"[ChannelConfig] Loaded {Count} channels from DB: {Types}",
|
||||
channels.Count,
|
||||
string.Join(", ", channels.Keys));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[ChannelConfig] Failed to load from DB — using defaults");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reload config from DB (for admin refresh endpoints).</summary>
|
||||
public Task RefreshAsync(CancellationToken ct = default) => LoadAsync(ct);
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Parsing
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private static ProviderConfig? ParseChannel(JsonElement ch)
|
||||
{
|
||||
var channelType = Str(ch, "channelType");
|
||||
var displayName = Str(ch, "displayName");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(channelType) || string.IsNullOrWhiteSpace(displayName))
|
||||
return null;
|
||||
|
||||
return new ProviderConfig
|
||||
{
|
||||
ChannelType = channelType,
|
||||
DisplayName = displayName,
|
||||
Description = Str(ch, "description"),
|
||||
Icon = Str(ch, "icon"),
|
||||
Color = Str(ch, "color"),
|
||||
Enabled = Bool(ch, "enabled", true),
|
||||
IsStub = Bool(ch, "isStub", true),
|
||||
Endpoint = Str(ch, "endpoint"),
|
||||
InternalKey = Str(ch, "internalKey"),
|
||||
MinDailyBudget = Dec(ch, "minDailyBudget", 5m),
|
||||
MinMonthlyBudget = Dec(ch, "minMonthlyBudget", 150m),
|
||||
SupportedObjectives = StringList(ch, "supportedObjectives"),
|
||||
SupportedCreativeFormats = StringList(ch, "supportedCreativeFormats"),
|
||||
ApprovalEstimateHours = Int(ch, "approvalEstimateHours", 24),
|
||||
MetricsRefreshIntervalMinutes = Int(ch, "metricsRefreshIntervalMinutes", 60),
|
||||
AuthMethod = Str(ch, "authMethod"),
|
||||
KeyVaultSecretName = Str(ch, "keyVaultSecretName"),
|
||||
StatusMappings = StringDict(ch, "statusMappings")
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Allocation (stays in appsettings — simple scalars)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private AllocationSettings LoadAllocationFromConfig()
|
||||
{
|
||||
var section = _cfg.GetSection("MultiChannel:Allocation");
|
||||
if (!section.Exists())
|
||||
return new AllocationSettings();
|
||||
|
||||
return new AllocationSettings
|
||||
{
|
||||
MinMultiChannelMonthlyBudget = section.GetValue("MinMultiChannelMonthlyBudget", 500.00m),
|
||||
MaxChannelsPerInitiative = section.GetValue("MaxChannelsPerInitiative", 5),
|
||||
DefaultAllocationStrategy = section.GetValue("DefaultAllocationStrategy", "template") ?? "template",
|
||||
PerformanceEvalIntervalDays = section.GetValue("PerformanceEvalIntervalDays", 7),
|
||||
PerformanceLookbackDays = section.GetValue("PerformanceLookbackDays", 14),
|
||||
PerformanceLearningPeriodDays = section.GetValue("PerformanceLearningPeriodDays", 14),
|
||||
MaxAllocationShiftPct = section.GetValue("MaxAllocationShiftPct", 15.00m),
|
||||
MinChannelAllocationPct = section.GetValue("MinChannelAllocationPct", 10.00m),
|
||||
MaxChannelAllocationPct = section.GetValue("MaxChannelAllocationPct", 80.00m)
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Defaults (used until DB load completes or if DB is unavailable)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private MultiChannelConfig BuildDefaults()
|
||||
{
|
||||
return new MultiChannelConfig
|
||||
{
|
||||
Channels = new Dictionary<string, ProviderConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["google_ads"] = new()
|
||||
{
|
||||
ChannelType = "google_ads",
|
||||
DisplayName = "Google Ads",
|
||||
Description = "Search, Display, Shopping & Performance Max across Google properties",
|
||||
Icon = "google",
|
||||
Color = "#4285F4",
|
||||
Enabled = true,
|
||||
IsStub = false,
|
||||
MinDailyBudget = 10m,
|
||||
MinMonthlyBudget = 300m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "text", "image", "responsive", "video" },
|
||||
ApprovalEstimateHours = 24,
|
||||
AuthMethod = "mcc"
|
||||
},
|
||||
["meta"] = new()
|
||||
{
|
||||
ChannelType = "meta",
|
||||
DisplayName = "Meta Ads",
|
||||
Description = "Facebook, Instagram, Messenger & Threads advertising",
|
||||
Icon = "meta",
|
||||
Color = "#1877F2",
|
||||
Enabled = true,
|
||||
IsStub = true,
|
||||
MinDailyBudget = 5m,
|
||||
MinMonthlyBudget = 250m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "image", "video", "carousel", "stories" },
|
||||
ApprovalEstimateHours = 48,
|
||||
AuthMethod = "oauth2"
|
||||
},
|
||||
["tiktok"] = new()
|
||||
{
|
||||
ChannelType = "tiktok",
|
||||
DisplayName = "TikTok Ads",
|
||||
Description = "In-feed video ads across TikTok and partner apps",
|
||||
Icon = "tiktok",
|
||||
Color = "#000000",
|
||||
Enabled = true,
|
||||
IsStub = true,
|
||||
MinDailyBudget = 20m,
|
||||
MinMonthlyBudget = 200m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "video", "image", "spark_ads" },
|
||||
ApprovalEstimateHours = 24,
|
||||
AuthMethod = "oauth2"
|
||||
}
|
||||
},
|
||||
Allocation = new AllocationSettings()
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// JSON helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private static string? Str(JsonElement el, string prop)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String)
|
||||
return v.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Bool(JsonElement el, string prop, bool def = false)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v))
|
||||
{
|
||||
if (v.ValueKind == JsonValueKind.True) return true;
|
||||
if (v.ValueKind == JsonValueKind.False) return false;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
private static int Int(JsonElement el, string prop, int def = 0)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number)
|
||||
return v.GetInt32();
|
||||
return def;
|
||||
}
|
||||
|
||||
private static decimal Dec(JsonElement el, string prop, decimal def = 0m)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number)
|
||||
return v.GetDecimal();
|
||||
return def;
|
||||
}
|
||||
|
||||
private static List<string> StringList(JsonElement el, string prop)
|
||||
{
|
||||
var list = new List<string>();
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in v.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
list.Add(item.GetString()!);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> StringDict(JsonElement el, string prop)
|
||||
{
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var p in v.EnumerateObject())
|
||||
{
|
||||
if (p.Value.ValueKind == JsonValueKind.String)
|
||||
dict[p.Name] = p.Value.GetString()!;
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public sealed class ExecutionService
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ImageStorageService _imageStorage;
|
||||
private readonly ILogger<ExecutionService> _logger;
|
||||
|
||||
// Operations that don't require a linked account (health checks, etc.)
|
||||
@@ -19,17 +20,31 @@ public sealed class ExecutionService
|
||||
"Ping", "TestPing", "ListAccessibleCustomers"
|
||||
};
|
||||
|
||||
// Providers that require Google Ads account validation
|
||||
private static readonly HashSet<string> GoogleAccountProviders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"google"
|
||||
};
|
||||
|
||||
// Creative operations that return images and need blob storage processing
|
||||
private static readonly HashSet<string> CreativeImageOperations = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CreateDraft", "GetImages"
|
||||
};
|
||||
|
||||
public ExecutionService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ClientContext client,
|
||||
ImageStorageService imageStorage,
|
||||
ILogger<ExecutionService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_client = client;
|
||||
_imageStorage = imageStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,33 +61,55 @@ public sealed class ExecutionService
|
||||
var service = reqJson.TryGetProperty("service", out var sv) ? sv.GetString() ?? "system" : "system";
|
||||
var action = reqJson.TryGetProperty("action", out var av) ? av.GetString() ?? "ping" : "ping";
|
||||
|
||||
// Legacy support: if "operation" is provided, use it as action
|
||||
string? operation = action;
|
||||
// Operation: explicit "operation" field takes priority, then falls back to "action"
|
||||
string operation = action;
|
||||
if (reqJson.TryGetProperty("operation", out var opProp) && opProp.ValueKind == JsonValueKind.String)
|
||||
operation = opProp.GetString();
|
||||
operation = opProp.GetString() ?? action;
|
||||
|
||||
// TenantId priority: 1) request body, 2) ClientContext, 3) null
|
||||
// TenantId priority: 1) request body, 2) ClientContext (header), 3) default MCC, 4) null
|
||||
string? tenantId = null;
|
||||
if (reqJson.TryGetProperty("tenantId", out var tid) && tid.ValueKind == JsonValueKind.String)
|
||||
tenantId = tid.GetString();
|
||||
tenantId ??= _client.TenantId;
|
||||
|
||||
// Agency model fallback: use default MCC customer ID if no tenant specified
|
||||
// This ensures real API calls work even before per-client subaccounts exist
|
||||
bool tenantIsSystemDefault = false;
|
||||
if (string.IsNullOrWhiteSpace(tenantId) && GoogleAccountProviders.Contains(provider))
|
||||
{
|
||||
tenantId = _cfg["GoogleAds:DefaultLoginCustomerId"]
|
||||
?? _cfg["GOOGLE_DEFAULT_CUSTOMER_ID"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_DEFAULT_CUSTOMER_ID");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantIsSystemDefault = true;
|
||||
_logger.LogInformation("[Execution] Using default MCC customer ID as tenantId | RequestId={RequestId}", requestId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Action={Action} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, action, _client.IsDevBypass);
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Operation={Operation} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, operation, _client.IsDevBypass);
|
||||
|
||||
// ================================================================
|
||||
// AGENCY MODEL: Validate account and get loginCustomerId
|
||||
// AGENCY MODEL: Validate Google account (only for Google provider)
|
||||
// Skip validation if tenantId is the system-configured MCC default
|
||||
// (admin pre-configured, not user-supplied)
|
||||
// ================================================================
|
||||
string? loginCustomerId = null;
|
||||
string? validatedClientName = null;
|
||||
|
||||
// Only validate if operation requires a linked account
|
||||
bool requiresAccount = !string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId);
|
||||
// Only validate if provider requires it AND operation requires a linked account
|
||||
// AND tenantId is user-provided (not the system MCC default)
|
||||
bool requiresGoogleAccount =
|
||||
GoogleAccountProviders.Contains(provider) &&
|
||||
!string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId) &&
|
||||
!tenantIsSystemDefault;
|
||||
|
||||
if (requiresAccount)
|
||||
if (requiresGoogleAccount)
|
||||
{
|
||||
var validation = await ValidateGoogleAccountAsync(tenantId!, ct);
|
||||
|
||||
@@ -106,7 +143,7 @@ public sealed class ExecutionService
|
||||
requestId, tenantId, loginCustomerId, validatedClientName);
|
||||
}
|
||||
|
||||
// Log start (now includes clientId and routing info)
|
||||
// Log start (includes routing info)
|
||||
int? logId = null;
|
||||
var startRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
@@ -116,7 +153,7 @@ public sealed class ExecutionService
|
||||
tenantId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
operation,
|
||||
loginCustomerId,
|
||||
sessionId = _client.SessionId,
|
||||
userId = _client.UserId,
|
||||
@@ -131,8 +168,8 @@ public sealed class ExecutionService
|
||||
logId = e.GetInt32();
|
||||
}
|
||||
|
||||
// Inject/override fields in request before forwarding to provider
|
||||
var enrichedRequest = EnrichRequest(reqJson, requestId, tenantId, loginCustomerId);
|
||||
// Build enriched request for provider
|
||||
var enrichedRequest = BuildProviderRequest(reqJson, requestId, operation, tenantId, loginCustomerId);
|
||||
|
||||
// Forward to provider (URL based on provider type)
|
||||
var sw = Stopwatch.StartNew();
|
||||
@@ -143,6 +180,11 @@ public sealed class ExecutionService
|
||||
var providerUrl = GetProviderUrl(provider);
|
||||
var key = GetProviderKey(provider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
{
|
||||
throw new InvalidOperationException($"No provider URL configured for '{provider}'. Check environment variables.");
|
||||
}
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
@@ -155,17 +197,45 @@ public sealed class ExecutionService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId}", requestId);
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId} Provider={Provider}", requestId, provider);
|
||||
providerStatus = 500;
|
||||
providerResp = JsonSerializer.Serialize(new { ok = false, requestId, error = ex.Message });
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, providerStatus, sw.ElapsedMilliseconds);
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Provider={Provider} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, provider, providerStatus, sw.ElapsedMilliseconds);
|
||||
|
||||
// Log finish (includes clientId and routing info for correlation)
|
||||
// ================================================================
|
||||
// CREATIVE IMAGE PROCESSING: Store images in blob storage
|
||||
// ================================================================
|
||||
if (provider.Equals("creative", StringComparison.OrdinalIgnoreCase) &&
|
||||
CreativeImageOperations.Contains(operation) &&
|
||||
providerStatus >= 200 && providerStatus < 300 &&
|
||||
_imageStorage.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[Execution] Processing Creative images | RequestId={RequestId} ClientId={ClientId}",
|
||||
requestId, clientId);
|
||||
|
||||
providerResp = await _imageStorage.ProcessCreativeDraftAsync(
|
||||
clientId ?? "unknown",
|
||||
providerResp,
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"[Execution] Image storage failed, returning original response | RequestId={RequestId}",
|
||||
requestId);
|
||||
// Continue with original response - non-fatal error
|
||||
}
|
||||
}
|
||||
|
||||
// Log finish (includes routing info for correlation)
|
||||
var finishRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
action = "finish",
|
||||
@@ -174,10 +244,10 @@ public sealed class ExecutionService
|
||||
clientId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
operation,
|
||||
providerStatus,
|
||||
elapsedMs = sw.ElapsedMilliseconds,
|
||||
resp = JsonDocument.Parse(providerResp).RootElement
|
||||
resp = SafeParseJson(providerResp)
|
||||
});
|
||||
|
||||
_ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct);
|
||||
@@ -187,6 +257,52 @@ public sealed class ExecutionService
|
||||
return wrappedResponse;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Provider request building
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Build a clean request object for the provider container.
|
||||
/// Ensures "operation" is always set explicitly so providers can dispatch on it.
|
||||
/// Includes session context so providers know who initiated the request.
|
||||
/// </summary>
|
||||
private string BuildProviderRequest(JsonElement original, string requestId, string operation,
|
||||
string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
var request = new Dictionary<string, object?>
|
||||
{
|
||||
["requestId"] = requestId,
|
||||
["operation"] = operation,
|
||||
["tenantId"] = tenantId,
|
||||
["loginCustomerId"] = loginCustomerId,
|
||||
["session"] = new
|
||||
{
|
||||
sessionId = _client.SessionId,
|
||||
clientId = _client.ClientId,
|
||||
userId = _client.UserId,
|
||||
isDevBypass = _client.IsDevBypass
|
||||
}
|
||||
};
|
||||
|
||||
// Copy payload if present (provider-specific data)
|
||||
if (original.TryGetProperty("payload", out var payload))
|
||||
{
|
||||
request["payload"] = payload;
|
||||
}
|
||||
|
||||
// Copy service/action for providers that use them
|
||||
if (original.TryGetProperty("service", out var svc))
|
||||
request["service"] = svc.GetString();
|
||||
if (original.TryGetProperty("action", out var act))
|
||||
request["action"] = act.GetString();
|
||||
|
||||
return JsonSerializer.Serialize(request);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Account validation (Google-specific)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a Google Ads customer ID is linked in the database.
|
||||
/// Returns loginCustomerId if account is found.
|
||||
@@ -258,36 +374,19 @@ public sealed class ExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider.
|
||||
/// </summary>
|
||||
private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(original.GetRawText());
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(doc.RootElement.GetRawText())
|
||||
?? new Dictionary<string, JsonElement>();
|
||||
|
||||
// Add/override requestId
|
||||
dict["requestId"] = JsonDocument.Parse($"\"{requestId}\"").RootElement;
|
||||
|
||||
// Add tenantId if we have one
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
dict["tenantId"] = JsonDocument.Parse($"\"{tenantId}\"").RootElement;
|
||||
}
|
||||
|
||||
// Add loginCustomerId (manager account) if we have one
|
||||
if (!string.IsNullOrWhiteSpace(loginCustomerId))
|
||||
{
|
||||
dict["loginCustomerId"] = JsonDocument.Parse($"\"{loginCustomerId}\"").RootElement;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict);
|
||||
}
|
||||
// ================================================================
|
||||
// Response wrapping
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Wrap provider response with Gateway metadata.
|
||||
/// </summary>
|
||||
private static object SafeParseJson(string raw)
|
||||
{
|
||||
try { return JsonDocument.Parse(raw).RootElement; }
|
||||
catch { return raw[..Math.Min(raw.Length, 500)]; }
|
||||
}
|
||||
|
||||
private static string WrapResponse(string providerResp, int status, long elapsedMs, string requestId, string? clientId)
|
||||
{
|
||||
try
|
||||
@@ -319,9 +418,10 @@ public sealed class ExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of account validation.
|
||||
/// </summary>
|
||||
// ================================================================
|
||||
// Validation result
|
||||
// ================================================================
|
||||
|
||||
private sealed class AccountValidation
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
@@ -345,6 +445,10 @@ public sealed class ExecutionService
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Provider routing
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Get provider URL based on provider type.
|
||||
/// </summary>
|
||||
@@ -353,9 +457,12 @@ public sealed class ExecutionService
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"creative" => _cfg["CREATIVE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"intelligence" => _cfg["INTELLIGENCE_API_URL"]?.TrimEnd('/') ?? "",
|
||||
"msads" => _cfg["MSADS_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
_ => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? ""
|
||||
_ => "" // No default fallback ? unknown providers fail explicitly
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,9 +474,12 @@ public sealed class ExecutionService
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
|
||||
"creative" => _cfg["CREATIVE_INTERNAL_KEY"] ?? "",
|
||||
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "",
|
||||
"intelligence" => _cfg["INTELLIGENCE_INTERNAL_KEY"] ?? "",
|
||||
"msads" => _cfg["MSADS_INTERNAL_KEY"] ?? "",
|
||||
_ => _cfg["GOOGLE_INTERNAL_KEY"] ?? ""
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
478
Gateway/Services/ForecastService.cs
Normal file
478
Gateway/Services/ForecastService.cs
Normal file
@@ -0,0 +1,478 @@
|
||||
using Gateway.Models;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fans out to provider services for forecast estimates, normalizes the responses,
|
||||
/// scores them by objective, and derives recommended allocation percentages.
|
||||
///
|
||||
/// Called by ForecastController for the wizard budget step.
|
||||
/// Same capability can serve admin seed workflow later.
|
||||
/// </summary>
|
||||
public sealed class ForecastService
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<ForecastService> _logger;
|
||||
|
||||
private const int MIN_ALLOCATION = 15;
|
||||
private const int MAX_ALLOCATION = 85;
|
||||
|
||||
public ForecastService(IHttpClientFactory http, IConfiguration cfg, ILogger<ForecastService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate forecast estimates across requested channels and return
|
||||
/// normalized comparison with recommended allocation.
|
||||
/// </summary>
|
||||
public async Task<ChannelForecastResponse> ForecastAsync(ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var channels = request.Channels ?? new List<string> { "google_ads" };
|
||||
var weights = ObjectiveWeights.For(request.Objective);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Forecast] Starting | Objective={Obj} Budget={Budget} Channels={Ch}",
|
||||
request.Objective, request.MonthlyBudget, string.Join(",", channels));
|
||||
|
||||
// ── Fan out to providers in parallel ──
|
||||
var tasks = new Dictionary<string, Task<ProviderForecastResult>>();
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
tasks[channel] = channel switch
|
||||
{
|
||||
"google_ads" => FetchGoogleForecastAsync(request, ct),
|
||||
"meta" => FetchMetaForecastAsync(request, ct),
|
||||
"tiktok" => Task.FromResult(TemplateForecast("tiktok", request.MonthlyBudget, channels.Count)),
|
||||
_ => Task.FromResult(TemplateForecast(channel, request.MonthlyBudget, channels.Count))
|
||||
};
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks.Values);
|
||||
|
||||
// ── Collect results ──
|
||||
var results = new Dictionary<string, ProviderForecastResult>();
|
||||
foreach (var (channel, task) in tasks)
|
||||
{
|
||||
results[channel] = task.Result;
|
||||
}
|
||||
|
||||
// ── Score and derive allocation ──
|
||||
var scored = ScoreChannels(results, weights);
|
||||
var allocations = DeriveAllocations(scored);
|
||||
|
||||
// ── Build response ──
|
||||
var channelEstimates = new List<ChannelEstimate>();
|
||||
foreach (var (channel, result) in results)
|
||||
{
|
||||
var pct = allocations[channel];
|
||||
var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2);
|
||||
|
||||
channelEstimates.Add(new ChannelEstimate
|
||||
{
|
||||
Provider = channel,
|
||||
AllocationPercent = pct,
|
||||
AllocatedBudget = allocated,
|
||||
Estimates = new ChannelEstimateMetrics
|
||||
{
|
||||
Impressions = result.Impressions,
|
||||
Reach = result.Reach,
|
||||
Clicks = result.Clicks,
|
||||
Conversions = result.Conversions,
|
||||
AvgCpc = result.AvgCpc,
|
||||
AvgCpm = result.AvgCpm,
|
||||
EstimatedCpa = result.EstimatedCpa,
|
||||
Ctr = result.Ctr
|
||||
},
|
||||
EfficiencyScore = Math.Round(scored[channel], 3),
|
||||
StrengthLabel = GetStrengthLabel(channel, request.Objective),
|
||||
Confidence = result.Confidence,
|
||||
DataSource = result.DataSource
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by allocation descending
|
||||
channelEstimates.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent));
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation("[Forecast] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds);
|
||||
|
||||
return new ChannelForecastResponse
|
||||
{
|
||||
Ok = true,
|
||||
Objective = request.Objective,
|
||||
TotalBudget = request.MonthlyBudget,
|
||||
Channels = channelEstimates,
|
||||
Recommendation = BuildRecommendation(channelEstimates, request.Objective),
|
||||
Metadata = new ForecastMeta
|
||||
{
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
ForecastPeriod = "30 days"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Provider calls
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private async Task<ProviderForecastResult> FetchGoogleForecastAsync(
|
||||
ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerUrl = _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "";
|
||||
var key = _cfg["GOOGLE_INTERNAL_KEY"] ?? "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
return EmulatedGoogleFallback(request);
|
||||
|
||||
// Build provider request matching existing ProviderRequest pattern
|
||||
var payload = new
|
||||
{
|
||||
keywords = request.Keywords,
|
||||
geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List<long>(),
|
||||
monthlyBudget = request.MonthlyBudget,
|
||||
currencyCode = "USD",
|
||||
forecastDays = 30
|
||||
};
|
||||
|
||||
var providerRequest = new
|
||||
{
|
||||
operation = "KeywordForecast",
|
||||
requestId = Guid.NewGuid().ToString("N"),
|
||||
payload
|
||||
};
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(providerRequest, _jsonOpts),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var resp = await httpClient.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[Forecast] Google provider returned {Status}", (int)resp.StatusCode);
|
||||
return EmulatedGoogleFallback(request);
|
||||
}
|
||||
|
||||
// Parse provider response: { ok, data: { provider, monthly, metrics, ... } }
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
var data = root.TryGetProperty("data", out var d) ? d : root;
|
||||
|
||||
var monthly = data.GetProperty("monthly");
|
||||
var metrics = data.GetProperty("metrics");
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "google_ads",
|
||||
Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0,
|
||||
Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0,
|
||||
Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0,
|
||||
Reach = null, // Google Search doesn't provide reach
|
||||
AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0,
|
||||
AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0,
|
||||
Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0,
|
||||
EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null
|
||||
? cpa.GetDecimal() : null,
|
||||
Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low",
|
||||
DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Forecast] Google provider call failed");
|
||||
return EmulatedGoogleFallback(request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProviderForecastResult> FetchMetaForecastAsync(
|
||||
ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
// TODO Phase 2: Call MetaApi /internal/execute with DeliveryEstimate operation
|
||||
// For now, return realistic emulated Meta estimates
|
||||
await Task.CompletedTask;
|
||||
|
||||
var budget = request.MonthlyBudget;
|
||||
var rng = new Random((int)(budget * 77));
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
// Meta: strong reach/impressions, moderate clicks, lower CPC than Google
|
||||
var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0); // $12.50 – $20.50
|
||||
var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0;
|
||||
var reach = impressions * 0.42; // ~2.4 frequency
|
||||
var clickRate = 0.012 + (rng.NextDouble() * 0.008); // 1.2% – 2.0% CTR
|
||||
var clicks = impressions * clickRate;
|
||||
var convRate = 0.025 + (rng.NextDouble() * 0.015);
|
||||
var conversions = clicks * convRate;
|
||||
var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0;
|
||||
var cpa = conversions > 0 ? budget / (decimal)conversions : (decimal?)null;
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "meta",
|
||||
Impressions = Math.Round(impressions),
|
||||
Clicks = Math.Round(clicks),
|
||||
Conversions = Math.Round(conversions, 1),
|
||||
Reach = Math.Round(reach),
|
||||
AvgCpc = Math.Round(avgCpc, 2),
|
||||
AvgCpm = Math.Round(cpm, 2),
|
||||
Ctr = Math.Round(clickRate, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Template-only fallback for channels without API forecasting (e.g., TikTok)</summary>
|
||||
private static ProviderForecastResult TemplateForecast(string provider, decimal totalBudget, int channelCount)
|
||||
{
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = provider,
|
||||
Confidence = "none",
|
||||
DataSource = "template"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Client-side Google emulation when provider is unreachable</summary>
|
||||
private ProviderForecastResult EmulatedGoogleFallback(ChannelForecastRequest request)
|
||||
{
|
||||
var budget = request.MonthlyBudget;
|
||||
var kwCount = Math.Max(request.Keywords.Count, 1);
|
||||
var rng = new Random((int)(budget * 100) + kwCount);
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20);
|
||||
var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0;
|
||||
var impressions = clicks / 0.045;
|
||||
var conversions = clicks * 0.035;
|
||||
var ctr = impressions > 0 ? clicks / impressions : 0;
|
||||
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
|
||||
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "google_ads",
|
||||
Impressions = Math.Round(impressions),
|
||||
Clicks = Math.Round(clicks),
|
||||
Conversions = Math.Round(conversions, 1),
|
||||
AvgCpc = Math.Round(baseCpc, 2),
|
||||
AvgCpm = Math.Round((decimal)cpm, 2),
|
||||
Ctr = Math.Round(ctr, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Scoring: objective-weighted efficiency
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static Dictionary<string, double> ScoreChannels(
|
||||
Dictionary<string, ProviderForecastResult> results, MetricWeights w)
|
||||
{
|
||||
// Only score channels that have real estimates
|
||||
var scoreable = results
|
||||
.Where(r => r.Value.DataSource != "template")
|
||||
.ToDictionary(r => r.Key, r => r.Value);
|
||||
|
||||
if (scoreable.Count == 0)
|
||||
return results.ToDictionary(r => r.Key, _ => 1.0);
|
||||
|
||||
// For each "more is better" metric: normalize to 0–1 (best = 1.0)
|
||||
// For each "less is better" metric: invert (lowest = 1.0)
|
||||
//double Norm(Func<ProviderForecastResult, double> selector, bool invert = false)
|
||||
//{
|
||||
// Not used directly — we normalize per-channel below
|
||||
// return 0;
|
||||
// }
|
||||
|
||||
var scores = new Dictionary<string, double>();
|
||||
|
||||
// Find max/min across scoreable channels for normalization
|
||||
var maxImp = scoreable.Values.Max(r => r.Impressions);
|
||||
var maxReach = scoreable.Values.Max(r => r.Reach ?? 0);
|
||||
var maxClicks = scoreable.Values.Max(r => r.Clicks);
|
||||
var maxConv = scoreable.Values.Max(r => r.Conversions);
|
||||
var maxCtr = scoreable.Values.Max(r => r.Ctr);
|
||||
|
||||
var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min();
|
||||
var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min();
|
||||
var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0).Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min();
|
||||
|
||||
foreach (var (channel, r) in scoreable)
|
||||
{
|
||||
double score = 0;
|
||||
|
||||
// "More is better" — value / max
|
||||
score += w.Impressions * SafeDiv(r.Impressions, maxImp);
|
||||
score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1);
|
||||
score += w.Clicks * SafeDiv(r.Clicks, maxClicks);
|
||||
score += w.Conversions * SafeDiv(r.Conversions, maxConv);
|
||||
score += w.Ctr * SafeDiv(r.Ctr, maxCtr);
|
||||
|
||||
// "Less is better" — min / value
|
||||
score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0);
|
||||
score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0);
|
||||
score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0);
|
||||
|
||||
scores[channel] = score;
|
||||
}
|
||||
|
||||
// Template-only channels get the average score
|
||||
var avgScore = scores.Values.Average();
|
||||
foreach (var channel in results.Keys.Except(scoreable.Keys))
|
||||
{
|
||||
scores[channel] = avgScore * 0.5; // Slight penalty for no data
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> DeriveAllocations(Dictionary<string, double> scores)
|
||||
{
|
||||
var total = scores.Values.Sum();
|
||||
if (total == 0)
|
||||
{
|
||||
// Even split
|
||||
var even = 100 / scores.Count;
|
||||
return scores.ToDictionary(s => s.Key, _ => even);
|
||||
}
|
||||
|
||||
// Proportional split
|
||||
var raw = scores.ToDictionary(s => s.Key, s => (int)Math.Round(s.Value / total * 100));
|
||||
|
||||
// Apply floor/ceiling constraints
|
||||
foreach (var key in raw.Keys.ToList())
|
||||
{
|
||||
raw[key] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[key]));
|
||||
}
|
||||
|
||||
// Normalize to exactly 100%
|
||||
var sum = raw.Values.Sum();
|
||||
if (sum != 100 && raw.Count > 0)
|
||||
{
|
||||
var diff = 100 - sum;
|
||||
// Add/subtract difference from the highest-scored channel
|
||||
var topChannel = raw.OrderByDescending(r => r.Value).First().Key;
|
||||
raw[topChannel] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[topChannel] + diff));
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static double SafeDiv(double numerator, double denominator)
|
||||
=> denominator > 0 ? numerator / denominator : 0;
|
||||
|
||||
private static string GetStrengthLabel(string channel, string objective) => channel switch
|
||||
{
|
||||
"google_ads" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for search visibility",
|
||||
"traffic" => "Strong for search intent clicks",
|
||||
"leads" => "Strong for high-intent leads",
|
||||
"sales" => "Strong for purchase intent",
|
||||
_ => "Search & intent targeting"
|
||||
},
|
||||
"meta" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for reach & discovery",
|
||||
"traffic" => "Strong for social traffic",
|
||||
"leads" => "Strong for lead gen forms",
|
||||
"sales" => "Strong for retargeting & social proof",
|
||||
_ => "Social reach & engagement"
|
||||
},
|
||||
"tiktok" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for viral reach",
|
||||
_ => "Video-first engagement"
|
||||
},
|
||||
_ => "Advertising channel"
|
||||
};
|
||||
|
||||
private static ForecastRecommendation BuildRecommendation(
|
||||
List<ChannelEstimate> channels, string objective)
|
||||
{
|
||||
if (channels.Count < 2)
|
||||
return new ForecastRecommendation
|
||||
{
|
||||
Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.",
|
||||
Highlights = new List<string>()
|
||||
};
|
||||
|
||||
var top = channels[0];
|
||||
var second = channels[1];
|
||||
var highlights = new List<string>();
|
||||
|
||||
// Compare key metrics
|
||||
if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0)
|
||||
{
|
||||
var clickRatio = top.Estimates.Clicks / second.Estimates.Clicks;
|
||||
if (clickRatio > 1.3)
|
||||
highlights.Add($"{ChannelDisplayName(top.Provider)}: ~{clickRatio:F0}x more clicks per dollar");
|
||||
}
|
||||
|
||||
if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0)
|
||||
{
|
||||
var impRatio = second.Estimates.Impressions / top.Estimates.Impressions;
|
||||
if (impRatio > 1.5)
|
||||
highlights.Add($"{ChannelDisplayName(second.Provider)}: ~{impRatio:F0}x more impressions per dollar");
|
||||
}
|
||||
|
||||
if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0)
|
||||
{
|
||||
highlights.Add($"CPA range: ${Math.Min(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0}–${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels");
|
||||
}
|
||||
|
||||
return new ForecastRecommendation
|
||||
{
|
||||
Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " +
|
||||
$"between {ChannelDisplayName(top.Provider)} and {ChannelDisplayName(second.Provider)}, " +
|
||||
$"optimized for {objective}.",
|
||||
Highlights = highlights
|
||||
};
|
||||
}
|
||||
|
||||
private static string ChannelDisplayName(string provider) => provider switch
|
||||
{
|
||||
"google_ads" => "Google",
|
||||
"meta" => "Meta",
|
||||
"tiktok" => "TikTok",
|
||||
_ => provider
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Internal result from a single provider call</summary>
|
||||
private sealed class ProviderForecastResult
|
||||
{
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public double Impressions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "none";
|
||||
}
|
||||
}
|
||||
353
Gateway/Services/ImageStorageService.cs
Normal file
353
Gateway/Services/ImageStorageService.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles downloading images from source URLs and storing them in Azure Blob Storage.
|
||||
/// Used when processing Creative drafts to ensure all image URLs are permanent.
|
||||
///
|
||||
/// Blob structure: {clientId}/drafts/{draftId}/{orientation}.{ext}
|
||||
/// Example: client-42/drafts/a1b2c3d4e5f6/landscape.jpg
|
||||
///
|
||||
/// This structure enables:
|
||||
/// - Client isolation (easy to list/delete all client assets)
|
||||
/// - Draft organization (images grouped per draft)
|
||||
/// - Future expansion (campaigns, versions, etc.)
|
||||
/// - Per-client access control via SAS tokens if needed
|
||||
/// </summary>
|
||||
public class ImageStorageService
|
||||
{
|
||||
private readonly BlobServiceClient _blobClient;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<ImageStorageService> _logger;
|
||||
private readonly string _containerName;
|
||||
private readonly string _blobBaseUrl;
|
||||
private readonly bool _isConfigured;
|
||||
|
||||
public ImageStorageService(
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<ImageStorageService> logger,
|
||||
IConfiguration config,
|
||||
BlobServiceClient blobClient)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
_blobClient = blobClient;
|
||||
_containerName = config["BlobStorage:ContainerName"] ?? "creative-images";
|
||||
_blobBaseUrl = config["BlobStorage:BaseUrl"] ?? "https://usimadpcreatives.blob.core.windows.net";
|
||||
_isConfigured = blobClient != null;
|
||||
|
||||
if (!_isConfigured)
|
||||
{
|
||||
_logger.LogWarning("[ImageStorage] Blob storage not configured - images will use source URLs");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("[ImageStorage] Blob storage configured: {BaseUrl}/{Container}", _blobBaseUrl, _containerName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether blob storage is configured and available.
|
||||
/// </summary>
|
||||
public bool IsConfigured => _isConfigured;
|
||||
|
||||
/// <summary>
|
||||
/// Process a Creative draft response, downloading and storing images in blob storage.
|
||||
/// Returns the modified JSON with blob URLs replacing source URLs.
|
||||
/// </summary>
|
||||
public async Task<string> ProcessCreativeDraftAsync(
|
||||
string clientId,
|
||||
string providerResponseJson,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured)
|
||||
{
|
||||
_logger.LogDebug("[ImageStorage] Skipping image processing - not configured");
|
||||
return providerResponseJson;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(providerResponseJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check if this is a successful response with data
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
return providerResponseJson;
|
||||
|
||||
if (!root.TryGetProperty("data", out var dataProp))
|
||||
return providerResponseJson;
|
||||
|
||||
// Check if data has images array
|
||||
if (!dataProp.TryGetProperty("images", out var imagesProp) ||
|
||||
imagesProp.ValueKind != JsonValueKind.Array ||
|
||||
imagesProp.GetArrayLength() == 0)
|
||||
{
|
||||
_logger.LogDebug("[ImageStorage] No images in draft response");
|
||||
return providerResponseJson;
|
||||
}
|
||||
|
||||
// Get draftId
|
||||
var draftId = dataProp.TryGetProperty("draftId", out var draftIdProp)
|
||||
? draftIdProp.GetString() ?? Guid.NewGuid().ToString("N")[..12]
|
||||
: Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
_logger.LogInformation(
|
||||
"[ImageStorage] Processing {Count} images for client {ClientId} draft {DraftId}",
|
||||
imagesProp.GetArrayLength(), clientId, draftId);
|
||||
|
||||
// Ensure container exists
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: ct);
|
||||
|
||||
// Process each image and collect results
|
||||
var processedImages = new List<Dictionary<string, object?>>();
|
||||
|
||||
foreach (var image in imagesProp.EnumerateArray())
|
||||
{
|
||||
var processedImage = await ProcessSingleImageAsync(
|
||||
containerClient, clientId, draftId, image, ct);
|
||||
processedImages.Add(processedImage);
|
||||
}
|
||||
|
||||
// Rebuild the response with updated image URLs
|
||||
return RebuildResponseWithProcessedImages(doc, processedImages);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[ImageStorage] Failed to process draft images, returning original response");
|
||||
return providerResponseJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a single image: download and upload to blob storage.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, object?>> ProcessSingleImageAsync(
|
||||
BlobContainerClient container,
|
||||
string clientId,
|
||||
string draftId,
|
||||
JsonElement image,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Extract image properties
|
||||
var imageId = image.TryGetProperty("imageId", out var idProp) ? idProp.GetString() : null;
|
||||
var sourceUrl = image.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null;
|
||||
var downloadUrl = image.TryGetProperty("downloadUrl", out var dlProp) ? dlProp.GetString() : null;
|
||||
var orientation = image.TryGetProperty("orientation", out var orProp) ? orProp.GetString() ?? "unknown" : "unknown";
|
||||
var source = image.TryGetProperty("source", out var srcProp) ? srcProp.GetString() : "unknown";
|
||||
var width = image.TryGetProperty("width", out var wProp) ? wProp.GetInt32() : 0;
|
||||
var height = image.TryGetProperty("height", out var hProp) ? hProp.GetInt32() : 0;
|
||||
var altText = image.TryGetProperty("altText", out var altProp) ? altProp.GetString() : null;
|
||||
var attribution = image.TryGetProperty("attribution", out var attrProp) ? attrProp.GetString() : null;
|
||||
|
||||
// Build result with original properties
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["imageId"] = imageId,
|
||||
["url"] = sourceUrl, // Will be replaced with blob URL on success
|
||||
["source"] = source,
|
||||
["orientation"] = orientation,
|
||||
["width"] = width,
|
||||
["height"] = height,
|
||||
["altText"] = altText,
|
||||
["attribution"] = attribution,
|
||||
["downloadUrl"] = downloadUrl,
|
||||
["blobStored"] = false // Track whether we stored it
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(sourceUrl))
|
||||
{
|
||||
_logger.LogWarning("[ImageStorage] Image has no URL, skipping");
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Download image bytes (prefer download URL for higher quality)
|
||||
var fetchUrl = !string.IsNullOrEmpty(downloadUrl) ? downloadUrl : sourceUrl;
|
||||
|
||||
var httpClient = _httpFactory.CreateClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
_logger.LogDebug("[ImageStorage] Downloading from {Url}", fetchUrl);
|
||||
|
||||
using var response = await httpClient.GetAsync(fetchUrl, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/jpeg";
|
||||
var extension = GetExtensionFromContentType(contentType);
|
||||
|
||||
// Build blob path: {clientId}/drafts/{draftId}/{orientation}.{ext}
|
||||
var blobName = $"{clientId}/drafts/{draftId}/{orientation}.{extension}";
|
||||
var blobClient = container.GetBlobClient(blobName);
|
||||
|
||||
// Upload with proper content type and caching headers
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
|
||||
var uploadOptions = new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders
|
||||
{
|
||||
ContentType = contentType,
|
||||
CacheControl = "public, max-age=31536000" // 1 year cache
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = source ?? "unknown",
|
||||
["originalUrl"] = sourceUrl,
|
||||
["orientation"] = orientation,
|
||||
["width"] = width.ToString(),
|
||||
["height"] = height.ToString(),
|
||||
["clientId"] = clientId,
|
||||
["draftId"] = draftId
|
||||
}
|
||||
};
|
||||
|
||||
await blobClient.UploadAsync(stream, uploadOptions, ct);
|
||||
|
||||
// Build permanent blob URL
|
||||
var blobUrl = $"{_blobBaseUrl}/{_containerName}/{blobName}";
|
||||
|
||||
result["url"] = blobUrl;
|
||||
result["blobStored"] = true;
|
||||
result["originalUrl"] = sourceUrl; // Keep original for reference
|
||||
|
||||
_logger.LogInformation("[ImageStorage] Stored {Orientation} image: {BlobUrl}", orientation, blobUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[ImageStorage] Failed to store {Orientation} image, keeping original URL", orientation);
|
||||
// Keep original URL as fallback
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild the provider response JSON with processed image data.
|
||||
/// </summary>
|
||||
private static string RebuildResponseWithProcessedImages(
|
||||
JsonDocument original,
|
||||
List<Dictionary<string, object?>> processedImages)
|
||||
{
|
||||
var root = original.RootElement;
|
||||
|
||||
// Build new response maintaining structure
|
||||
var response = new Dictionary<string, object?>
|
||||
{
|
||||
["ok"] = root.GetProperty("ok").GetBoolean(),
|
||||
["requestId"] = root.TryGetProperty("requestId", out var rid) ? rid.GetString() : null
|
||||
};
|
||||
|
||||
// Rebuild data object with processed images
|
||||
if (root.TryGetProperty("data", out var dataProp))
|
||||
{
|
||||
var data = new Dictionary<string, object?>();
|
||||
|
||||
// Copy all data properties except images
|
||||
foreach (var prop in dataProp.EnumerateObject())
|
||||
{
|
||||
if (prop.Name == "images")
|
||||
continue;
|
||||
|
||||
data[prop.Name] = JsonElementToObject(prop.Value);
|
||||
}
|
||||
|
||||
// Add processed images
|
||||
data["images"] = processedImages;
|
||||
|
||||
response["data"] = data;
|
||||
}
|
||||
|
||||
// Copy error if present
|
||||
if (root.TryGetProperty("error", out var errorProp))
|
||||
{
|
||||
response["error"] = JsonElementToObject(errorProp);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert JsonElement to object for serialization.
|
||||
/// </summary>
|
||||
private static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => element.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
|
||||
JsonValueKind.Array => element.EnumerateArray()
|
||||
.Select(JsonElementToObject).ToList(),
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all images for a specific draft.
|
||||
/// </summary>
|
||||
public async Task DeleteDraftImagesAsync(string clientId, string draftId, CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured) return;
|
||||
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
|
||||
var prefix = $"{clientId}/drafts/{draftId}/";
|
||||
await foreach (var blob in containerClient.GetBlobsAsync(
|
||||
traits: BlobTraits.None,
|
||||
states: BlobStates.None,
|
||||
prefix: prefix,
|
||||
cancellationToken: ct))
|
||||
{
|
||||
await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct);
|
||||
_logger.LogInformation("[ImageStorage] Deleted blob {Name}", blob.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all images for a client.
|
||||
/// </summary>
|
||||
public async Task DeleteClientImagesAsync(string clientId, CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured) return;
|
||||
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
|
||||
var prefix = $"{clientId}/";
|
||||
var count = 0;
|
||||
|
||||
await foreach (var blob in containerClient.GetBlobsAsync(
|
||||
traits: BlobTraits.None,
|
||||
states: BlobStates.None,
|
||||
prefix: prefix,
|
||||
cancellationToken: ct))
|
||||
{
|
||||
await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct);
|
||||
count++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("[ImageStorage] Deleted {Count} blobs for client {ClientId}", count, clientId);
|
||||
}
|
||||
|
||||
private static string GetExtensionFromContentType(string contentType) => contentType switch
|
||||
{
|
||||
"image/png" => "png",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
"image/svg+xml" => "svg",
|
||||
_ => "jpg"
|
||||
};
|
||||
}
|
||||
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; }
|
||||
}
|
||||
214
Gateway/Services/IntelligenceApiClient.cs
Normal file
214
Gateway/Services/IntelligenceApiClient.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using Gateway.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for IntelligenceApi — the spend distribution engine container.
|
||||
///
|
||||
/// The Gateway injects clientCategory from ClientContext and provider config
|
||||
/// before forwarding requests. The client portal never calls IntelligenceApi
|
||||
/// directly; all routing goes through the Gateway.
|
||||
///
|
||||
/// FALLBACK: If IntelligenceApi is unreachable, ForecastController falls back
|
||||
/// to the local ForecastService (identical to the General engine output).
|
||||
/// This means a container restart or deployment never breaks the wizard.
|
||||
/// </summary>
|
||||
public sealed class IntelligenceApiClient
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<IntelligenceApiClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts =
|
||||
new(JsonSerializerDefaults.Web);
|
||||
|
||||
public IntelligenceApiClient(
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ILogger<IntelligenceApiClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward raw census data for a ZCTA to the Intelligence container for
|
||||
/// market analysis derivation (age chips, income tiers, insight strings).
|
||||
/// Returns the raw JSON response string, or null if the container is
|
||||
/// unreachable — caller falls back to returning raw census data.
|
||||
/// </summary>
|
||||
public async Task<string?> GetDemographicAnalysisAsync(
|
||||
string zcta,
|
||||
JsonElement censusData,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = _cfg["INTELLIGENCE_API_URL"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping demographics analysis");
|
||||
return null;
|
||||
}
|
||||
|
||||
var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
zcta,
|
||||
census = censusData
|
||||
}, _jsonOpts);
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"{baseUrl.TrimEnd('/')}/api/demographics/analyze");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(internalKey))
|
||||
msg.Headers.Add("X-Internal-Key", internalKey);
|
||||
|
||||
msg.Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
_logger.LogInformation("[IntelligenceApiClient] Demographics analysis | ZCTA={Zcta}", zcta);
|
||||
|
||||
using var resp = await client.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Demographics non-success {Status}", (int)resp.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Demographics analysis timed out | ZCTA={Zcta}", zcta);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[IntelligenceApiClient] Demographics analysis failed | ZCTA={Zcta}", zcta);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward a channel forecast request to IntelligenceApi with
|
||||
/// clientCategory injected. Returns null if the service is unreachable
|
||||
/// or returns an error — caller should fall back to ForecastService.
|
||||
/// </summary>
|
||||
public async Task<ChannelForecastResponse?> GetSpendDistributionAsync(
|
||||
ChannelForecastRequest request,
|
||||
string? clientCategory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = _cfg["INTELLIGENCE_API_URL"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
||||
|
||||
// Build the IntelligenceApi request — forward everything from the
|
||||
// original wizard request, plus inject clientCategory and provider config
|
||||
var intelligenceRequest = new
|
||||
{
|
||||
clientCategory = clientCategory ?? "General",
|
||||
objective = request.Objective,
|
||||
businessCategory = request.BusinessCategory,
|
||||
keywords = request.Keywords,
|
||||
geoTargeting = request.GeoTargeting,
|
||||
audience = request.Audience,
|
||||
monthlyBudget = request.MonthlyBudget,
|
||||
channels = request.Channels,
|
||||
|
||||
// Forward provider URLs so the engine can call providers directly
|
||||
providerUrls = new Dictionary<string, string>
|
||||
{
|
||||
["google_ads"] = _cfg["GOOGLE_PROVIDER_URL"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL") ?? ""
|
||||
},
|
||||
internalKeys = new Dictionary<string, string>
|
||||
{
|
||||
["google_ads"] = _cfg["GOOGLE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY") ?? ""
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"{baseUrl.TrimEnd('/')}/api/spend-distribution");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(internalKey))
|
||||
msg.Headers.Add("X-Internal-Key", internalKey);
|
||||
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(intelligenceRequest, _jsonOpts),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
_logger.LogInformation(
|
||||
"[IntelligenceApiClient] Calling engine | Category={Category} Budget={Budget}",
|
||||
clientCategory, request.MonthlyBudget);
|
||||
|
||||
using var resp = await client.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[IntelligenceApiClient] Non-success {Status}: {Body}",
|
||||
(int)resp.StatusCode, body[..Math.Min(body.Length, 200)]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map IntelligenceApi response shape → Gateway ChannelForecastResponse
|
||||
// The shapes are intentionally aligned so this is a straight deserialize.
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Engine returned ok=false");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-serialize then deserialize into the Gateway model
|
||||
// (avoids a hard dependency on IntelligenceApi model types in Gateway)
|
||||
var result = JsonSerializer.Deserialize<ChannelForecastResponse>(body, _jsonOpts);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[IntelligenceApiClient] OK | Engine={Engine} Channels={N}",
|
||||
root.TryGetProperty("metadata", out var meta)
|
||||
&& meta.TryGetProperty("engine", out var eng)
|
||||
? eng.GetString() : "?",
|
||||
result?.Channels?.Count ?? 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Request timed out");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[IntelligenceApiClient] Request failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
339
Gateway/Services/MetricSyncService.cs
Normal file
339
Gateway/Services/MetricSyncService.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates pulling campaign performance metrics from providers
|
||||
/// and writing them into the database via spPerformanceMetric.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Get active channel campaigns (from spChannelCampaign listByClient)
|
||||
/// 2. For each channel campaign with an external campaign ID:
|
||||
/// - Call the appropriate provider's reporting endpoint
|
||||
/// - Transform provider response into standard metric format
|
||||
/// - Upsert into tbPerformanceMetric via spPerformanceMetric.upsertBatch
|
||||
/// 3. After metrics are synced, trigger recommendation evaluation
|
||||
///
|
||||
/// Called by:
|
||||
/// - Admin endpoint (manual trigger)
|
||||
/// - Background polling (future: Azure Functions timer trigger)
|
||||
/// </summary>
|
||||
public sealed class MetricSyncService
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<MetricSyncService> _logger;
|
||||
|
||||
public MetricSyncService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ILogger<MetricSyncService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync metrics for a specific client's active campaigns.
|
||||
/// </summary>
|
||||
public async Task<SyncResult> SyncClientMetricsAsync(
|
||||
string clientId, string? startDate, string? endDate, CancellationToken ct)
|
||||
{
|
||||
var result = new SyncResult { ClientId = clientId };
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Get active channel campaigns for this client
|
||||
var listResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelCampaign, "listByClient",
|
||||
JsonSerializer.Serialize(new { clientId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(listResp))
|
||||
{
|
||||
result.Error = "Failed to retrieve channel campaigns";
|
||||
return result;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(listResp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
result.Error = "Channel campaign query returned error";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract campaigns array
|
||||
JsonElement campaigns;
|
||||
if (root.TryGetProperty("channelCampaigns", out campaigns) ||
|
||||
root.TryGetProperty("channels", out campaigns))
|
||||
{
|
||||
// ok
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Error = "No channel campaigns found in response";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (campaigns.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
result.Error = "Channel campaigns is not an array";
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Sync each active channel campaign
|
||||
foreach (var cc in campaigns.EnumerateArray())
|
||||
{
|
||||
var chcId = cc.TryGetProperty("channelCampaignId", out var chcIdProp) ? chcIdProp.GetInt64() :
|
||||
cc.TryGetProperty("chcId", out var chcProp) ? chcProp.GetInt64() : 0;
|
||||
var channelType = cc.TryGetProperty("channelType", out var ctProp) ? ctProp.GetString() :
|
||||
cc.TryGetProperty("chcChannelType", out var chcCtProp) ? chcCtProp.GetString() : null;
|
||||
var status = cc.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
cc.TryGetProperty("chcStatus", out var chcStProp) ? chcStProp.GetString() : null;
|
||||
|
||||
if (chcId == 0 || string.IsNullOrWhiteSpace(channelType)) continue;
|
||||
if (status != "active") continue;
|
||||
|
||||
result.CampaignsProcessed++;
|
||||
|
||||
try
|
||||
{
|
||||
var provider = MapChannelToProvider(channelType);
|
||||
var providerUrl = GetProviderUrl(provider);
|
||||
var providerKey = GetProviderKey(provider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] No URL for provider {Provider}, skipping chcId={ChcId}",
|
||||
provider, chcId);
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get external campaign ID
|
||||
// providerPayload from the channel campaign contains the external mapping
|
||||
var externalCampaignId = cc.TryGetProperty("externalCampaignId", out var extIdProp)
|
||||
? extIdProp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(externalCampaignId))
|
||||
{
|
||||
// Try to extract from providerPayload JSON
|
||||
if (cc.TryGetProperty("providerPayload", out var ppProp) &&
|
||||
ppProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ppDoc = JsonDocument.Parse(ppProp.GetString()!);
|
||||
externalCampaignId = ppDoc.RootElement.TryGetProperty("externalId", out var eidProp)
|
||||
? eidProp.GetString() : null;
|
||||
}
|
||||
catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(externalCampaignId))
|
||||
{
|
||||
_logger.LogDebug("[MetricSync] No externalCampaignId for chcId={ChcId}, skipping", chcId);
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Call provider reporting endpoint
|
||||
var reportPayload = new
|
||||
{
|
||||
operation = "GetCampaignReport",
|
||||
tenantId = GetTenantId(cc),
|
||||
requestId = Guid.NewGuid().ToString("N"),
|
||||
payload = new
|
||||
{
|
||||
campaignId = externalCampaignId,
|
||||
startDate = startDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd"),
|
||||
endDate = endDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd")
|
||||
}
|
||||
};
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", providerKey);
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(reportPayload),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var resp = await httpClient.SendAsync(msg, ct);
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] Provider returned {Status} for chcId={ChcId}",
|
||||
resp.StatusCode, chcId);
|
||||
result.Errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse provider response and extract daily rows
|
||||
using var respDoc = JsonDocument.Parse(respBody);
|
||||
var respRoot = respDoc.RootElement;
|
||||
|
||||
JsonElement data;
|
||||
if (respRoot.TryGetProperty("data", out data) ||
|
||||
respRoot.TryGetProperty("Data", out data))
|
||||
{
|
||||
// ok
|
||||
}
|
||||
else
|
||||
{
|
||||
data = respRoot;
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("rows", out var rowsEl) ||
|
||||
rowsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] No rows in provider response for chcId={ChcId}", chcId);
|
||||
result.Errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform rows into upsertBatch format
|
||||
var metrics = new List<object>();
|
||||
foreach (var row in rowsEl.EnumerateArray())
|
||||
{
|
||||
var metricDate = row.TryGetProperty("date", out var dProp) ? dProp.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(metricDate)) continue;
|
||||
|
||||
metrics.Add(new
|
||||
{
|
||||
channelCampaignId = chcId,
|
||||
metricDate,
|
||||
impressions = GetLong(row, "impressions"),
|
||||
clicks = GetLong(row, "clicks"),
|
||||
spend = GetDecimal(row, "spend") ?? (GetLong(row, "costMicros") / 1_000_000.0m),
|
||||
conversions = GetDecimal(row, "conversions") ?? 0,
|
||||
conversionValue = GetDecimal(row, "conversionValue") ?? 0,
|
||||
sourceAttribution = "provider"
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.Count == 0) continue;
|
||||
|
||||
// Upsert into database
|
||||
var upsertResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.PerformanceMetric, "upsertBatch",
|
||||
JsonSerializer.Serialize(new { metrics }), ct: ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[MetricSync] Synced {Count} rows for chcId={ChcId} channel={Channel}",
|
||||
metrics.Count, chcId, channelType);
|
||||
|
||||
result.MetricsWritten += metrics.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Error syncing chcId={ChcId}", chcId);
|
||||
result.Errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Trigger recommendation evaluation for this client
|
||||
if (result.MetricsWritten > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var evalResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Recommendation, "evaluate",
|
||||
JsonSerializer.Serialize(new { clientId }), ct: ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evalResp))
|
||||
{
|
||||
using var evalDoc = JsonDocument.Parse(evalResp);
|
||||
if (evalDoc.RootElement.TryGetProperty("generated", out var genProp))
|
||||
result.RecommendationsGenerated = genProp.GetInt32();
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[MetricSync] Evaluation complete for client {ClientId} | Recommendations={Recommendations}",
|
||||
clientId, result.RecommendationsGenerated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Evaluation failed for client {ClientId}", clientId);
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Sync failed for client {ClientId}", clientId);
|
||||
result.Error = ex.Message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static string MapChannelToProvider(string channelType) =>
|
||||
channelType.ToLowerInvariant() switch
|
||||
{
|
||||
"google_ads" or "google" => "google",
|
||||
"meta" or "facebook" => "meta",
|
||||
"tiktok" => "tiktok",
|
||||
_ => channelType
|
||||
};
|
||||
|
||||
private string GetProviderUrl(string provider) =>
|
||||
provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private string GetProviderKey(string provider) =>
|
||||
provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
|
||||
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private static string? GetTenantId(JsonElement cc)
|
||||
{
|
||||
if (cc.TryGetProperty("externalAccountId", out var eaProp)) return eaProp.GetString();
|
||||
if (cc.TryGetProperty("chcExternalAccountId", out var chcEaProp)) return chcEaProp.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long GetLong(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
|
||||
private static decimal? GetDecimal(JsonElement el, string prop)
|
||||
{
|
||||
if (!el.TryGetProperty(prop, out var p)) return null;
|
||||
return p.ValueKind == JsonValueKind.Number ? p.GetDecimal() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of a metric sync operation.</summary>
|
||||
public sealed class SyncResult
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public int CampaignsProcessed { get; set; }
|
||||
public int MetricsWritten { get; set; }
|
||||
public int RecommendationsGenerated { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
public int Errors { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
160
Gateway/Services/ProviderStatusNormalizer.cs
Normal file
160
Gateway/Services/ProviderStatusNormalizer.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
}
|
||||
@@ -9,10 +9,38 @@
|
||||
|
||||
"Auth": {
|
||||
"AllowDevBypass": false,
|
||||
|
||||
"Microsoft": {
|
||||
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
|
||||
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e",
|
||||
"StaffTenantId": "0be4c23a-6941-4bdb-b397-a4faf88de4b3",
|
||||
"StaffClientId": "b0f29246-91e7-4615-96db-5de9b6f8da2e"
|
||||
},
|
||||
|
||||
"EntraId": {
|
||||
"Instance": "https://login.microsoftonline.com/",
|
||||
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
|
||||
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
|
||||
}
|
||||
},
|
||||
|
||||
"BlobStorage": {
|
||||
"ConnectionString": "",
|
||||
"ContainerName": "creative-images",
|
||||
"BaseUrl": "https://usimadpcreatives.blob.core.windows.net"
|
||||
},
|
||||
|
||||
"MultiChannel": {
|
||||
"Allocation": {
|
||||
"MinMultiChannelMonthlyBudget": 500.00,
|
||||
"MaxChannelsPerInitiative": 5,
|
||||
"DefaultAllocationStrategy": "template",
|
||||
"PerformanceEvalIntervalDays": 7,
|
||||
"PerformanceLookbackDays": 14,
|
||||
"PerformanceLearningPeriodDays": 14,
|
||||
"MaxAllocationShiftPct": 15.00,
|
||||
"MinChannelAllocationPct": 10.00,
|
||||
"MaxChannelAllocationPct": 80.00
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
# Google Ads API Configuration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes how to configure the GoogleApi service to connect to the real Google Ads API. The service supports both **emulated mode** (for testing without Google credentials) and **real API mode**.
|
||||
|
||||
## Configuration Levels
|
||||
|
||||
| Level | Storage | Examples |
|
||||
|-------|---------|----------|
|
||||
| **Platform secrets** | Azure Key Vault | Developer token, OAuth client secret |
|
||||
| **Platform config** | App Settings / appsettings.json | API version, timeouts |
|
||||
| **Per-account credentials** | Database (tbGoogleCredential) | Refresh tokens per linked account |
|
||||
|
||||
## Quick Start (Test Account)
|
||||
|
||||
1. Create a Google Ads test manager account
|
||||
2. Get a developer token (works immediately for test accounts)
|
||||
3. Set up OAuth credentials in Google Cloud Console
|
||||
4. Configure the environment variables below
|
||||
|
||||
## Environment Variables for Azure Container Apps
|
||||
> **Note:** This service runs **server-to-server**. There is **no interactive OAuth UI** at runtime.
|
||||
> Generate the refresh token once (out-of-band) and store it securely (Key Vault / secrets).
|
||||
|
||||
|
||||
### GoogleApi Service
|
||||
|
||||
```bash
|
||||
# ==========================================
|
||||
# Core Settings
|
||||
# ==========================================
|
||||
|
||||
# Enable real Google Ads API calls (default: false)
|
||||
GoogleAds__EnableRealApi=true
|
||||
|
||||
# API version (default: v22)
|
||||
GoogleAds__ApiVersion=v22
|
||||
|
||||
# ==========================================
|
||||
# Authentication - Developer Token
|
||||
# Required for all API calls
|
||||
# ==========================================
|
||||
|
||||
# Your developer token from Google Ads API Center
|
||||
# Format: 22-character alphanumeric string
|
||||
# Get from: https://ads.google.com/aw/apicenter
|
||||
GoogleAds__DeveloperToken=YOUR_DEVELOPER_TOKEN_HERE
|
||||
|
||||
# ==========================================
|
||||
# Authentication - OAuth 2.0
|
||||
# Required for authenticating API requests
|
||||
# ==========================================
|
||||
|
||||
# OAuth Client ID from Google Cloud Console
|
||||
GoogleAds__OAuth__ClientId=YOUR_CLIENT_ID.apps.googleusercontent.com
|
||||
|
||||
# OAuth Client Secret from Google Cloud Console
|
||||
# SENSITIVE - Use Key Vault reference in production
|
||||
GoogleAds__OAuth__ClientSecret=YOUR_CLIENT_SECRET
|
||||
|
||||
# Refresh token for platform-level access
|
||||
# Generated via OAuth flow or gcloud CLI
|
||||
# SENSITIVE - Use Key Vault reference in production
|
||||
GoogleAds__OAuth__RefreshToken=YOUR_REFRESH_TOKEN
|
||||
|
||||
# ==========================================
|
||||
# Manager Account (Optional)
|
||||
# Required if accessing client accounts under a manager
|
||||
# ==========================================
|
||||
|
||||
# Default login customer ID (manager account)
|
||||
# Format: 1234567890 (no dashes)
|
||||
GoogleAds__DefaultLoginCustomerId=1234567890
|
||||
|
||||
# ==========================================
|
||||
# Internal Authentication
|
||||
# For Gateway -> GoogleApi communication
|
||||
# ==========================================
|
||||
|
||||
# Shared secret for internal API authentication
|
||||
# SENSITIVE - Use Key Vault reference
|
||||
GOOGLE_INTERNAL_KEY=your-secure-internal-key
|
||||
|
||||
# ==========================================
|
||||
# Optional Settings
|
||||
# ==========================================
|
||||
|
||||
# HTTP timeout in seconds (default: 60)
|
||||
GoogleAds__TimeoutSeconds=60
|
||||
|
||||
# Max retry attempts (default: 3)
|
||||
GoogleAds__MaxRetries=3
|
||||
```
|
||||
|
||||
### Azure Key Vault References
|
||||
|
||||
For sensitive values, use Key Vault references in Azure Container Apps:
|
||||
|
||||
```bash
|
||||
# Instead of plain values:
|
||||
GoogleAds__DeveloperToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsDeveloperToken/)
|
||||
GoogleAds__OAuth__ClientSecret=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsClientSecret/)
|
||||
GoogleAds__OAuth__RefreshToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsRefreshToken/)
|
||||
GOOGLE_INTERNAL_KEY=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleInternalKey/)
|
||||
```
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### 1. Create Google Ads Manager Account
|
||||
|
||||
1. Go to https://ads.google.com/aw/apicenter
|
||||
2. Sign in with a Google account NOT linked to production ads
|
||||
3. Create a new manager account
|
||||
4. For test accounts, click "Create a test manager account" link
|
||||
|
||||
### 2. Get Developer Token
|
||||
|
||||
1. In your manager account, go to **Tools & Settings > API Center**
|
||||
2. Your developer token will be displayed
|
||||
3. For test accounts: Token works immediately
|
||||
4. For production: Apply for Basic Access (takes a few days)
|
||||
|
||||
### 3. Create Google Cloud Project
|
||||
|
||||
1. Go to https://console.cloud.google.com
|
||||
2. Create a new project (or use existing)
|
||||
3. Enable the **Google Ads API**:
|
||||
- Go to APIs & Services > Library
|
||||
- Search "Google Ads API"
|
||||
- Click Enable
|
||||
|
||||
### 4. Create OAuth Credentials
|
||||
|
||||
1. Go to APIs & Services > Credentials
|
||||
2. Click **Create Credentials > OAuth client ID**
|
||||
3. Application type: **Desktop app** (for initial testing)
|
||||
4. Download the JSON file
|
||||
5. Note the Client ID and Client Secret
|
||||
|
||||
### 5. Generate Refresh Token
|
||||
|
||||
Option A: Using gcloud CLI
|
||||
```bash
|
||||
# Install gcloud CLI if not installed
|
||||
gcloud auth login --cred-file=path/to/client_secret.json
|
||||
|
||||
gcloud auth print-access-token \
|
||||
--scopes='https://www.googleapis.com/auth/adwords'
|
||||
```
|
||||
|
||||
Option B: Using OAuth Playground
|
||||
1. Go to https://developers.google.com/oauthplayground/
|
||||
2. Click gear icon > Use your own credentials
|
||||
3. Enter your Client ID and Secret
|
||||
4. Select Google Ads API scope: `https://www.googleapis.com/auth/adwords`
|
||||
5. Click Authorize APIs, sign in
|
||||
6. Click "Exchange authorization code for tokens"
|
||||
7. Copy the Refresh Token
|
||||
|
||||
### 6. Create Test Client Account
|
||||
|
||||
1. In your test manager account
|
||||
2. Click Accounts > + > Create new account
|
||||
3. This creates a test client account under your manager
|
||||
4. Note the Customer ID (format: XXX-XXX-XXXX)
|
||||
|
||||
### 7. Configure Azure Container App
|
||||
|
||||
In Azure Portal > Container Apps > Your GoogleApi App > Settings > Environment Variables:
|
||||
|
||||
Add each variable from the list above, using Key Vault references for sensitive values.
|
||||
|
||||
## Testing the Configuration
|
||||
|
||||
### Check Health Endpoint
|
||||
|
||||
```bash
|
||||
curl https://your-googleapi-url/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"service": "GoogleApi",
|
||||
"status": "healthy",
|
||||
"config": {
|
||||
"realApiEnabled": true,
|
||||
"apiVersion": "v18",
|
||||
"developerTokenSet": true,
|
||||
"oauthConfigured": true,
|
||||
"defaultLoginCustomerId": "1234567890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test API Call (via Gateway)
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-gateway-url/api/execution/request \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Dev-ClientId: test-client" \
|
||||
-H "X-Dev-TenantId: 1234567890" \
|
||||
-d '{
|
||||
"operation": "ListAccessibleCustomers",
|
||||
"payload": {}
|
||||
}'
|
||||
```
|
||||
|
||||
## Credential Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Gateway │────▶│ GoogleApi │────▶│ Google Ads │
|
||||
└─────────────┘ └─────────────┘ │ API │
|
||||
│ └─────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
┌─────▼─────┐ ┌────▼────┐
|
||||
│ Config │ │ Database │
|
||||
│(env vars) │ │(per-acct)│
|
||||
└───────────┘ └──────────┘
|
||||
|
||||
Config provides: Database provides:
|
||||
- Developer Token - Per-account refresh tokens
|
||||
- OAuth Client ID/Secret - Account-specific credentials
|
||||
- Default refresh token - Linked customer IDs
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "UNAUTHENTICATED" Error
|
||||
- Check developer token is correct
|
||||
- Verify OAuth credentials
|
||||
- Ensure refresh token hasn't expired
|
||||
|
||||
### "PERMISSION_DENIED" Error
|
||||
- Developer token may not be approved for production
|
||||
- Verify account access permissions
|
||||
- Check login-customer-id is correct
|
||||
|
||||
### "INVALID_CUSTOMER_ID" Error
|
||||
- Customer ID format should be 10 digits, no dashes
|
||||
- Verify account exists and is accessible
|
||||
|
||||
### Token Exchange Fails
|
||||
- Client ID/Secret mismatch
|
||||
- Refresh token was revoked
|
||||
- OAuth consent was withdrawn
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit secrets** to source control
|
||||
2. **Use Azure Key Vault** for all sensitive values
|
||||
3. **Rotate refresh tokens** periodically
|
||||
4. **Audit API access** via tbAdpApiLog
|
||||
5. **Limit developer token access** - one token per application
|
||||
6. **Use test accounts** for development and testing
|
||||
@@ -12,8 +12,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Google.Ads.GoogleAds" Version="24.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<PackageReference Include="Google.Ads.GoogleAds" Version="25.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
58
GoogleApi/Models/AudienceModels.cs
Normal file
58
GoogleApi/Models/AudienceModels.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace GoogleApi.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audience segment from Google Ads (affinity, in-market, life events, etc.)
|
||||
/// </summary>
|
||||
public class AudienceSegment
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty; // AFFINITY, IN_MARKET, LIFE_EVENT, DETAILED_DEMOGRAPHIC
|
||||
public string? ParentName { get; set; }
|
||||
public int? ParentId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing all available audience segments
|
||||
/// </summary>
|
||||
public class AudienceSegmentsResponse
|
||||
{
|
||||
public List<AudienceSegment> Affinity { get; set; } = new();
|
||||
public List<AudienceSegment> InMarket { get; set; } = new();
|
||||
public List<AudienceSegment> LifeEvents { get; set; } = new();
|
||||
public List<AudienceSegment> DetailedDemographics { get; set; } = new();
|
||||
public int TotalCount => Affinity.Count + InMarket.Count + LifeEvents.Count + DetailedDemographics.Count;
|
||||
public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Geo target constant for location targeting
|
||||
/// </summary>
|
||||
public class GeoTarget
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string CanonicalName { get; set; } = string.Empty;
|
||||
public string TargetType { get; set; } = string.Empty; // City, State, Country, etc.
|
||||
public string? CountryCode { get; set; }
|
||||
public string? ParentGeoTarget { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for geo target search
|
||||
/// </summary>
|
||||
public class GeoTargetSearchResponse
|
||||
{
|
||||
public List<GeoTarget> Results { get; set; } = new();
|
||||
public string Query { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for searching geo targets
|
||||
/// </summary>
|
||||
public class GeoTargetSearchPayload
|
||||
{
|
||||
public string Query { get; set; } = string.Empty;
|
||||
public string? CountryCode { get; set; }
|
||||
public int MaxResults { get; set; } = 20;
|
||||
}
|
||||
59
GoogleApi/Models/ForecastModels.cs
Normal file
59
GoogleApi/Models/ForecastModels.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace GoogleApi.Models;
|
||||
|
||||
#region Forecast Payloads
|
||||
|
||||
/// <summary>
|
||||
/// Payload for KeywordForecast operation.
|
||||
/// Gateway sends targeting + budget; we translate to GenerateKeywordForecastMetrics.
|
||||
/// </summary>
|
||||
public sealed class KeywordForecastPayload
|
||||
{
|
||||
/// <summary>Keywords to forecast (from wizard Step 1 URL analysis)</summary>
|
||||
public List<string> Keywords { get; set; } = new();
|
||||
|
||||
/// <summary>Geo target IDs (Google Ads geo constants)</summary>
|
||||
public List<long> GeoTargetIds { get; set; } = new();
|
||||
|
||||
/// <summary>Monthly budget in whole currency units allocated to this channel</summary>
|
||||
public decimal MonthlyBudget { get; set; }
|
||||
|
||||
/// <summary>Currency code</summary>
|
||||
public string CurrencyCode { get; set; } = "USD";
|
||||
|
||||
/// <summary>Forecast period in days (default 30)</summary>
|
||||
public int ForecastDays { get; set; } = 30;
|
||||
|
||||
/// <summary>Campaign type for bid simulation</summary>
|
||||
public CampaignType CampaignType { get; set; } = CampaignType.Search;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from keyword forecast — monthly estimated metrics.
|
||||
/// </summary>
|
||||
public sealed class KeywordForecastResponse
|
||||
{
|
||||
public string Provider { get; set; } = "google";
|
||||
public ForecastEstimates Monthly { get; set; } = new();
|
||||
public ForecastMetrics Metrics { get; set; } = new();
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "emulated";
|
||||
}
|
||||
|
||||
public sealed class ForecastEstimates
|
||||
{
|
||||
public double Impressions { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public decimal Cost { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForecastMetrics
|
||||
{
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -36,6 +36,25 @@ public sealed class ListCampaignsPayload
|
||||
|
||||
#endregion
|
||||
|
||||
#region Account Payloads
|
||||
|
||||
public sealed class CreateCustomerClientPayload
|
||||
{
|
||||
/// <summary>Display name for the new sub-account (used for billing reconciliation)</summary>
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Currency code (e.g. "USD")</summary>
|
||||
public string CurrencyCode { get; set; } = "USD";
|
||||
|
||||
/// <summary>Time zone (e.g. "America/Los_Angeles")</summary>
|
||||
public string TimeZone { get; set; } = "America/Los_Angeles";
|
||||
|
||||
/// <summary>Optional descriptive name visible in MCC (defaults to AccountName)</summary>
|
||||
public string? DescriptiveName { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reporting Payloads
|
||||
|
||||
public sealed class CampaignStatsPayload
|
||||
|
||||
@@ -47,6 +47,9 @@ builder.Services.AddSwaggerGen(c =>
|
||||
|
||||
// Core services
|
||||
builder.Services.AddSingleton<GoogleAdsClientFactory>();
|
||||
builder.Services.AddSingleton<AudienceService>();
|
||||
builder.Services.AddSingleton<ReportingService>();
|
||||
builder.Services.AddSingleton<KeywordForecastService>();
|
||||
builder.Services.AddSingleton<GoogleAdsService>();
|
||||
|
||||
// Auth filter for internal calls from Gateway
|
||||
|
||||
318
GoogleApi/Services/AudienceService.cs
Normal file
318
GoogleApi/Services/AudienceService.cs
Normal file
@@ -0,0 +1,318 @@
|
||||
using Google.Ads.GoogleAds.Lib;
|
||||
using Google.Ads.GoogleAds.V22.Services;
|
||||
using Google.Ads.GoogleAds.V22.Resources;
|
||||
using Google.Ads.GoogleAds.V22.Enums;
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
// Alias to avoid namespace conflicts
|
||||
using GAdsServices = global::Google.Ads.GoogleAds.Services;
|
||||
|
||||
namespace GoogleApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying Google Ads audience segments and geo targets.
|
||||
/// </summary>
|
||||
public sealed class AudienceService
|
||||
{
|
||||
private readonly GoogleAdsConfig _config;
|
||||
private readonly GoogleAdsClientFactory _clientFactory;
|
||||
private readonly ILogger<AudienceService> _logger;
|
||||
|
||||
public AudienceService(
|
||||
IOptions<GoogleAdsConfig> config,
|
||||
GoogleAdsClientFactory clientFactory,
|
||||
ILogger<AudienceService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_clientFactory = clientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all available audience segments (affinity, in-market, life events, detailed demographics)
|
||||
/// </summary>
|
||||
public async Task<ProviderResponse> GetAudienceSegmentsAsync(
|
||||
GoogleAdsContext context,
|
||||
string requestId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("[Audience] Fetching audience segments | RequestId={RequestId}", requestId);
|
||||
|
||||
if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId))
|
||||
{
|
||||
return GetEmulatedAudienceSegments(requestId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _clientFactory.CreateClient(context);
|
||||
var googleAdsService = client.GetService(GAdsServices.V22.GoogleAdsService);
|
||||
var customerId = context.CustomerId;
|
||||
|
||||
var response = new AudienceSegmentsResponse();
|
||||
|
||||
// Query user interests (Affinity + In-Market)
|
||||
var userInterestQuery = @"
|
||||
SELECT
|
||||
user_interest.user_interest_id,
|
||||
user_interest.name,
|
||||
user_interest.taxonomy_type,
|
||||
user_interest.availabilities
|
||||
FROM user_interest
|
||||
WHERE user_interest.taxonomy_type IN ('AFFINITY', 'IN_MARKET')";
|
||||
|
||||
var userInterestResults = googleAdsService.Search(customerId, userInterestQuery);
|
||||
|
||||
foreach (var row in userInterestResults)
|
||||
{
|
||||
var ui = row.UserInterest;
|
||||
var segment = new AudienceSegment
|
||||
{
|
||||
Id = ui.UserInterestId,
|
||||
Name = ui.Name,
|
||||
Type = ui.TaxonomyType.ToString()
|
||||
};
|
||||
|
||||
if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.Affinity)
|
||||
response.Affinity.Add(segment);
|
||||
else if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.InMarket)
|
||||
response.InMarket.Add(segment);
|
||||
}
|
||||
|
||||
// Query life events
|
||||
var lifeEventQuery = @"
|
||||
SELECT
|
||||
life_event.id,
|
||||
life_event.name
|
||||
FROM life_event";
|
||||
|
||||
var lifeEventResults = googleAdsService.Search(customerId, lifeEventQuery);
|
||||
|
||||
foreach (var row in lifeEventResults)
|
||||
{
|
||||
var le = row.LifeEvent;
|
||||
response.LifeEvents.Add(new AudienceSegment
|
||||
{
|
||||
Id = le.Id,
|
||||
Name = le.Name,
|
||||
Type = "LIFE_EVENT"
|
||||
});
|
||||
}
|
||||
|
||||
// Query detailed demographics
|
||||
var detailedDemoQuery = @"
|
||||
SELECT
|
||||
detailed_demographic.id,
|
||||
detailed_demographic.name
|
||||
FROM detailed_demographic";
|
||||
|
||||
var detailedDemoResults = googleAdsService.Search(customerId, detailedDemoQuery);
|
||||
|
||||
foreach (var row in detailedDemoResults)
|
||||
{
|
||||
var dd = row.DetailedDemographic;
|
||||
response.DetailedDemographics.Add(new AudienceSegment
|
||||
{
|
||||
Id = dd.Id,
|
||||
Name = dd.Name,
|
||||
Type = "DETAILED_DEMOGRAPHIC"
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Audience] Retrieved {Total} segments (Affinity={Affinity}, InMarket={InMarket}, LifeEvents={Life}, Demographics={Demo}) | RequestId={RequestId}",
|
||||
response.TotalCount, response.Affinity.Count, response.InMarket.Count,
|
||||
response.LifeEvents.Count, response.DetailedDemographics.Count, requestId);
|
||||
|
||||
return ProviderResponse.Success(requestId, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Audience] Failed to fetch segments | RequestId={RequestId}", requestId);
|
||||
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search for geo target constants by name
|
||||
/// </summary>
|
||||
public async Task<ProviderResponse> SearchGeoTargetsAsync(
|
||||
GeoTargetSearchPayload payload,
|
||||
GoogleAdsContext context,
|
||||
string requestId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("[Audience] Searching geo targets: {Query} | RequestId={RequestId}",
|
||||
payload.Query, requestId);
|
||||
|
||||
if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId))
|
||||
{
|
||||
return GetEmulatedGeoTargets(payload.Query, requestId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _clientFactory.CreateClient(context);
|
||||
var geoService = client.GetService(GAdsServices.V22.GeoTargetConstantService);
|
||||
|
||||
var request = new SuggestGeoTargetConstantsRequest
|
||||
{
|
||||
Locale = "en",
|
||||
CountryCode = payload.CountryCode ?? "US",
|
||||
LocationNames = new SuggestGeoTargetConstantsRequest.Types.LocationNames()
|
||||
};
|
||||
request.LocationNames.Names.Add(payload.Query);
|
||||
|
||||
var response = await geoService.SuggestGeoTargetConstantsAsync(request);
|
||||
|
||||
var results = response.GeoTargetConstantSuggestions
|
||||
.Take(payload.MaxResults)
|
||||
.Select(s => new GeoTarget
|
||||
{
|
||||
Id = s.GeoTargetConstant.Id,
|
||||
Name = s.GeoTargetConstant.Name,
|
||||
CanonicalName = s.GeoTargetConstant.CanonicalName,
|
||||
TargetType = s.GeoTargetConstant.TargetType,
|
||||
CountryCode = s.GeoTargetConstant.CountryCode,
|
||||
ParentGeoTarget = s.GeoTargetConstant.ParentGeoTarget
|
||||
})
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("[Audience] Found {Count} geo targets for '{Query}' | RequestId={RequestId}",
|
||||
results.Count, payload.Query, requestId);
|
||||
|
||||
return ProviderResponse.Success(requestId, new GeoTargetSearchResponse
|
||||
{
|
||||
Query = payload.Query,
|
||||
Results = results
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Audience] Failed to search geo targets | RequestId={RequestId}", requestId);
|
||||
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
#region Emulated Responses
|
||||
|
||||
private ProviderResponse GetEmulatedAudienceSegments(string requestId)
|
||||
{
|
||||
_logger.LogInformation("[Audience] Returning emulated audience segments | RequestId={RequestId}", requestId);
|
||||
|
||||
var response = new AudienceSegmentsResponse
|
||||
{
|
||||
Affinity = new List<AudienceSegment>
|
||||
{
|
||||
new() { Id = 80001, Name = "Sports & Fitness/Sports Fans", Type = "AFFINITY" },
|
||||
new() { Id = 80002, Name = "Sports & Fitness/Health & Fitness Buffs", Type = "AFFINITY" },
|
||||
new() { Id = 80003, Name = "Technology/Technophiles", Type = "AFFINITY" },
|
||||
new() { Id = 80004, Name = "Travel/Travel Buffs", Type = "AFFINITY" },
|
||||
new() { Id = 80005, Name = "Food & Dining/Foodies", Type = "AFFINITY" },
|
||||
new() { Id = 80006, Name = "Home & Garden/Home Decor Enthusiasts", Type = "AFFINITY" },
|
||||
new() { Id = 80007, Name = "Media & Entertainment/Movie Lovers", Type = "AFFINITY" },
|
||||
new() { Id = 80008, Name = "Media & Entertainment/Music Lovers", Type = "AFFINITY" },
|
||||
new() { Id = 80009, Name = "Shoppers/Value Shoppers", Type = "AFFINITY" },
|
||||
new() { Id = 80010, Name = "Shoppers/Luxury Shoppers", Type = "AFFINITY" },
|
||||
new() { Id = 80011, Name = "Lifestyles & Hobbies/Pet Lovers", Type = "AFFINITY" },
|
||||
new() { Id = 80012, Name = "Lifestyles & Hobbies/Outdoor Enthusiasts", Type = "AFFINITY" },
|
||||
new() { Id = 80013, Name = "News & Politics/Avid News Readers", Type = "AFFINITY" },
|
||||
new() { Id = 80014, Name = "Beauty & Wellness/Beauty Mavens", Type = "AFFINITY" },
|
||||
new() { Id = 80015, Name = "Vehicles & Transportation/Auto Enthusiasts", Type = "AFFINITY" },
|
||||
},
|
||||
InMarket = new List<AudienceSegment>
|
||||
{
|
||||
new() { Id = 90001, Name = "Apparel & Accessories/Athletic Apparel", Type = "IN_MARKET" },
|
||||
new() { Id = 90002, Name = "Autos & Vehicles/Motor Vehicles (New)", Type = "IN_MARKET" },
|
||||
new() { Id = 90003, Name = "Autos & Vehicles/Motor Vehicles (Used)", Type = "IN_MARKET" },
|
||||
new() { Id = 90004, Name = "Business Services/Advertising & Marketing Services", Type = "IN_MARKET" },
|
||||
new() { Id = 90005, Name = "Consumer Electronics/Computers & Peripherals", Type = "IN_MARKET" },
|
||||
new() { Id = 90006, Name = "Consumer Electronics/Mobile Phones", Type = "IN_MARKET" },
|
||||
new() { Id = 90007, Name = "Education/Primary & Secondary Schools (K-12)", Type = "IN_MARKET" },
|
||||
new() { Id = 90008, Name = "Employment/Jobs", Type = "IN_MARKET" },
|
||||
new() { Id = 90009, Name = "Financial Services/Insurance/Auto Insurance", Type = "IN_MARKET" },
|
||||
new() { Id = 90010, Name = "Financial Services/Investment Services", Type = "IN_MARKET" },
|
||||
new() { Id = 90011, Name = "Home & Garden/Home Improvement", Type = "IN_MARKET" },
|
||||
new() { Id = 90012, Name = "Real Estate/Residential Properties", Type = "IN_MARKET" },
|
||||
new() { Id = 90013, Name = "Software/Business Software", Type = "IN_MARKET" },
|
||||
new() { Id = 90014, Name = "Travel/Hotels & Accommodations", Type = "IN_MARKET" },
|
||||
new() { Id = 90015, Name = "Travel/Air Travel", Type = "IN_MARKET" },
|
||||
},
|
||||
LifeEvents = new List<AudienceSegment>
|
||||
{
|
||||
new() { Id = 70001, Name = "About to graduate college", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70002, Name = "Getting married soon", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70003, Name = "Recently married", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70004, Name = "Moving soon", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70005, Name = "Recently moved", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70006, Name = "Purchasing a home soon", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70007, Name = "Recently purchased a home", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70008, Name = "Starting a new job", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70009, Name = "Retiring soon", Type = "LIFE_EVENT" },
|
||||
new() { Id = 70010, Name = "Recently started a business", Type = "LIFE_EVENT" },
|
||||
},
|
||||
DetailedDemographics = new List<AudienceSegment>
|
||||
{
|
||||
new() { Id = 60001, Name = "Parental status/Parents/Parents of infants (0-1 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60002, Name = "Parental status/Parents/Parents of toddlers (1-3 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60003, Name = "Parental status/Parents/Parents of preschoolers (3-5 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60004, Name = "Parental status/Parents/Parents of grade schoolers (6-12 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60005, Name = "Parental status/Parents/Parents of teens (13-17 years)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60006, Name = "Marital status/Single", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60007, Name = "Marital status/In a relationship", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60008, Name = "Marital status/Married", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60009, Name = "Education/Current college students", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60010, Name = "Education/Bachelor's degree", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60011, Name = "Education/Advanced degree", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60012, Name = "Homeownership/Homeowners", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60013, Name = "Homeownership/Renters", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60014, Name = "Employment/Industry/Technology", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
new() { Id = 60015, Name = "Employment/Company size/Large employers (10,000+)", Type = "DETAILED_DEMOGRAPHIC" },
|
||||
},
|
||||
RetrievedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return ProviderResponse.Success(requestId, response);
|
||||
}
|
||||
|
||||
private ProviderResponse GetEmulatedGeoTargets(string query, string requestId)
|
||||
{
|
||||
_logger.LogInformation("[Audience] Returning emulated geo targets for '{Query}' | RequestId={RequestId}",
|
||||
query, requestId);
|
||||
|
||||
var allTargets = new List<GeoTarget>
|
||||
{
|
||||
new() { Id = 9061285, Name = "Huntington Beach", CanonicalName = "Huntington Beach,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 1013962, Name = "Orange County", CanonicalName = "Orange County,California,United States", TargetType = "County", CountryCode = "US" },
|
||||
new() { Id = 21137, Name = "California", CanonicalName = "California,United States", TargetType = "State", CountryCode = "US" },
|
||||
new() { Id = 1014221, Name = "Los Angeles", CanonicalName = "Los Angeles,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 1014218, Name = "Los Angeles County", CanonicalName = "Los Angeles County,California,United States", TargetType = "County", CountryCode = "US" },
|
||||
new() { Id = 9031936, Name = "Irvine", CanonicalName = "Irvine,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 9031935, Name = "Costa Mesa", CanonicalName = "Costa Mesa,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 9031938, Name = "Newport Beach", CanonicalName = "Newport Beach,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 9031937, Name = "Santa Ana", CanonicalName = "Santa Ana,California,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 1023191, Name = "New York", CanonicalName = "New York,New York,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 21167, Name = "New York", CanonicalName = "New York,United States", TargetType = "State", CountryCode = "US" },
|
||||
new() { Id = 1014895, Name = "Chicago", CanonicalName = "Chicago,Illinois,United States", TargetType = "City", CountryCode = "US" },
|
||||
new() { Id = 2840, Name = "United States", CanonicalName = "United States", TargetType = "Country", CountryCode = "US" },
|
||||
};
|
||||
|
||||
// Simple filter by query
|
||||
var queryLower = query.ToLowerInvariant();
|
||||
var matches = allTargets
|
||||
.Where(t => t.Name.ToLowerInvariant().Contains(queryLower) ||
|
||||
t.CanonicalName.ToLowerInvariant().Contains(queryLower))
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
return ProviderResponse.Success(requestId, new GeoTargetSearchResponse
|
||||
{
|
||||
Query = query,
|
||||
Results = matches
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -23,15 +23,24 @@ public sealed class GoogleAdsService
|
||||
{
|
||||
private readonly GoogleAdsConfig _config;
|
||||
private readonly GoogleAdsClientFactory _clientFactory;
|
||||
private readonly AudienceService _audienceService;
|
||||
private readonly ReportingService _reportingService;
|
||||
private readonly KeywordForecastService _forecastService;
|
||||
private readonly ILogger<GoogleAdsService> _logger;
|
||||
|
||||
public GoogleAdsService(
|
||||
IOptions<GoogleAdsConfig> config,
|
||||
GoogleAdsClientFactory clientFactory,
|
||||
AudienceService audienceService,
|
||||
ReportingService reportingService,
|
||||
KeywordForecastService forecastService,
|
||||
ILogger<GoogleAdsService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_clientFactory = clientFactory;
|
||||
_audienceService = audienceService;
|
||||
_reportingService = reportingService;
|
||||
_forecastService = forecastService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -65,7 +74,20 @@ public sealed class GoogleAdsService
|
||||
"GetCampaignStats" => GetCampaignStats(request, requestId),
|
||||
"GetAccountStats" => GetAccountStats(request, requestId),
|
||||
|
||||
// Reporting (Campaign Intelligence)
|
||||
"GetCampaignReport" => await _reportingService.GetCampaignReportAsync(request, context, requestId, ct),
|
||||
"GetAccountReport" => await _reportingService.GetAccountReportAsync(request, context, requestId, ct),
|
||||
|
||||
"ListAccessibleCustomers" => await ListAccessibleCustomersAsync(context, requestId, ct),
|
||||
"CreateCustomerClient" => await CreateCustomerClientAsync(request, context, requestId, ct),
|
||||
|
||||
// Audience Operations
|
||||
"GetAudienceSegments" => await _audienceService.GetAudienceSegmentsAsync(context, requestId, ct),
|
||||
"SearchGeoTargets" => await _audienceService.SearchGeoTargetsAsync(
|
||||
request.GetPayload<GeoTargetSearchPayload>(), context, requestId, ct),
|
||||
|
||||
// Forecast
|
||||
"KeywordForecast" => await ExecuteForecastAsync(request, context, requestId, ct),
|
||||
|
||||
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
|
||||
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
|
||||
@@ -94,6 +116,14 @@ public sealed class GoogleAdsService
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
private async Task<ProviderResponse> ExecuteForecastAsync(
|
||||
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<KeywordForecastPayload>();
|
||||
var result = await _forecastService.ForecastAsync(payload, context, ct);
|
||||
return ProviderResponse.Success(requestId, result);
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> CreateCampaignAsync(
|
||||
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
@@ -495,6 +525,100 @@ ORDER BY campaign.name";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> CreateCustomerClientAsync(
|
||||
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<CreateCustomerClientPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.AccountName))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "AccountName is required");
|
||||
|
||||
// For emulated mode
|
||||
if (!_clientFactory.IsRealApiEnabled)
|
||||
{
|
||||
var fakeId = new Random().NextInt64(1000000000, 9999999999).ToString();
|
||||
_logger.LogInformation("[GoogleAds] EMULATED: Created customer client {AccountName} => {CustomerId}",
|
||||
payload.AccountName, fakeId);
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
customerId = fakeId,
|
||||
accountName = payload.AccountName,
|
||||
currencyCode = payload.CurrencyCode,
|
||||
timeZone = payload.TimeZone,
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// CreateCustomerClient runs against the MCC (manager account).
|
||||
// The context.CustomerId should be the MCC ID.
|
||||
var mccId = GoogleAdsClientFactory.NormalizeCustomerId(
|
||||
context.CustomerId ?? request.TenantId ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mccId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION",
|
||||
"TenantId (MCC customer ID) is required to create a sub-account");
|
||||
|
||||
// Force LoginCustomerId = MCC for this call
|
||||
var mccContext = new GoogleAdsContext
|
||||
{
|
||||
CustomerId = mccId,
|
||||
LoginCustomerId = mccId
|
||||
};
|
||||
|
||||
GoogleAdsClient client = _clientFactory.CreateClient(mccContext);
|
||||
|
||||
CustomerServiceClient customerService =
|
||||
client.GetService(GAdsServices.V22.CustomerService);
|
||||
|
||||
var customerClient = new Customer
|
||||
{
|
||||
DescriptiveName = payload.DescriptiveName ?? payload.AccountName,
|
||||
CurrencyCode = payload.CurrencyCode,
|
||||
TimeZone = payload.TimeZone
|
||||
};
|
||||
|
||||
var response = await customerService.CreateCustomerClientAsync(
|
||||
new CreateCustomerClientRequest
|
||||
{
|
||||
CustomerId = mccId,
|
||||
CustomerClient = customerClient
|
||||
},
|
||||
cancellationToken: ct);
|
||||
|
||||
// Response contains the resource name like "customers/9336988646/customerClients/1234567890"
|
||||
var newCustomerId = response.ResourceName?.Split('/').LastOrDefault() ?? response.ResourceName;
|
||||
|
||||
_logger.LogInformation(
|
||||
"[GoogleAds] Created customer client {AccountName} => {NewCustomerId} under MCC {MccId}",
|
||||
payload.AccountName, newCustomerId, mccId);
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
customerId = newCustomerId,
|
||||
resourceName = response.ResourceName,
|
||||
accountName = payload.AccountName,
|
||||
descriptiveName = payload.DescriptiveName ?? payload.AccountName,
|
||||
currencyCode = payload.CurrencyCode,
|
||||
timeZone = payload.TimeZone,
|
||||
mccId = mccId,
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
catch (GoogleAdsException gex)
|
||||
{
|
||||
_logger.LogError(gex, "Google Ads API error creating customer client | RequestId={RequestId}", requestId);
|
||||
return HandleGoogleAdsException(gex, requestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create customer client via real API");
|
||||
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateId() => Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
private static AdvertisingChannelTypeEnum.Types.AdvertisingChannelType MapChannelType(CampaignType type)
|
||||
|
||||
219
GoogleApi/Services/KeywordForecastService.cs
Normal file
219
GoogleApi/Services/KeywordForecastService.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using Google.Ads.GoogleAds.Lib;
|
||||
using Google.Ads.GoogleAds.V22.Services;
|
||||
using Google.Ads.GoogleAds.V22.Common;
|
||||
using Google.Ads.GoogleAds.V22.Enums;
|
||||
using Google.Ads.GoogleAds.V22.Resources;
|
||||
|
||||
namespace GoogleApi.Services;
|
||||
|
||||
using GAdsServices = global::Google.Ads.GoogleAds.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates keyword-level forecast metrics via Google Ads KeywordPlanIdeaService.
|
||||
/// Used by the wizard to show estimated performance before campaign creation.
|
||||
///
|
||||
/// EnableRealApi=false → emulated estimates based on budget + keyword count.
|
||||
/// EnableRealApi=true → calls GenerateKeywordForecastMetrics.
|
||||
/// </summary>
|
||||
public sealed class KeywordForecastService
|
||||
{
|
||||
private readonly GoogleAdsConfig _config;
|
||||
private readonly GoogleAdsClientFactory _clientFactory;
|
||||
private readonly ILogger<KeywordForecastService> _logger;
|
||||
|
||||
public KeywordForecastService(
|
||||
IOptions<GoogleAdsConfig> config,
|
||||
GoogleAdsClientFactory clientFactory,
|
||||
ILogger<KeywordForecastService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_clientFactory = clientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<KeywordForecastResponse> ForecastAsync(
|
||||
KeywordForecastPayload payload,
|
||||
GoogleAdsContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (payload.Keywords.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("[KeywordForecast] No keywords provided");
|
||||
return EmptyForecast();
|
||||
}
|
||||
|
||||
if (_clientFactory.IsRealApiEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ForecastRealAsync(payload, context, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[KeywordForecast] Real API failed, falling back to emulated");
|
||||
return ForecastEmulated(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return ForecastEmulated(payload);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Real API: GenerateKeywordForecastMetrics
|
||||
// ================================================================
|
||||
|
||||
private async Task<KeywordForecastResponse> ForecastRealAsync(
|
||||
KeywordForecastPayload payload,
|
||||
GoogleAdsContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[KeywordForecast] Real API | Keywords={Count} Budget={Budget} Geos={Geos}",
|
||||
payload.Keywords.Count, payload.MonthlyBudget, payload.GeoTargetIds.Count);
|
||||
|
||||
var client = _clientFactory.CreateClient(context);
|
||||
var service = client.GetService(GAdsServices.V22.KeywordPlanIdeaService);
|
||||
|
||||
// Build campaign to forecast
|
||||
var campaign = new CampaignToForecast
|
||||
{
|
||||
KeywordPlanNetwork = KeywordPlanNetworkEnum.Types.KeywordPlanNetwork.GoogleSearch
|
||||
};
|
||||
|
||||
// Geo targets
|
||||
foreach (var geoId in payload.GeoTargetIds)
|
||||
{
|
||||
campaign.GeoModifiers.Add(new CriterionBidModifier
|
||||
{
|
||||
GeoTargetConstant = GeoTargetConstantName.Format(geoId.ToString())
|
||||
});
|
||||
}
|
||||
|
||||
// Ad group with keywords (cap at 20 for sanity)
|
||||
var adGroup = new ForecastAdGroup();
|
||||
foreach (var keyword in payload.Keywords.Take(20))
|
||||
{
|
||||
adGroup.BiddableKeywords.Add(new BiddableKeyword
|
||||
{
|
||||
MaxCpcBidMicros = 2_000_000, // $2.00 default bid for simulation
|
||||
Keyword = new KeywordInfo
|
||||
{
|
||||
Text = keyword,
|
||||
MatchType = KeywordMatchTypeEnum.Types.KeywordMatchType.Broad
|
||||
}
|
||||
});
|
||||
}
|
||||
campaign.AdGroups.Add(adGroup);
|
||||
|
||||
var request = new GenerateKeywordForecastMetricsRequest
|
||||
{
|
||||
Campaign = campaign,
|
||||
ForecastPeriod = new DateRange
|
||||
{
|
||||
StartDate = DateTime.UtcNow.ToString("yyyy-MM-dd"),
|
||||
EndDate = DateTime.UtcNow.AddDays(payload.ForecastDays).ToString("yyyy-MM-dd")
|
||||
},
|
||||
CustomerId = context.CustomerId
|
||||
};
|
||||
|
||||
var response = await service.GenerateKeywordForecastMetricsAsync(request);
|
||||
var m = response.CampaignForecastMetrics;
|
||||
|
||||
// V22 SDK returns non-nullable primitives (0 when no data)
|
||||
var impressions = m.Impressions;
|
||||
var clicks = m.Clicks;
|
||||
var costMicros = m.CostMicros;
|
||||
var conversions = m.Conversions;
|
||||
var avgCpcMicros = m.AverageCpcMicros;
|
||||
|
||||
var cost = costMicros / 1_000_000m;
|
||||
var avgCpc = avgCpcMicros / 1_000_000m;
|
||||
var ctr = impressions > 0 ? clicks / impressions : 0;
|
||||
var avgCpm = impressions > 0 ? (cost / (decimal)impressions) * 1000m : 0;
|
||||
var cpa = conversions > 0 ? cost / (decimal)conversions : (decimal?)null;
|
||||
|
||||
_logger.LogInformation(
|
||||
"[KeywordForecast] Real result | Imp={Imp} Clicks={Clicks} Cost={Cost}",
|
||||
impressions, clicks, cost);
|
||||
|
||||
return new KeywordForecastResponse
|
||||
{
|
||||
Provider = "google",
|
||||
Monthly = new ForecastEstimates
|
||||
{
|
||||
Impressions = impressions,
|
||||
Clicks = clicks,
|
||||
Cost = cost,
|
||||
Conversions = conversions
|
||||
},
|
||||
Metrics = new ForecastMetrics
|
||||
{
|
||||
AvgCpc = avgCpc,
|
||||
AvgCpm = avgCpm,
|
||||
Ctr = ctr,
|
||||
EstimatedCpa = cpa
|
||||
},
|
||||
Confidence = "medium",
|
||||
DataSource = "keywordForecast"
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Emulated: budget-proportional estimates with realistic variance
|
||||
// ================================================================
|
||||
|
||||
private KeywordForecastResponse ForecastEmulated(KeywordForecastPayload payload)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[KeywordForecast] Emulated | Keywords={Count} Budget={Budget}",
|
||||
payload.Keywords.Count, payload.MonthlyBudget);
|
||||
|
||||
// Realistic Search ranges for SMB tier
|
||||
var keywordFactor = Math.Min(payload.Keywords.Count, 20) / 20.0;
|
||||
var baseCpc = 2.50m - (decimal)(keywordFactor * 1.20); // $1.30 – $2.50
|
||||
|
||||
var budget = payload.MonthlyBudget;
|
||||
var clicks = budget > 0 ? (double)(budget / baseCpc) : 0;
|
||||
var impressions = clicks / 0.045; // ~4.5% CTR
|
||||
var conversions = clicks * 0.035; // ~3.5% conv rate
|
||||
var ctr = impressions > 0 ? clicks / impressions : 0;
|
||||
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
|
||||
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
|
||||
|
||||
// Seeded variance (±15%) — same inputs → same output, but not canned-looking
|
||||
var rng = new Random((int)(budget * 100) + payload.Keywords.Count);
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
return new KeywordForecastResponse
|
||||
{
|
||||
Provider = "google",
|
||||
Monthly = new ForecastEstimates
|
||||
{
|
||||
Impressions = Math.Round(impressions * v),
|
||||
Clicks = Math.Round(clicks * v),
|
||||
Cost = Math.Round(budget * (decimal)v, 2),
|
||||
Conversions = Math.Round(conversions * v, 1)
|
||||
},
|
||||
Metrics = new ForecastMetrics
|
||||
{
|
||||
AvgCpc = Math.Round(baseCpc, 2),
|
||||
AvgCpm = Math.Round((decimal)cpm, 2),
|
||||
Ctr = Math.Round(ctr, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null
|
||||
},
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
private static KeywordForecastResponse EmptyForecast() => new()
|
||||
{
|
||||
Provider = "google",
|
||||
Confidence = "none",
|
||||
DataSource = "none"
|
||||
};
|
||||
}
|
||||
382
GoogleApi/Services/ReportingService.cs
Normal file
382
GoogleApi/Services/ReportingService.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
using GoogleApi.Configuration;
|
||||
using GoogleApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using Google.Ads.GoogleAds;
|
||||
using Google.Ads.GoogleAds.Lib;
|
||||
using Google.Ads.GoogleAds.V22.Errors;
|
||||
using Google.Ads.GoogleAds.V22.Services;
|
||||
|
||||
namespace GoogleApi.Services;
|
||||
|
||||
using GAdsServices = global::Google.Ads.GoogleAds.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reporting service for pulling campaign performance metrics from Google Ads.
|
||||
/// Supports both real API calls and emulated responses for development.
|
||||
///
|
||||
/// Operations:
|
||||
/// - GetCampaignReport: Daily metrics for a specific campaign
|
||||
/// - GetAccountReport: Daily metrics across all campaigns in an account
|
||||
/// - GetCampaignList: Campaign status/budget summary (lightweight)
|
||||
/// </summary>
|
||||
public sealed class ReportingService
|
||||
{
|
||||
private readonly GoogleAdsConfig _config;
|
||||
private readonly GoogleAdsClientFactory _clientFactory;
|
||||
private readonly ILogger<ReportingService> _logger;
|
||||
|
||||
public ReportingService(
|
||||
IOptions<GoogleAdsConfig> config,
|
||||
GoogleAdsClientFactory clientFactory,
|
||||
ILogger<ReportingService> logger)
|
||||
{
|
||||
_config = config.Value;
|
||||
_clientFactory = clientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get daily campaign performance report.
|
||||
/// Returns impressions, clicks, spend, conversions, conversion value per day.
|
||||
/// </summary>
|
||||
public async Task<ProviderResponse> GetCampaignReportAsync(
|
||||
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<ReportingPayload>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.CampaignId))
|
||||
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
|
||||
|
||||
var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd");
|
||||
var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");
|
||||
|
||||
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
|
||||
return await GetCampaignReportRealAsync(payload.CampaignId, startDate, endDate, context, requestId, ct);
|
||||
|
||||
return GetCampaignReportEmulated(payload.CampaignId, startDate, endDate, requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get daily account-level performance report across all campaigns.
|
||||
/// Used for syncing metrics into tbPerformanceMetric.
|
||||
/// </summary>
|
||||
public async Task<ProviderResponse> GetAccountReportAsync(
|
||||
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
var payload = request.GetPayload<ReportingPayload>();
|
||||
|
||||
var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd");
|
||||
var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");
|
||||
|
||||
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
|
||||
return await GetAccountReportRealAsync(startDate, endDate, context, requestId, ct);
|
||||
|
||||
return GetAccountReportEmulated(startDate, endDate, requestId);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Real API Implementation
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private async Task<ProviderResponse> GetCampaignReportRealAsync(
|
||||
string campaignId, string startDate, string endDate,
|
||||
GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _clientFactory.CreateClient(context);
|
||||
var gaService = client.GetService(GAdsServices.V22.GoogleAdsService);
|
||||
|
||||
// Extract numeric campaign ID from resource name if needed
|
||||
var numericId = campaignId.Contains('/') ? campaignId.Split('/').Last() : campaignId;
|
||||
|
||||
var query = $@"
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
segments.date,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value,
|
||||
metrics.all_conversions,
|
||||
metrics.ctr,
|
||||
metrics.average_cpc
|
||||
FROM campaign
|
||||
WHERE campaign.id = {numericId}
|
||||
AND segments.date BETWEEN '{startDate}' AND '{endDate}'
|
||||
ORDER BY segments.date";
|
||||
|
||||
var rows = new List<object>();
|
||||
|
||||
var searchRequest = new SearchGoogleAdsRequest
|
||||
{
|
||||
CustomerId = context.CustomerId,
|
||||
Query = query
|
||||
};
|
||||
|
||||
var response = gaService.Search(searchRequest);
|
||||
|
||||
foreach (var row in response)
|
||||
{
|
||||
rows.Add(new
|
||||
{
|
||||
date = row.Segments.Date,
|
||||
campaignId = row.Campaign.Id,
|
||||
campaignName = row.Campaign.Name,
|
||||
campaignStatus = row.Campaign.Status.ToString(),
|
||||
impressions = row.Metrics.Impressions,
|
||||
clicks = row.Metrics.Clicks,
|
||||
costMicros = row.Metrics.CostMicros,
|
||||
spend = row.Metrics.CostMicros / 1_000_000.0,
|
||||
conversions = row.Metrics.Conversions,
|
||||
conversionValue = row.Metrics.ConversionsValue,
|
||||
allConversions = row.Metrics.AllConversions,
|
||||
ctr = row.Metrics.Ctr,
|
||||
averageCpcMicros = row.Metrics.AverageCpc
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Reporting] Retrieved {Count} rows for campaign {CampaignId} | RequestId={RequestId}",
|
||||
rows.Count, campaignId, requestId);
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId,
|
||||
dateRange = new { start = startDate, end = endDate },
|
||||
rows,
|
||||
rowCount = rows.Count,
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
catch (GoogleAdsException gex)
|
||||
{
|
||||
_logger.LogError(gex, "[Reporting] Google Ads error for campaign {CampaignId}", campaignId);
|
||||
return HandleGoogleAdsException(gex, requestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Reporting] Error fetching campaign report");
|
||||
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProviderResponse> GetAccountReportRealAsync(
|
||||
string startDate, string endDate,
|
||||
GoogleAdsContext context, string requestId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _clientFactory.CreateClient(context);
|
||||
var gaService = client.GetService(GAdsServices.V22.GoogleAdsService);
|
||||
|
||||
var query = $@"
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
segments.date,
|
||||
metrics.impressions,
|
||||
metrics.clicks,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.conversions_value,
|
||||
metrics.all_conversions
|
||||
FROM campaign
|
||||
WHERE segments.date BETWEEN '{startDate}' AND '{endDate}'
|
||||
AND campaign.status != 'REMOVED'
|
||||
ORDER BY segments.date, campaign.id";
|
||||
|
||||
var rows = new List<object>();
|
||||
|
||||
var searchRequest = new SearchGoogleAdsRequest
|
||||
{
|
||||
CustomerId = context.CustomerId,
|
||||
Query = query
|
||||
};
|
||||
|
||||
var response = gaService.Search(searchRequest);
|
||||
|
||||
foreach (var row in response)
|
||||
{
|
||||
rows.Add(new
|
||||
{
|
||||
date = row.Segments.Date,
|
||||
campaignId = row.Campaign.Id,
|
||||
campaignName = row.Campaign.Name,
|
||||
campaignStatus = row.Campaign.Status.ToString(),
|
||||
channelType = row.Campaign.AdvertisingChannelType.ToString(),
|
||||
impressions = row.Metrics.Impressions,
|
||||
clicks = row.Metrics.Clicks,
|
||||
costMicros = row.Metrics.CostMicros,
|
||||
spend = row.Metrics.CostMicros / 1_000_000.0,
|
||||
conversions = row.Metrics.Conversions,
|
||||
conversionValue = row.Metrics.ConversionsValue,
|
||||
allConversions = row.Metrics.AllConversions
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Reporting] Retrieved {Count} rows for account {CustomerId} | RequestId={RequestId}",
|
||||
rows.Count, context.CustomerId, requestId);
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
customerId = context.CustomerId,
|
||||
dateRange = new { start = startDate, end = endDate },
|
||||
rows,
|
||||
rowCount = rows.Count,
|
||||
emulated = false
|
||||
});
|
||||
}
|
||||
catch (GoogleAdsException gex)
|
||||
{
|
||||
_logger.LogError(gex, "[Reporting] Google Ads error for account {CustomerId}", context.CustomerId);
|
||||
return HandleGoogleAdsException(gex, requestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Reporting] Error fetching account report");
|
||||
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Emulated Responses
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private ProviderResponse GetCampaignReportEmulated(
|
||||
string campaignId, string startDate, string endDate, string requestId)
|
||||
{
|
||||
_logger.LogInformation("[Reporting] EMULATED: Campaign report for {CampaignId}", campaignId);
|
||||
|
||||
var rows = GenerateEmulatedDailyRows(startDate, endDate);
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
campaignId,
|
||||
dateRange = new { start = startDate, end = endDate },
|
||||
rows,
|
||||
rowCount = rows.Count,
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
private ProviderResponse GetAccountReportEmulated(
|
||||
string startDate, string endDate, string requestId)
|
||||
{
|
||||
_logger.LogInformation("[Reporting] EMULATED: Account report");
|
||||
|
||||
// Generate rows for 3 emulated campaigns
|
||||
var allRows = new List<object>();
|
||||
var campaigns = new[]
|
||||
{
|
||||
new { id = "emu_camp_001", name = "Search - Brand Terms", channel = "SEARCH" },
|
||||
new { id = "emu_camp_002", name = "Display - Awareness", channel = "DISPLAY" },
|
||||
new { id = "emu_camp_003", name = "PMax - Conversions", channel = "PERFORMANCE_MAX" }
|
||||
};
|
||||
|
||||
foreach (var camp in campaigns)
|
||||
{
|
||||
var rows = GenerateEmulatedDailyRows(startDate, endDate);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
// Merge campaign info into each row
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["campaignId"] = camp.id,
|
||||
["campaignName"] = camp.name,
|
||||
["campaignStatus"] = "ENABLED",
|
||||
["channelType"] = camp.channel
|
||||
};
|
||||
|
||||
// Merge the row properties
|
||||
var rowJson = System.Text.Json.JsonSerializer.Serialize(row);
|
||||
var rowDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(rowJson);
|
||||
if (rowDict != null)
|
||||
foreach (var kv in rowDict) dict[kv.Key] = kv.Value;
|
||||
|
||||
allRows.Add(dict);
|
||||
}
|
||||
}
|
||||
|
||||
return ProviderResponse.Success(requestId, new
|
||||
{
|
||||
customerId = "emulated",
|
||||
dateRange = new { start = startDate, end = endDate },
|
||||
rows = allRows,
|
||||
rowCount = allRows.Count,
|
||||
emulated = true
|
||||
});
|
||||
}
|
||||
|
||||
private static List<object> GenerateEmulatedDailyRows(string startDate, string endDate)
|
||||
{
|
||||
var rows = new List<object>();
|
||||
var rng = new Random(42); // deterministic seed for consistent emulation
|
||||
|
||||
if (!DateTime.TryParse(startDate, out var start)) start = DateTime.UtcNow.AddDays(-30);
|
||||
if (!DateTime.TryParse(endDate, out var end)) end = DateTime.UtcNow.AddDays(-1);
|
||||
|
||||
for (var date = start; date <= end; date = date.AddDays(1))
|
||||
{
|
||||
var impressions = rng.Next(800, 3500);
|
||||
var clicks = (int)(impressions * (0.015 + rng.NextDouble() * 0.04));
|
||||
var costMicros = (long)(clicks * (450_000 + rng.Next(0, 300_000)));
|
||||
var conversions = Math.Round(clicks * (0.03 + rng.NextDouble() * 0.05), 2);
|
||||
var conversionValue = Math.Round(conversions * (25 + rng.NextDouble() * 75), 2);
|
||||
|
||||
rows.Add(new
|
||||
{
|
||||
date = date.ToString("yyyy-MM-dd"),
|
||||
impressions,
|
||||
clicks,
|
||||
costMicros,
|
||||
spend = Math.Round(costMicros / 1_000_000.0, 2),
|
||||
conversions,
|
||||
conversionValue,
|
||||
allConversions = Math.Round(conversions * 1.15, 2),
|
||||
ctr = impressions > 0 ? Math.Round((double)clicks / impressions, 4) : 0.0,
|
||||
averageCpcMicros = clicks > 0 ? costMicros / clicks : 0L
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Error Handling
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static ProviderResponse HandleGoogleAdsException(GoogleAdsException gex, string requestId)
|
||||
{
|
||||
var errorDetails = gex.Failure?.Errors?.Select(e => new
|
||||
{
|
||||
errorCode = e.ErrorCode?.ToString(),
|
||||
message = e.Message,
|
||||
trigger = e.Trigger?.StringValue,
|
||||
location = e.Location?.FieldPathElements?.Select(f => f.FieldName).ToArray()
|
||||
}).ToList();
|
||||
|
||||
return ProviderResponse.Fail(requestId, "GOOGLE_ADS_ERROR", gex.Message, new
|
||||
{
|
||||
googleRequestId = gex.RequestId,
|
||||
errors = errorDetails
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for reporting operations.
|
||||
/// </summary>
|
||||
public sealed class ReportingPayload
|
||||
{
|
||||
public string? CampaignId { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
}
|
||||
48
IntelligenceApi/Controllers/DemographicsController.cs
Normal file
48
IntelligenceApi/Controllers/DemographicsController.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using IntelligenceApi.Engines;
|
||||
using IntelligenceApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace IntelligenceApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Demographics analysis endpoint.
|
||||
///
|
||||
/// Called exclusively by the Gateway's IntelligenceApiClient after it fetches
|
||||
/// raw census data from the database. This container derives audience
|
||||
/// recommendations from the raw data — age chips, income tiers, insights.
|
||||
///
|
||||
/// POST /api/demographics/analyze
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/demographics")]
|
||||
public sealed class DemographicsController : ControllerBase
|
||||
{
|
||||
private readonly DemographicsAnalyzer _analyzer;
|
||||
private readonly ILogger<DemographicsController> _log;
|
||||
|
||||
public DemographicsController(DemographicsAnalyzer analyzer, ILogger<DemographicsController> log)
|
||||
{
|
||||
_analyzer = analyzer;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpPost("analyze")]
|
||||
public IActionResult Analyze([FromBody] DemographicAnalysisRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Zcta))
|
||||
return BadRequest(new { ok = false, error = "zcta and census data are required" });
|
||||
|
||||
_log.LogInformation("[Demographics] Analyze | ZCTA={Zcta}", request.Zcta);
|
||||
|
||||
try
|
||||
{
|
||||
var result = _analyzer.Analyze(request);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Demographics] Analysis error | ZCTA={Zcta}", request.Zcta);
|
||||
return StatusCode(500, new { ok = false, error = "Demographics analysis error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
122
IntelligenceApi/Controllers/InternalController.cs
Normal file
122
IntelligenceApi/Controllers/InternalController.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using IntelligenceApi.Engines;
|
||||
using IntelligenceApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace IntelligenceApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Internal execution endpoint — the single entry point for Gateway-routed requests.
|
||||
///
|
||||
/// Called exclusively by the Gateway's ExecutionService via:
|
||||
/// POST /internal/execute
|
||||
/// Headers: X-Internal-Key, X-Request-Id
|
||||
/// Body: { "operation": "Ping" | "SpendDistribution", "payload": { ... } }
|
||||
///
|
||||
/// Operations:
|
||||
/// Ping — liveness check, no payload required
|
||||
/// SpendDistribution — routes to EngineRouter; payload maps to SpendDistributionRequest
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal/execute")]
|
||||
public sealed class InternalController : ControllerBase
|
||||
{
|
||||
private readonly EngineRouter _router;
|
||||
private readonly ILogger<InternalController> _log;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts =
|
||||
new(JsonSerializerDefaults.Web);
|
||||
|
||||
public InternalController(EngineRouter router, ILogger<InternalController> log)
|
||||
{
|
||||
_router = router;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Execute(
|
||||
[FromBody] InternalExecuteRequest? request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request == null)
|
||||
return BadRequest(new { ok = false, error = "Request body required" });
|
||||
|
||||
var requestId = request.RequestId ?? HttpContext.Request.Headers["X-Request-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
|
||||
var operation = (request.Operation ?? "").Trim();
|
||||
|
||||
_log.LogInformation(
|
||||
"[Internal] Operation={Operation} RequestId={RequestId}",
|
||||
operation, requestId);
|
||||
|
||||
return operation.ToLowerInvariant() switch
|
||||
{
|
||||
"ping" => Ok(new { ok = true, requestId, service = "IntelligenceApi", timestamp = DateTimeOffset.UtcNow }),
|
||||
"spenddistribution" => await SpendDistribution(request.Payload, requestId, ct),
|
||||
_ => BadRequest(new { ok = false, requestId, error = $"Unknown operation: '{operation}'" })
|
||||
};
|
||||
}
|
||||
|
||||
// ── Spend Distribution ───────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> SpendDistribution(
|
||||
JsonElement? payload,
|
||||
string requestId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (payload == null || payload.Value.ValueKind == JsonValueKind.Null)
|
||||
return BadRequest(new { ok = false, requestId, error = "Payload required for SpendDistribution" });
|
||||
|
||||
SpendDistributionRequest? distRequest;
|
||||
try
|
||||
{
|
||||
distRequest = JsonSerializer.Deserialize<SpendDistributionRequest>(
|
||||
payload.Value.GetRawText(), _jsonOpts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[Internal] Payload deserialize failed | RequestId={RequestId} Error={Error}",
|
||||
requestId, ex.Message);
|
||||
return BadRequest(new { ok = false, requestId, error = "Invalid payload shape for SpendDistribution" });
|
||||
}
|
||||
|
||||
if (distRequest == null)
|
||||
return BadRequest(new { ok = false, requestId, error = "Payload required for SpendDistribution" });
|
||||
|
||||
if (distRequest.MonthlyBudget <= 0)
|
||||
return BadRequest(new { ok = false, requestId, error = "monthlyBudget must be greater than zero" });
|
||||
|
||||
if (distRequest.Keywords.Count == 0)
|
||||
return BadRequest(new { ok = false, requestId, error = "At least one keyword is required" });
|
||||
|
||||
try
|
||||
{
|
||||
var engine = _router.Resolve(distRequest.ClientCategory);
|
||||
var response = await engine.RecommendAsync(distRequest, ct);
|
||||
|
||||
_log.LogInformation(
|
||||
"[Internal] SpendDistribution OK | RequestId={RequestId} Engine={Engine} Channels={N}",
|
||||
requestId, response.Metadata.Engine, response.Channels.Count);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Internal] SpendDistribution error | RequestId={RequestId}", requestId);
|
||||
return StatusCode(500, new { ok = false, requestId, error = "Intelligence service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request model ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Shape sent by Gateway's ExecutionService to every provider container.
|
||||
/// Matches the object built in ExecutionService.BuildProviderRequest().
|
||||
/// </summary>
|
||||
public sealed class InternalExecuteRequest
|
||||
{
|
||||
public string? Operation { get; set; }
|
||||
public string? RequestId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public JsonElement? Payload { get; set; }
|
||||
}
|
||||
67
IntelligenceApi/Controllers/SpendDistributionController.cs
Normal file
67
IntelligenceApi/Controllers/SpendDistributionController.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using IntelligenceApi.Engines;
|
||||
using IntelligenceApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace IntelligenceApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Spend distribution endpoint — the single public surface of IntelligenceApi.
|
||||
///
|
||||
/// Called exclusively by the Gateway (never directly by the client portal).
|
||||
/// The Gateway injects clientCategory from ClientContext before forwarding.
|
||||
///
|
||||
/// POST /api/spend-distribution
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/spend-distribution")]
|
||||
public sealed class SpendDistributionController : ControllerBase
|
||||
{
|
||||
private readonly EngineRouter _router;
|
||||
private readonly ILogger<SpendDistributionController> _log;
|
||||
|
||||
public SpendDistributionController(EngineRouter router, ILogger<SpendDistributionController> log)
|
||||
{
|
||||
_router = router;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a spend distribution recommendation.
|
||||
/// clientCategory in the request body determines which engine runs.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Recommend(
|
||||
[FromBody] SpendDistributionRequest? request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request == null)
|
||||
return BadRequest(new { ok = false, error = "Request body required" });
|
||||
|
||||
if (request.MonthlyBudget <= 0)
|
||||
return BadRequest(new { ok = false, error = "monthlyBudget must be greater than zero" });
|
||||
|
||||
if (request.Keywords.Count == 0)
|
||||
return BadRequest(new { ok = false, error = "At least one keyword is required" });
|
||||
|
||||
_log.LogInformation(
|
||||
"[SpendDistribution] Request | Category={Category} Budget={Budget} Objective={Obj}",
|
||||
request.ClientCategory, request.MonthlyBudget, request.Objective);
|
||||
|
||||
try
|
||||
{
|
||||
var engine = _router.Resolve(request.ClientCategory);
|
||||
var response = await engine.RecommendAsync(request, ct);
|
||||
|
||||
_log.LogInformation(
|
||||
"[SpendDistribution] OK | Engine={Engine} Channels={N}",
|
||||
response.Metadata.Engine, response.Channels.Count);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[SpendDistribution] Unhandled error");
|
||||
return StatusCode(500, new { ok = false, error = "Intelligence service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
91
IntelligenceApi/Engines/DemographicsAnalyzer.cs
Normal file
91
IntelligenceApi/Engines/DemographicsAnalyzer.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using IntelligenceApi.Models;
|
||||
|
||||
namespace IntelligenceApi.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Derives audience recommendations from raw census data for a ZCTA.
|
||||
///
|
||||
/// This logic was previously embedded in the Gateway's DemographicsController
|
||||
/// as BuildMarketAnalysis(). It belongs here — IntelligenceApi owns all
|
||||
/// recommendation and analysis logic; the Gateway is a thin proxy.
|
||||
///
|
||||
/// Registered as a singleton: stateless, no IO.
|
||||
/// </summary>
|
||||
public sealed class DemographicsAnalyzer
|
||||
{
|
||||
public DemographicAnalysisResponse Analyze(DemographicAnalysisRequest request)
|
||||
{
|
||||
var c = request.Census;
|
||||
var zcta = request.Zcta;
|
||||
|
||||
// ── Age skew ──────────────────────────────────────────────────────────
|
||||
var youngPct = c.Pct18to24 + c.Pct25to34;
|
||||
var maturePct = c.Pct55to64 + c.Pct65plus;
|
||||
|
||||
string ageSkew;
|
||||
if (youngPct > maturePct + 10) ageSkew = "young";
|
||||
else if (maturePct > youngPct + 10) ageSkew = "mature";
|
||||
else ageSkew = "balanced";
|
||||
|
||||
// ── Recommended age chips ─────────────────────────────────────────────
|
||||
// Include brackets with meaningful population share.
|
||||
var ageRanges = new List<string>();
|
||||
if (c.Pct18to24 >= 10) ageRanges.Add("AGE_18_24");
|
||||
if (c.Pct25to34 >= 12) ageRanges.Add("AGE_25_34");
|
||||
if (c.Pct35to44 >= 12) ageRanges.Add("AGE_35_44");
|
||||
if (c.Pct45to54 >= 12) ageRanges.Add("AGE_45_54");
|
||||
if (c.Pct55to64 >= 10) ageRanges.Add("AGE_55_64");
|
||||
if (c.Pct65plus >= 12) ageRanges.Add("AGE_65_UP");
|
||||
|
||||
// Fallback: if no bracket clears the threshold, default to prime brackets
|
||||
if (ageRanges.Count == 0)
|
||||
{
|
||||
ageRanges.Add("AGE_25_34");
|
||||
ageRanges.Add("AGE_35_44");
|
||||
}
|
||||
|
||||
// ── Recommended income chips ──────────────────────────────────────────
|
||||
var incomes = c.MedianIncome switch
|
||||
{
|
||||
> 100_000 => new List<string> { "TOP_10", "TOP_11_20" },
|
||||
> 75_000 => new List<string> { "TOP_11_20", "TOP_21_30" },
|
||||
> 50_000 => new List<string> { "TOP_21_30", "TOP_31_40" },
|
||||
_ => new List<string> { "TOP_41_50", "LOWER_50" }
|
||||
};
|
||||
|
||||
// ── Human-readable insights ───────────────────────────────────────────
|
||||
var insights = new List<string>();
|
||||
|
||||
if (c.TotalPopulation > 0) insights.Add($"{c.TotalPopulation:N0} people");
|
||||
if (c.MedianIncome > 0) insights.Add($"Median income ${c.MedianIncome:N0}");
|
||||
if (c.PctBachelorPlus > 0) insights.Add($"{c.PctBachelorPlus}% college-educated");
|
||||
|
||||
if (c.PctOwnerOccupied > 55) insights.Add($"{c.PctOwnerOccupied}% homeowners");
|
||||
else if (c.PctRenterOccupied > 55) insights.Add($"{c.PctRenterOccupied}% renters");
|
||||
|
||||
if (c.PctFamilyHouseholds > 60) insights.Add($"{c.PctFamilyHouseholds}% families");
|
||||
else if (c.PctLivingAlone > 35) insights.Add($"{c.PctLivingAlone}% single-person households");
|
||||
|
||||
insights.Add(ageSkew switch
|
||||
{
|
||||
"young" => "Skews younger (18–34)",
|
||||
"mature" => "Skews older (55+)",
|
||||
_ => "Balanced age distribution"
|
||||
});
|
||||
|
||||
return new DemographicAnalysisResponse
|
||||
{
|
||||
Ok = true,
|
||||
Zcta = zcta,
|
||||
Census = c,
|
||||
Recommendations = new AudienceRecommendations
|
||||
{
|
||||
AgeRanges = ageRanges,
|
||||
Incomes = incomes,
|
||||
AgeSkew = ageSkew,
|
||||
MarketScope = "local" // single ZIP is always local scope
|
||||
},
|
||||
Insights = insights
|
||||
};
|
||||
}
|
||||
}
|
||||
75
IntelligenceApi/Engines/EngineRouter.cs
Normal file
75
IntelligenceApi/Engines/EngineRouter.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using IntelligenceApi.Engines.Franchisee;
|
||||
using IntelligenceApi.Engines.Franchisor;
|
||||
using IntelligenceApi.Engines.General;
|
||||
|
||||
namespace IntelligenceApi.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Routes incoming spend distribution requests to the correct engine
|
||||
/// based on clientCategory.
|
||||
///
|
||||
/// ADDING A NEW ENGINE:
|
||||
/// 1. Create a folder under Engines/ (e.g. Engines/FoodFranchisee/)
|
||||
/// 2. Implement ISpendDistributionEngine
|
||||
/// 3. Register in Program.cs
|
||||
/// 4. Add the category string to the routing table below
|
||||
///
|
||||
/// The General engine is the fallback for any unrecognised category,
|
||||
/// ensuring existing clients are never broken by new category additions.
|
||||
/// </summary>
|
||||
public sealed class EngineRouter
|
||||
{
|
||||
private readonly GeneralEngine _general;
|
||||
private readonly FranchiseeEngine _franchisee;
|
||||
private readonly FranchisorEngine _franchisor;
|
||||
private readonly ILogger<EngineRouter> _logger;
|
||||
|
||||
public EngineRouter(
|
||||
GeneralEngine general,
|
||||
FranchiseeEngine franchisee,
|
||||
FranchisorEngine franchisor,
|
||||
ILogger<EngineRouter> logger)
|
||||
{
|
||||
_general = general;
|
||||
_franchisee = franchisee;
|
||||
_franchisor = franchisor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ISpendDistributionEngine Resolve(string? clientCategory)
|
||||
{
|
||||
var engine = (clientCategory ?? "General").Trim() switch
|
||||
{
|
||||
// ── Exact category matches ──────────────────────────────
|
||||
"General" => (ISpendDistributionEngine)_general,
|
||||
"Franchisee" => _franchisee,
|
||||
"Franchisor" => _franchisor,
|
||||
|
||||
// ── Future sub-categories route to their parent stub
|
||||
// until a dedicated engine is built.
|
||||
// e.g. "FoodFranchisee" => _foodFranchisee (not yet registered)
|
||||
// falls through to Franchisee as the nearest match.
|
||||
var c when c.EndsWith("Franchisee", StringComparison.OrdinalIgnoreCase)
|
||||
=> _franchisee,
|
||||
var c when c.EndsWith("Franchisor", StringComparison.OrdinalIgnoreCase)
|
||||
=> _franchisor,
|
||||
|
||||
// ── Unknown / unrecognised — safe fallback ──────────────
|
||||
var c => LogAndFallback(c)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"[EngineRouter] Category={Category} → Engine={Engine}",
|
||||
clientCategory, engine.EngineName);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
private ISpendDistributionEngine LogAndFallback(string category)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[EngineRouter] Unrecognised category '{Category}' — falling back to GeneralEngine",
|
||||
category);
|
||||
return _general;
|
||||
}
|
||||
}
|
||||
59
IntelligenceApi/Engines/Franchisee/FranchiseeEngine.cs
Normal file
59
IntelligenceApi/Engines/Franchisee/FranchiseeEngine.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using IntelligenceApi.Models;
|
||||
|
||||
namespace IntelligenceApi.Engines.Franchisee;
|
||||
|
||||
/// <summary>
|
||||
/// Spend distribution engine for Franchisee clients.
|
||||
///
|
||||
/// CURRENT STATUS: Stub — delegates to GeneralEngine logic.
|
||||
/// Returns General recommendations with engine name set to "Franchisee"
|
||||
/// so billing and logging correctly identify the tier.
|
||||
///
|
||||
/// PLANNED: Premium AI-driven recommendations incorporating:
|
||||
/// - Proximity analysis to sibling franchisee locations
|
||||
/// (avoid cannibalisation, identify territory gaps)
|
||||
/// - Local competitor density from Google Maps / Places API
|
||||
/// - Demographic fit scoring per geo zone
|
||||
/// - Franchisor brand guidelines (approved channels, spend floors)
|
||||
/// - Historical performance benchmarks across the franchise network
|
||||
/// - Dayparting patterns specific to the franchise category
|
||||
/// (e.g. lunch peaks for food, weekend spikes for home services)
|
||||
///
|
||||
/// IMPLEMENTATION PATH:
|
||||
/// 1. Inject IFranchiseeDataService (location DB + geo queries)
|
||||
/// 2. Inject ICompetitorIntelligenceService (Places API or similar)
|
||||
/// 3. Replace scoring weights with category-trained model output
|
||||
/// 4. Surface franchise-specific highlights in DistributionRecommendation
|
||||
/// </summary>
|
||||
public sealed class FranchiseeEngine : ISpendDistributionEngine
|
||||
{
|
||||
private readonly General.GeneralEngine _general;
|
||||
private readonly ILogger<FranchiseeEngine> _logger;
|
||||
|
||||
public string EngineName => "Franchisee";
|
||||
|
||||
public FranchiseeEngine(General.GeneralEngine general, ILogger<FranchiseeEngine> logger)
|
||||
{
|
||||
_general = general;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SpendDistributionResponse> RecommendAsync(
|
||||
SpendDistributionRequest request, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[FranchiseeEngine] Stub — delegating to GeneralEngine | Budget={Budget}",
|
||||
request.MonthlyBudget);
|
||||
|
||||
// Delegate to General for now
|
||||
var response = await _general.RecommendAsync(request, ct);
|
||||
|
||||
// Override engine name so billing / logging is correct
|
||||
response.Metadata.Engine = EngineName;
|
||||
|
||||
// TODO: Augment recommendation with franchise-specific insights
|
||||
// once data services are wired in.
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
58
IntelligenceApi/Engines/Franchisor/FranchisorEngine.cs
Normal file
58
IntelligenceApi/Engines/Franchisor/FranchisorEngine.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using IntelligenceApi.Models;
|
||||
|
||||
namespace IntelligenceApi.Engines.Franchisor;
|
||||
|
||||
/// <summary>
|
||||
/// Spend distribution engine for Franchisor (brand / network owner) clients.
|
||||
///
|
||||
/// CURRENT STATUS: Stub — delegates to GeneralEngine logic.
|
||||
///
|
||||
/// PLANNED: Network-level AI recommendations incorporating:
|
||||
/// - Co-op budget allocation across franchisee network
|
||||
/// (brand-level national vs. local tier split)
|
||||
/// - Network-wide performance benchmarks and outlier detection
|
||||
/// - Territory coverage analysis — identifying under-served markets
|
||||
/// - Brand consistency enforcement across provider configurations
|
||||
/// - Consolidated reporting roll-up across all franchisee accounts
|
||||
/// - Seasonal and promotional campaign coordination
|
||||
/// - Franchisee performance ranking to guide co-op investment priority
|
||||
///
|
||||
/// DISTINCTION FROM FRANCHISEE ENGINE:
|
||||
/// Franchisee = single-location optimisation (local)
|
||||
/// Franchisor = multi-location orchestration (network-wide)
|
||||
///
|
||||
/// IMPLEMENTATION PATH:
|
||||
/// 1. Inject IFranchiseeNetworkService (all locations, territories, tiers)
|
||||
/// 2. Inject INetworkPerformanceService (aggregate metrics across accounts)
|
||||
/// 3. Implement network-aware allocation: national brand % + local co-op %
|
||||
/// 4. Surface network health summary in DistributionRecommendation
|
||||
/// </summary>
|
||||
public sealed class FranchisorEngine : ISpendDistributionEngine
|
||||
{
|
||||
private readonly General.GeneralEngine _general;
|
||||
private readonly ILogger<FranchisorEngine> _logger;
|
||||
|
||||
public string EngineName => "Franchisor";
|
||||
|
||||
public FranchisorEngine(General.GeneralEngine general, ILogger<FranchisorEngine> logger)
|
||||
{
|
||||
_general = general;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SpendDistributionResponse> RecommendAsync(
|
||||
SpendDistributionRequest request, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[FranchisorEngine] Stub — delegating to GeneralEngine | Budget={Budget}",
|
||||
request.MonthlyBudget);
|
||||
|
||||
var response = await _general.RecommendAsync(request, ct);
|
||||
response.Metadata.Engine = EngineName;
|
||||
|
||||
// TODO: Replace with network-aware allocation once
|
||||
// IFranchiseeNetworkService is implemented.
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
478
IntelligenceApi/Engines/General/GeneralEngine.cs
Normal file
478
IntelligenceApi/Engines/General/GeneralEngine.cs
Normal file
@@ -0,0 +1,478 @@
|
||||
using IntelligenceApi.Models;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace IntelligenceApi.Engines.General;
|
||||
|
||||
/// <summary>
|
||||
/// Default spend distribution engine for General (small business) clients.
|
||||
///
|
||||
/// This is a direct transplant of ForecastService from the Gateway.
|
||||
/// Behavior is identical to the original — existing clients see no change.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Fan out to provider APIs in parallel (Google live, others emulated)
|
||||
/// 2. Normalize metrics across providers
|
||||
/// 3. Score each channel using objective-weighted metrics
|
||||
/// 4. Derive allocation percentages (min 15%, max 85%)
|
||||
/// 5. Return sorted channel estimates with recommendation text
|
||||
///
|
||||
/// No AI cost — runs entirely on rules + provider data.
|
||||
/// </summary>
|
||||
public sealed class GeneralEngine : ISpendDistributionEngine
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly ILogger<GeneralEngine> _logger;
|
||||
|
||||
private const int MIN_ALLOCATION = 15;
|
||||
private const int MAX_ALLOCATION = 85;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts =
|
||||
new(JsonSerializerDefaults.Web);
|
||||
|
||||
public string EngineName => "General";
|
||||
|
||||
public GeneralEngine(IHttpClientFactory http, ILogger<GeneralEngine> logger)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SpendDistributionResponse> RecommendAsync(
|
||||
SpendDistributionRequest request, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var channels = request.Channels ?? new List<string> { "google_ads" };
|
||||
var weights = ObjectiveWeights.For(request.Objective);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[GeneralEngine] Starting | Objective={Obj} Budget={Budget} Channels={Ch}",
|
||||
request.Objective, request.MonthlyBudget, string.Join(",", channels));
|
||||
|
||||
// ── Fan out to providers in parallel ──
|
||||
var tasks = new Dictionary<string, Task<ProviderResult>>();
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
tasks[channel] = channel switch
|
||||
{
|
||||
"google_ads" => FetchGoogleAsync(request, ct),
|
||||
"meta" => FetchMetaAsync(request, ct),
|
||||
_ => Task.FromResult(TemplateForecast(channel))
|
||||
};
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks.Values);
|
||||
|
||||
var results = tasks.ToDictionary(t => t.Key, t => t.Value.Result);
|
||||
|
||||
// ── Score and allocate ──
|
||||
var scored = ScoreChannels(results, weights);
|
||||
var allocations = DeriveAllocations(scored);
|
||||
|
||||
// ── Build response ──
|
||||
var channelAllocations = new List<ChannelAllocation>();
|
||||
foreach (var (channel, result) in results)
|
||||
{
|
||||
var pct = allocations[channel];
|
||||
var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2);
|
||||
|
||||
channelAllocations.Add(new ChannelAllocation
|
||||
{
|
||||
Provider = channel,
|
||||
AllocationPercent = pct,
|
||||
AllocatedBudget = allocated,
|
||||
Estimates = new AllocationMetrics
|
||||
{
|
||||
Impressions = result.Impressions,
|
||||
Reach = result.Reach,
|
||||
Clicks = result.Clicks,
|
||||
Conversions = result.Conversions,
|
||||
AvgCpc = result.AvgCpc,
|
||||
AvgCpm = result.AvgCpm,
|
||||
EstimatedCpa = result.EstimatedCpa,
|
||||
Ctr = result.Ctr
|
||||
},
|
||||
EfficiencyScore = Math.Round(scored[channel], 3),
|
||||
StrengthLabel = GetStrengthLabel(channel, request.Objective),
|
||||
Confidence = result.Confidence,
|
||||
DataSource = result.DataSource
|
||||
});
|
||||
}
|
||||
|
||||
channelAllocations.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent));
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation("[GeneralEngine] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds);
|
||||
|
||||
return new SpendDistributionResponse
|
||||
{
|
||||
Ok = true,
|
||||
Objective = request.Objective,
|
||||
TotalBudget = request.MonthlyBudget,
|
||||
Channels = channelAllocations,
|
||||
Recommendation = BuildRecommendation(channelAllocations, request.Objective),
|
||||
Metadata = new DistributionMeta
|
||||
{
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
ForecastPeriod = "30 days",
|
||||
Engine = EngineName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Provider calls
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private async Task<ProviderResult> FetchGoogleAsync(
|
||||
SpendDistributionRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerUrl = request.ProviderUrls?.GetValueOrDefault("google_ads") ?? "";
|
||||
var key = request.InternalKeys?.GetValueOrDefault("google_ads") ?? "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
return EmulatedGoogle(request);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
keywords = request.Keywords,
|
||||
geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List<long>(),
|
||||
monthlyBudget = request.MonthlyBudget,
|
||||
currencyCode = "USD",
|
||||
forecastDays = 30
|
||||
};
|
||||
|
||||
var providerRequest = new
|
||||
{
|
||||
operation = "KeywordForecast",
|
||||
requestId = Guid.NewGuid().ToString("N"),
|
||||
payload
|
||||
};
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(providerRequest, _jsonOpts),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var resp = await httpClient.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[GeneralEngine] Google provider {Status}", (int)resp.StatusCode);
|
||||
return EmulatedGoogle(request);
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
var data = root.TryGetProperty("data", out var d) ? d : root;
|
||||
var monthly = data.GetProperty("monthly");
|
||||
var metrics = data.GetProperty("metrics");
|
||||
|
||||
return new ProviderResult
|
||||
{
|
||||
Provider = "google_ads",
|
||||
Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0,
|
||||
Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0,
|
||||
Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0,
|
||||
Reach = null,
|
||||
AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0,
|
||||
AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0,
|
||||
Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0,
|
||||
EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null
|
||||
? cpa.GetDecimal() : null,
|
||||
Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low",
|
||||
DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[GeneralEngine] Google provider call failed");
|
||||
return EmulatedGoogle(request);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ProviderResult> FetchMetaAsync(
|
||||
SpendDistributionRequest request, CancellationToken ct)
|
||||
{
|
||||
// Phase 2: call MetaApi /internal/execute → DeliveryEstimate
|
||||
await Task.CompletedTask;
|
||||
|
||||
var budget = request.MonthlyBudget;
|
||||
var rng = new Random((int)(budget * 77));
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0);
|
||||
var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0;
|
||||
var reach = impressions * 0.42;
|
||||
var clickRate = 0.012 + (rng.NextDouble() * 0.008);
|
||||
var clicks = impressions * clickRate;
|
||||
var conversions = clicks * (0.025 + rng.NextDouble() * 0.015);
|
||||
var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0;
|
||||
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
|
||||
|
||||
return new ProviderResult
|
||||
{
|
||||
Provider = "meta",
|
||||
Impressions = Math.Round(impressions),
|
||||
Clicks = Math.Round(clicks),
|
||||
Conversions = Math.Round(conversions, 1),
|
||||
Reach = Math.Round(reach),
|
||||
AvgCpc = Math.Round(avgCpc, 2),
|
||||
AvgCpm = Math.Round(cpm, 2),
|
||||
Ctr = Math.Round(clickRate, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
private static ProviderResult TemplateForecast(string provider) =>
|
||||
new() { Provider = provider, Confidence = "none", DataSource = "template" };
|
||||
|
||||
private ProviderResult EmulatedGoogle(SpendDistributionRequest request)
|
||||
{
|
||||
var budget = request.MonthlyBudget;
|
||||
var kwCount = Math.Max(request.Keywords.Count, 1);
|
||||
var rng = new Random((int)(budget * 100) + kwCount);
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20);
|
||||
var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0;
|
||||
var impressions = clicks / 0.045;
|
||||
var conversions = clicks * 0.035;
|
||||
var ctr = impressions > 0 ? clicks / impressions : 0;
|
||||
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
|
||||
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
|
||||
|
||||
return new ProviderResult
|
||||
{
|
||||
Provider = "google_ads",
|
||||
Impressions = Math.Round(impressions),
|
||||
Clicks = Math.Round(clicks),
|
||||
Conversions = Math.Round(conversions, 1),
|
||||
AvgCpc = Math.Round(baseCpc, 2),
|
||||
AvgCpm = Math.Round((decimal)cpm, 2),
|
||||
Ctr = Math.Round(ctr, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Scoring
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static Dictionary<string, double> ScoreChannels(
|
||||
Dictionary<string, ProviderResult> results, MetricWeights w)
|
||||
{
|
||||
var scoreable = results
|
||||
.Where(r => r.Value.DataSource != "template")
|
||||
.ToDictionary(r => r.Key, r => r.Value);
|
||||
|
||||
if (scoreable.Count == 0)
|
||||
return results.ToDictionary(r => r.Key, _ => 1.0);
|
||||
|
||||
var maxImp = scoreable.Values.Max(r => r.Impressions);
|
||||
var maxReach = scoreable.Values.Max(r => r.Reach ?? 0);
|
||||
var maxClicks = scoreable.Values.Max(r => r.Clicks);
|
||||
var maxConv = scoreable.Values.Max(r => r.Conversions);
|
||||
var maxCtr = scoreable.Values.Max(r => r.Ctr);
|
||||
var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min();
|
||||
var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min();
|
||||
var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0)
|
||||
.Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min();
|
||||
|
||||
var scores = new Dictionary<string, double>();
|
||||
|
||||
foreach (var (channel, r) in scoreable)
|
||||
{
|
||||
double score = 0;
|
||||
score += w.Impressions * SafeDiv(r.Impressions, maxImp);
|
||||
score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1);
|
||||
score += w.Clicks * SafeDiv(r.Clicks, maxClicks);
|
||||
score += w.Conversions * SafeDiv(r.Conversions, maxConv);
|
||||
score += w.Ctr * SafeDiv(r.Ctr, maxCtr);
|
||||
score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0);
|
||||
score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0);
|
||||
score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0);
|
||||
scores[channel] = score;
|
||||
}
|
||||
|
||||
var avg = scores.Values.Average();
|
||||
foreach (var channel in results.Keys.Except(scoreable.Keys))
|
||||
scores[channel] = avg * 0.5;
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> DeriveAllocations(Dictionary<string, double> scores)
|
||||
{
|
||||
var total = scores.Values.Sum();
|
||||
if (total == 0)
|
||||
{
|
||||
var even = 100 / scores.Count;
|
||||
return scores.ToDictionary(s => s.Key, _ => even);
|
||||
}
|
||||
|
||||
var raw = scores.ToDictionary(
|
||||
s => s.Key,
|
||||
s => Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION,
|
||||
(int)Math.Round(s.Value / total * 100))));
|
||||
|
||||
var sum = raw.Values.Sum();
|
||||
if (sum != 100 && raw.Count > 0)
|
||||
{
|
||||
var diff = 100 - sum;
|
||||
var top = raw.OrderByDescending(r => r.Value).First().Key;
|
||||
raw[top] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[top] + diff));
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Copy helpers (keep identical to Gateway originals)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static double SafeDiv(double n, double d) => d > 0 ? n / d : 0;
|
||||
|
||||
private static string GetStrengthLabel(string channel, string objective) => channel switch
|
||||
{
|
||||
"google_ads" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for search visibility",
|
||||
"traffic" => "Strong for search intent clicks",
|
||||
"leads" => "Strong for high-intent leads",
|
||||
"sales" => "Strong for purchase intent",
|
||||
_ => "Search & intent targeting"
|
||||
},
|
||||
"meta" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for reach & discovery",
|
||||
"traffic" => "Strong for social traffic",
|
||||
"leads" => "Strong for lead gen forms",
|
||||
"sales" => "Strong for retargeting & social proof",
|
||||
_ => "Social reach & engagement"
|
||||
},
|
||||
"tiktok" => "Video-first engagement",
|
||||
_ => "Advertising channel"
|
||||
};
|
||||
|
||||
private static DistributionRecommendation BuildRecommendation(
|
||||
List<ChannelAllocation> channels, string objective)
|
||||
{
|
||||
if (channels.Count < 2)
|
||||
return new DistributionRecommendation
|
||||
{
|
||||
Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.",
|
||||
Highlights = new List<string>()
|
||||
};
|
||||
|
||||
var top = channels[0];
|
||||
var second = channels[1];
|
||||
var highlights = new List<string>();
|
||||
|
||||
if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0)
|
||||
{
|
||||
var ratio = top.Estimates.Clicks / second.Estimates.Clicks;
|
||||
if (ratio > 1.3)
|
||||
highlights.Add($"{DisplayName(top.Provider)}: ~{ratio:F0}x more clicks per dollar");
|
||||
}
|
||||
|
||||
if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0)
|
||||
{
|
||||
var ratio = second.Estimates.Impressions / top.Estimates.Impressions;
|
||||
if (ratio > 1.5)
|
||||
highlights.Add($"{DisplayName(second.Provider)}: ~{ratio:F0}x more impressions per dollar");
|
||||
}
|
||||
|
||||
if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0)
|
||||
{
|
||||
highlights.Add(
|
||||
$"CPA range: ${Math.Min(top.Estimates.EstimatedCpa!.Value, second.Estimates.EstimatedCpa!.Value):F0}" +
|
||||
$"–${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels");
|
||||
}
|
||||
|
||||
return new DistributionRecommendation
|
||||
{
|
||||
Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " +
|
||||
$"between {DisplayName(top.Provider)} and {DisplayName(second.Provider)}, " +
|
||||
$"optimized for {objective}.",
|
||||
Highlights = highlights
|
||||
};
|
||||
}
|
||||
|
||||
private static string DisplayName(string p) => p switch
|
||||
{
|
||||
"google_ads" => "Google",
|
||||
"meta" => "Meta",
|
||||
"tiktok" => "TikTok",
|
||||
_ => p
|
||||
};
|
||||
|
||||
// ── Internal result from a single provider ──
|
||||
private sealed class ProviderResult
|
||||
{
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public double Impressions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Objective-weighted scoring (copied from Gateway ForecastModels)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class MetricWeights
|
||||
{
|
||||
public double Reach { get; }
|
||||
public double Impressions { get; }
|
||||
public double Cpm { get; }
|
||||
public double Clicks { get; }
|
||||
public double Cpc { get; }
|
||||
public double Ctr { get; }
|
||||
public double Conversions { get; }
|
||||
public double Cpa { get; }
|
||||
|
||||
public MetricWeights(double reach, double impressions, double cpm,
|
||||
double clicks, double cpc, double ctr, double conversions, double cpa)
|
||||
{
|
||||
Reach = reach; Impressions = impressions; Cpm = cpm;
|
||||
Clicks = clicks; Cpc = cpc; Ctr = ctr;
|
||||
Conversions = conversions; Cpa = cpa;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ObjectiveWeights
|
||||
{
|
||||
private static readonly Dictionary<string, MetricWeights> _weights =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// reach imp cpm clicks cpc ctr conv cpa
|
||||
["awareness"] = new MetricWeights(0.35, 0.25, 0.20, 0.05, 0.05, 0.05, 0.00, 0.00),
|
||||
["traffic"] = new MetricWeights(0.05, 0.10, 0.10, 0.30, 0.30, 0.15, 0.00, 0.00),
|
||||
["leads"] = new MetricWeights(0.05, 0.05, 0.05, 0.15, 0.15, 0.10, 0.25, 0.20),
|
||||
["sales"] = new MetricWeights(0.05, 0.05, 0.05, 0.10, 0.10, 0.10, 0.30, 0.25),
|
||||
};
|
||||
|
||||
private static readonly MetricWeights _default =
|
||||
new(0.10, 0.10, 0.10, 0.20, 0.20, 0.10, 0.10, 0.10);
|
||||
|
||||
public static MetricWeights For(string objective) =>
|
||||
_weights.TryGetValue(objective, out var w) ? w : _default;
|
||||
}
|
||||
35
IntelligenceApi/Engines/ISpendDistributionEngine.cs
Normal file
35
IntelligenceApi/Engines/ISpendDistributionEngine.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using IntelligenceApi.Models;
|
||||
|
||||
namespace IntelligenceApi.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for all spend distribution engines.
|
||||
///
|
||||
/// Each engine encapsulates the logic for recommending how a client should
|
||||
/// distribute their ad budget across providers. Engines vary by client category:
|
||||
///
|
||||
/// General — rules-based scoring (default, free tier)
|
||||
/// Franchisee — location-aware, franchise-specific signals (premium)
|
||||
/// Franchisor — network-wide co-op budget management (premium)
|
||||
/// FoodFranchisee — geo density, competitor proximity, demographics (AI-driven)
|
||||
///
|
||||
/// The contract is intentionally narrow: one method in, one model out.
|
||||
/// Each engine can call external APIs, ML models, or run local logic —
|
||||
/// the caller doesn't need to know which.
|
||||
/// </summary>
|
||||
public interface ISpendDistributionEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable name for logging, metadata, and billing attribution.
|
||||
/// e.g. "General", "Franchisee", "FoodFranchisee"
|
||||
/// </summary>
|
||||
string EngineName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Generate a spend distribution recommendation for the given request.
|
||||
/// Must never throw — return a valid response with reduced confidence on errors.
|
||||
/// </summary>
|
||||
Task<SpendDistributionResponse> RecommendAsync(
|
||||
SpendDistributionRequest request,
|
||||
CancellationToken ct);
|
||||
}
|
||||
6
IntelligenceApi/IntelligenceAPI.http
Normal file
6
IntelligenceApi/IntelligenceAPI.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@IntelligenceAPI_HostAddress = http://localhost:5271
|
||||
|
||||
GET {{IntelligenceAPI_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
10
IntelligenceApi/IntelligenceApi.csproj
Normal file
10
IntelligenceApi/IntelligenceApi.csproj
Normal file
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>IntelligenceApi</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
75
IntelligenceApi/Models/DemographicsModels.cs
Normal file
75
IntelligenceApi/Models/DemographicsModels.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
namespace IntelligenceApi.Models;
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Request: Gateway → IntelligenceApi
|
||||
// Gateway sends raw census data fetched from DB,
|
||||
// plus the ZCTA for context.
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class DemographicAnalysisRequest
|
||||
{
|
||||
/// <summary>5-digit ZIP code / ZCTA</summary>
|
||||
public string Zcta { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Raw census row from spDemographics.
|
||||
/// All numeric fields; nulls treated as zero.
|
||||
/// </summary>
|
||||
public CensusData Census { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class CensusData
|
||||
{
|
||||
public int TotalPopulation { get; set; }
|
||||
public int TotalHouseholds { get; set; }
|
||||
public int MedianIncome { get; set; }
|
||||
public int MedianHomeValue { get; set; }
|
||||
public decimal Pct18to24 { get; set; }
|
||||
public decimal Pct25to34 { get; set; }
|
||||
public decimal Pct35to44 { get; set; }
|
||||
public decimal Pct45to54 { get; set; }
|
||||
public decimal Pct55to64 { get; set; }
|
||||
public decimal Pct65plus { get; set; }
|
||||
public decimal PctBachelorPlus { get; set; }
|
||||
public decimal PctOwnerOccupied { get; set; }
|
||||
public decimal PctRenterOccupied { get; set; }
|
||||
public decimal PctFamilyHouseholds { get; set; }
|
||||
public decimal PctLivingAlone { get; set; }
|
||||
public decimal PctHispanic { get; set; }
|
||||
public decimal PctAsian { get; set; }
|
||||
public decimal PctBlack { get; set; }
|
||||
public decimal PctRemoteWork { get; set; }
|
||||
public decimal PctPublicTransit { get; set; }
|
||||
public decimal UnemploymentRate { get; set; }
|
||||
public decimal PctIncomeUnder30k { get; set; }
|
||||
public decimal PctIncome30kTo75k { get; set; }
|
||||
public decimal PctIncome75kTo150k { get; set; }
|
||||
public decimal PctIncome150kPlus { get; set; }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Response: IntelligenceApi → Gateway → Client
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class DemographicAnalysisResponse
|
||||
{
|
||||
public bool Ok { get; set; } = true;
|
||||
public string Zcta { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Raw census metrics passed through for display</summary>
|
||||
public CensusData Census { get; set; } = new();
|
||||
|
||||
/// <summary>Derived recommendations for wizard chip auto-population</summary>
|
||||
public AudienceRecommendations Recommendations { get; set; } = new();
|
||||
|
||||
/// <summary>Human-readable summary strings for the insight bar</summary>
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AudienceRecommendations
|
||||
{
|
||||
public List<string> AgeRanges { get; set; } = new();
|
||||
public List<string> Incomes { get; set; } = new();
|
||||
public string AgeSkew { get; set; } = "balanced";
|
||||
public string MarketScope { get; set; } = "local";
|
||||
}
|
||||
117
IntelligenceApi/Models/SpendDistributionModels.cs
Normal file
117
IntelligenceApi/Models/SpendDistributionModels.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
namespace IntelligenceApi.Models;
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Request: Gateway → IntelligenceApi
|
||||
// Gateway injects clientCategory from ClientContext
|
||||
// before forwarding the wizard's forecast request.
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class SpendDistributionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Client category — the primary engine selector.
|
||||
/// Values: General | Franchisee | Franchisor | FoodFranchisee | etc.
|
||||
/// Injected by the Gateway from ClientContext.ClientCategory.
|
||||
/// </summary>
|
||||
public string ClientCategory { get; set; } = "General";
|
||||
|
||||
/// <summary>Advertising objective: awareness, traffic, leads, sales</summary>
|
||||
public string Objective { get; set; } = "traffic";
|
||||
|
||||
/// <summary>Business category from wizard Step 1 (industry)</summary>
|
||||
public string? BusinessCategory { get; set; }
|
||||
|
||||
/// <summary>Keywords from URL analysis</summary>
|
||||
public List<string> Keywords { get; set; } = new();
|
||||
|
||||
/// <summary>Geo targeting from audience step</summary>
|
||||
public GeoTargeting? GeoTargeting { get; set; }
|
||||
|
||||
/// <summary>Audience parameters</summary>
|
||||
public AudienceParams? Audience { get; set; }
|
||||
|
||||
/// <summary>Monthly budget in whole dollars</summary>
|
||||
public decimal MonthlyBudget { get; set; }
|
||||
|
||||
/// <summary>Channels to estimate</summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider base URLs forwarded from Gateway config.
|
||||
/// Allows engines to call providers without needing their own config.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? ProviderUrls { get; set; }
|
||||
|
||||
/// <summary>Internal API keys forwarded from Gateway config.</summary>
|
||||
public Dictionary<string, string>? InternalKeys { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GeoTargeting
|
||||
{
|
||||
public List<string>? ZipCodes { get; set; }
|
||||
public double? RadiusMiles { get; set; }
|
||||
public List<long>? GeoTargetIds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AudienceParams
|
||||
{
|
||||
public int? AgeMin { get; set; }
|
||||
public int? AgeMax { get; set; }
|
||||
public List<string>? Genders { get; set; }
|
||||
public List<string>? Interests { get; set; }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Response: IntelligenceApi → Gateway → Client
|
||||
// Identical shape to ChannelForecastResponse in
|
||||
// the Gateway so no client changes are needed.
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class SpendDistributionResponse
|
||||
{
|
||||
public bool Ok { get; set; } = true;
|
||||
public string Objective { get; set; } = string.Empty;
|
||||
public decimal TotalBudget { get; set; }
|
||||
public List<ChannelAllocation> Channels { get; set; } = new();
|
||||
public DistributionRecommendation? Recommendation { get; set; }
|
||||
public DistributionMeta Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ChannelAllocation
|
||||
{
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public int AllocationPercent { get; set; }
|
||||
public decimal AllocatedBudget { get; set; }
|
||||
public AllocationMetrics Estimates { get; set; } = new();
|
||||
public double EfficiencyScore { get; set; }
|
||||
public string StrengthLabel { get; set; } = string.Empty;
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "none";
|
||||
}
|
||||
|
||||
public sealed class AllocationMetrics
|
||||
{
|
||||
public double Impressions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DistributionRecommendation
|
||||
{
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public List<string> Highlights { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class DistributionMeta
|
||||
{
|
||||
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string ForecastPeriod { get; set; } = "30 days";
|
||||
|
||||
/// <summary>Which engine handled this request — useful for debugging and billing.</summary>
|
||||
public string Engine { get; set; } = "General";
|
||||
}
|
||||
105
IntelligenceApi/Program.cs
Normal file
105
IntelligenceApi/Program.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using IntelligenceApi.Engines;
|
||||
using IntelligenceApi.Engines.Franchisee;
|
||||
using IntelligenceApi.Engines.Franchisor;
|
||||
using IntelligenceApi.Engines.General;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --------------------
|
||||
// Container-friendly HTTP binding
|
||||
// --------------------
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? "8081";
|
||||
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
|
||||
|
||||
// --------------------
|
||||
// Services
|
||||
// --------------------
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// ── Engines ──
|
||||
// General is a singleton — stateless, no DB dependency, safe to share.
|
||||
// Franchisee and Franchisor delegate to General; register as singletons too.
|
||||
// When real data services are added, switch to Scoped.
|
||||
builder.Services.AddSingleton<GeneralEngine>();
|
||||
builder.Services.AddSingleton<FranchiseeEngine>();
|
||||
builder.Services.AddSingleton<FranchisorEngine>();
|
||||
builder.Services.AddSingleton<EngineRouter>();
|
||||
builder.Services.AddSingleton<DemographicsAnalyzer>();
|
||||
|
||||
// --------------------
|
||||
// Security: internal-only access
|
||||
// Simple shared key check — requests must include X-Internal-Key header
|
||||
// matching the INTELLIGENCE_INTERNAL_KEY environment variable.
|
||||
// The Gateway sets this key; the container is not publicly exposed.
|
||||
// --------------------
|
||||
var internalKey = builder.Configuration["INTELLIGENCE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Health check (no auth — used by ACA liveness probe)
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "IntelligenceApi",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
service = "IntelligenceApi",
|
||||
version = "1.0.0",
|
||||
status = "Spend distribution engine running"
|
||||
}));
|
||||
|
||||
// Internal key middleware — validates all /api/* requests
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
|
||||
// Pass health and root through unauthenticated
|
||||
if (path == "/" || path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// In development, skip key check if not configured
|
||||
if (string.IsNullOrWhiteSpace(internalKey))
|
||||
{
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = 503;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Service not configured (missing INTELLIGENCE_INTERNAL_KEY)"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.TryGetValue("X-Internal-Key", out var key)
|
||||
|| key.FirstOrDefault() != internalKey)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
Console.WriteLine($"[IntelligenceApi] Starting on port {port}");
|
||||
Console.WriteLine($"[IntelligenceApi] Internal key configured: {!string.IsNullOrWhiteSpace(internalKey)}");
|
||||
|
||||
app.Run();
|
||||
52
IntelligenceApi/Properties/launchSettings.json
Normal file
52
IntelligenceApi/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:5271"
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://localhost:7044;http://localhost:5271"
|
||||
},
|
||||
"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:50991",
|
||||
"sslPort": 44357
|
||||
}
|
||||
}
|
||||
}
|
||||
9
IntelligenceApi/appsettings.Development.json
Normal file
9
IntelligenceApi/appsettings.Development.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"IntelligenceApi": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
IntelligenceApi/appsettings.json
Normal file
9
IntelligenceApi/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
38
Management/Controllers/Admin/AdminCampaignsController.cs
Normal file
38
Management/Controllers/Admin/AdminCampaignsController.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for campaign (initiative) management.
|
||||
/// Lists initiatives across all clients with their channel campaign details.
|
||||
/// Requires Admin role.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/campaigns - List all initiatives with channels
|
||||
/// GET /api/admin/campaigns/{id} - Get initiative detail with channels
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/campaigns")]
|
||||
public sealed class AdminCampaignsController : AdminControllerBase
|
||||
{
|
||||
public AdminCampaignsController(SqlService sql, ClientContext client, ILogger<AdminCampaignsController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
/// <summary>
|
||||
/// List all initiatives across all clients, with nested channel campaigns.
|
||||
/// Optional filters: status, clientId, dateFrom, dateTo.
|
||||
/// </summary>
|
||||
[HttpPost("list")]
|
||||
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc("spAdminCampaigns", "list", body.ToString(), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get initiative by ID with full channel campaign details.
|
||||
/// </summary>
|
||||
[HttpGet("{initiativeId:long}")]
|
||||
public Task<IActionResult> Get(long initiativeId, CancellationToken ct)
|
||||
=> CallProc("spAdminCampaigns", "get", new { initiativeId }, ct);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Client activity log — queries tbAccessLog (populated by Gateway's AccessLogMiddleware).
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// POST /api/admin/client-activity/list - Paginated activity for a specific client
|
||||
/// POST /api/admin/client-activity/summary - Request counts + last-seen per client
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/client-activity")]
|
||||
public sealed class AdminClientActivityController : AdminControllerBase
|
||||
{
|
||||
private const string Proc = "spClientActivity";
|
||||
|
||||
public AdminClientActivityController(SqlService sql, ClientContext client, ILogger<AdminClientActivityController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
[HttpPost("list")]
|
||||
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc(Proc, "list", body.ToString(), ct);
|
||||
|
||||
[HttpPost("summary")]
|
||||
public Task<IActionResult> Summary([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc(Proc, "summary", body.ToString(), ct);
|
||||
}
|
||||
188
Management/Controllers/Admin/AdminClientDocumentsController.cs
Normal file
188
Management/Controllers/Admin/AdminClientDocumentsController.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Client document management (admin view).
|
||||
/// Admins can list, upload, download, and delete documents for any client.
|
||||
/// All documents created here have scope='client' and a required clientId.
|
||||
///
|
||||
/// POST /api/admin/client-documents/list - List docs for a client
|
||||
/// POST /api/admin/client-documents/upload - Upload doc for a client
|
||||
/// GET /api/admin/client-documents/{id}/download
|
||||
/// DELETE /api/admin/client-documents/{id}
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/client-documents")]
|
||||
public sealed class AdminClientDocumentsController : AdminControllerBase
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public AdminClientDocumentsController(SqlService sql, ClientContext client, IConfiguration config, ILogger<AdminClientDocumentsController> log)
|
||||
: base(sql, client, log)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
// ── POST /api/admin/client-documents/list ────────────────────────────────
|
||||
[HttpPost("list")]
|
||||
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clientId = body.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
return BadRequest(new { ok = false, error = "clientId is required" });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { scope = "client", clientId });
|
||||
var result = await Sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Client document list failed");
|
||||
return StatusCode(500, new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/admin/client-documents/upload ──────────────────────────────
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(52_428_800)]
|
||||
public async Task<IActionResult> Upload(
|
||||
IFormFile file,
|
||||
[FromForm] string clientId,
|
||||
[FromForm] string category,
|
||||
[FromForm] string? description = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { ok = false, message = "No file provided" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
return BadRequest(new { ok = false, message = "clientId is required" });
|
||||
|
||||
try
|
||||
{
|
||||
byte[] fileBytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
await file.CopyToAsync(ms, ct);
|
||||
fileBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
docFileName = file.FileName,
|
||||
docMimeType = file.ContentType,
|
||||
docFileSize = file.Length,
|
||||
docCategory = category,
|
||||
docDescription = description,
|
||||
docUploadedBy = Client.Email,
|
||||
docScope = "client",
|
||||
docCltId = clientId
|
||||
});
|
||||
|
||||
Logger.LogInformation("[ClientDocs] Upload {FileName} for client {ClientId} by {User}",
|
||||
file.FileName, clientId, Client.Email);
|
||||
|
||||
var result = await ExecUploadAsync(rqst, fileBytes, ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Client document upload failed: {FileName}", file?.FileName);
|
||||
return StatusCode(500, new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/admin/client-documents/{id}/download ────────────────────────
|
||||
[HttpGet("{id:long}/download")]
|
||||
public async Task<IActionResult> Download(long id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql")
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
|
||||
await using var conn = new SqlConnection(cs);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = 60
|
||||
};
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = JsonSerializer.Serialize(new { docId = id }) });
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return NotFound(new { ok = false, message = "Document not found" });
|
||||
|
||||
var fileName = reader.GetString(reader.GetOrdinal("docFileName"));
|
||||
var mimeType = reader.GetString(reader.GetOrdinal("docMimeType"));
|
||||
var content = (byte[])reader["docContent"];
|
||||
|
||||
return File(content, mimeType, fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Client document download failed: docId={DocId}", id);
|
||||
return StatusCode(500, new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /api/admin/client-documents/{id} ──────────────────────────────
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<IActionResult> Delete(long id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogInformation("[ClientDocs] Delete docId={DocId} by {User}", id, Client.Email);
|
||||
var rqst = JsonSerializer.Serialize(new { docId = id });
|
||||
var result = await Sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Client document delete failed: docId={DocId}", id);
|
||||
return StatusCode(500, new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Upload helper ────────────────────────────────────────────────────────
|
||||
private async Task<string> ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct)
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql")
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
|
||||
await using var conn = new SqlConnection(cs);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = 60
|
||||
};
|
||||
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst });
|
||||
cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent });
|
||||
|
||||
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
cmd.Parameters.Add(pResp);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
return pResp.Value as string
|
||||
?? JsonSerializer.Serialize(new { ok = false, message = "No response from database" });
|
||||
}
|
||||
}
|
||||
84
Management/Controllers/Admin/AdminClientUsersController.cs
Normal file
84
Management/Controllers/Admin/AdminClientUsersController.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Client-planet user management — tbClientUser / tbClientUserRole.
|
||||
/// No admin-plane concepts here whatsoever.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/client-users - List client users
|
||||
/// GET /api/admin/client-users/{userId} - Get single user
|
||||
/// PUT /api/admin/client-users/{userId} - Update display name / status
|
||||
/// DELETE /api/admin/client-users/{userId} - Deactivate
|
||||
/// POST /api/admin/client-users/{userId}/link - Link to a client
|
||||
/// DELETE /api/admin/client-users/{userId}/link/{clientId} - Unlink from a client
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/client-users")]
|
||||
public sealed class AdminClientUsersController : AdminControllerBase
|
||||
{
|
||||
private const string Proc = "spClientUsers";
|
||||
|
||||
public AdminClientUsersController(SqlService sql, ClientContext client, ILogger<AdminClientUsersController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
[HttpPost("list")]
|
||||
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc(Proc, "list", body.ToString(), ct);
|
||||
|
||||
[HttpGet("{userId}")]
|
||||
public Task<IActionResult> Get(string userId, CancellationToken ct)
|
||||
=> CallProc(Proc, "get", new { userId }, ct);
|
||||
|
||||
[HttpPut("{userId}")]
|
||||
public Task<IActionResult> Update(string userId, [FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[ClientUsers] Update {Id} by {User}", userId, Client.Email);
|
||||
return CallProc(Proc, "update", new
|
||||
{
|
||||
userId,
|
||||
displayName = Str(body, "displayName"),
|
||||
status = Str(body, "status")
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpDelete("{userId}")]
|
||||
public Task<IActionResult> Deactivate(string userId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[ClientUsers] Deactivate {Id} by {User}", userId, Client.Email);
|
||||
return CallProc(Proc, "deactivate", new { userId }, ct);
|
||||
}
|
||||
|
||||
[HttpPost("{userId}/link")]
|
||||
public Task<IActionResult> LinkToClient(string userId, [FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
var clientId = Str(body, "clientId");
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
return Task.FromResult(ValidationError("clientId is required"));
|
||||
|
||||
Logger.LogInformation("[ClientUsers] Link user {UserId} → client {ClientId} by {User}",
|
||||
userId, clientId, Client.Email);
|
||||
|
||||
return CallProc(Proc, "linkToClient", new
|
||||
{
|
||||
userId,
|
||||
clientId,
|
||||
role = Str(body, "role") ?? "User"
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpDelete("{userId}/link/{clientId}")]
|
||||
public Task<IActionResult> UnlinkFromClient(string userId, string clientId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[ClientUsers] Unlink user {UserId} from client {ClientId} by {User}",
|
||||
userId, clientId, Client.Email);
|
||||
return CallProc(Proc, "unlinkFromClient", new { userId, clientId }, ct);
|
||||
}
|
||||
|
||||
private static string? Str(JsonElement el, string key) =>
|
||||
el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
||||
}
|
||||
@@ -1,92 +1,341 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Management.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for client (organization) management.
|
||||
/// Requires Admin role.
|
||||
/// Admin endpoints for client lifecycle management.
|
||||
/// Auth enforced at middleware level (/api/admin/* → Session + Admin role).
|
||||
/// All operations pass JSON to stored procs (@action/@rqst/@resp).
|
||||
///
|
||||
/// APPROVAL FLOW (POST /api/admin/clients):
|
||||
/// 1. Fetch full applicant from Registration Function — OID comes from server, never browser
|
||||
/// 2. spClientManagement 'create' → tbClient + tbClientUser + tbClientUserRole (atomic)
|
||||
/// 3. POST JSON to each provider container → sub-account creation
|
||||
/// 4. spClientManagement 'recordAdAccount' per provider result
|
||||
/// 5. spNotification 'queue' → welcome + provisioning alert queued for async send
|
||||
/// 6. Registration 'complete' → mark registration closed
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/clients - List clients
|
||||
/// GET /api/admin/clients/{id} - Get client
|
||||
/// POST /api/admin/clients - Create client
|
||||
/// PUT /api/admin/clients/{id} - Update client
|
||||
/// DELETE /api/admin/clients/{id} - Deactivate client
|
||||
/// GET /api/admin/clients - List clients
|
||||
/// GET /api/admin/clients/{clientId} - Get client detail
|
||||
/// POST /api/admin/clients - Approve from registration
|
||||
/// PUT /api/admin/clients/{clientId} - Update profile
|
||||
/// POST /api/admin/clients/{clientId}/suspend - Suspend
|
||||
/// POST /api/admin/clients/{clientId}/cancel - Cancel
|
||||
/// POST /api/admin/clients/{clientId}/reactivate - Reactivate
|
||||
/// GET /api/admin/clients/{clientId}/defaults - Wizard pre-fill
|
||||
///
|
||||
/// REGISTRATION PROXY:
|
||||
/// GET /api/registration/pending - List pending applicants
|
||||
/// GET /api/registration/{id} - Get single applicant
|
||||
/// POST /api/registration/{id}/reject - Reject applicant
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/clients")]
|
||||
public sealed class AdminClientsController : AdminControllerBase
|
||||
{
|
||||
public AdminClientsController(SqlService sql, ClientContext client, ILogger<AdminClientsController> log)
|
||||
: base(sql, client, log) { }
|
||||
private readonly RegistrationClient _registration;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
/// <summary>
|
||||
/// List all clients with optional filtering.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken ct = default)
|
||||
=> CallProc("spAdminClients", "list", new { status, page, pageSize }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get client by ID.
|
||||
/// </summary>
|
||||
[HttpGet("{clientId}")]
|
||||
public Task<IActionResult> Get(string clientId, CancellationToken ct)
|
||||
=> CallProc("spAdminClients", "get", new { clientId }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new client.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public Task<IActionResult> Create([FromBody] CreateClientRequest request, CancellationToken ct)
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.ClientName))
|
||||
return Task.FromResult(ValidationError("clientName is required"));
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
Logger.LogWarning("[Admin] CreateClient | Name={Name} | By={User}", request.ClientName, Client.Email);
|
||||
return CallProc("spAdminClients", "create", new { clientName = request.ClientName.Trim() }, ct);
|
||||
public AdminClientsController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
RegistrationClient registration,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ILogger<AdminClientsController> log)
|
||||
: base(sql, client, log)
|
||||
{
|
||||
_registration = registration;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
private const string Proc = "spClientManagement";
|
||||
|
||||
// ── CRUD + Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
[HttpPost("list")]
|
||||
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc(Proc, "list", body.ToString(), ct);
|
||||
|
||||
[HttpGet("{clientId}")]
|
||||
public Task<IActionResult> Get(string clientId, CancellationToken ct)
|
||||
=> CallProc(Proc, "get", new { clientId }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Update client.
|
||||
/// Approve a registration. Only registrationId is needed from the browser —
|
||||
/// all data including the CIAM OID is fetched from the Registration Function server-side.
|
||||
/// </summary>
|
||||
[HttpPut("{clientId}")]
|
||||
public Task<IActionResult> Update(string clientId, [FromBody] UpdateClientRequest request, CancellationToken ct)
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Approve([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UpdateClient | Id={Id} | By={User}", clientId, Client.Email);
|
||||
return CallProc("spAdminClients", "update", new
|
||||
var registrationId = Str(body, "registrationId");
|
||||
if (string.IsNullOrWhiteSpace(registrationId))
|
||||
return ValidationError("registrationId is required");
|
||||
|
||||
// ── 1. Fetch full applicant — OID comes from server record, not browser ──
|
||||
var regDoc = await _registration.GetByIdAsync(registrationId, ct);
|
||||
if (regDoc == null)
|
||||
return StatusCode(502, new { ok = false, error = "Registration service unavailable" });
|
||||
|
||||
if (!regDoc.RootElement.TryGetProperty("applicant", out var app))
|
||||
return NotFound(new { ok = false, error = "Registration not found" });
|
||||
|
||||
var entraSub = Str(app, "entraSubjectId");
|
||||
var regEmail = Str(app, "contactEmail");
|
||||
var regName = Str(app, "contactName");
|
||||
var bizName = Str(app, "businessName");
|
||||
var websiteUrl = Str(app, "websiteUrl");
|
||||
var bizCategory = Str(app, "businessCategory");
|
||||
var cltCategory = Str(app, "clientCategory") ?? "General";
|
||||
var contactPhone= Str(app, "contactPhone");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entraSub))
|
||||
return BadRequest(new { ok = false, error = "Applicant has no verified identity — they must sign in through the registration portal before approval." });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bizName))
|
||||
return ValidationError("Registration has no business name");
|
||||
|
||||
Logger.LogInformation("[Approve] '{Name}' regId={RegId} OID={OID} by {User}",
|
||||
bizName, registrationId, entraSub, Client.Email);
|
||||
|
||||
// ── 2. Create tbClient + tbClientUser + tbClientUserRole (atomic in proc) ──
|
||||
var createResp = await Sql.ExecProcAsync("dbo.spClientManagement", "create",
|
||||
Json(new
|
||||
{
|
||||
registrantEntraSub = entraSub,
|
||||
registrantEmail = regEmail,
|
||||
registrantName = regName,
|
||||
name = bizName.Trim(),
|
||||
websiteUrl,
|
||||
businessCategory = bizCategory,
|
||||
contactName = regName,
|
||||
contactEmail = regEmail,
|
||||
contactPhone,
|
||||
clientCategory = cltCategory,
|
||||
approvedByEmail = Client.Email,
|
||||
registrationRef = registrationId
|
||||
}), ct: ct);
|
||||
|
||||
using var createDoc = JsonDocument.Parse(createResp);
|
||||
var cr = createDoc.RootElement;
|
||||
|
||||
if (!Bol(cr, "ok"))
|
||||
return BadRequest(new { ok = false, error = Str(cr, "error") ?? "DB create failed" });
|
||||
|
||||
var clientId = Str(cr, "clientId")!;
|
||||
var wasCreated = Bol(cr, "created");
|
||||
|
||||
// ── 3 + 4. Provider sub-accounts ─────────────────────────────────
|
||||
var providerResults = new List<object>();
|
||||
|
||||
if (wasCreated)
|
||||
{
|
||||
foreach (var provider in new[] { "google", "meta", "tiktok" })
|
||||
{
|
||||
var url = _cfg[$"{provider.ToUpper()}_PROVIDER_URL"]?.TrimEnd('/');
|
||||
var intKey = _cfg[$"{provider.ToUpper()}_INTERNAL_KEY"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
providerResults.Add(new { provider, status = "Skipped", error = "Not configured" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var http = _http.CreateClient();
|
||||
http.Timeout = TimeSpan.FromSeconds(30);
|
||||
if (!string.IsNullOrWhiteSpace(intKey))
|
||||
http.DefaultRequestHeaders.Add("X-Internal-Key", intKey);
|
||||
|
||||
var provResp = await http.PostAsync(
|
||||
$"{url}/api/accounts/create",
|
||||
new StringContent(
|
||||
Json(new { clientId, clientName = bizName, contactEmail = regEmail }),
|
||||
Encoding.UTF8, "application/json"),
|
||||
ct);
|
||||
|
||||
var provBody = await provResp.Content.ReadAsStringAsync(ct);
|
||||
using var provDoc = JsonDocument.Parse(provBody);
|
||||
var pv = provDoc.RootElement;
|
||||
|
||||
if (provResp.IsSuccessStatusCode && Bol(pv, "ok"))
|
||||
{
|
||||
var extId = Str(pv, "externalAccountId");
|
||||
var loginId = Str(pv, "loginAccountId");
|
||||
|
||||
await Sql.ExecProcAsync("dbo.spClientManagement", "recordAdAccount",
|
||||
Json(new { clientId, network = provider, externalAccountId = extId, loginAccountId = loginId, status = "Active" }),
|
||||
ct: ct);
|
||||
|
||||
providerResults.Add(new { provider, status = "Succeeded", externalAccountId = extId });
|
||||
Logger.LogInformation("[Approve] {Provider} account created: {ExtId}", provider, extId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var err = Str(pv, "error") ?? $"HTTP {(int)provResp.StatusCode}";
|
||||
providerResults.Add(new { provider, status = "Failed", error = err });
|
||||
Logger.LogWarning("[Approve] {Provider} failed: {Error}", provider, err);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
providerResults.Add(new { provider, status = "Failed", error = ex.Message });
|
||||
Logger.LogError(ex, "[Approve] {Provider} threw", provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Queue notifications ────────────────────────────────────────
|
||||
if (wasCreated && !string.IsNullOrWhiteSpace(regEmail))
|
||||
{
|
||||
await Sql.ExecProcAsync("dbo.spNotification", "queue",
|
||||
Json(new
|
||||
{
|
||||
type = "approval_welcome",
|
||||
toEmail = regEmail,
|
||||
toName = regName,
|
||||
subject = $"Your AdPlatform account is approved — {bizName}",
|
||||
bodyJson = Json(new { clientId, clientName = bizName, providerResults })
|
||||
}), ct: ct);
|
||||
|
||||
var failures = providerResults
|
||||
.Where(r => { using var d = JsonDocument.Parse(Json(r)); return Str(d.RootElement, "status") == "Failed"; })
|
||||
.ToList();
|
||||
|
||||
if (failures.Any() && !string.IsNullOrWhiteSpace(Client.Email))
|
||||
{
|
||||
await Sql.ExecProcAsync("dbo.spNotification", "queue",
|
||||
Json(new
|
||||
{
|
||||
type = "provisioning_alert",
|
||||
toEmail = Client.Email,
|
||||
toName = Client.Email,
|
||||
subject = $"[AdPlatform] Provisioning issue — {bizName}",
|
||||
bodyJson = Json(new { clientId, clientName = bizName, failures })
|
||||
}), ct: ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Mark registration complete ─────────────────────────────────
|
||||
try
|
||||
{
|
||||
await _registration.CompleteAsync(registrationId, clientId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "[Approve] Failed to mark registration {RegId} complete (non-fatal)", registrationId);
|
||||
}
|
||||
|
||||
return Ok(new { ok = true, clientId, name = bizName, status = "Active", created = wasCreated, providerResults });
|
||||
}
|
||||
|
||||
[HttpPut("{clientId}")]
|
||||
public Task<IActionResult> Update(string clientId, [FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] Updating client {Id} by {User}", clientId, Client.Email);
|
||||
return CallProc(Proc, "updateProfile", new
|
||||
{
|
||||
clientId,
|
||||
clientName = request?.ClientName?.Trim(),
|
||||
status = request?.Status
|
||||
name = Str(body, "name"),
|
||||
websiteUrl = Str(body, "websiteUrl"),
|
||||
description = Str(body, "description"),
|
||||
contactName = Str(body, "contactName"),
|
||||
contactEmail = Str(body, "contactEmail"),
|
||||
contactPhone = Str(body, "contactPhone"),
|
||||
notes = Str(body, "notes"),
|
||||
category = Str(body, "clientCategory")
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivate client (soft delete).
|
||||
/// </summary>
|
||||
[HttpDelete("{clientId}")]
|
||||
public Task<IActionResult> Delete(string clientId, CancellationToken ct)
|
||||
[HttpPost("{clientId}/suspend")]
|
||||
public Task<IActionResult> Suspend(string clientId, [FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] DeleteClient | Id={Id} | By={User}", clientId, Client.Email);
|
||||
return CallProc("spAdminClients", "delete", new { clientId }, ct);
|
||||
Logger.LogWarning("[Admin] Suspending {Id} by {User}", clientId, Client.Email);
|
||||
return CallProc(Proc, "updateStatus", new
|
||||
{
|
||||
clientId, newStatus = "Suspended",
|
||||
reason = Str(body, "reason"), changedByEmail = Client.Email
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed class CreateClientRequest
|
||||
{
|
||||
public string? ClientName { get; set; }
|
||||
}
|
||||
[HttpPost("{clientId}/cancel")]
|
||||
public Task<IActionResult> Cancel(string clientId, [FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] Cancelling {Id} by {User}", clientId, Client.Email);
|
||||
return CallProc(Proc, "updateStatus", new
|
||||
{
|
||||
clientId, newStatus = "Cancelled",
|
||||
reason = Str(body, "reason"), changedByEmail = Client.Email
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public sealed class UpdateClientRequest
|
||||
{
|
||||
public string? ClientName { get; set; }
|
||||
public string? Status { get; set; }
|
||||
[HttpPost("{clientId}/reactivate")]
|
||||
public Task<IActionResult> Reactivate(string clientId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] Reactivating {Id} by {User}", clientId, Client.Email);
|
||||
return CallProc(Proc, "updateStatus", new
|
||||
{
|
||||
clientId, newStatus = "Active", changedByEmail = Client.Email
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpGet("{clientId}/defaults")]
|
||||
public Task<IActionResult> Defaults(string clientId, CancellationToken ct)
|
||||
=> CallProc(Proc, "defaults", new { clientId }, ct);
|
||||
|
||||
// ── Registration Proxy ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("/api/registration/pending")]
|
||||
public async Task<IActionResult> GetPending(CancellationToken ct)
|
||||
{
|
||||
var result = await _registration.GetPendingAsync(ct);
|
||||
if (result == null)
|
||||
return StatusCode(502, new { ok = false, error = "Registration service unavailable" });
|
||||
return Content(result.RootElement.GetRawText(), "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("/api/registration/{registrationId}")]
|
||||
public async Task<IActionResult> GetById(string registrationId, CancellationToken ct)
|
||||
{
|
||||
var result = await _registration.GetByIdAsync(registrationId, ct);
|
||||
if (result == null)
|
||||
return StatusCode(502, new { ok = false, error = "Registration service unavailable" });
|
||||
return Content(result.RootElement.GetRawText(), "application/json");
|
||||
}
|
||||
|
||||
[HttpPost("/api/registration/{registrationId}/reject")]
|
||||
public async Task<IActionResult> Reject(string registrationId, [FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] Rejecting {Id} by {User}", registrationId, Client.Email);
|
||||
var result = await _registration.RejectAsync(registrationId, Str(body, "reason"), ct);
|
||||
if (result == null)
|
||||
return StatusCode(502, new { ok = false, error = "Registration service unavailable" });
|
||||
return Content(result.RootElement.GetRawText(), "application/json");
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static string? Str(JsonElement el, string key) =>
|
||||
el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
||||
|
||||
private static bool Bol(JsonElement el, string key) =>
|
||||
el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string Json(object o) =>
|
||||
JsonSerializer.Serialize(o, JsonOpts);
|
||||
}
|
||||
|
||||
@@ -24,11 +24,16 @@ public abstract class AdminControllerBase : ControllerBase
|
||||
/// <summary>
|
||||
/// Execute stored procedure and return appropriate IActionResult.
|
||||
/// </summary>
|
||||
protected async Task<IActionResult> CallProc(string proc, string action, object rqst, CancellationToken ct)
|
||||
protected Task<IActionResult> CallProc(string proc, string action, string rqst, CancellationToken ct)
|
||||
=> CallProcInternal(proc, action, rqst, ct);
|
||||
|
||||
protected Task<IActionResult> CallProc(string proc, string action, object rqst, CancellationToken ct)
|
||||
=> CallProcInternal(proc, action, JsonSerializer.Serialize(rqst), ct);
|
||||
|
||||
private async Task<IActionResult> CallProcInternal(string proc, string action, string json, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(rqst);
|
||||
var resp = await Sql.ExecProcAsync($"dbo.{proc}", action, json, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
|
||||
50
Management/Controllers/Admin/AdminHelpController.cs
Normal file
50
Management/Controllers/Admin/AdminHelpController.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Management.Controllers.Admin;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin CRUD for help content — requires admin session.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/help")]
|
||||
public class AdminHelpController : AdminControllerBase
|
||||
{
|
||||
public AdminHelpController(SqlService sql, ClientContext client, ILogger<AdminHelpController> logger)
|
||||
: base(sql, client, logger) { }
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/help
|
||||
/// List all help content entries (active and inactive).
|
||||
/// </summary>
|
||||
[HttpPost("list")]
|
||||
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
return await CallProc("spHelp", "list", body.ToString(), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/admin/help
|
||||
/// Create or update a help entry by helpKey (upsert).
|
||||
/// Body: { helpKey, title, body, isActive }
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Upsert([FromBody] JsonElement payload, CancellationToken ct)
|
||||
{
|
||||
return await CallProc("spHelp", "upsert", payload, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/help/{key}
|
||||
/// Delete a help entry by key.
|
||||
/// </summary>
|
||||
[HttpDelete("{key}")]
|
||||
public async Task<IActionResult> Delete(string key, CancellationToken ct)
|
||||
{
|
||||
return await CallProc("spHelp", "delete",
|
||||
new { helpKey = key, adminId = Client.UserId }, ct);
|
||||
}
|
||||
}
|
||||
112
Management/Controllers/Admin/AdminMetricSyncController.cs
Normal file
112
Management/Controllers/Admin/AdminMetricSyncController.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoint for manually triggering metric sync.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// POST /api/admin/sync/metrics/{clientId} - Sync a specific client
|
||||
/// POST /api/admin/sync/metrics - Sync all active clients
|
||||
///
|
||||
/// Proxies to Gateway /api/sync/metrics/* using an internal service key.
|
||||
/// Gateway owns provider connectivity; Management owns the trigger surface.
|
||||
/// Configure via GATEWAY_URL + INTERNAL_SERVICE_KEY env vars.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/sync")]
|
||||
public sealed class AdminMetricSyncController : AdminControllerBase
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public AdminMetricSyncController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
ILogger<AdminMetricSyncController> log,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration config)
|
||||
: base(sql, client, log)
|
||||
{
|
||||
_http = http;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger metric sync for a specific client.
|
||||
/// </summary>
|
||||
[HttpPost("metrics/{clientId}")]
|
||||
public async Task<IActionResult> SyncClient(
|
||||
string clientId,
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] MetricSync triggered for client {ClientId} | By={User}",
|
||||
clientId, Client.Email);
|
||||
|
||||
return await ProxyToGateway($"metrics/{Uri.EscapeDataString(clientId)}", startDate, endDate, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger metric sync for all active clients.
|
||||
/// </summary>
|
||||
[HttpPost("metrics")]
|
||||
public async Task<IActionResult> SyncAll(
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] MetricSync ALL clients triggered | By={User}", Client.Email);
|
||||
|
||||
return await ProxyToGateway("metrics/all", startDate, endDate, ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Proxy helper
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> ProxyToGateway(
|
||||
string path, string? startDate, string? endDate, CancellationToken ct)
|
||||
{
|
||||
var gatewayUrl = _config["GATEWAY_URL"]?.TrimEnd('/');
|
||||
var serviceKey = _config["INTERNAL_SERVICE_KEY"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(gatewayUrl))
|
||||
return StatusCode(500, new { ok = false, error = "GATEWAY_URL not configured" });
|
||||
if (string.IsNullOrWhiteSpace(serviceKey))
|
||||
return StatusCode(500, new { ok = false, error = "INTERNAL_SERVICE_KEY not configured" });
|
||||
|
||||
var qs = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(startDate)) qs.Add($"startDate={Uri.EscapeDataString(startDate)}");
|
||||
if (!string.IsNullOrWhiteSpace(endDate)) qs.Add($"endDate={Uri.EscapeDataString(endDate)}");
|
||||
|
||||
var url = $"{gatewayUrl}/api/sync/{path}";
|
||||
if (qs.Count > 0) url += "?" + string.Join("&", qs);
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient();
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
req.Headers.Add("X-Service-Key", serviceKey);
|
||||
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
Logger.LogInformation("[Admin] MetricSync Gateway response {Status} | Path={Path}",
|
||||
(int)resp.StatusCode, path);
|
||||
|
||||
return resp.IsSuccessStatusCode
|
||||
? Content(body, "application/json")
|
||||
: StatusCode((int)resp.StatusCode, new { ok = false, error = $"Gateway returned {(int)resp.StatusCode}", detail = body });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[Admin] MetricSync proxy error | Path={Path}", path);
|
||||
return StatusCode(500, new { ok = false, error = "Sync trigger failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
84
Management/Controllers/Admin/AdminModifiersController.cs
Normal file
84
Management/Controllers/Admin/AdminModifiersController.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for audience-based allocation modifiers.
|
||||
/// Table: dbo.tbAllocationModifier
|
||||
/// Proc: dbo.spAllocationRecommend (list / update actions)
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/modifiers - List all modifiers
|
||||
/// PUT /api/admin/modifiers/{id} - Update a modifier
|
||||
/// POST /api/admin/modifiers/preview - Preview recommendation with factors
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/modifiers")]
|
||||
public sealed class AdminModifiersController : AdminControllerBase
|
||||
{
|
||||
public AdminModifiersController(SqlService sql, ClientContext client, ILogger<AdminModifiersController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
[HttpGet]
|
||||
public Task<IActionResult> List(CancellationToken ct)
|
||||
=> CallProc("spAllocationRecommend", "list", new { }, ct);
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public Task<IActionResult> Update(int id, [FromBody] UpdateModifierRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request?.PctAdjustment is < -50 or > 50)
|
||||
return Task.FromResult(ValidationError("pctAdjustment must be between -50 and 50"));
|
||||
|
||||
Logger.LogWarning("[Admin] UpdateModifier | Id={Id} | By={User}", id, Client.Email);
|
||||
|
||||
return CallProc("spAllocationRecommend", "update", new
|
||||
{
|
||||
id,
|
||||
pctAdjustment = request?.PctAdjustment,
|
||||
minBudgetAdj = request?.MinBudgetAdj,
|
||||
rationale = request?.Rationale?.Trim(),
|
||||
isActive = request?.IsActive
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview a recommendation with given factors — same proc, recommend action.
|
||||
/// Lets admins test how modifiers affect channel mix without going through the wizard.
|
||||
/// </summary>
|
||||
[HttpPost("preview")]
|
||||
public Task<IActionResult> Preview([FromBody] PreviewRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.BusinessCategory))
|
||||
return Task.FromResult(ValidationError("businessCategory is required"));
|
||||
if (string.IsNullOrWhiteSpace(request?.Objective))
|
||||
return Task.FromResult(ValidationError("objective is required"));
|
||||
|
||||
return CallProc("spAllocationRecommend", "recommend", new
|
||||
{
|
||||
businessCategory = request.BusinessCategory.Trim(),
|
||||
objective = request.Objective.Trim(),
|
||||
ageSkew = request.AgeSkew?.Trim(),
|
||||
marketScope = request.MarketScope?.Trim()
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
||||
public sealed class UpdateModifierRequest
|
||||
{
|
||||
public int? PctAdjustment { get; set; }
|
||||
public int? MinBudgetAdj { get; set; }
|
||||
public string? Rationale { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PreviewRequest
|
||||
{
|
||||
public string? BusinessCategory { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public string? AgeSkew { get; set; }
|
||||
public string? MarketScope { get; set; }
|
||||
}
|
||||
120
Management/Controllers/Admin/AdminObjectiveMappingController.cs
Normal file
120
Management/Controllers/Admin/AdminObjectiveMappingController.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for objective-to-channel mapping management.
|
||||
/// Table: dbo.tbObjectiveMapping
|
||||
/// Maps platform objectives to provider-specific objectives with capability flags.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/objectives - List mappings (filterable)
|
||||
/// GET /api/admin/objectives/{id} - Get mapping
|
||||
/// POST /api/admin/objectives - Create mapping
|
||||
/// PUT /api/admin/objectives/{id} - Update mapping
|
||||
/// DELETE /api/admin/objectives/{id} - Delete mapping
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/objectives")]
|
||||
public sealed class AdminObjectiveMappingController : AdminControllerBase
|
||||
{
|
||||
public AdminObjectiveMappingController(SqlService sql, ClientContext client, ILogger<AdminObjectiveMappingController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
[HttpPost("list")]
|
||||
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc("spAdminObjectiveMapping", "list", body.ToString(), ct);
|
||||
|
||||
[HttpGet("{mappingId:int}")]
|
||||
public Task<IActionResult> Get(int mappingId, CancellationToken ct)
|
||||
=> CallProc("spAdminObjectiveMapping", "get", new { mappingId }, ct);
|
||||
|
||||
[HttpPost]
|
||||
public Task<IActionResult> Create([FromBody] CreateMappingRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.ChannelType))
|
||||
return Task.FromResult(ValidationError("channelType is required"));
|
||||
if (string.IsNullOrWhiteSpace(request?.PlatformObjective))
|
||||
return Task.FromResult(ValidationError("platformObjective is required"));
|
||||
if (string.IsNullOrWhiteSpace(request?.ProviderObjective))
|
||||
return Task.FromResult(ValidationError("providerObjective is required"));
|
||||
if (string.IsNullOrWhiteSpace(request?.ProviderObjectiveLabel))
|
||||
return Task.FromResult(ValidationError("providerObjectiveLabel is required"));
|
||||
|
||||
Logger.LogWarning("[Admin] CreateObjectiveMapping | {Objective} → {Channel}/{Provider} | By={User}",
|
||||
request.PlatformObjective, request.ChannelType, request.ProviderObjective, Client.Email);
|
||||
|
||||
return CallProc("spAdminObjectiveMapping", "create", new
|
||||
{
|
||||
channelType = request.ChannelType.Trim(),
|
||||
platformObjective = request.PlatformObjective.Trim(),
|
||||
providerObjective = request.ProviderObjective.Trim(),
|
||||
providerObjectiveLabel = request.ProviderObjectiveLabel.Trim(),
|
||||
supportsObjectiveChange = request.SupportsObjectiveChange ?? false,
|
||||
supportsBudgetChange = request.SupportsBudgetChange ?? true,
|
||||
supportsTargetingChange = request.SupportsTargetingChange ?? true,
|
||||
supportsStatusToggle = request.SupportsStatusToggle ?? true,
|
||||
notes = request.Notes?.Trim()
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpPut("{mappingId:int}")]
|
||||
public Task<IActionResult> Update(int mappingId, [FromBody] UpdateMappingRequest request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UpdateObjectiveMapping | Id={Id} | By={User}", mappingId, Client.Email);
|
||||
|
||||
return CallProc("spAdminObjectiveMapping", "update", new
|
||||
{
|
||||
mappingId,
|
||||
channelType = request?.ChannelType?.Trim(),
|
||||
platformObjective = request?.PlatformObjective?.Trim(),
|
||||
providerObjective = request?.ProviderObjective?.Trim(),
|
||||
providerObjectiveLabel = request?.ProviderObjectiveLabel?.Trim(),
|
||||
supportsObjectiveChange = request?.SupportsObjectiveChange,
|
||||
supportsBudgetChange = request?.SupportsBudgetChange,
|
||||
supportsTargetingChange = request?.SupportsTargetingChange,
|
||||
supportsStatusToggle = request?.SupportsStatusToggle,
|
||||
notes = request?.Notes?.Trim(),
|
||||
isActive = request?.IsActive
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpDelete("{mappingId:int}")]
|
||||
public Task<IActionResult> Delete(int mappingId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] DeleteObjectiveMapping | Id={Id} | By={User}", mappingId, Client.Email);
|
||||
return CallProc("spAdminObjectiveMapping", "delete", new { mappingId }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
||||
public sealed class CreateMappingRequest
|
||||
{
|
||||
public string? ChannelType { get; set; }
|
||||
public string? PlatformObjective { get; set; }
|
||||
public string? ProviderObjective { get; set; }
|
||||
public string? ProviderObjectiveLabel { get; set; }
|
||||
public bool? SupportsObjectiveChange { get; set; }
|
||||
public bool? SupportsBudgetChange { get; set; }
|
||||
public bool? SupportsTargetingChange { get; set; }
|
||||
public bool? SupportsStatusToggle { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateMappingRequest
|
||||
{
|
||||
public string? ChannelType { get; set; }
|
||||
public string? PlatformObjective { get; set; }
|
||||
public string? ProviderObjective { get; set; }
|
||||
public string? ProviderObjectiveLabel { get; set; }
|
||||
public bool? SupportsObjectiveChange { get; set; }
|
||||
public bool? SupportsBudgetChange { get; set; }
|
||||
public bool? SupportsTargetingChange { get; set; }
|
||||
public bool? SupportsStatusToggle { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
179
Management/Controllers/Admin/AdminRecommendationsController.cs
Normal file
179
Management/Controllers/Admin/AdminRecommendationsController.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for the recommendation engine.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/recommendations/rules - List all rules
|
||||
/// GET /api/admin/recommendations/rules/{id} - Get rule
|
||||
/// POST /api/admin/recommendations/rules - Create rule
|
||||
/// PUT /api/admin/recommendations/rules/{id} - Update rule
|
||||
/// DELETE /api/admin/recommendations/rules/{id} - Delete rule
|
||||
/// POST /api/admin/recommendations/evaluate - Trigger evaluation
|
||||
/// POST /api/admin/recommendations/cleanup - Cleanup old records
|
||||
///
|
||||
/// Client-facing endpoints (list, dismiss, resolve) remain on Gateway
|
||||
/// at /api/recommendations — scoped to the authenticated CIAM session.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/recommendations")]
|
||||
public sealed class AdminRecommendationsController : AdminControllerBase
|
||||
{
|
||||
public AdminRecommendationsController(
|
||||
SqlService sql, ClientContext client, ILogger<AdminRecommendationsController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Rule Management
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("rules")]
|
||||
public Task<IActionResult> ListRules(
|
||||
[FromQuery] string? category,
|
||||
[FromQuery] string? channel,
|
||||
CancellationToken ct)
|
||||
=> CallProc("spRecommendation", "rules.list", new { category, channel }, ct);
|
||||
|
||||
[HttpGet("rules/{ruleId:int}")]
|
||||
public Task<IActionResult> GetRule(int ruleId, CancellationToken ct)
|
||||
=> CallProc("spRecommendation", "rules.get", new { ruleId }, ct);
|
||||
|
||||
[HttpPost("rules")]
|
||||
public Task<IActionResult> CreateRule([FromBody] RuleRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Code))
|
||||
return Task.FromResult(ValidationError("code is required"));
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return Task.FromResult(ValidationError("name is required"));
|
||||
|
||||
Logger.LogInformation("[Admin] CreateRecommendationRule {Code} | By={User}", request.Code, Client.Email);
|
||||
|
||||
return CallProc("spRecommendation", "rules.create", new
|
||||
{
|
||||
request.Code,
|
||||
request.Name,
|
||||
request.Category,
|
||||
request.Metric,
|
||||
request.Operator,
|
||||
request.Threshold,
|
||||
request.ThresholdType,
|
||||
request.Severity,
|
||||
request.Channel,
|
||||
request.Objective,
|
||||
request.Message,
|
||||
request.AdminNotes,
|
||||
request.LookbackDays,
|
||||
request.MinDataDays,
|
||||
request.CooldownHours,
|
||||
request.SortOrder
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpPut("rules/{ruleId:int}")]
|
||||
public Task<IActionResult> UpdateRule(int ruleId, [FromBody] RuleRequest request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] UpdateRecommendationRule {Id} | By={User}", ruleId, Client.Email);
|
||||
|
||||
return CallProc("spRecommendation", "rules.update", new
|
||||
{
|
||||
ruleId,
|
||||
request.Name,
|
||||
request.Category,
|
||||
request.Metric,
|
||||
request.Operator,
|
||||
request.Threshold,
|
||||
request.ThresholdType,
|
||||
request.Severity,
|
||||
request.Channel,
|
||||
request.Objective,
|
||||
request.Message,
|
||||
request.AdminNotes,
|
||||
request.LookbackDays,
|
||||
request.MinDataDays,
|
||||
request.CooldownHours,
|
||||
request.IsActive,
|
||||
request.SortOrder
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpDelete("rules/{ruleId:int}")]
|
||||
public Task<IActionResult> DeleteRule(int ruleId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] DeleteRecommendationRule {Id} | By={User}", ruleId, Client.Email);
|
||||
return CallProc("spRecommendation", "rules.delete", new { ruleId }, ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Evaluation Engine
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Trigger rule evaluation for a campaign, initiative, client, or all active campaigns.
|
||||
/// </summary>
|
||||
[HttpPost("evaluate")]
|
||||
public Task<IActionResult> Evaluate([FromBody] EvaluateRequest? request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] Evaluate | ClientId={Client} InitiativeId={Init} | By={User}",
|
||||
request?.ClientId, request?.InitiativeId, Client.Email);
|
||||
|
||||
return CallProc("spRecommendation", "evaluate", new
|
||||
{
|
||||
channelCampaignId = request?.ChannelCampaignId,
|
||||
initiativeId = request?.InitiativeId,
|
||||
clientId = request?.ClientId
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup expired and old recommendations.
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
public Task<IActionResult> Cleanup([FromBody] CleanupRequest? request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogInformation("[Admin] RecommendationCleanup daysToKeep={Days} | By={User}",
|
||||
request?.DaysToKeep ?? 90, Client.Email);
|
||||
|
||||
return CallProc("spRecommendation", "cleanup",
|
||||
new { daysToKeep = request?.DaysToKeep ?? 90 }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request DTOs ──
|
||||
|
||||
public sealed class RuleRequest
|
||||
{
|
||||
public string? Code { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? Metric { get; set; }
|
||||
public string? Operator { get; set; }
|
||||
public decimal? Threshold { get; set; }
|
||||
public string? ThresholdType { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public string? AdminNotes { get; set; }
|
||||
public int? LookbackDays { get; set; }
|
||||
public int? MinDataDays { get; set; }
|
||||
public int? CooldownHours { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EvaluateRequest
|
||||
{
|
||||
public long? ChannelCampaignId { get; set; }
|
||||
public long? InitiativeId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CleanupRequest
|
||||
{
|
||||
public int? DaysToKeep { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
@@ -24,14 +25,9 @@ public sealed class AdminSessionsController : AdminControllerBase
|
||||
/// <summary>
|
||||
/// List sessions with optional filtering.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public Task<IActionResult> List(
|
||||
[FromQuery] string? clientId,
|
||||
[FromQuery] string? userId,
|
||||
[FromQuery] bool activeOnly = true,
|
||||
[FromQuery] int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
=> CallProc("spAdminSessions", "list", new { clientId, userId, activeOnly, limit }, ct);
|
||||
[HttpPost("list")]
|
||||
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc("spAdminSessions", "list", body.ToString(), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a session.
|
||||
@@ -57,9 +53,10 @@ public sealed class AdminSessionsController : AdminControllerBase
|
||||
/// Cleanup expired sessions.
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
public Task<IActionResult> Cleanup([FromQuery] int daysOld = 30, CancellationToken ct = default)
|
||||
public Task<IActionResult> Cleanup([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
var daysOld = body.TryGetProperty("daysOld", out var d) ? d.GetInt32() : 30;
|
||||
Logger.LogWarning("[Admin] CleanupSessions | DaysOld={DaysOld} | By={User}", daysOld, Client.Email);
|
||||
return CallProc("spAdminSessions", "cleanup", new { daysOld }, ct);
|
||||
return CallProc("spAdminSessions", "cleanup", body.ToString(), ct);
|
||||
}
|
||||
}
|
||||
|
||||
220
Management/Controllers/Admin/AdminTemplateConfigController.cs
Normal file
220
Management/Controllers/Admin/AdminTemplateConfigController.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for template configuration metadata —
|
||||
/// business categories and objective display properties.
|
||||
///
|
||||
/// Both are managed through a single stored procedure
|
||||
/// (spAdminTemplateConfig) with action-based routing.
|
||||
///
|
||||
/// CHANNEL ENDPOINTS:
|
||||
/// GET /api/admin/template-config/channels - List channels (with mapping/template counts)
|
||||
/// GET /api/admin/template-config/channels/{id} - Get channel
|
||||
/// POST /api/admin/template-config/channels - Create channel
|
||||
/// PUT /api/admin/template-config/channels/{id} - Update channel (code rename cascades to mappings + templates)
|
||||
/// DELETE /api/admin/template-config/channels/{id} - Delete channel (blocked if references exist)
|
||||
///
|
||||
/// CATEGORY ENDPOINTS:
|
||||
/// GET /api/admin/template-config/categories - List categories (with template counts)
|
||||
/// GET /api/admin/template-config/categories/{id} - Get category
|
||||
/// POST /api/admin/template-config/categories - Create category
|
||||
/// PUT /api/admin/template-config/categories/{id} - Update category (rename cascades to templates)
|
||||
/// DELETE /api/admin/template-config/categories/{id} - Delete category (blocked if templates exist)
|
||||
///
|
||||
/// OBJECTIVE ENDPOINTS:
|
||||
/// GET /api/admin/template-config/objectives - List objectives (with template counts + mapping status)
|
||||
/// GET /api/admin/template-config/objectives/{id} - Get objective
|
||||
/// PUT /api/admin/template-config/objectives/{id} - Update objective display properties (color, sort)
|
||||
///
|
||||
/// NOTE: Objectives are a controlled set — they cannot be created or deleted
|
||||
/// because they must stay in sync with tbObjectiveMapping and spInitiative
|
||||
/// validation. Only display properties (color, sortOrder, isActive) are editable.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/template-config")]
|
||||
public sealed class AdminTemplateConfigController : AdminControllerBase
|
||||
{
|
||||
public AdminTemplateConfigController(SqlService sql, ClientContext client, ILogger<AdminTemplateConfigController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// CATEGORIES
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[HttpGet("categories")]
|
||||
public Task<IActionResult> ListCategories(CancellationToken ct = default)
|
||||
=> CallProc("spAdminTemplateConfig", "categories.list", new { }, ct);
|
||||
|
||||
[HttpGet("categories/{categoryId:int}")]
|
||||
public Task<IActionResult> GetCategory(int categoryId, CancellationToken ct)
|
||||
=> CallProc("spAdminTemplateConfig", "categories.get", new { categoryId }, ct);
|
||||
|
||||
[HttpPost("categories")]
|
||||
public Task<IActionResult> CreateCategory([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Name))
|
||||
return Task.FromResult(ValidationError("name is required"));
|
||||
|
||||
Logger.LogWarning("[Admin] CreateCategory | Name={Name} | By={User}",
|
||||
request.Name, Client.Email);
|
||||
|
||||
return CallProc("spAdminTemplateConfig", "categories.create", new
|
||||
{
|
||||
name = request.Name.Trim(),
|
||||
icon = request.Icon?.Trim(),
|
||||
sortOrder = request.SortOrder
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpPut("categories/{categoryId:int}")]
|
||||
public Task<IActionResult> UpdateCategory(int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UpdateCategory | Id={Id} | By={User}", categoryId, Client.Email);
|
||||
|
||||
return CallProc("spAdminTemplateConfig", "categories.update", new
|
||||
{
|
||||
categoryId,
|
||||
name = request?.Name?.Trim(),
|
||||
icon = request?.Icon?.Trim(),
|
||||
sortOrder = request?.SortOrder,
|
||||
isActive = request?.IsActive
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpDelete("categories/{categoryId:int}")]
|
||||
public Task<IActionResult> DeleteCategory(int categoryId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] DeleteCategory | Id={Id} | By={User}", categoryId, Client.Email);
|
||||
return CallProc("spAdminTemplateConfig", "categories.delete", new { categoryId }, ct);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// OBJECTIVES
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[HttpGet("objectives")]
|
||||
public Task<IActionResult> ListObjectives(CancellationToken ct = default)
|
||||
=> CallProc("spAdminTemplateConfig", "objectives.list", new { }, ct);
|
||||
|
||||
[HttpGet("objectives/{objectiveId:int}")]
|
||||
public Task<IActionResult> GetObjective(int objectiveId, CancellationToken ct)
|
||||
=> CallProc("spAdminTemplateConfig", "objectives.get", new { objectiveId }, ct);
|
||||
|
||||
[HttpPut("objectives/{objectiveId:int}")]
|
||||
public Task<IActionResult> UpdateObjective(int objectiveId, [FromBody] UpdateObjectiveRequest request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UpdateObjective | Id={Id} | By={User}", objectiveId, Client.Email);
|
||||
|
||||
return CallProc("spAdminTemplateConfig", "objectives.update", new
|
||||
{
|
||||
objectiveId,
|
||||
color = request?.Color?.Trim(),
|
||||
sortOrder = request?.SortOrder,
|
||||
isActive = request?.IsActive
|
||||
}, ct);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// CHANNELS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[HttpGet("channels")]
|
||||
public Task<IActionResult> ListChannels(CancellationToken ct = default)
|
||||
=> CallProc("spAdminTemplateConfig", "channels.list", new { }, ct);
|
||||
|
||||
[HttpGet("channels/{channelId:int}")]
|
||||
public Task<IActionResult> GetChannel(int channelId, CancellationToken ct)
|
||||
=> CallProc("spAdminTemplateConfig", "channels.get", new { channelId }, ct);
|
||||
|
||||
[HttpPost("channels")]
|
||||
public Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Code))
|
||||
return Task.FromResult(ValidationError("code is required"));
|
||||
if (string.IsNullOrWhiteSpace(request?.Label))
|
||||
return Task.FromResult(ValidationError("label is required"));
|
||||
|
||||
Logger.LogWarning("[Admin] CreateChannel | Code={Code} | By={User}",
|
||||
request.Code, Client.Email);
|
||||
|
||||
return CallProc("spAdminTemplateConfig", "channels.create", new
|
||||
{
|
||||
code = request.Code.Trim().ToLowerInvariant(),
|
||||
label = request.Label.Trim(),
|
||||
color = request.Color?.Trim(),
|
||||
icon = request.Icon?.Trim(),
|
||||
sortOrder = request.SortOrder
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpPut("channels/{channelId:int}")]
|
||||
public Task<IActionResult> UpdateChannel(int channelId, [FromBody] UpdateChannelRequest request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UpdateChannel | Id={Id} | By={User}", channelId, Client.Email);
|
||||
|
||||
return CallProc("spAdminTemplateConfig", "channels.update", new
|
||||
{
|
||||
channelId,
|
||||
code = request?.Code?.Trim()?.ToLowerInvariant(),
|
||||
label = request?.Label?.Trim(),
|
||||
color = request?.Color?.Trim(),
|
||||
icon = request?.Icon?.Trim(),
|
||||
sortOrder = request?.SortOrder,
|
||||
isActive = request?.IsActive
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpDelete("channels/{channelId:int}")]
|
||||
public Task<IActionResult> DeleteChannel(int channelId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] DeleteChannel | Id={Id} | By={User}", channelId, Client.Email);
|
||||
return CallProc("spAdminTemplateConfig", "channels.delete", new { channelId }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DTOs ───────────────────────────────────────────────────
|
||||
|
||||
public sealed class CreateCategoryRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Icon { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateCategoryRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Icon { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateObjectiveRequest
|
||||
{
|
||||
public string? Color { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateChannelRequest
|
||||
{
|
||||
public string? Code { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public string? Icon { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateChannelRequest
|
||||
{
|
||||
public string? Code { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public string? Icon { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
115
Management/Controllers/Admin/AdminTemplatesController.cs
Normal file
115
Management/Controllers/Admin/AdminTemplatesController.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for allocation template management.
|
||||
/// Table: dbo.tbAllocationTemplate
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/templates - List templates (filterable)
|
||||
/// GET /api/admin/templates/{id} - Get template
|
||||
/// POST /api/admin/templates - Create template
|
||||
/// PUT /api/admin/templates/{id} - Update template
|
||||
/// DELETE /api/admin/templates/{id} - Delete template
|
||||
/// GET /api/admin/templates/categories - Distinct business categories
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/templates")]
|
||||
public sealed class AdminTemplatesController : AdminControllerBase
|
||||
{
|
||||
public AdminTemplatesController(SqlService sql, ClientContext client, ILogger<AdminTemplatesController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
[HttpPost("list")]
|
||||
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc("spAdminTemplates", "list", body.ToString(), ct);
|
||||
|
||||
[HttpGet("{templateId:int}")]
|
||||
public Task<IActionResult> Get(int templateId, CancellationToken ct)
|
||||
=> CallProc("spAdminTemplates", "get", new { templateId }, ct);
|
||||
|
||||
[HttpPost]
|
||||
public Task<IActionResult> Create([FromBody] CreateTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.ChannelType))
|
||||
return Task.FromResult(ValidationError("channelType is required"));
|
||||
if (string.IsNullOrWhiteSpace(request?.BusinessCategory))
|
||||
return Task.FromResult(ValidationError("businessCategory is required"));
|
||||
if (string.IsNullOrWhiteSpace(request?.Objective))
|
||||
return Task.FromResult(ValidationError("objective is required"));
|
||||
if (request.RecommendedPct is null or < 0 or > 100)
|
||||
return Task.FromResult(ValidationError("recommendedPct must be between 0 and 100"));
|
||||
|
||||
Logger.LogWarning("[Admin] CreateTemplate | {Channel}/{Category}/{Objective} = {Pct}% | By={User}",
|
||||
request.ChannelType, request.BusinessCategory, request.Objective, request.RecommendedPct, Client.Email);
|
||||
|
||||
return CallProc("spAdminTemplates", "create", new
|
||||
{
|
||||
channelType = request.ChannelType.Trim(),
|
||||
businessCategory = request.BusinessCategory.Trim(),
|
||||
objective = request.Objective.Trim(),
|
||||
recommendedPct = request.RecommendedPct,
|
||||
minBudgetRequired = request.MinBudgetRequired ?? 0m,
|
||||
rationale = request.Rationale?.Trim()
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpPut("{templateId:int}")]
|
||||
public Task<IActionResult> Update(int templateId, [FromBody] UpdateTemplateRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request?.RecommendedPct is < 0 or > 100)
|
||||
return Task.FromResult(ValidationError("recommendedPct must be between 0 and 100"));
|
||||
|
||||
Logger.LogWarning("[Admin] UpdateTemplate | Id={Id} | By={User}", templateId, Client.Email);
|
||||
|
||||
return CallProc("spAdminTemplates", "update", new
|
||||
{
|
||||
templateId,
|
||||
channelType = request?.ChannelType?.Trim(),
|
||||
businessCategory = request?.BusinessCategory?.Trim(),
|
||||
objective = request?.Objective?.Trim(),
|
||||
recommendedPct = request?.RecommendedPct,
|
||||
minBudgetRequired = request?.MinBudgetRequired,
|
||||
rationale = request?.Rationale?.Trim(),
|
||||
isActive = request?.IsActive
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpDelete("{templateId:int}")]
|
||||
public Task<IActionResult> Delete(int templateId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] DeleteTemplate | Id={Id} | By={User}", templateId, Client.Email);
|
||||
return CallProc("spAdminTemplates", "delete", new { templateId }, ct);
|
||||
}
|
||||
|
||||
[HttpGet("categories")]
|
||||
public Task<IActionResult> Categories(CancellationToken ct)
|
||||
=> CallProc("spAdminTemplates", "categories", new { }, ct);
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
||||
public sealed class CreateTemplateRequest
|
||||
{
|
||||
public string? ChannelType { get; set; }
|
||||
public string? BusinessCategory { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public decimal? RecommendedPct { get; set; }
|
||||
public decimal? MinBudgetRequired { get; set; }
|
||||
public string? Rationale { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateRequest
|
||||
{
|
||||
public string? ChannelType { get; set; }
|
||||
public string? BusinessCategory { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public decimal? RecommendedPct { get; set; }
|
||||
public decimal? MinBudgetRequired { get; set; }
|
||||
public string? Rationale { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for user management.
|
||||
/// Requires Admin role.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/users - List users
|
||||
/// GET /api/admin/users/{id} - Get user
|
||||
/// POST /api/admin/users - Create user
|
||||
/// PUT /api/admin/users/{id} - Update user
|
||||
/// DELETE /api/admin/users/{id} - Deactivate user
|
||||
/// POST /api/admin/users/{id}/clients - Link user to client
|
||||
/// DELETE /api/admin/users/{id}/clients/{cltId} - Unlink user from client
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/users")]
|
||||
public sealed class AdminUsersController : AdminControllerBase
|
||||
{
|
||||
public AdminUsersController(SqlService sql, ClientContext client, ILogger<AdminUsersController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
/// <summary>
|
||||
/// List users with optional filtering.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? clientId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken ct = default)
|
||||
=> CallProc("spAdminUsers", "list", new { status, clientId, page, pageSize }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get user by ID.
|
||||
/// </summary>
|
||||
[HttpGet("{userId}")]
|
||||
public Task<IActionResult> Get(string userId, CancellationToken ct)
|
||||
=> CallProc("spAdminUsers", "get", new { userId }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new user.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public Task<IActionResult> Create([FromBody] CreateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Email))
|
||||
return Task.FromResult(ValidationError("email is required"));
|
||||
|
||||
Logger.LogWarning("[Admin] CreateUser | Email={Email} | By={User}", request.Email, Client.Email);
|
||||
return CallProc("spAdminUsers", "create", new
|
||||
{
|
||||
email = request.Email.Trim(),
|
||||
displayName = request.DisplayName?.Trim(),
|
||||
clientId = request.ClientId,
|
||||
role = request.Role ?? "User"
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update user.
|
||||
/// </summary>
|
||||
[HttpPut("{userId}")]
|
||||
public Task<IActionResult> Update(string userId, [FromBody] UpdateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UpdateUser | Id={Id} | By={User}", userId, Client.Email);
|
||||
return CallProc("spAdminUsers", "update", new
|
||||
{
|
||||
userId,
|
||||
displayName = request?.DisplayName?.Trim(),
|
||||
status = request?.Status
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivate user (soft delete).
|
||||
/// </summary>
|
||||
[HttpDelete("{userId}")]
|
||||
public Task<IActionResult> Delete(string userId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] DeleteUser | Id={Id} | By={User}", userId, Client.Email);
|
||||
return CallProc("spAdminUsers", "delete", new { userId }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link user to client with role.
|
||||
/// </summary>
|
||||
[HttpPost("{userId}/clients")]
|
||||
public Task<IActionResult> LinkToClient(string userId, [FromBody] LinkUserRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.ClientId))
|
||||
return Task.FromResult(ValidationError("clientId is required"));
|
||||
|
||||
Logger.LogWarning("[Admin] LinkUser | UserId={UserId} ClientId={ClientId} | By={User}",
|
||||
userId, request.ClientId, Client.Email);
|
||||
return CallProc("spAdminUsers", "linkToClient", new
|
||||
{
|
||||
userId,
|
||||
clientId = request.ClientId,
|
||||
role = request.Role ?? "User"
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlink user from client.
|
||||
/// </summary>
|
||||
[HttpDelete("{userId}/clients/{clientId}")]
|
||||
public Task<IActionResult> UnlinkFromClient(string userId, string clientId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UnlinkUser | UserId={UserId} ClientId={ClientId} | By={User}",
|
||||
userId, clientId, Client.Email);
|
||||
return CallProc("spAdminUsers", "unlinkFromClient", new { userId, clientId }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed class CreateUserRequest
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUserRequest
|
||||
{
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LinkUserRequest
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
177
Management/Controllers/Admin/DocumentController.cs
Normal file
177
Management/Controllers/Admin/DocumentController.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Internal document management — scope='internal' only.
|
||||
/// Client-scoped documents are managed through the Gateway.
|
||||
///
|
||||
/// POST /api/documents/list - List internal documents
|
||||
/// POST /api/documents - Upload internal document
|
||||
/// GET /api/documents/{id}/download
|
||||
/// DELETE /api/documents/{id}
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/documents")]
|
||||
public sealed class DocumentController : AdminControllerBase
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public DocumentController(SqlService sql, ClientContext client, IConfiguration config, ILogger<DocumentController> log)
|
||||
: base(sql, client, log)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
// ── POST /api/documents/list ─────────────────────────────────────────────
|
||||
[HttpPost("list")]
|
||||
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Always internal scope for Management API
|
||||
var rqst = JsonSerializer.Serialize(new { scope = "internal" });
|
||||
var result = await Sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Document list failed");
|
||||
return StatusCode(500, new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/documents ──────────────────────────────────────────────────
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(52_428_800)]
|
||||
public async Task<IActionResult> Upload(
|
||||
IFormFile file,
|
||||
[FromForm] string category,
|
||||
[FromForm] string? description = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { ok = false, message = "No file provided" });
|
||||
|
||||
try
|
||||
{
|
||||
byte[] fileBytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
await file.CopyToAsync(ms, ct);
|
||||
fileBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
docFileName = file.FileName,
|
||||
docMimeType = file.ContentType,
|
||||
docFileSize = file.Length,
|
||||
docCategory = category,
|
||||
docDescription = description,
|
||||
docUploadedBy = Client.Email,
|
||||
docScope = "internal", // Management API = internal only
|
||||
docCltId = (string?)null
|
||||
});
|
||||
|
||||
var result = await ExecUploadAsync(rqst, fileBytes, ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Document upload failed: {FileName}", file?.FileName);
|
||||
return StatusCode(500, new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/documents/{id}/download ─────────────────────────────────────
|
||||
[HttpGet("{id:long}/download")]
|
||||
public async Task<IActionResult> Download(long id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql")
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
|
||||
await using var conn = new SqlConnection(cs);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = 60
|
||||
};
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = JsonSerializer.Serialize(new { docId = id }) });
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return NotFound(new { ok = false, message = "Document not found" });
|
||||
|
||||
var fileName = reader.GetString(reader.GetOrdinal("docFileName"));
|
||||
var mimeType = reader.GetString(reader.GetOrdinal("docMimeType"));
|
||||
var content = (byte[])reader["docContent"];
|
||||
|
||||
return File(content, mimeType, fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Document download failed: docId={DocId}", id);
|
||||
return StatusCode(500, new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /api/documents/{id} ───────────────────────────────────────────
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<IActionResult> Delete(long id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogInformation("[Documents] Delete docId={DocId} by {User}", id, Client.Email);
|
||||
var rqst = JsonSerializer.Serialize(new { docId = id });
|
||||
var result = await Sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Document delete failed: docId={DocId}", id);
|
||||
return StatusCode(500, new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Upload helper: binary @filecontent passed separately ────────────────
|
||||
private async Task<string> ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct)
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql")
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
|
||||
await using var conn = new SqlConnection(cs);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = 60
|
||||
};
|
||||
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst });
|
||||
cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent });
|
||||
|
||||
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
cmd.Parameters.Add(pResp);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
return pResp.Value as string
|
||||
?? JsonSerializer.Serialize(new { ok = false, message = "No response from database" });
|
||||
}
|
||||
}
|
||||
67
Management/Controllers/AdminReportingController.cs
Normal file
67
Management/Controllers/AdminReportingController.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for campaign performance reporting and intelligence.
|
||||
/// Provides normalized metrics across all providers (Google, Meta, TikTok).
|
||||
/// Requires Admin role.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// POST /api/admin/reporting/summary - KPI summary across all campaigns
|
||||
/// POST /api/admin/reporting/campaigns - Per-campaign performance metrics
|
||||
/// GET /api/admin/reporting/campaigns/{id} - Detailed metrics for one initiative
|
||||
/// POST /api/admin/reporting/insights - Optimization recommendations
|
||||
/// POST /api/admin/reporting/analysis - Post-campaign analysis data
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/reporting")]
|
||||
public sealed class AdminReportingController : AdminControllerBase
|
||||
{
|
||||
public AdminReportingController(SqlService sql, ClientContext client, ILogger<AdminReportingController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
/// <summary>
|
||||
/// KPI summary: totals for spend, impressions, clicks, conversions, CTR, CPC, ROAS.
|
||||
/// Body: { dateFrom?, dateTo?, clientId? }
|
||||
/// </summary>
|
||||
[HttpPost("summary")]
|
||||
public Task<IActionResult> Summary([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc("spAdminReporting", "summary", body.ToString(), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Per-campaign performance list with channel breakdowns.
|
||||
/// Body: { status?, clientId?, dateFrom?, dateTo?, sortBy?, sortDir?, page?, pageSize? }
|
||||
/// </summary>
|
||||
[HttpPost("campaigns")]
|
||||
public Task<IActionResult> Campaigns([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc("spAdminReporting", "campaigns", body.ToString(), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed metrics for a single initiative with daily time-series
|
||||
/// and per-channel breakdowns.
|
||||
/// </summary>
|
||||
[HttpGet("campaigns/{initiativeId:long}")]
|
||||
public Task<IActionResult> CampaignDetail(long initiativeId, CancellationToken ct)
|
||||
=> CallProc("spAdminReporting", "detail", new { initiativeId }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Optimization insights and recommendations.
|
||||
/// Body: { severity?, clientId? }
|
||||
/// </summary>
|
||||
[HttpPost("insights")]
|
||||
public Task<IActionResult> Insights([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc("spAdminReporting", "insights", body.ToString(), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Post-campaign analysis: completed campaigns with ROI, cost-efficiency,
|
||||
/// and channel-level performance comparisons.
|
||||
/// Body: { clientId?, dateFrom?, dateTo? }
|
||||
/// </summary>
|
||||
[HttpPost("analysis")]
|
||||
public Task<IActionResult> Analysis([FromBody] JsonElement body, CancellationToken ct)
|
||||
=> CallProc("spAdminReporting", "analysis", body.ToString(), ct);
|
||||
}
|
||||
67
Management/Controllers/HelpController.cs
Normal file
67
Management/Controllers/HelpController.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Management.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Public help content endpoint — anonymous, no auth required.
|
||||
/// Returns 200 with default message when key not found, so client
|
||||
/// never needs to handle a 404.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/help")]
|
||||
public class HelpController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ILogger<HelpController> _logger;
|
||||
|
||||
public HelpController(SqlService sql, ILogger<HelpController> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/help/{key}
|
||||
/// Returns active help content for the given key, or a friendly
|
||||
/// default if no content has been authored yet.
|
||||
/// </summary>
|
||||
[HttpGet("{key}")]
|
||||
public async Task<IActionResult> GetHelp(string key, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { helpKey = key });
|
||||
var resp = await _sql.ExecProcAsync("dbo.spHelp", "get", rqst, ct: ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var ok) && ok.GetBoolean())
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
|
||||
// Key not found — return 200 with friendly default so clients
|
||||
// don't need special 404 handling
|
||||
return Ok(new
|
||||
{
|
||||
ok = true,
|
||||
title = "Help",
|
||||
body = "<p>No information available for this topic yet.</p>"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving help content for key: {Key}", key);
|
||||
return Ok(new
|
||||
{
|
||||
ok = true,
|
||||
title = "Help",
|
||||
body = "<p>No information available for this topic yet.</p>"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,13 @@ public sealed class MonitoringController : ControllerBase
|
||||
private readonly ClientContext _client;
|
||||
private readonly ILogger<MonitoringController> _log;
|
||||
|
||||
public MonitoringController(SqlService sql, ClientContext client, ILogger<MonitoringController> log)
|
||||
private readonly Management.Services.GraphService _graph;
|
||||
|
||||
public MonitoringController(SqlService sql, ClientContext client, Management.Services.GraphService graph, ILogger<MonitoringController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_graph = graph;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
@@ -53,10 +56,10 @@ public sealed class MonitoringController : ControllerBase
|
||||
/// <summary>
|
||||
/// Detailed system statistics.
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> Stats([FromQuery] int hours = 24, CancellationToken ct = default)
|
||||
[HttpPost("stats")]
|
||||
public async Task<IActionResult> Stats([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { hours });
|
||||
var rqst = body.ToString();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -73,4 +76,47 @@ public sealed class MonitoringController : ControllerBase
|
||||
return StatusCode(500, new { ok = false, error = "Stats failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staff user list — distinct staff who have ever performed an action,
|
||||
/// derived directly from tbAdminActivity.
|
||||
/// </summary>
|
||||
[HttpGet("staff")]
|
||||
public async Task<IActionResult> Staff(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await _sql.ExecProcAsync("dbo.spAdminActivity", "distinct-staff", "{}", ct: ct);
|
||||
return Content(json, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Monitoring] Staff list error");
|
||||
return StatusCode(500, new { ok = false, error = "Staff list failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admin activity log — all mutating requests by staff members.
|
||||
/// Accessible to Staff.Admin and Staff.Tech.
|
||||
/// Body: { oid?, dateFrom?, dateTo?, page?, pageSize? }
|
||||
/// </summary>
|
||||
[HttpPost("activity")]
|
||||
public async Task<IActionResult> Activity([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spAdminActivity", "list", body.ToString(), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Monitoring] Activity log error");
|
||||
return StatusCode(500, new { ok = false, error = "Activity log failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class WeatherForecastController : ControllerBase
|
||||
{
|
||||
private static readonly string[] Summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
private readonly ILogger<WeatherForecastController> _logger;
|
||||
|
||||
public WeatherForecastController(ILogger<WeatherForecastController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet(Name = "GetWeatherForecast")]
|
||||
public IEnumerable<WeatherForecast> Get()
|
||||
{
|
||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
@@ -9,11 +9,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" Version="1.18.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.4.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Microsoft.Graph" Version="5.103.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.16.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Management.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -19,6 +20,12 @@ builder.Services.AddScoped<SqlService>();
|
||||
builder.Services.AddScoped<ClientContext>();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Registration Function client (typed HttpClient)
|
||||
builder.Services.AddHttpClient<RegistrationClient>();
|
||||
|
||||
// Graph API service — app-only credentials for org tenant user listing
|
||||
builder.Services.AddSingleton<GraphService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Middleware pipeline
|
||||
@@ -47,7 +54,10 @@ app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
clients = new[] { "GET/POST /api/admin/clients", "GET/PUT/DELETE /api/admin/clients/{id}" },
|
||||
users = new[] { "GET/POST /api/admin/users", "GET/PUT/DELETE /api/admin/users/{id}" },
|
||||
sessions = new[] { "GET /api/admin/sessions", "POST /api/admin/sessions/{id}/revoke" }
|
||||
sessions = new[] { "GET /api/admin/sessions", "POST /api/admin/sessions/{id}/revoke" },
|
||||
templates = new[] { "GET/POST /api/admin/templates", "GET/PUT/DELETE /api/admin/templates/{id}", "GET /api/admin/templates/categories" },
|
||||
objectives = new[] { "GET/POST /api/admin/objectives", "GET/PUT/DELETE /api/admin/objectives/{id}" },
|
||||
reporting = new[] { "GET /api/admin/reporting/summary", "GET /api/admin/reporting/campaigns", "GET /api/admin/reporting/campaigns/{id}", "GET /api/admin/reporting/insights", "GET /api/admin/reporting/analysis" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -55,6 +65,9 @@ app.MapGet("/", () => Results.Ok(new
|
||||
// Authentication middleware
|
||||
app.UseMiddleware<ClientAuthMiddleware>();
|
||||
|
||||
// Activity logging — fires after auth so ClientContext is populated
|
||||
app.UseMiddleware<ActivityLoggingMiddleware>();
|
||||
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
-- ============================================================
|
||||
-- spAdminClients: Client (organization) management
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spAdminClients]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: create
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'create'
|
||||
BEGIN
|
||||
DECLARE @cName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
|
||||
|
||||
IF @cName IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientName is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltName = @cName)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client name already exists"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
DECLARE @cId UNIQUEIDENTIFIER = NEWID();
|
||||
INSERT INTO dbo.tbClient (cltId, cltName, cltStatus)
|
||||
VALUES (@cId, @cName, 'Active');
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
@cId AS clientId,
|
||||
@cName AS clientName,
|
||||
'Active' AS status
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: get
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'get'
|
||||
BEGIN
|
||||
DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
|
||||
IF @gId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @gId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
c.cltId AS clientId,
|
||||
c.cltName AS clientName,
|
||||
c.cltStatus AS status,
|
||||
c.cltCreatedUtc AS createdAt,
|
||||
(SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount,
|
||||
(SELECT COUNT(*) FROM dbo.tbAdAccount WHERE accCltId = c.cltId) AS accountCount
|
||||
FROM dbo.tbClient c WHERE c.cltId = @gId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: list
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'list'
|
||||
BEGIN
|
||||
DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
|
||||
DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1);
|
||||
DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50);
|
||||
|
||||
DECLARE @clients NVARCHAR(MAX);
|
||||
SELECT @clients = (
|
||||
SELECT
|
||||
c.cltId AS clientId,
|
||||
c.cltName AS clientName,
|
||||
c.cltStatus AS status,
|
||||
c.cltCreatedUtc AS createdAt,
|
||||
(SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount
|
||||
FROM dbo.tbClient c
|
||||
WHERE @lStatus IS NULL OR c.cltStatus = @lStatus
|
||||
ORDER BY c.cltName
|
||||
OFFSET (@lPage - 1) * @lPageSize ROWS
|
||||
FETCH NEXT @lPageSize ROWS ONLY
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
DECLARE @lTotal INT;
|
||||
SELECT @lTotal = COUNT(*) FROM dbo.tbClient WHERE @lStatus IS NULL OR cltStatus = @lStatus;
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
JSON_QUERY(ISNULL(@clients, '[]')) AS clients,
|
||||
@lTotal AS totalCount,
|
||||
@lPage AS page,
|
||||
@lPageSize AS pageSize
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: update
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'update'
|
||||
BEGIN
|
||||
DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @uName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
|
||||
DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
|
||||
|
||||
IF @uId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @uId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbClient
|
||||
SET cltName = ISNULL(@uName, cltName),
|
||||
cltStatus = ISNULL(@uStatus, cltStatus)
|
||||
WHERE cltId = @uId;
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
cltId AS clientId,
|
||||
cltName AS clientName,
|
||||
cltStatus AS status
|
||||
FROM dbo.tbClient WHERE cltId = @uId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: delete (soft delete)
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'delete'
|
||||
BEGIN
|
||||
DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
|
||||
IF @dId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbClient SET cltStatus = 'Inactive' WHERE cltId = @dId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
@@ -1,111 +0,0 @@
|
||||
-- ============================================================
|
||||
-- spAdminSessions: Session management
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spAdminSessions]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: list
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'list'
|
||||
BEGIN
|
||||
DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @lUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
DECLARE @lActiveOnly BIT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.activeOnly') AS BIT), 1);
|
||||
DECLARE @lLimit INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.limit') AS INT), 100);
|
||||
|
||||
DECLARE @sessions NVARCHAR(MAX);
|
||||
SELECT @sessions = (
|
||||
SELECT TOP (@lLimit)
|
||||
s.sesId AS sessionId,
|
||||
u.usrId AS userId,
|
||||
u.usrEmail AS userEmail,
|
||||
u.usrDisplayName AS displayName,
|
||||
c.cltId AS clientId,
|
||||
c.cltName AS clientName,
|
||||
s.sesCreatedUtc AS createdAt,
|
||||
s.sesExpiresUtc AS expiresAt,
|
||||
s.sesLastActivityUtc AS lastActivity,
|
||||
s.sesIpAddress AS ipAddress,
|
||||
s.sesIsRevoked AS isRevoked
|
||||
FROM dbo.tbSession s
|
||||
JOIN dbo.tbUser u ON u.usrId = s.sesUsrId
|
||||
JOIN dbo.tbClient c ON c.cltId = s.sesCltId
|
||||
WHERE (@lClientId IS NULL OR c.cltId = @lClientId)
|
||||
AND (@lUserId IS NULL OR u.usrId = @lUserId)
|
||||
AND (@lActiveOnly = 0 OR (s.sesIsRevoked = 0 AND s.sesExpiresUtc > SYSUTCDATETIME()))
|
||||
ORDER BY s.sesLastActivityUtc DESC
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
JSON_QUERY(ISNULL(@sessions, '[]')) AS sessions
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: revoke
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'revoke'
|
||||
BEGIN
|
||||
DECLARE @rSessionId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.sessionId'));
|
||||
|
||||
IF @rSessionId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"sessionId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesId = @rSessionId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: revokeAllForUser
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'revokeAllForUser'
|
||||
BEGIN
|
||||
DECLARE @raUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
|
||||
IF @raUserId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesUsrId = @raUserId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: cleanup
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'cleanup'
|
||||
BEGIN
|
||||
DECLARE @daysOld INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.daysOld') AS INT), 30);
|
||||
|
||||
DELETE FROM dbo.tbSession
|
||||
WHERE sesExpiresUtc < DATEADD(DAY, -@daysOld, SYSUTCDATETIME());
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsDeleted FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
@@ -1,288 +0,0 @@
|
||||
-- ============================================================
|
||||
-- spAdminUsers: User management
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spAdminUsers]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: create
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'create'
|
||||
BEGIN
|
||||
DECLARE @cEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
|
||||
DECLARE @cDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
|
||||
DECLARE @cClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @cRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User');
|
||||
|
||||
IF @cEmail IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"email is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrEmail = @cEmail)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User with this email already exists"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF @cClientId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @cClientId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
DECLARE @cUserId UNIQUEIDENTIFIER = NEWID();
|
||||
DECLARE @cEntraSub NVARCHAR(100) = 'pending-' + CAST(@cUserId AS NVARCHAR(50));
|
||||
|
||||
INSERT INTO dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus)
|
||||
VALUES (@cUserId, @cEntraSub, 'Pending', @cEntraSub, @cEmail, @cDisplayName, 'Active');
|
||||
|
||||
IF @cClientId IS NOT NULL
|
||||
BEGIN
|
||||
INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
|
||||
VALUES (@cUserId, @cClientId, @cRole);
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
@cUserId AS userId,
|
||||
@cEmail AS email,
|
||||
@cDisplayName AS displayName,
|
||||
@cClientId AS clientId,
|
||||
@cRole AS [role]
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: get
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'get'
|
||||
BEGIN
|
||||
DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
|
||||
IF @gId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @gId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
u.usrId AS userId,
|
||||
u.usrEmail AS email,
|
||||
u.usrDisplayName AS displayName,
|
||||
u.usrStatus AS status,
|
||||
u.usrCreatedUtc AS createdAt,
|
||||
(
|
||||
SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role]
|
||||
FROM dbo.tbUserClientRole r
|
||||
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId
|
||||
WHERE r.ucrUsrId = u.usrId
|
||||
FOR JSON PATH
|
||||
) AS clients
|
||||
FROM dbo.tbUser u WHERE u.usrId = @gId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: list
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'list'
|
||||
BEGIN
|
||||
DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
|
||||
DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1);
|
||||
DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50);
|
||||
|
||||
DECLARE @users NVARCHAR(MAX);
|
||||
SELECT @users = (
|
||||
SELECT
|
||||
u.usrId AS userId,
|
||||
u.usrEmail AS email,
|
||||
u.usrDisplayName AS displayName,
|
||||
u.usrStatus AS status,
|
||||
u.usrCreatedUtc AS createdAt,
|
||||
(
|
||||
SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role]
|
||||
FROM dbo.tbUserClientRole r
|
||||
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId
|
||||
WHERE r.ucrUsrId = u.usrId
|
||||
FOR JSON PATH
|
||||
) AS clients
|
||||
FROM dbo.tbUser u
|
||||
WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus)
|
||||
AND (@lClientId IS NULL OR EXISTS (
|
||||
SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId
|
||||
))
|
||||
ORDER BY u.usrEmail
|
||||
OFFSET (@lPage - 1) * @lPageSize ROWS
|
||||
FETCH NEXT @lPageSize ROWS ONLY
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
DECLARE @lTotal INT;
|
||||
SELECT @lTotal = COUNT(*)
|
||||
FROM dbo.tbUser u
|
||||
WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus)
|
||||
AND (@lClientId IS NULL OR EXISTS (
|
||||
SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId
|
||||
));
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
JSON_QUERY(ISNULL(@users, '[]')) AS users,
|
||||
@lTotal AS totalCount,
|
||||
@lPage AS page,
|
||||
@lPageSize AS pageSize
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: update
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'update'
|
||||
BEGIN
|
||||
DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
DECLARE @uDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
|
||||
DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
|
||||
|
||||
IF @uId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @uId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbUser
|
||||
SET usrDisplayName = ISNULL(@uDisplayName, usrDisplayName),
|
||||
usrStatus = ISNULL(@uStatus, usrStatus)
|
||||
WHERE usrId = @uId;
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
usrId AS userId,
|
||||
usrEmail AS email,
|
||||
usrDisplayName AS displayName,
|
||||
usrStatus AS status
|
||||
FROM dbo.tbUser WHERE usrId = @uId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: delete (soft delete)
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'delete'
|
||||
BEGIN
|
||||
DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
|
||||
IF @dId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbUser SET usrStatus = 'Inactive' WHERE usrId = @dId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: linkToClient
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'linkToClient'
|
||||
BEGIN
|
||||
DECLARE @luUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
DECLARE @luClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @luRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User');
|
||||
|
||||
IF @luUserId IS NULL OR @luClientId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId and clientId are required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @luUserId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @luClientId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId)
|
||||
BEGIN
|
||||
UPDATE dbo.tbUserClientRole
|
||||
SET ucrRole = @luRole
|
||||
WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'updated' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
|
||||
VALUES (@luUserId, @luClientId, @luRole);
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'created' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: unlinkFromClient
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'unlinkFromClient'
|
||||
BEGIN
|
||||
DECLARE @ruUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
DECLARE @ruClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
|
||||
IF @ruUserId IS NULL OR @ruClientId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId and clientId are required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
DELETE FROM dbo.tbUserClientRole
|
||||
WHERE ucrUsrId = @ruUserId AND ucrCltId = @ruClientId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
@@ -1,106 +0,0 @@
|
||||
-- ============================================================
|
||||
-- spMonitoring: System health and statistics
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spMonitoring]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: health
|
||||
-- System health overview
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'health'
|
||||
BEGIN
|
||||
DECLARE @clientCount INT, @userCount INT, @sessionCount INT, @logCount24h INT;
|
||||
|
||||
SELECT @clientCount = COUNT(*) FROM dbo.tbClient WHERE cltStatus = 'Active';
|
||||
SELECT @userCount = COUNT(*) FROM dbo.tbUser WHERE usrStatus = 'Active';
|
||||
SELECT @sessionCount = COUNT(*) FROM dbo.tbSession WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME();
|
||||
|
||||
-- Check if tbAdpApiLog exists (may not be in all installations)
|
||||
IF OBJECT_ID('dbo.tbAdpApiLog', 'U') IS NOT NULL
|
||||
EXEC sp_executesql N'SELECT @cnt = COUNT(*) FROM dbo.tbAdpApiLog WHERE createdUtc > DATEADD(HOUR, -24, SYSUTCDATETIME())',
|
||||
N'@cnt INT OUTPUT', @cnt = @logCount24h OUTPUT;
|
||||
ELSE
|
||||
SET @logCount24h = 0;
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
@clientCount AS activeClients,
|
||||
@userCount AS activeUsers,
|
||||
@sessionCount AS activeSessions,
|
||||
@logCount24h AS apiCalls24h,
|
||||
SYSUTCDATETIME() AS serverTimeUtc
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: stats
|
||||
-- Detailed statistics
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'stats'
|
||||
BEGIN
|
||||
DECLARE @hours INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.hours') AS INT), 24);
|
||||
|
||||
-- Clients by status
|
||||
DECLARE @clientsByStatus NVARCHAR(MAX);
|
||||
SELECT @clientsByStatus = (
|
||||
SELECT cltStatus AS status, COUNT(*) AS [count]
|
||||
FROM dbo.tbClient
|
||||
GROUP BY cltStatus
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
-- Users by status
|
||||
DECLARE @usersByStatus NVARCHAR(MAX);
|
||||
SELECT @usersByStatus = (
|
||||
SELECT usrStatus AS status, COUNT(*) AS [count]
|
||||
FROM dbo.tbUser
|
||||
GROUP BY usrStatus
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
-- Sessions stats
|
||||
DECLARE @activeSessions INT, @expiredSessions INT, @revokedSessions INT;
|
||||
SELECT @activeSessions = COUNT(*) FROM dbo.tbSession
|
||||
WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME();
|
||||
SELECT @expiredSessions = COUNT(*) FROM dbo.tbSession
|
||||
WHERE sesIsRevoked = 0 AND sesExpiresUtc <= SYSUTCDATETIME();
|
||||
SELECT @revokedSessions = COUNT(*) FROM dbo.tbSession
|
||||
WHERE sesIsRevoked = 1;
|
||||
|
||||
-- Recent registrations (last 7 days)
|
||||
DECLARE @recentClients INT, @recentUsers INT;
|
||||
SELECT @recentClients = COUNT(*) FROM dbo.tbClient
|
||||
WHERE cltCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME());
|
||||
SELECT @recentUsers = COUNT(*) FROM dbo.tbUser
|
||||
WHERE usrCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME());
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
JSON_QUERY(ISNULL(@clientsByStatus, '[]')) AS clientsByStatus,
|
||||
JSON_QUERY(ISNULL(@usersByStatus, '[]')) AS usersByStatus,
|
||||
@activeSessions AS activeSessions,
|
||||
@expiredSessions AS expiredSessions,
|
||||
@revokedSessions AS revokedSessions,
|
||||
@recentClients AS newClientsLast7Days,
|
||||
@recentUsers AS newUsersLast7Days,
|
||||
SYSUTCDATETIME() AS serverTimeUtc
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
@@ -1,151 +0,0 @@
|
||||
-- ============================================================
|
||||
-- spOnboarding: User/Client registration
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spOnboarding]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: status
|
||||
-- Check if user is registered and has client access
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'status'
|
||||
BEGIN
|
||||
DECLARE @sSubject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), '');
|
||||
DECLARE @sEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
|
||||
|
||||
DECLARE @sUserId UNIQUEIDENTIFIER;
|
||||
DECLARE @sUserEmail NVARCHAR(256);
|
||||
|
||||
SELECT @sUserId = usrId, @sUserEmail = usrEmail
|
||||
FROM dbo.tbUser
|
||||
WHERE usrEntraSub = @sSubject;
|
||||
|
||||
-- User doesn't exist
|
||||
IF @sUserId IS NULL
|
||||
BEGIN
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
CAST(0 AS BIT) AS isRegistered,
|
||||
@sEmail AS email
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- Check for client access
|
||||
DECLARE @clients NVARCHAR(MAX);
|
||||
SELECT @clients = (
|
||||
SELECT
|
||||
c.cltId AS clientId,
|
||||
c.cltName AS clientName,
|
||||
r.ucrRole AS [role]
|
||||
FROM dbo.tbUserClientRole r
|
||||
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId AND c.cltStatus = 'Active'
|
||||
WHERE r.ucrUsrId = @sUserId
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
IF @clients IS NULL OR @clients = '[]'
|
||||
BEGIN
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
CAST(0 AS BIT) AS isRegistered,
|
||||
@sUserId AS userId,
|
||||
@sUserEmail AS email
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
CAST(1 AS BIT) AS isRegistered,
|
||||
@sUserId AS userId,
|
||||
@sUserEmail AS email,
|
||||
JSON_QUERY(@clients) AS clients
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: register
|
||||
-- Creates client + links user as Admin
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'register'
|
||||
BEGIN
|
||||
DECLARE @provider VARCHAR(30) = NULLIF(JSON_VALUE(@j, '$.provider'), '');
|
||||
DECLARE @subject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), '');
|
||||
DECLARE @email NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
|
||||
DECLARE @displayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
|
||||
DECLARE @clientName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
|
||||
|
||||
-- Validation
|
||||
IF @provider IS NULL OR @subject IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"provider and subject are required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF @clientName IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientName is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- Find or create user
|
||||
DECLARE @userId UNIQUEIDENTIFIER;
|
||||
|
||||
SELECT @userId = usrId
|
||||
FROM dbo.tbUser
|
||||
WHERE usrEntraSub = @subject;
|
||||
|
||||
IF @userId IS NULL
|
||||
BEGIN
|
||||
SET @userId = NEWID();
|
||||
INSERT dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus)
|
||||
VALUES (@userId, @subject, @provider, @subject, @email, @displayName, 'Active');
|
||||
END
|
||||
|
||||
-- Check if user already has client access
|
||||
IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @userId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User is already registered"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- Create client
|
||||
DECLARE @clientId UNIQUEIDENTIFIER = NEWID();
|
||||
INSERT dbo.tbClient (cltId, cltName, cltStatus)
|
||||
VALUES (@clientId, @clientName, 'Active');
|
||||
|
||||
-- Link user as Admin
|
||||
INSERT dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
|
||||
VALUES (@userId, @clientId, 'Admin');
|
||||
|
||||
-- Return success
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
@userId AS userId,
|
||||
@clientId AS clientId,
|
||||
@clientName AS clientName,
|
||||
'Admin' AS [role]
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user