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

@@ -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,11 +248,26 @@ 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[]
{
validIssuers = new[]
{
$"https://login.microsoftonline.com/{tenantId}/v2.0",
$"https://sts.windows.net/{tenantId}/"
};
@@ -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
}
}
}