Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -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

View 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";
}

View 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
View 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

Binary file not shown.

View 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
View 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();

View 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"
}
}
}
}

View 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) { }
}

View 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
}

View 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;
}
}

View 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";
}
}
}

View 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
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning"
}
}
}

25
Creative/appsettings.json Normal file
View 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"
}
}

View File

@@ -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;

View 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; }
}

View 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" });
}
}

View 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; }
}

View File

@@ -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");
}
}

View 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" });
}
}
}

View 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; } }

View 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
});
}
}

View 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" });
}
}
}

View 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; }
}

View File

@@ -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";
}
}

View File

@@ -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>

View 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

View 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

View 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

View 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;
}

View 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;
}

View File

@@ -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

View 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 };
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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;
}

View 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;
}
}

View File

@@ -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"] ?? ""
_ => ""
};
}
}

View 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 01 (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";
}
}

View 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"
};
}

View 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; }
}

View 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;
}
}
}

View 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; }
}

View 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);
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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>

View 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;
}

View 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

View File

@@ -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

View File

@@ -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

View 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
}

View File

@@ -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)

View 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"
};
}

View 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; }
}

View 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" });
}
}
}

View 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; }
}

View 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" });
}
}
}

View 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 (1834)",
"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
};
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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);
}

View File

@@ -0,0 +1,6 @@
@IntelligenceAPI_HostAddress = http://localhost:5271
GET {{IntelligenceAPI_HostAddress}}/weatherforecast/
Accept: application/json
###

View 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>

View 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";
}

View 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
View 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();

View 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
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"IntelligenceApi": "Debug"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View 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);
}

View File

@@ -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);
}

View 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" });
}
}

View 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;
}

View File

@@ -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);
}

View File

@@ -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))

View 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);
}
}

View 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 });
}
}
}

View 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; }
}

View 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; }
}

View 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; }
}

View File

@@ -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);
}
}

View 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; }
}

View 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; }
}

View File

@@ -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; }
}

View 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" });
}
}

View 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);
}

View 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>"
});
}
}
}

View File

@@ -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 });
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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();

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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