diff --git a/AdPlatformServers.sln b/AdPlatformServers.sln
index c4eb886..95dcd95 100644
--- a/AdPlatformServers.sln
+++ b/AdPlatformServers.sln
@@ -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
diff --git a/Creative/Configuration/CreativeConfig.cs b/Creative/Configuration/CreativeConfig.cs
new file mode 100644
index 0000000..18a199e
--- /dev/null
+++ b/Creative/Configuration/CreativeConfig.cs
@@ -0,0 +1,74 @@
+namespace Creative.Configuration;
+
+///
+/// Configuration for the Creative service.
+/// Bound from appsettings.json section "Creative".
+/// Override via environment variables: Creative__OpenAiApiKey, etc.
+///
+public class CreativeConfig
+{
+ public const string SectionName = "Creative";
+
+ ///
+ /// When false, returns emulated/mock creative assets.
+ /// When true, calls OpenAI and performs real URL scraping.
+ ///
+ public bool EnableRealApi { get; set; } = false;
+
+ ///
+ /// OpenAI API key for copy generation.
+ ///
+ public string? OpenAiApiKey { get; set; }
+
+ ///
+ /// OpenAI model to use. Default: gpt-4o-mini.
+ ///
+ public string OpenAiModel { get; set; } = "gpt-4o-mini";
+
+ ///
+ /// Max tokens for OpenAI responses.
+ ///
+ public int OpenAiMaxTokens { get; set; } = 1000;
+
+ ///
+ /// Timeout in seconds for URL scraping.
+ ///
+ public int ScrapeTimeoutSeconds { get; set; } = 15;
+
+ ///
+ /// Timeout in seconds for OpenAI API calls.
+ ///
+ public int OpenAiTimeoutSeconds { get; set; } = 30;
+
+ // ── Image Provider ──────────────────────────────────────
+
+ ///
+ /// Image provider: "emulated" | "unsplash" | "dalle".
+ /// Default: emulated (placeholder images).
+ ///
+ public string ImageProvider { get; set; } = "emulated";
+
+ ///
+ /// Unsplash Access Key (optional - basic search works without it,
+ /// but rate limits are generous with a free key from unsplash.com/developers).
+ ///
+ public string? UnsplashAccessKey { get; set; }
+
+ ///
+ /// Number of images to return per draft. Default: 3
+ /// (landscape, square, portrait for responsive display ads).
+ ///
+ public int ImageCount { get; set; } = 3;
+
+ ///
+ /// DALL-E model to use when ImageProvider=dalle.
+ /// Default: dall-e-3.
+ ///
+ public string DalleModel { get; set; } = "dall-e-3";
+
+ ///
+ /// DALL-E image size. Default: 1024x1024.
+ /// Options: 1024x1024, 1792x1024, 1024x1792.
+ ///
+ public string DalleSize { get; set; } = "1024x1024";
+}
diff --git a/Creative/Controllers/InternalController.cs b/Creative/Controllers/InternalController.cs
new file mode 100644
index 0000000..8bf1444
--- /dev/null
+++ b/Creative/Controllers/InternalController.cs
@@ -0,0 +1,48 @@
+using Creative.Models;
+using Creative.Security;
+using Creative.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Creative.Controllers;
+
+///
+/// Internal endpoint called by Gateway.
+/// Single dispatch point: POST /internal/execute
+///
+[ApiController]
+[Route("internal")]
+public class InternalController : ControllerBase
+{
+ private readonly CreativeService _service;
+ private readonly ILogger _logger;
+
+ public InternalController(CreativeService service, ILogger logger)
+ {
+ _service = service;
+ _logger = logger;
+ }
+
+ ///
+ /// Execute a creative operation.
+ /// Called by Gateway with X-Internal-Key header.
+ ///
+ [HttpPost("execute")]
+ [ServiceFilter(typeof(InternalAuthFilter))]
+ public async Task 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);
+ }
+}
diff --git a/Creative/Creative.csproj b/Creative/Creative.csproj
new file mode 100644
index 0000000..88619cc
--- /dev/null
+++ b/Creative/Creative.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+ True
+ mcr.microsoft.com/dotnet/aspnet:8.0
+ creative
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Creative/Creative.zip b/Creative/Creative.zip
new file mode 100644
index 0000000..d4b2a7d
Binary files /dev/null and b/Creative/Creative.zip differ
diff --git a/Creative/Models/CreativeModels.cs b/Creative/Models/CreativeModels.cs
new file mode 100644
index 0000000..6f58714
--- /dev/null
+++ b/Creative/Models/CreativeModels.cs
@@ -0,0 +1,206 @@
+using System.Text.Json.Serialization;
+
+namespace Creative.Models;
+
+// ============================================================
+// Request / Response envelope (matches ProviderModels pattern)
+// ============================================================
+
+///
+/// Inbound request from Gateway via /internal/execute.
+///
+public class CreativeRequest
+{
+ [JsonPropertyName("operation")]
+ public string? Operation { get; set; }
+
+ [JsonPropertyName("requestId")]
+ public string? RequestId { get; set; }
+
+ [JsonPropertyName("payload")]
+ public Dictionary? 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; }
+}
+
+///
+/// Standard response envelope.
+///
+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
+// ============================================================
+
+///
+/// Result of scraping and analyzing a URL.
+///
+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 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;
+}
+
+///
+/// A single text asset (headline or description) for Google Ads.
+///
+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; }
+}
+
+///
+/// An image asset sourced for the campaign.
+///
+public class ImageAsset
+{
+ [JsonPropertyName("imageId")]
+ public string ImageId { get; set; } = "";
+
+ [JsonPropertyName("url")]
+ public string Url { get; set; } = "";
+
+ ///
+ /// Where the image came from: "emulated" | "unsplash" | "dalle"
+ ///
+ [JsonPropertyName("source")]
+ public string Source { get; set; } = "emulated";
+
+ ///
+ /// Orientation/aspect: "landscape" | "square" | "portrait"
+ ///
+ [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; }
+
+ ///
+ /// Attribution line (required by Unsplash TOS, informational for others).
+ ///
+ [JsonPropertyName("attribution")]
+ public string? Attribution { get; set; }
+
+ ///
+ /// Direct download/full-res URL if different from display URL.
+ ///
+ [JsonPropertyName("downloadUrl")]
+ public string? DownloadUrl { get; set; }
+}
+
+///
+/// Complete set of generated assets for a campaign draft.
+///
+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 Headlines { get; set; } = new();
+
+ [JsonPropertyName("descriptions")]
+ public List Descriptions { get; set; } = new();
+
+ [JsonPropertyName("images")]
+ public List Images { get; set; } = new();
+
+ ///
+ /// Copy source: "emulated" | "openai"
+ ///
+ [JsonPropertyName("source")]
+ public string Source { get; set; } = "emulated";
+
+ ///
+ /// Image source: "emulated" | "unsplash" | "dalle"
+ ///
+ [JsonPropertyName("imageSource")]
+ public string ImageSource { get; set; } = "emulated";
+
+ [JsonPropertyName("createdAt")]
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/Creative/Program.cs b/Creative/Program.cs
new file mode 100644
index 0000000..f59f81e
--- /dev/null
+++ b/Creative/Program.cs
@@ -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(
+ builder.Configuration.GetSection(CreativeConfig.SectionName));
+
+var creativeConfig = builder.Configuration
+ .GetSection(CreativeConfig.SectionName)
+ .Get();
+
+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();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+
+// Auth filter for internal calls from Gateway
+builder.Services.AddScoped();
+
+// --------------------
+// 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();
+
+ 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();
diff --git a/Creative/Properties/launchSettings.json b/Creative/Properties/launchSettings.json
new file mode 100644
index 0000000..cfc8e18
--- /dev/null
+++ b/Creative/Properties/launchSettings.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/Creative/Security/InternalAuthFilter.cs b/Creative/Security/InternalAuthFilter.cs
new file mode 100644
index 0000000..93e606e
--- /dev/null
+++ b/Creative/Security/InternalAuthFilter.cs
@@ -0,0 +1,50 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace Creative.Security;
+
+///
+/// Validates X-Internal-Key header on internal endpoints.
+/// Gateway sends this key when forwarding requests.
+///
+public class InternalAuthFilter : IActionFilter
+{
+ private readonly IConfiguration _config;
+ private readonly ILogger _logger;
+
+ public InternalAuthFilter(IConfiguration config, ILogger 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) { }
+}
diff --git a/Creative/Services/CopyGeneratorService.cs b/Creative/Services/CopyGeneratorService.cs
new file mode 100644
index 0000000..414d371
--- /dev/null
+++ b/Creative/Services/CopyGeneratorService.cs
@@ -0,0 +1,233 @@
+using System.Text;
+using System.Text.Json;
+using Creative.Configuration;
+using Creative.Models;
+using Microsoft.Extensions.Options;
+
+namespace Creative.Services;
+
+///
+/// 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
+///
+public class CopyGeneratorService
+{
+ private const int MaxHeadlineChars = 30;
+ private const int MaxDescriptionChars = 90;
+
+ private readonly CreativeConfig _config;
+ private readonly IHttpClientFactory _httpFactory;
+ private readonly ILogger _logger;
+
+ public CopyGeneratorService(
+ IOptions config,
+ IHttpClientFactory httpFactory,
+ ILogger logger)
+ {
+ _config = config.Value;
+ _httpFactory = httpFactory;
+ _logger = logger;
+ }
+
+ ///
+ /// Generate text assets from analyzed URL content.
+ /// Returns validated headlines and descriptions.
+ ///
+ public async Task<(List Headlines, List 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, List, 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, List, 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();
+ var descriptions = new List();
+
+ 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, List, 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
+ {
+ $"{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
+ {
+ $"{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
+
+ ///
+ /// Validate and truncate asset text to meet Google Ads character limits.
+ ///
+ 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
+}
diff --git a/Creative/Services/CreativeService.cs b/Creative/Services/CreativeService.cs
new file mode 100644
index 0000000..3400e8a
--- /dev/null
+++ b/Creative/Services/CreativeService.cs
@@ -0,0 +1,230 @@
+using System.Text.Json;
+using Creative.Models;
+
+namespace Creative.Services;
+
+///
+/// Main creative service - dispatches operations to appropriate handlers.
+/// Stateless: returns JSON, Gateway handles persistence.
+///
+public class CreativeService
+{
+ private readonly ScraperService _scraper;
+ private readonly CopyGeneratorService _copyGen;
+ private readonly ImageGeneratorService _imageGen;
+ private readonly ILogger _logger;
+
+ public CreativeService(
+ ScraperService scraper,
+ CopyGeneratorService copyGen,
+ ImageGeneratorService imageGen,
+ ILogger logger)
+ {
+ _scraper = scraper;
+ _copyGen = copyGen;
+ _imageGen = imageGen;
+ _logger = logger;
+ }
+
+ ///
+ /// Main dispatch method - routes to appropriate operation handler.
+ ///
+ public async Task 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
+ // ============================================================
+
+ ///
+ /// Health check.
+ ///
+ private static CreativeResponse Ping(string requestId)
+ {
+ return CreativeResponse.Success(requestId, new
+ {
+ pong = true,
+ timestamp = DateTimeOffset.UtcNow
+ });
+ }
+
+ ///
+ /// Scrape and analyze a URL. Returns structured content.
+ /// Payload: { "url": "https://..." }
+ ///
+ private async Task 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);
+ }
+
+ ///
+ /// Generate text assets from previously analyzed content.
+ /// Payload: { "analysis": { ... } } (UrlAnalysis object)
+ ///
+ private async Task 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(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
+ });
+ }
+
+ ///
+ /// Get images matching previously analyzed content.
+ /// Payload: { "analysis": { ... } } (UrlAnalysis object)
+ ///
+ private async Task 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(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
+ });
+ }
+
+ ///
+ /// Full pipeline: scrape URL → generate copy → source images → return campaign draft.
+ /// Payload: { "url": "https://..." }
+ /// Gateway persists the returned draft to tbCreativeDraft.
+ ///
+ private async Task 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;
+ }
+}
diff --git a/Creative/Services/ImageGeneratorService.cs b/Creative/Services/ImageGeneratorService.cs
new file mode 100644
index 0000000..fce0322
--- /dev/null
+++ b/Creative/Services/ImageGeneratorService.cs
@@ -0,0 +1,414 @@
+using System.Text;
+using System.Text.Json;
+using Creative.Configuration;
+using Creative.Models;
+using Microsoft.Extensions.Options;
+
+namespace Creative.Services;
+
+///
+/// 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)
+///
+public class ImageGeneratorService
+{
+ private readonly CreativeConfig _config;
+ private readonly IHttpClientFactory _httpFactory;
+ private readonly ILogger _logger;
+
+ public ImageGeneratorService(
+ IOptions config,
+ IHttpClientFactory httpFactory,
+ ILogger logger)
+ {
+ _config = config.Value;
+ _httpFactory = httpFactory;
+ _logger = logger;
+ }
+
+ ///
+ /// Get images matching the analyzed content.
+ /// Returns a list of ImageAssets and the provider name used.
+ ///
+ public async Task<(List 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, string) EmulateGeneration(UrlAnalysis analysis)
+ {
+ _logger.LogInformation("[ImageGen] Emulated images for {Url}", analysis.Url);
+
+ var keyword = ExtractSearchKeyword(analysis);
+ var images = new List
+ {
+ 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, 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();
+ 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");
+ }
+
+ ///
+ /// Fallback using source.unsplash.com redirect (no API key needed, no rate limit).
+ /// Returns a random photo matching the keyword at the requested dimensions.
+ ///
+ 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, 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();
+
+ // 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");
+ }
+
+ ///
+ /// Build a DALL-E prompt from the analysis. Aims for clean,
+ /// professional ad imagery — not artistic or abstract.
+ ///
+ 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
+ // ============================================================
+
+ ///
+ /// Extract a concise search keyword from the analysis.
+ /// Uses category first, then title, then domain.
+ ///
+ 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";
+ }
+ }
+}
diff --git a/Creative/Services/ScraperService.cs b/Creative/Services/ScraperService.cs
new file mode 100644
index 0000000..6ee0191
--- /dev/null
+++ b/Creative/Services/ScraperService.cs
@@ -0,0 +1,161 @@
+using Creative.Configuration;
+using Creative.Models;
+using HtmlAgilityPack;
+using Microsoft.Extensions.Options;
+
+namespace Creative.Services;
+
+///
+/// Scrapes a URL and extracts structured business data.
+/// Supports emulated mode for development without network calls.
+///
+public class ScraperService
+{
+ private readonly CreativeConfig _config;
+ private readonly IHttpClientFactory _httpFactory;
+ private readonly ILogger _logger;
+
+ public ScraperService(
+ IOptions config,
+ IHttpClientFactory httpFactory,
+ ILogger logger)
+ {
+ _config = config.Value;
+ _httpFactory = httpFactory;
+ _logger = logger;
+ }
+
+ ///
+ /// Analyze a URL - scrape and extract structured content.
+ ///
+ public async Task AnalyzeUrlAsync(string url, CancellationToken ct)
+ {
+ if (!_config.EnableRealApi)
+ return EmulateAnalysis(url);
+
+ return await ScrapeRealAsync(url, ct);
+ }
+
+ #region Real Implementation
+
+ private async Task 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();
+ 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
+ {
+ $"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
+}
diff --git a/Creative/appsettings.Development.json b/Creative/appsettings.Development.json
new file mode 100644
index 0000000..a6e86ac
--- /dev/null
+++ b/Creative/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/Creative/appsettings.json b/Creative/appsettings.json
new file mode 100644
index 0000000..4353e1a
--- /dev/null
+++ b/Creative/appsettings.json
@@ -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"
+ }
+}
diff --git a/Gateway/Controllers/AuthController.cs b/Gateway/Controllers/AuthController.cs
index 1c391ef..0a64b3f 100644
--- a/Gateway/Controllers/AuthController.cs
+++ b/Gateway/Controllers/AuthController.cs
@@ -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 " and "Bearer ".
+ // 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;
diff --git a/Gateway/Controllers/CampaignIntelligenceController.cs b/Gateway/Controllers/CampaignIntelligenceController.cs
new file mode 100644
index 0000000..330432c
--- /dev/null
+++ b/Gateway/Controllers/CampaignIntelligenceController.cs
@@ -0,0 +1,185 @@
+using Gateway.Data;
+using Gateway.Security;
+using Microsoft.AspNetCore.Mvc;
+using System.Text.Json;
+
+namespace Gateway.Controllers;
+
+///
+/// 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
+///
+[ApiController]
+[Route("api/intelligence")]
+public sealed class CampaignIntelligenceController : ControllerBase
+{
+ private readonly SqlService _sql;
+ private readonly ClientContext _client;
+ private readonly AuthorizationGuard _guard;
+ private readonly ILogger _log;
+
+ public CampaignIntelligenceController(
+ SqlService sql,
+ ClientContext client,
+ AuthorizationGuard guard,
+ ILogger log)
+ {
+ _sql = sql;
+ _client = client;
+ _guard = guard;
+ _log = log;
+ }
+
+ // ────────────────────────────────────────────────
+ // Campaign Health Overview
+ // ────────────────────────────────────────────────
+
+ ///
+ /// Get health overview for all active initiatives.
+ /// Returns green/yellow/red status per channel campaign based on active recommendations.
+ ///
+ [HttpGet("health")]
+ public async Task 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
+ // ────────────────────────────────────────────────
+
+ ///
+ /// Get budget pacing analysis for an initiative.
+ /// Shows actual vs expected spend velocity with projections.
+ ///
+ [HttpGet("{initiativeId:long}/pacing")]
+ public async Task 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
+ // ────────────────────────────────────────────────
+
+ ///
+ /// Comprehensive post-campaign analysis.
+ /// Cross-platform comparison with daily trends, efficiency metrics,
+ /// and recommendation history.
+ ///
+ [HttpGet("{initiativeId:long}/report")]
+ public async Task 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)
+ // ────────────────────────────────────────────────
+
+ ///
+ /// Record an intraday metric snapshot for pacing analysis.
+ /// Called by the background polling service between daily aggregations.
+ /// Admin-only endpoint.
+ ///
+ [HttpPost("snapshot")]
+ public async Task 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);
+ }
+
+ ///
+ /// Batch insert intraday snapshots.
+ /// Admin-only endpoint.
+ ///
+ [HttpPost("snapshot/batch")]
+ public async Task 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 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