Initial import into Gitea
This commit is contained in:
@@ -44,10 +44,18 @@ public sealed class AuthController : ControllerBase
|
||||
_log.LogWarning("[Session] Authenticated: ClientId={ClientId}, Email={Email}",
|
||||
_client.ClientId, _client.Email);
|
||||
|
||||
// Gateway handles CIAM client sessions only.
|
||||
// Staff apps authenticate directly to Management API via JWT Bearer — never via Gateway.
|
||||
if (_client.IsStaff)
|
||||
{
|
||||
_log.LogWarning("[Session] Staff token rejected — use JWT Bearer directly to Management API");
|
||||
return StatusCode(403, new { ok = false, error = "Staff authentication does not use Gateway sessions" });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = _client.AuthProvider ?? "EntraExternalId",
|
||||
subject = _client.ClientId,
|
||||
subject = _client.ClientId,
|
||||
email = _client.Email,
|
||||
displayName = _client.ClientName,
|
||||
clientId = request?.PreferredClientId,
|
||||
@@ -56,13 +64,15 @@ public sealed class AuthController : ControllerBase
|
||||
sessionDurationHours = request?.SessionDurationHours ?? 24
|
||||
});
|
||||
|
||||
_log.LogWarning("[Session] Calling spSession with: {Rqst}", rqst);
|
||||
_log.LogWarning("[Session] Calling proc with: {Rqst}", rqst);
|
||||
|
||||
_log.LogWarning("[Session] Using proc=dbo.spClientSession");
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "createFromIdentity", rqst, ct: ct);
|
||||
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "createFromIdentity", rqst, ct: ct);
|
||||
|
||||
_log.LogWarning("[Session] spSession response: {Resp}", resp ?? "(null)");
|
||||
_log.LogWarning("[Session] Proc response: {Resp}", resp ?? "(null)");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -118,7 +128,7 @@ public sealed class AuthController : ControllerBase
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = _client.AuthProvider ?? "EntraExternalId",
|
||||
subject = _client.ClientId,
|
||||
subject = _client.ClientId,
|
||||
email = _client.Email,
|
||||
displayName = _client.ClientName,
|
||||
companyName = request.CompanyName,
|
||||
@@ -173,10 +183,11 @@ public sealed class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var signoffProc = "dbo.spClientSession"; // Gateway handles client sessions only
|
||||
|
||||
try
|
||||
{
|
||||
await _sql.ExecProcAsync("dbo.spSession", "signoff", rqst, ct: ct);
|
||||
await _sql.ExecProcAsync(signoffProc, "signoff", rqst, ct: ct);
|
||||
return Ok(new { ok = true, message = "Signed out successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -204,10 +215,11 @@ public sealed class AuthController : ControllerBase
|
||||
sessionToken = token,
|
||||
sessionDurationHours = request?.SessionDurationHours ?? 24
|
||||
});
|
||||
var refreshProc = "dbo.spClientSession"; // Gateway handles client sessions only
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "refresh", rqst, ct: ct);
|
||||
var resp = await _sql.ExecProcAsync(refreshProc, "refresh", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -251,7 +263,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: ct);
|
||||
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -304,7 +316,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "switchClient", rqst, ct: ct);
|
||||
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "switchClient", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -338,11 +350,18 @@ public sealed class AuthController : ControllerBase
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
return token;
|
||||
|
||||
// Check Authorization header (for session tokens, not JWTs)
|
||||
// Check Authorization header — accept both "Session <token>" and "Bearer <token>".
|
||||
// NOTE: Bearer here is a session token (not an Entra JWT) because the middleware
|
||||
// only routes to these controller actions after session validation succeeds.
|
||||
// The JWT-only endpoint (/api/auth/session) never calls ExtractSessionToken().
|
||||
var auth = Request.Headers.Authorization.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.IsNullOrWhiteSpace(auth))
|
||||
{
|
||||
return auth.Substring(8).Trim();
|
||||
if (auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase))
|
||||
return auth.Substring(8).Trim();
|
||||
|
||||
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return auth.Substring(7).Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
185
Gateway/Controllers/CampaignIntelligenceController.cs
Normal file
185
Gateway/Controllers/CampaignIntelligenceController.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign intelligence endpoints: health overview, budget pacing,
|
||||
/// and post-campaign analysis.
|
||||
///
|
||||
/// SECURITY MODEL:
|
||||
/// - Every endpoint requires authenticated session (via middleware)
|
||||
/// - Initiative endpoints verify ownership before data access
|
||||
/// - Client-level endpoints scoped via injected ClientContext
|
||||
/// - ClientId is always injected server-side, never from request body
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/intelligence")]
|
||||
public sealed class CampaignIntelligenceController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<CampaignIntelligenceController> _log;
|
||||
|
||||
public CampaignIntelligenceController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<CampaignIntelligenceController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Campaign Health Overview
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Get health overview for all active initiatives.
|
||||
/// Returns green/yellow/red status per channel campaign based on active recommendations.
|
||||
/// </summary>
|
||||
[HttpGet("health")]
|
||||
public async Task<IActionResult> Health(CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "health",
|
||||
JsonSerializer.Serialize(new { clientId = _client.ClientId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Budget Pacing
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Get budget pacing analysis for an initiative.
|
||||
/// Shows actual vs expected spend velocity with projections.
|
||||
/// </summary>
|
||||
[HttpGet("{initiativeId:long}/pacing")]
|
||||
public async Task<IActionResult> Pacing(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "pacing",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Post-Campaign Report
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive post-campaign analysis.
|
||||
/// Cross-platform comparison with daily trends, efficiency metrics,
|
||||
/// and recommendation history.
|
||||
/// </summary>
|
||||
[HttpGet("{initiativeId:long}/report")]
|
||||
public async Task<IActionResult> PostCampaignReport(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "postCampaign",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Metric Snapshots (internal / polling service)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Record an intraday metric snapshot for pacing analysis.
|
||||
/// Called by the background polling service between daily aggregations.
|
||||
/// Admin-only endpoint.
|
||||
/// </summary>
|
||||
[HttpPost("snapshot")]
|
||||
public async Task<IActionResult> Snapshot([FromBody] SnapshotRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireServiceKey();
|
||||
if (!ok) return StatusCode(403, new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "snapshot",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
channelCampaignId = request.ChannelCampaignId,
|
||||
date = request.Date,
|
||||
impressions = request.Impressions,
|
||||
clicks = request.Clicks,
|
||||
spend = request.Spend,
|
||||
conversions = request.Conversions
|
||||
}), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch insert intraday snapshots.
|
||||
/// Admin-only endpoint.
|
||||
/// </summary>
|
||||
[HttpPost("snapshot/batch")]
|
||||
public async Task<IActionResult> SnapshotBatch([FromBody] SnapshotBatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireServiceKey();
|
||||
if (!ok) return StatusCode(403, new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.CampaignIntelligence, "snapshotBatch",
|
||||
JsonSerializer.Serialize(new { snapshots = request.Snapshots }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return NotFound(JsonSerializer.Deserialize<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "CampaignIntelligence {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class SnapshotRequest
|
||||
{
|
||||
public long? ChannelCampaignId { get; set; }
|
||||
public string? Date { get; set; }
|
||||
public long? Impressions { get; set; }
|
||||
public long? Clicks { get; set; }
|
||||
public decimal? Spend { get; set; }
|
||||
public decimal? Conversions { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SnapshotBatchRequest
|
||||
{
|
||||
public object[]? Snapshots { get; set; }
|
||||
}
|
||||
212
Gateway/Controllers/ClientDocumentController.cs
Normal file
212
Gateway/Controllers/ClientDocumentController.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Client-facing document endpoints.
|
||||
/// All operations are scoped to the authenticated client — clientId is always
|
||||
/// injected from ClientContext, never trusted from the request body.
|
||||
///
|
||||
/// POST /api/documents/list - List client's own documents
|
||||
/// POST /api/documents - Upload a document (multipart)
|
||||
/// GET /api/documents/{id}/download - Download (enforces client ownership)
|
||||
/// DELETE /api/documents/{id} - Soft delete (enforces client ownership)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/documents")]
|
||||
public sealed class ClientDocumentController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<ClientDocumentController> _log;
|
||||
|
||||
public ClientDocumentController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
IConfiguration config,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<ClientDocumentController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_config = config;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ── POST /api/documents/list ─────────────────────────────────────────────
|
||||
[HttpPost("list")]
|
||||
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
scope = "client",
|
||||
clientId = _client.ClientId // always from session, never from body
|
||||
});
|
||||
|
||||
var result = await _sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Client document list failed");
|
||||
return StatusCode(500, new { ok = false, error = "Document service error" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/documents ──────────────────────────────────────────────────
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(52_428_800)]
|
||||
public async Task<IActionResult> Upload(
|
||||
IFormFile file,
|
||||
[FromForm] string category,
|
||||
[FromForm] string? description = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { ok = false, error = "No file provided" });
|
||||
|
||||
try
|
||||
{
|
||||
byte[] fileBytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
await file.CopyToAsync(ms, ct);
|
||||
fileBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
docFileName = file.FileName,
|
||||
docMimeType = file.ContentType,
|
||||
docFileSize = file.Length,
|
||||
docCategory = category,
|
||||
docDescription = description,
|
||||
docUploadedBy = _client.Email,
|
||||
docScope = "client",
|
||||
docCltId = _client.ClientId // injected from session
|
||||
});
|
||||
|
||||
_log.LogInformation("[ClientDocs] Upload {FileName} | Client={ClientId}",
|
||||
file.FileName, _client.ClientId);
|
||||
|
||||
var result = await ExecUploadAsync(rqst, fileBytes, ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Client document upload failed: {FileName}", file?.FileName);
|
||||
return StatusCode(500, new { ok = false, error = "Upload failed" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/documents/{id}/download ─────────────────────────────────────
|
||||
[HttpGet("{id:long}/download")]
|
||||
public async Task<IActionResult> Download(long id, CancellationToken ct = default)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql")
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
|
||||
await using var conn = new SqlConnection(cs);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = 60
|
||||
};
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value =
|
||||
JsonSerializer.Serialize(new { docId = id, clientId = _client.ClientId }) });
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return NotFound(new { ok = false, error = "Document not found" });
|
||||
|
||||
var fileName = reader.GetString(reader.GetOrdinal("docFileName"));
|
||||
var mimeType = reader.GetString(reader.GetOrdinal("docMimeType"));
|
||||
var content = (byte[])reader["docContent"];
|
||||
|
||||
return File(content, mimeType, fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Client document download failed: docId={DocId}", id);
|
||||
return StatusCode(500, new { ok = false, error = "Download failed" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /api/documents/{id} ───────────────────────────────────────────
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<IActionResult> Delete(long id, CancellationToken ct = default)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
_log.LogInformation("[ClientDocs] Delete docId={DocId} | Client={ClientId}", id, _client.ClientId);
|
||||
|
||||
// Pass clientId so the SP enforces ownership before deleting
|
||||
var rqst = JsonSerializer.Serialize(new { docId = id, clientId = _client.ClientId });
|
||||
var result = await _sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct);
|
||||
return Content(result, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Client document delete failed: docId={DocId}", id);
|
||||
return StatusCode(500, new { ok = false, error = "Delete failed" });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Upload helper: binary passed separately from JSON rqst ──────────────
|
||||
private async Task<string> ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct)
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql")
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
|
||||
await using var conn = new SqlConnection(cs);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = 60
|
||||
};
|
||||
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst });
|
||||
cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent });
|
||||
|
||||
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
cmd.Parameters.Add(pResp);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
return pResp.Value as string
|
||||
?? JsonSerializer.Serialize(new { ok = false, error = "No response from database" });
|
||||
}
|
||||
}
|
||||
179
Gateway/Controllers/DemographicsController.cs
Normal file
179
Gateway/Controllers/DemographicsController.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Census demographic data endpoints for the campaign wizard.
|
||||
///
|
||||
/// GET /api/demographics/{zcta} — fetch census data from DB, forward to
|
||||
/// Intelligence container for derived
|
||||
/// recommendations. Falls back to raw census
|
||||
/// data if Intelligence is unreachable.
|
||||
/// POST /api/demographics/list — multiple ZCTAs (raw data only)
|
||||
/// POST /api/demographics/search — find ZCTAs by criteria (raw data only)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/demographics")]
|
||||
public sealed class DemographicsController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly IntelligenceApiClient _intelligence;
|
||||
private readonly ILogger<DemographicsController> _log;
|
||||
|
||||
public DemographicsController(
|
||||
SqlService sql,
|
||||
AuthorizationGuard guard,
|
||||
IntelligenceApiClient intelligence,
|
||||
ILogger<DemographicsController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_guard = guard;
|
||||
_intelligence = intelligence;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch raw census data for a ZCTA, then forward to Intelligence container
|
||||
/// for derived audience recommendations (age chips, income tiers, insights).
|
||||
/// Falls back to raw census data only if Intelligence is unreachable.
|
||||
/// </summary>
|
||||
[HttpGet("{zcta}")]
|
||||
public async Task<IActionResult> Get(string zcta, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(zcta) || zcta.Length != 5 || !zcta.All(char.IsDigit))
|
||||
return BadRequest(new { ok = false, error = "Valid 5-digit ZIP code is required" });
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Fetch raw census data from DB
|
||||
var censusJson = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Demographics, "get",
|
||||
JsonSerializer.Serialize(new { zcta }),
|
||||
ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(censusJson))
|
||||
return NotFound(new { ok = false, error = "ZIP code not found" });
|
||||
|
||||
using var doc = JsonDocument.Parse(censusJson);
|
||||
var censusRoot = doc.RootElement;
|
||||
|
||||
if (censusRoot.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return NotFound(new { ok = false, error = "ZIP code not found" });
|
||||
|
||||
// 2. Forward to Intelligence container for market analysis derivation
|
||||
var analysis = await _intelligence.GetDemographicAnalysisAsync(zcta, censusRoot, ct);
|
||||
|
||||
if (analysis != null)
|
||||
{
|
||||
_log.LogInformation("[Demographics] Analysis by Intelligence container | ZCTA={Zcta}", zcta);
|
||||
return Content(analysis, "application/json");
|
||||
}
|
||||
|
||||
// 3. Fallback: return raw census data
|
||||
_log.LogInformation("[Demographics] Intelligence unavailable — raw census | ZCTA={Zcta}", zcta);
|
||||
return Content(censusJson, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Demographics get error for ZCTA {Zcta}", zcta);
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Get demographics for multiple ZCTAs (raw census data).</summary>
|
||||
[HttpPost("list")]
|
||||
public async Task<IActionResult> List([FromBody] ZctaListRequest? request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (request?.Zctas == null || request.Zctas.Length == 0)
|
||||
return BadRequest(new { ok = false, error = "zctas array is required" });
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
zctas = request.Zctas,
|
||||
page = request.Page ?? 1,
|
||||
pageSize = request.PageSize ?? 50
|
||||
});
|
||||
|
||||
var resp = await _sql.ExecProcAsync(SqlNames.Procs.Demographics, "list", rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Demographics list error");
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Search ZCTAs by demographic criteria (raw census data).</summary>
|
||||
[HttpPost("search")]
|
||||
public async Task<IActionResult> Search([FromBody] DemographicSearchRequest? request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
zctaPrefix = request?.ZctaPrefix,
|
||||
minIncome = request?.MinIncome,
|
||||
maxIncome = request?.MaxIncome,
|
||||
minPopulation = request?.MinPopulation,
|
||||
minBachelorPct = request?.MinBachelorPct,
|
||||
minAge25to34Pct = request?.MinAge25to34Pct,
|
||||
minHomeValue = request?.MinHomeValue,
|
||||
page = request?.Page ?? 1,
|
||||
pageSize = request?.PageSize ?? 50
|
||||
});
|
||||
|
||||
var resp = await _sql.ExecProcAsync(SqlNames.Procs.Demographics, "search", rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Demographics search error");
|
||||
return StatusCode(500, new { ok = false, error = "Demographics service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class ZctaListRequest
|
||||
{
|
||||
public string[]? Zctas { get; set; }
|
||||
public int? Page { get; set; }
|
||||
public int? PageSize { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DemographicSearchRequest
|
||||
{
|
||||
public string? ZctaPrefix { get; set; }
|
||||
public int? MinIncome { get; set; }
|
||||
public int? MaxIncome { get; set; }
|
||||
public int? MinPopulation { get; set; }
|
||||
public decimal? MinBachelorPct { get; set; }
|
||||
public decimal? MinAge25to34Pct { get; set; }
|
||||
public int? MinHomeValue { get; set; }
|
||||
public int? Page { get; set; }
|
||||
public int? PageSize { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
@@ -9,17 +10,25 @@ namespace Gateway.Controllers;
|
||||
public sealed class ExecutionController : ControllerBase
|
||||
{
|
||||
private readonly ExecutionService _svc;
|
||||
public ExecutionController(ExecutionService svc) => _svc = svc;
|
||||
private readonly ClientContext _client;
|
||||
|
||||
public ExecutionController(ExecutionService svc, ClientContext client)
|
||||
{
|
||||
_svc = svc;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
[HttpPost("request")]
|
||||
public async Task<IActionResult> Execute([FromBody] JsonElement body)
|
||||
{
|
||||
// SECURITY: Require authenticated session
|
||||
if (!_client.IsAuthenticated)
|
||||
return Unauthorized(new { ok = false, error = "Authentication required" });
|
||||
|
||||
if (body.ValueKind == JsonValueKind.Undefined || body.ValueKind == JsonValueKind.Null)
|
||||
return BadRequest(new { ok = false, error = "Missing request body" });
|
||||
|
||||
var resp = await _svc.ExecuteAsync(body, HttpContext.RequestAborted);
|
||||
|
||||
// resp is JsonElement / JsonDocument / string json — you decide.
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
96
Gateway/Controllers/ForecastController.cs
Normal file
96
Gateway/Controllers/ForecastController.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Gateway.Models;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Channel forecast endpoint for the campaign wizard.
|
||||
///
|
||||
/// Routes to IntelligenceApi (category-aware engine container) when configured.
|
||||
/// Falls back to local ForecastService (General/rules-based) if IntelligenceApi
|
||||
/// is unreachable — ensuring the wizard never breaks during deployments.
|
||||
///
|
||||
/// ROUTING LOGIC:
|
||||
/// 1. Try IntelligenceApi — passes clientCategory so the engine router
|
||||
/// can select the correct model (General, Franchisee, Franchisor, etc.)
|
||||
/// 2. If unreachable / error → fall back to local ForecastService
|
||||
///
|
||||
/// SECURITY: Requires authenticated client session.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/forecast")]
|
||||
public sealed class ForecastController : ControllerBase
|
||||
{
|
||||
private readonly ForecastService _forecastService;
|
||||
private readonly IntelligenceApiClient _intelligenceClient;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<ForecastController> _log;
|
||||
|
||||
public ForecastController(
|
||||
ForecastService forecastService,
|
||||
IntelligenceApiClient intelligenceClient,
|
||||
ClientContext client,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<ForecastController> log)
|
||||
{
|
||||
_forecastService = forecastService;
|
||||
_intelligenceClient = intelligenceClient;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate channel performance estimates for given targeting + budget.
|
||||
/// Called by the wizard AllocationStep when budget changes.
|
||||
///
|
||||
/// POST /api/forecast/channel-estimate
|
||||
/// </summary>
|
||||
[HttpPost("channel-estimate")]
|
||||
public async Task<IActionResult> ChannelEstimate(
|
||||
[FromBody] ChannelForecastRequest? request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (request == null)
|
||||
return BadRequest(new { ok = false, error = "Request body is required" });
|
||||
|
||||
if (request.MonthlyBudget <= 0)
|
||||
return BadRequest(new { ok = false, error = "monthlyBudget must be greater than zero" });
|
||||
|
||||
if (request.Keywords.Count == 0)
|
||||
return BadRequest(new { ok = false, error = "At least one keyword is required" });
|
||||
|
||||
_log.LogInformation(
|
||||
"[Forecast] Request | Category={Category} Budget={Budget} Objective={Obj}",
|
||||
_client.ClientCategory, request.MonthlyBudget, request.Objective);
|
||||
|
||||
try
|
||||
{
|
||||
// ── 1. Try IntelligenceApi (category-aware) ──
|
||||
var result = await _intelligenceClient.GetSpendDistributionAsync(
|
||||
request, _client.ClientCategory, ct);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
_log.LogInformation("[Forecast] Served by IntelligenceApi");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── 2. Fallback: local ForecastService (General engine equivalent) ──
|
||||
_log.LogInformation("[Forecast] IntelligenceApi unavailable — using local ForecastService");
|
||||
var fallback = await _forecastService.ForecastAsync(request, ct);
|
||||
return Ok(fallback);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Forecast] Error");
|
||||
return StatusCode(500, new { ok = false, error = "Forecast service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
502
Gateway/Controllers/InitiativeController.cs
Normal file
502
Gateway/Controllers/InitiativeController.cs
Normal file
@@ -0,0 +1,502 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-channel initiative endpoints.
|
||||
///
|
||||
/// SECURITY MODEL:
|
||||
/// - Every endpoint requires authenticated session (via middleware)
|
||||
/// - Every resource-specific endpoint validates ownership (initiative → client)
|
||||
/// - Status changes are restricted to valid client-initiated transitions
|
||||
/// - Sync endpoint is restricted to admin role
|
||||
/// - Budget values are validated server-side against channel minimums
|
||||
/// - ClientId is injected server-side, never trusted from request body
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/initiative")]
|
||||
public sealed class InitiativeController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly MultiChannelConfig _config;
|
||||
private readonly InitiativeLaunchService _launch;
|
||||
private readonly ProviderStatusNormalizer _statusNorm;
|
||||
private readonly ILogger<InitiativeController> _log;
|
||||
|
||||
public InitiativeController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
AuthorizationGuard guard,
|
||||
IOptions<MultiChannelConfig> config,
|
||||
InitiativeLaunchService launch,
|
||||
ProviderStatusNormalizer statusNorm,
|
||||
ILogger<InitiativeController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_config = config.Value;
|
||||
_launch = launch;
|
||||
_statusNorm = statusNorm;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Initiative CRUD
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Create a new initiative with channel allocations.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInitiativeRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (request.TotalBudget.HasValue)
|
||||
{
|
||||
var (budgetOk, budgetErr) = _guard.ValidateBudget(
|
||||
request.TotalBudget.Value, request.BudgetPeriod, _config);
|
||||
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId,
|
||||
userId = _client.UserId,
|
||||
name = request.Name,
|
||||
objective = request.Objective,
|
||||
totalBudget = request.TotalBudget,
|
||||
budgetPeriod = request.BudgetPeriod ?? "monthly",
|
||||
startDate = request.StartDate,
|
||||
endDate = request.EndDate,
|
||||
allocationStrategy = request.AllocationStrategy ?? "manual",
|
||||
businessCategory = request.BusinessCategory,
|
||||
wizardId = request.WizardId,
|
||||
channels = request.Channels
|
||||
});
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "create", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stage an initiative for confirmation with server-calculated billing.
|
||||
/// </summary>
|
||||
[HttpPost("stage")]
|
||||
public async Task<IActionResult> Stage([FromBody] CreateInitiativeRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (request.InitiativeId.HasValue && request.InitiativeId > 0)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(request.InitiativeId.Value, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
}
|
||||
|
||||
if (request.TotalBudget.HasValue)
|
||||
{
|
||||
var (budgetOk, budgetErr) = _guard.ValidateBudget(
|
||||
request.TotalBudget.Value, request.BudgetPeriod, _config);
|
||||
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId,
|
||||
userId = _client.UserId,
|
||||
name = request.Name,
|
||||
objective = request.Objective,
|
||||
totalBudget = request.TotalBudget,
|
||||
budgetPeriod = request.BudgetPeriod ?? "monthly",
|
||||
startDate = request.StartDate,
|
||||
endDate = request.EndDate,
|
||||
allocationStrategy = request.AllocationStrategy ?? "manual",
|
||||
businessCategory = request.BusinessCategory,
|
||||
wizardId = request.WizardId,
|
||||
initiativeId = request.InitiativeId,
|
||||
channels = request.Channels
|
||||
});
|
||||
|
||||
return await Exec(SqlNames.Procs.InitiativeStage, "stage", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Get billing for a staged initiative.</summary>
|
||||
[HttpGet("{initiativeId:long}/billing")]
|
||||
public async Task<IActionResult> GetBilling(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
return await Exec(SqlNames.Procs.InitiativeStage, "getBilling",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
/// <summary>Get initiative by ID (ownership verified).</summary>
|
||||
[HttpGet("{initiativeId:long}")]
|
||||
public async Task<IActionResult> Get(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ownership.EntityJson))
|
||||
return Content(ownership.EntityJson, "application/json");
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "get",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
/// <summary>List initiatives for current client (always scoped).</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] string? status, [FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "list",
|
||||
JsonSerializer.Serialize(new { clientId = _client.ClientId, status, page, pageSize }), ct);
|
||||
}
|
||||
|
||||
/// <summary>Update initiative metadata (ownership verified, status stripped).</summary>
|
||||
[HttpPut("{initiativeId:long}")]
|
||||
public async Task<IActionResult> Update(long initiativeId, [FromBody] UpdateInitiativeRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
if (request.TotalBudget.HasValue)
|
||||
{
|
||||
var (budgetOk, budgetErr) = _guard.ValidateBudget(request.TotalBudget.Value, null, _config);
|
||||
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
|
||||
}
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "update",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
initiativeId,
|
||||
clientId = _client.ClientId,
|
||||
name = request.Name,
|
||||
totalBudget = request.TotalBudget,
|
||||
startDate = request.StartDate,
|
||||
endDate = request.EndDate,
|
||||
businessCategory = request.BusinessCategory
|
||||
}), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update status with transition enforcement.
|
||||
/// Clients: active↔paused, *→cancelled only. Admins: any transition.
|
||||
/// </summary>
|
||||
[HttpPatch("{initiativeId:long}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(long initiativeId, [FromBody] UpdateInitiativeStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Status))
|
||||
return BadRequest(new { ok = false, error = "status is required" });
|
||||
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var isAdmin = string.Equals(_client.Role, "admin", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isAdmin)
|
||||
{
|
||||
var (transOk, transErr) = _guard.ValidateClientStatusTransition(
|
||||
ownership.CurrentStatus, request.Status, "initiative");
|
||||
if (!transOk) return BadRequest(new { ok = false, error = transErr });
|
||||
}
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "updateStatus",
|
||||
JsonSerializer.Serialize(new { initiativeId, clientId = _client.ClientId, status = request.Status }), ct);
|
||||
}
|
||||
|
||||
/// <summary>Soft-delete (cannot delete active — cancel first).</summary>
|
||||
[HttpDelete("{initiativeId:long}")]
|
||||
public async Task<IActionResult> Delete(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
if (ownership.CurrentStatus == "active")
|
||||
return BadRequest(new { ok = false, error = "Cannot delete an active initiative. Cancel it first." });
|
||||
|
||||
return await Exec(SqlNames.Procs.Initiative, "delete",
|
||||
JsonSerializer.Serialize(new { initiativeId, clientId = _client.ClientId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Launch / Dispatch
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Launch a staged initiative (ownership + status verified).</summary>
|
||||
[HttpPost("{initiativeId:long}/launch")]
|
||||
public async Task<IActionResult> Launch(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
if (ownership.CurrentStatus != "staged")
|
||||
return BadRequest(new { ok = false, error = $"Initiative must be staged before launching (current: {ownership.CurrentStatus})" });
|
||||
|
||||
_log.LogInformation("[Initiative] Launch {InitiativeId} by {UserId}", initiativeId, _client.UserId);
|
||||
|
||||
var result = await _launch.LaunchAsync(initiativeId, _client.ClientId ?? "", _client.UserId, ct);
|
||||
|
||||
if (!result.Ok && result.Error != null)
|
||||
{
|
||||
_log.LogWarning("[Initiative] Launch failed {InitiativeId}: {Error}", initiativeId, result.Error);
|
||||
return BadRequest(result);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Channel Campaigns (all ownership-verified)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{initiativeId:long}/channels")]
|
||||
public async Task<IActionResult> ListChannels(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.ChannelCampaign, "list", JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
[HttpGet("channel/{channelCampaignId:long}")]
|
||||
public async Task<IActionResult> GetChannel(long channelCampaignId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyChannelOwnerAsync(channelCampaignId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.ChannelCampaign, "get", JsonSerializer.Serialize(new { channelCampaignId }), ct);
|
||||
}
|
||||
|
||||
/// <summary>Sync channel status — called by provider containers.</summary>
|
||||
[HttpPatch("channel/{channelCampaignId:long}/sync")]
|
||||
public async Task<IActionResult> SyncChannel(long channelCampaignId, [FromBody] SyncChannelRequest request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireServiceKey();
|
||||
if (!ok) return StatusCode(403, new { ok = false, error = err });
|
||||
|
||||
var normalizedStatus = _statusNorm.Resolve(request.ChannelType, request.Status, request.ProviderStatus);
|
||||
|
||||
_log.LogInformation("[Sync] Channel {Id} | {Provider} → {Status} | By={User}",
|
||||
channelCampaignId, request.ProviderStatus, normalizedStatus, _client.UserId);
|
||||
|
||||
return await Exec(SqlNames.Procs.ChannelCampaign, "sync",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
channelCampaignId,
|
||||
externalCampaignId = request.ExternalCampaignId,
|
||||
externalAccountId = request.ExternalAccountId,
|
||||
status = normalizedStatus,
|
||||
providerStatus = request.ProviderStatus
|
||||
}), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Budget Allocation (all ownership-verified)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{initiativeId:long}/allocation")]
|
||||
public async Task<IActionResult> GetAllocation(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "get", JsonSerializer.Serialize(new { initiativeId }), ct);
|
||||
}
|
||||
|
||||
[HttpPut("{initiativeId:long}/allocation")]
|
||||
public async Task<IActionResult> UpdateAllocation(long initiativeId, [FromBody] UpdateAllocationRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "update",
|
||||
JsonSerializer.Serialize(new { initiativeId, userId = _client.UserId, allocations = request.Allocations, reason = request.Reason }), ct);
|
||||
}
|
||||
|
||||
[HttpGet("{initiativeId:long}/allocation/recommend")]
|
||||
public async Task<IActionResult> GetRecommendation(long initiativeId, [FromQuery] string? businessCategory, [FromQuery] string? objective, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "recommend", JsonSerializer.Serialize(new { initiativeId, businessCategory, objective }), ct);
|
||||
}
|
||||
|
||||
[HttpPost("{initiativeId:long}/allocation/apply")]
|
||||
public async Task<IActionResult> ApplyAllocation(long initiativeId, [FromBody] ApplyAllocationRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "apply",
|
||||
JsonSerializer.Serialize(new { initiativeId, source = request.Source, allocations = request.Allocations, reason = request.Reason }), ct);
|
||||
}
|
||||
|
||||
[HttpGet("{initiativeId:long}/allocation/history")]
|
||||
public async Task<IActionResult> GetAllocationHistory(long initiativeId, [FromQuery] int? limit, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.Allocation, "history", JsonSerializer.Serialize(new { initiativeId, limit }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Channel Config (read-only)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("channels/available")]
|
||||
public async Task<IActionResult> GetAvailableChannels(CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
var mappingsResp = await _sql.ExecProcAsync(SqlNames.Procs.ObjectiveMapping, "list", "{}", ct: ct);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ok = true,
|
||||
channels = _config.EnabledChannels.Select(c => new
|
||||
{
|
||||
c.ChannelType, c.DisplayName, c.Description, c.Icon, c.Color,
|
||||
c.MinDailyBudget, c.MinMonthlyBudget, c.SupportedObjectives,
|
||||
c.SupportedCreativeFormats, c.ApprovalEstimateHours, c.IsStub
|
||||
}),
|
||||
allocation = new
|
||||
{
|
||||
_config.Allocation.MinMultiChannelMonthlyBudget,
|
||||
_config.Allocation.MaxChannelsPerInitiative,
|
||||
_config.Allocation.DefaultAllocationStrategy,
|
||||
_config.Allocation.MinChannelAllocationPct,
|
||||
_config.Allocation.MaxChannelAllocationPct
|
||||
},
|
||||
objectiveMappings = JsonSerializer.Deserialize<object>(mappingsResp ?? "{}")
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Status mappings — available to authenticated clients.</summary>
|
||||
[HttpGet("channels/status-mappings")]
|
||||
public IActionResult GetStatusMappings([FromQuery] string? channelType)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelType))
|
||||
return Ok(new { ok = true, channelType, mappings = _statusNorm.GetMappings(channelType) });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ok = true,
|
||||
channels = _config.Channels.Values.Select(c => new { c.ChannelType, c.DisplayName, c.Enabled, mappings = _statusNorm.GetMappings(c.ChannelType) })
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("templates")]
|
||||
public async Task<IActionResult> GetTemplates([FromQuery] string? businessCategory, [FromQuery] string? objective, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
return await Exec(SqlNames.Procs.Allocation, "getTemplates", JsonSerializer.Serialize(new { businessCategory, objective }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Performance Metrics (ownership-verified)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{initiativeId:long}/metrics")]
|
||||
public async Task<IActionResult> MetricsSummary(long initiativeId, [FromQuery] string? fromDate, [FromQuery] string? toDate, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.PerformanceMetric, "summary", JsonSerializer.Serialize(new { initiativeId, fromDate, toDate }), ct);
|
||||
}
|
||||
|
||||
[HttpGet("{initiativeId:long}/metrics/compare")]
|
||||
public async Task<IActionResult> MetricsCompare(long initiativeId, [FromQuery] int? lookbackDays, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
|
||||
return await Exec(SqlNames.Procs.PerformanceMetric, "compare", JsonSerializer.Serialize(new { initiativeId, lookbackDays }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return NotFound(JsonSerializer.Deserialize<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Initiative {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class CreateInitiativeRequest
|
||||
{
|
||||
public long? InitiativeId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public decimal? TotalBudget { get; set; }
|
||||
public string? BudgetPeriod { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public string? AllocationStrategy { get; set; }
|
||||
public string? BusinessCategory { get; set; }
|
||||
public string? WizardId { get; set; }
|
||||
public object[]? Channels { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateInitiativeRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public decimal? TotalBudget { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public string? BusinessCategory { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateInitiativeStatusRequest { public string? Status { get; set; } }
|
||||
public sealed class SyncChannelRequest
|
||||
{
|
||||
public string? ExternalCampaignId { get; set; }
|
||||
public string? ExternalAccountId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? ProviderStatus { get; set; }
|
||||
public string? ChannelType { get; set; }
|
||||
}
|
||||
public sealed class UpdateAllocationRequest { public object[]? Allocations { get; set; } public string? Reason { get; set; } }
|
||||
public sealed class ApplyAllocationRequest { public string? Source { get; set; } public object[]? Allocations { get; set; } public string? Reason { get; set; } }
|
||||
62
Gateway/Controllers/MetricSyncController.cs
Normal file
62
Gateway/Controllers/MetricSyncController.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Metric sync trigger — called by Management API or Azure Functions timer.
|
||||
/// Pulls campaign performance data from provider containers and writes
|
||||
/// it into the database, then triggers recommendation evaluation.
|
||||
/// Secured by internal service key (X-Service-Key header).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/sync")]
|
||||
public sealed class MetricSyncController : ControllerBase
|
||||
{
|
||||
private readonly MetricSyncService _sync;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<MetricSyncController> _log;
|
||||
|
||||
public MetricSyncController(
|
||||
MetricSyncService sync,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<MetricSyncController> log)
|
||||
{
|
||||
_sync = sync;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync metrics for a specific client.
|
||||
/// Pulls from all active channel campaign providers, writes to DB,
|
||||
/// then triggers recommendation evaluation.
|
||||
/// </summary>
|
||||
[HttpPost("metrics/{clientId}")]
|
||||
public async Task<IActionResult> SyncClient(
|
||||
string clientId,
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireServiceKey();
|
||||
if (!ok) return StatusCode(403, new { ok = false, error = err });
|
||||
|
||||
_log.LogInformation("[MetricSync] Manual sync triggered for client {ClientId}", clientId);
|
||||
|
||||
var result = await _sync.SyncClientMetricsAsync(clientId, startDate, endDate, ct);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ok = result.Success,
|
||||
clientId = result.ClientId,
|
||||
campaignsProcessed = result.CampaignsProcessed,
|
||||
metricsWritten = result.MetricsWritten,
|
||||
recommendationsGenerated = result.RecommendationsGenerated,
|
||||
skipped = result.Skipped,
|
||||
errors = result.Errors,
|
||||
error = result.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
153
Gateway/Controllers/RecommendationController.cs
Normal file
153
Gateway/Controllers/RecommendationController.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Client-facing recommendation endpoints.
|
||||
///
|
||||
/// Clients can view, dismiss, and resolve recommendations for their
|
||||
/// own campaigns. All endpoints are scoped to the authenticated client.
|
||||
///
|
||||
/// Admin operations (rule CRUD, evaluate, cleanup) live in the
|
||||
/// Management API at /api/admin/recommendations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/recommendations")]
|
||||
public sealed class RecommendationController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<RecommendationController> _log;
|
||||
|
||||
public RecommendationController(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
AuthorizationGuard guard,
|
||||
ILogger<RecommendationController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Client-Facing: List Recommendations
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Get active recommendations for the authenticated client's dashboard.
|
||||
/// Returns recommendations sorted by severity (critical first).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListByClient(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.Recommendation, "listByClient",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId,
|
||||
status = status ?? "active",
|
||||
limit = limit ?? 50
|
||||
}), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get recommendations for a specific initiative (ownership verified).
|
||||
/// </summary>
|
||||
[HttpGet("initiative/{initiativeId:long}")]
|
||||
public async Task<IActionResult> ListByInitiative(
|
||||
long initiativeId,
|
||||
[FromQuery] string? status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
return await Exec(SqlNames.Procs.Recommendation, "listByInitiative",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
initiativeId,
|
||||
status = status ?? "active"
|
||||
}), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Client-Facing: Manage Recommendations
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Dismiss a recommendation (user explicitly ignores it).
|
||||
/// </summary>
|
||||
[HttpPost("{recommendationId:long}/dismiss")]
|
||||
public async Task<IActionResult> Dismiss(long recommendationId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
// Ownership check: verify the recommendation belongs to this client
|
||||
// The SP itself filters by recId, but we pass userId for audit trail
|
||||
return await Exec(SqlNames.Procs.Recommendation, "dismiss",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
recommendationId,
|
||||
userId = _client.UserId
|
||||
}), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a recommendation (action was taken to address it).
|
||||
/// </summary>
|
||||
[HttpPost("{recommendationId:long}/resolve")]
|
||||
public async Task<IActionResult> Resolve(long recommendationId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
return await Exec(SqlNames.Procs.Recommendation, "resolve",
|
||||
JsonSerializer.Serialize(new { recommendationId }), ct);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return NotFound(JsonSerializer.Deserialize<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Recommendation {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
301
Gateway/Controllers/WizardController.cs
Normal file
301
Gateway/Controllers/WizardController.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign wizard endpoints.
|
||||
///
|
||||
/// SECURITY: Every wizard operation validates ownership (wizard → client).
|
||||
/// ClientId is always injected server-side.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/wizard")]
|
||||
public sealed class WizardController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<WizardController> _log;
|
||||
|
||||
public WizardController(SqlService sql, ClientContext client, AuthorizationGuard guard, ILogger<WizardController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get active categories + objectives for wizard Step 1.
|
||||
/// Client-authenticated (not admin). Read-only.
|
||||
/// Calls spAdminTemplateConfig with action 'public.config'.
|
||||
/// </summary>
|
||||
[HttpGet("config")]
|
||||
public async Task<IActionResult> GetConfig(CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.TemplateConfig,
|
||||
"public.config",
|
||||
"{}",
|
||||
ct: ct
|
||||
);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Config service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Wizard config error");
|
||||
return StatusCode(500, new { ok = false, error = "Config service error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Create a new wizard (no ownership check — creates for current client).</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateWizardRequest? request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId, // ← SERVER-SIDE
|
||||
userId = _client.UserId,
|
||||
name = request?.Name,
|
||||
url = request?.Url
|
||||
});
|
||||
|
||||
return await ExecAndReturn("create", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Get wizard by ID (ownership verified).</summary>
|
||||
[HttpGet("{wizardId}")]
|
||||
public async Task<IActionResult> Get(string wizardId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
// Return already-fetched entity
|
||||
if (!string.IsNullOrWhiteSpace(ownership.EntityJson))
|
||||
return Content(ownership.EntityJson, "application/json");
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId });
|
||||
return await ExecAndReturn("get", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>List wizards for current client (always scoped).</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] string? status, [FromQuery] int? limit, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
clientId = _client.ClientId, // ← scoped to authenticated client
|
||||
status,
|
||||
limit
|
||||
});
|
||||
|
||||
return await ExecAndReturn("listByClient", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Update step data (ownership verified, steps 1-4 only).</summary>
|
||||
[HttpPut("{wizardId}/step/{step:int}")]
|
||||
public async Task<IActionResult> UpdateStep(string wizardId, int step, [FromBody] UpdateStepRequest? request, CancellationToken ct)
|
||||
{
|
||||
if (step < 1 || step > 5)
|
||||
return BadRequest(new { ok = false, error = "Step must be 1-5" });
|
||||
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
wizardId,
|
||||
step,
|
||||
data = request?.Data,
|
||||
name = request?.Name
|
||||
});
|
||||
|
||||
return await ExecAndReturn("updateStep", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Navigate to step (ownership verified).</summary>
|
||||
[HttpPatch("{wizardId}/step/{step:int}")]
|
||||
public async Task<IActionResult> SetStep(string wizardId, int step, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId, step });
|
||||
return await ExecAndReturn("setStep", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Get wizard summary for review (ownership verified).</summary>
|
||||
[HttpGet("{wizardId}/summary")]
|
||||
public async Task<IActionResult> GetSummary(string wizardId, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId });
|
||||
return await ExecAndReturn("getSummary", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Submit wizard (ownership verified).</summary>
|
||||
[HttpPost("{wizardId}/submit")]
|
||||
public async Task<IActionResult> Submit(string wizardId, [FromBody] SubmitWizardRequest? request, CancellationToken ct)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
wizardId,
|
||||
campaignId = (string?)null,
|
||||
network = request?.Network ?? "google"
|
||||
});
|
||||
|
||||
return await ExecAndReturn("submit", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Update wizard status (ownership verified, transition rules applied).</summary>
|
||||
[HttpPatch("{wizardId}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(string wizardId, [FromBody] UpdateStatusRequest? request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Status))
|
||||
return BadRequest(new { ok = false, error = "status is required" });
|
||||
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
// Wizard status transitions: only allow cancel from draft
|
||||
var current = ownership.CurrentStatus ?? "draft";
|
||||
var requested = request.Status.ToLowerInvariant();
|
||||
var isAdmin = string.Equals(_client.Role, "admin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isAdmin && requested != "cancelled")
|
||||
return BadRequest(new { ok = false, error = $"Cannot change wizard status to '{requested}'" });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId, status = request.Status });
|
||||
return await ExecAndReturn("updateStatus", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>Delete wizard (ownership verified).</summary>
|
||||
[HttpDelete("{wizardId}")]
|
||||
public async Task<IActionResult> Delete(string wizardId, [FromQuery] bool force = false, CancellationToken ct = default)
|
||||
{
|
||||
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
|
||||
if (!ownership.IsAllowed)
|
||||
return NotFound(new { ok = false, error = ownership.Error });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { wizardId, force });
|
||||
return await ExecAndReturn("delete", rqst, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get audience-adjusted channel mix recommendation.
|
||||
/// Calls spAllocationRecommend with audience factors.
|
||||
/// </summary>
|
||||
[HttpPost("recommend")]
|
||||
public async Task<IActionResult> Recommend([FromBody] RecommendRequest? request, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = _guard.RequireAuth();
|
||||
if (!ok) return Unauthorized(new { ok = false, error = err });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request?.BusinessCategory) || string.IsNullOrWhiteSpace(request?.Objective))
|
||||
return BadRequest(new { ok = false, error = "businessCategory and objective are required" });
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
businessCategory = request.BusinessCategory,
|
||||
objective = request.Objective,
|
||||
ageSkew = request.AgeSkew,
|
||||
marketScope = request.MarketScope
|
||||
});
|
||||
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.AllocationRecommend,
|
||||
"recommend",
|
||||
rqst,
|
||||
ct: ct
|
||||
);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Recommendation service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Recommend error");
|
||||
return StatusCode(500, new { ok = false, error = "Recommendation service error" });
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Helper
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> ExecAndReturn(string action, string rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spCampaignWizard", action, rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Wizard service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return NotFound(JsonSerializer.Deserialize<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Wizard {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Wizard service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class CreateWizardRequest { public string? Name { get; set; } public string? Url { get; set; } }
|
||||
public sealed class UpdateStepRequest { public object? Data { get; set; } public string? Name { get; set; } }
|
||||
public sealed class SubmitWizardRequest { public string? Network { get; set; } }
|
||||
public sealed class UpdateStatusRequest { public string? Status { get; set; } }
|
||||
public sealed class RecommendRequest
|
||||
{
|
||||
public string? BusinessCategory { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public string? AgeSkew { get; set; }
|
||||
public string? MarketScope { get; set; }
|
||||
}
|
||||
@@ -1,14 +1,36 @@
|
||||
namespace Gateway.Data;
|
||||
namespace Gateway.Data;
|
||||
|
||||
public static class SqlNames
|
||||
{
|
||||
public static class Procs
|
||||
{
|
||||
public const string Client = "dbo.spClient";
|
||||
public const string User = "dbo.spUser";
|
||||
// ── Existing ──
|
||||
public const string Client = "dbo.spClient";
|
||||
public const string User = "dbo.spUser";
|
||||
public const string UserClientRole = "dbo.spUserClientRole";
|
||||
public const string AdAccount = "dbo.spAdAccount";
|
||||
public const string AdCampaign = "dbo.spAdCampaign";
|
||||
public const string Invoice = "dbo.spInvoice";
|
||||
public const string AdAccount = "dbo.spAdAccount";
|
||||
public const string AdCampaign = "dbo.spAdCampaign";
|
||||
public const string Invoice = "dbo.spInvoice";
|
||||
|
||||
// ── Multi-Channel ──
|
||||
public const string Initiative = "dbo.spInitiative";
|
||||
public const string ChannelCampaign = "dbo.spChannelCampaign";
|
||||
public const string ChannelConfig = "dbo.spChannelConfig";
|
||||
public const string InitiativeStage = "dbo.spInitiativeStage";
|
||||
public const string Allocation = "dbo.spAllocation";
|
||||
public const string ObjectiveMapping = "dbo.spObjectiveMapping";
|
||||
public const string PerformanceMetric = "dbo.spPerformanceMetric";
|
||||
|
||||
// ── Campaign Wizard ──
|
||||
public const string CampaignWizard = "dbo.spCampaignWizard";
|
||||
public const string TemplateConfig = "dbo.spAdminTemplateConfig";
|
||||
public const string AllocationRecommend = "dbo.spAllocationRecommend";
|
||||
|
||||
// ── Campaign Intelligence ──
|
||||
public const string CampaignIntelligence = "dbo.spCampaignIntelligence";
|
||||
public const string Recommendation = "dbo.spRecommendation";
|
||||
|
||||
// ── Census Demographics ──
|
||||
public const string Demographics = "dbo.spDemographics";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.4.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.16.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
321
Gateway/Migrations/001_ChannelConfig.sql
Normal file
321
Gateway/Migrations/001_ChannelConfig.sql
Normal file
@@ -0,0 +1,321 @@
|
||||
-- ============================================================
|
||||
-- 001_ChannelConfig.sql
|
||||
-- Move channel provider configuration from appsettings.json
|
||||
-- into database-driven configuration.
|
||||
-- ============================================================
|
||||
|
||||
-- ── Table ──────────────────────────────────────────────────
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'tbChannelConfig')
|
||||
BEGIN
|
||||
CREATE TABLE dbo.tbChannelConfig (
|
||||
chcChannelType VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||
chcDisplayName NVARCHAR(100) NOT NULL,
|
||||
chcDescription NVARCHAR(500) NULL,
|
||||
chcIcon VARCHAR(50) NULL,
|
||||
chcColor VARCHAR(20) NULL,
|
||||
chcEnabled BIT NOT NULL DEFAULT 1,
|
||||
chcIsStub BIT NOT NULL DEFAULT 1,
|
||||
chcEndpoint VARCHAR(500) NULL,
|
||||
chcInternalKey VARCHAR(500) NULL,
|
||||
chcMinDailyBudget DECIMAL(10,2) NOT NULL DEFAULT 5.00,
|
||||
chcMinMonthlyBudget DECIMAL(10,2) NOT NULL DEFAULT 150.00,
|
||||
chcSupportedObjectives NVARCHAR(500) NULL, -- JSON array: ["sales","leads","traffic"]
|
||||
chcSupportedCreativeFormats NVARCHAR(500) NULL, -- JSON array: ["text","image","video"]
|
||||
chcApprovalEstimateHours INT NOT NULL DEFAULT 24,
|
||||
chcMetricsRefreshIntervalMinutes INT NOT NULL DEFAULT 60,
|
||||
chcAuthMethod VARCHAR(50) NULL,
|
||||
chcKeyVaultSecretName VARCHAR(200) NULL,
|
||||
chcStatusMappings NVARCHAR(MAX) NULL, -- JSON object: {"ENABLED":"active",...}
|
||||
chcSortOrder INT NOT NULL DEFAULT 0,
|
||||
chcCreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||
chcUpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||
);
|
||||
PRINT 'Created table tbChannelConfig';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ── Stored Procedure ───────────────────────────────────────
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.spChannelConfig
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX) = '{}',
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
-- ── list: return all enabled channels ──
|
||||
IF @action = 'list'
|
||||
BEGIN
|
||||
SET @resp = (
|
||||
SELECT
|
||||
chcChannelType AS channelType,
|
||||
chcDisplayName AS displayName,
|
||||
chcDescription AS [description],
|
||||
chcIcon AS icon,
|
||||
chcColor AS color,
|
||||
chcEnabled AS [enabled],
|
||||
chcIsStub AS isStub,
|
||||
chcEndpoint AS endpoint,
|
||||
chcInternalKey AS internalKey,
|
||||
chcMinDailyBudget AS minDailyBudget,
|
||||
chcMinMonthlyBudget AS minMonthlyBudget,
|
||||
JSON_QUERY(chcSupportedObjectives) AS supportedObjectives,
|
||||
JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats,
|
||||
chcApprovalEstimateHours AS approvalEstimateHours,
|
||||
chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes,
|
||||
chcAuthMethod AS authMethod,
|
||||
chcKeyVaultSecretName AS keyVaultSecretName,
|
||||
JSON_QUERY(chcStatusMappings) AS statusMappings,
|
||||
chcSortOrder AS sortOrder
|
||||
FROM dbo.tbChannelConfig
|
||||
ORDER BY chcSortOrder, chcChannelType
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
IF @resp IS NULL SET @resp = '[]';
|
||||
SET @resp = '{"ok":true,"channels":' + @resp + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── listAll: return all channels including disabled (for admin) ──
|
||||
IF @action = 'listAll'
|
||||
BEGIN
|
||||
SET @resp = (
|
||||
SELECT
|
||||
chcChannelType AS channelType,
|
||||
chcDisplayName AS displayName,
|
||||
chcDescription AS [description],
|
||||
chcIcon AS icon,
|
||||
chcColor AS color,
|
||||
chcEnabled AS [enabled],
|
||||
chcIsStub AS isStub,
|
||||
chcEndpoint AS endpoint,
|
||||
chcMinDailyBudget AS minDailyBudget,
|
||||
chcMinMonthlyBudget AS minMonthlyBudget,
|
||||
JSON_QUERY(chcSupportedObjectives) AS supportedObjectives,
|
||||
JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats,
|
||||
chcApprovalEstimateHours AS approvalEstimateHours,
|
||||
chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes,
|
||||
chcAuthMethod AS authMethod,
|
||||
chcKeyVaultSecretName AS keyVaultSecretName,
|
||||
JSON_QUERY(chcStatusMappings) AS statusMappings,
|
||||
chcSortOrder AS sortOrder,
|
||||
chcCreatedAt AS createdAt,
|
||||
chcUpdatedAt AS updatedAt
|
||||
FROM dbo.tbChannelConfig
|
||||
ORDER BY chcSortOrder, chcChannelType
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
IF @resp IS NULL SET @resp = '[]';
|
||||
SET @resp = '{"ok":true,"channels":' + @resp + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── get: return single channel by type ──
|
||||
IF @action = 'get'
|
||||
BEGIN
|
||||
DECLARE @channelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = @channelType)
|
||||
BEGIN
|
||||
SET @resp = '{"ok":false,"error":"Channel not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
chcChannelType AS channelType,
|
||||
chcDisplayName AS displayName,
|
||||
chcDescription AS [description],
|
||||
chcIcon AS icon,
|
||||
chcColor AS color,
|
||||
chcEnabled AS [enabled],
|
||||
chcIsStub AS isStub,
|
||||
chcEndpoint AS endpoint,
|
||||
chcMinDailyBudget AS minDailyBudget,
|
||||
chcMinMonthlyBudget AS minMonthlyBudget,
|
||||
JSON_QUERY(chcSupportedObjectives) AS supportedObjectives,
|
||||
JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats,
|
||||
chcApprovalEstimateHours AS approvalEstimateHours,
|
||||
chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes,
|
||||
chcAuthMethod AS authMethod,
|
||||
chcKeyVaultSecretName AS keyVaultSecretName,
|
||||
JSON_QUERY(chcStatusMappings) AS statusMappings
|
||||
FROM dbo.tbChannelConfig
|
||||
WHERE chcChannelType = @channelType
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
|
||||
SET @resp = '{"ok":true,"channel":' + @resp + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── upsert: create or update a channel (admin) ──
|
||||
IF @action = 'upsert'
|
||||
BEGIN
|
||||
DECLARE @uChannelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType');
|
||||
|
||||
IF @uChannelType IS NULL
|
||||
BEGIN
|
||||
SET @resp = '{"ok":false,"error":"channelType is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
MERGE dbo.tbChannelConfig AS tgt
|
||||
USING (SELECT @uChannelType AS chcChannelType) AS src
|
||||
ON tgt.chcChannelType = src.chcChannelType
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET
|
||||
chcDisplayName = ISNULL(JSON_VALUE(@rqst, '$.displayName'), tgt.chcDisplayName),
|
||||
chcDescription = ISNULL(JSON_VALUE(@rqst, '$.description'), tgt.chcDescription),
|
||||
chcIcon = ISNULL(JSON_VALUE(@rqst, '$.icon'), tgt.chcIcon),
|
||||
chcColor = ISNULL(JSON_VALUE(@rqst, '$.color'), tgt.chcColor),
|
||||
chcEnabled = ISNULL(CAST(JSON_VALUE(@rqst, '$.enabled') AS BIT), tgt.chcEnabled),
|
||||
chcIsStub = ISNULL(CAST(JSON_VALUE(@rqst, '$.isStub') AS BIT), tgt.chcIsStub),
|
||||
chcEndpoint = CASE WHEN JSON_VALUE(@rqst, '$.endpoint') IS NOT NULL
|
||||
THEN JSON_VALUE(@rqst, '$.endpoint')
|
||||
ELSE tgt.chcEndpoint END,
|
||||
chcInternalKey = CASE WHEN JSON_VALUE(@rqst, '$.internalKey') IS NOT NULL
|
||||
THEN JSON_VALUE(@rqst, '$.internalKey')
|
||||
ELSE tgt.chcInternalKey END,
|
||||
chcMinDailyBudget = ISNULL(CAST(JSON_VALUE(@rqst, '$.minDailyBudget') AS DECIMAL(10,2)), tgt.chcMinDailyBudget),
|
||||
chcMinMonthlyBudget = ISNULL(CAST(JSON_VALUE(@rqst, '$.minMonthlyBudget') AS DECIMAL(10,2)), tgt.chcMinMonthlyBudget),
|
||||
chcSupportedObjectives = CASE WHEN JSON_QUERY(@rqst, '$.supportedObjectives') IS NOT NULL
|
||||
THEN JSON_QUERY(@rqst, '$.supportedObjectives')
|
||||
ELSE tgt.chcSupportedObjectives END,
|
||||
chcSupportedCreativeFormats = CASE WHEN JSON_QUERY(@rqst, '$.supportedCreativeFormats') IS NOT NULL
|
||||
THEN JSON_QUERY(@rqst, '$.supportedCreativeFormats')
|
||||
ELSE tgt.chcSupportedCreativeFormats END,
|
||||
chcApprovalEstimateHours = ISNULL(CAST(JSON_VALUE(@rqst, '$.approvalEstimateHours') AS INT), tgt.chcApprovalEstimateHours),
|
||||
chcMetricsRefreshIntervalMinutes = ISNULL(CAST(JSON_VALUE(@rqst, '$.metricsRefreshIntervalMinutes') AS INT), tgt.chcMetricsRefreshIntervalMinutes),
|
||||
chcAuthMethod = ISNULL(JSON_VALUE(@rqst, '$.authMethod'), tgt.chcAuthMethod),
|
||||
chcKeyVaultSecretName = ISNULL(JSON_VALUE(@rqst, '$.keyVaultSecretName'), tgt.chcKeyVaultSecretName),
|
||||
chcStatusMappings = CASE WHEN JSON_QUERY(@rqst, '$.statusMappings') IS NOT NULL
|
||||
THEN JSON_QUERY(@rqst, '$.statusMappings')
|
||||
ELSE tgt.chcStatusMappings END,
|
||||
chcSortOrder = ISNULL(CAST(JSON_VALUE(@rqst, '$.sortOrder') AS INT), tgt.chcSortOrder),
|
||||
chcUpdatedAt = GETUTCDATE()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor,
|
||||
chcEnabled, chcIsStub, chcEndpoint, chcInternalKey,
|
||||
chcMinDailyBudget, chcMinMonthlyBudget,
|
||||
chcSupportedObjectives, chcSupportedCreativeFormats,
|
||||
chcApprovalEstimateHours, chcMetricsRefreshIntervalMinutes,
|
||||
chcAuthMethod, chcKeyVaultSecretName, chcStatusMappings, chcSortOrder)
|
||||
VALUES (
|
||||
@uChannelType,
|
||||
JSON_VALUE(@rqst, '$.displayName'),
|
||||
JSON_VALUE(@rqst, '$.description'),
|
||||
JSON_VALUE(@rqst, '$.icon'),
|
||||
JSON_VALUE(@rqst, '$.color'),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.enabled') AS BIT), 1),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.isStub') AS BIT), 1),
|
||||
JSON_VALUE(@rqst, '$.endpoint'),
|
||||
JSON_VALUE(@rqst, '$.internalKey'),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.minDailyBudget') AS DECIMAL(10,2)), 5.00),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.minMonthlyBudget') AS DECIMAL(10,2)), 150.00),
|
||||
JSON_QUERY(@rqst, '$.supportedObjectives'),
|
||||
JSON_QUERY(@rqst, '$.supportedCreativeFormats'),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.approvalEstimateHours') AS INT), 24),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.metricsRefreshIntervalMinutes') AS INT), 60),
|
||||
JSON_VALUE(@rqst, '$.authMethod'),
|
||||
JSON_VALUE(@rqst, '$.keyVaultSecretName'),
|
||||
JSON_QUERY(@rqst, '$.statusMappings'),
|
||||
ISNULL(CAST(JSON_VALUE(@rqst, '$.sortOrder') AS INT), 0)
|
||||
);
|
||||
|
||||
SET @resp = '{"ok":true}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── delete: remove a channel (admin) ──
|
||||
IF @action = 'delete'
|
||||
BEGIN
|
||||
DECLARE @dChannelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType');
|
||||
|
||||
DELETE FROM dbo.tbChannelConfig WHERE chcChannelType = @dChannelType;
|
||||
|
||||
SET @resp = '{"ok":true,"deleted":' + CAST(@@ROWCOUNT AS VARCHAR) + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = '{"ok":false,"error":"Unknown action: ' + @action + '"}';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ── Seed Data ──────────────────────────────────────────────
|
||||
|
||||
-- Google Ads
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'google_ads')
|
||||
INSERT INTO dbo.tbChannelConfig (
|
||||
chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor,
|
||||
chcEnabled, chcIsStub, chcEndpoint,
|
||||
chcMinDailyBudget, chcMinMonthlyBudget,
|
||||
chcSupportedObjectives, chcSupportedCreativeFormats,
|
||||
chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName,
|
||||
chcStatusMappings, chcSortOrder
|
||||
) VALUES (
|
||||
'google_ads',
|
||||
'Google Ads',
|
||||
'Search, Display, Shopping & Performance Max across Google properties',
|
||||
'google', '#4285F4',
|
||||
1, 0, NULL,
|
||||
10.00, 300.00,
|
||||
'["awareness","traffic","conversions","leads","sales"]',
|
||||
'["text","image","responsive","video"]',
|
||||
24, 'mcc', 'google-ads-refresh-token',
|
||||
'{"ENABLED":"active","Enabled":"active","PAUSED":"paused","Paused":"paused","REMOVED":"cancelled","Removed":"cancelled","UNKNOWN":"error","UNSPECIFIED":"error"}',
|
||||
1
|
||||
);
|
||||
|
||||
-- Meta
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'meta')
|
||||
INSERT INTO dbo.tbChannelConfig (
|
||||
chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor,
|
||||
chcEnabled, chcIsStub, chcEndpoint,
|
||||
chcMinDailyBudget, chcMinMonthlyBudget,
|
||||
chcSupportedObjectives, chcSupportedCreativeFormats,
|
||||
chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName,
|
||||
chcStatusMappings, chcSortOrder
|
||||
) VALUES (
|
||||
'meta',
|
||||
'Meta Ads',
|
||||
'Facebook, Instagram, Messenger & Threads advertising',
|
||||
'meta', '#1877F2',
|
||||
1, 1, NULL,
|
||||
5.00, 250.00,
|
||||
'["awareness","traffic","conversions","leads","sales"]',
|
||||
'["image","video","carousel","stories"]',
|
||||
48, 'oauth2', 'meta-access-token',
|
||||
'{"ACTIVE":"active","PAUSED":"paused","DELETED":"cancelled","ARCHIVED":"completed","IN_PROCESS":"pending","WITH_ISSUES":"error","CAMPAIGN_PAUSED":"paused","ADSET_PAUSED":"paused","DISAPPROVED":"error","PREAPPROVED":"pending","PENDING_REVIEW":"pending","PENDING_BILLING_INFO":"error"}',
|
||||
2
|
||||
);
|
||||
|
||||
-- TikTok
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'tiktok')
|
||||
INSERT INTO dbo.tbChannelConfig (
|
||||
chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor,
|
||||
chcEnabled, chcIsStub, chcEndpoint,
|
||||
chcMinDailyBudget, chcMinMonthlyBudget,
|
||||
chcSupportedObjectives, chcSupportedCreativeFormats,
|
||||
chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName,
|
||||
chcStatusMappings, chcSortOrder
|
||||
) VALUES (
|
||||
'tiktok',
|
||||
'TikTok Ads',
|
||||
'In-feed video ads across TikTok and partner apps',
|
||||
'tiktok', '#000000',
|
||||
1, 1, NULL,
|
||||
20.00, 200.00,
|
||||
'["awareness","traffic","conversions","leads","sales"]',
|
||||
'["video","image","spark_ads"]',
|
||||
24, 'oauth2', 'tiktok-access-token',
|
||||
'{"ENABLE":"active","CAMPAIGN_STATUS_ENABLE":"active","DISABLE":"paused","CAMPAIGN_STATUS_DISABLE":"paused","DELETE":"cancelled","CAMPAIGN_STATUS_DELETE":"cancelled","BUDGET_EXCEED":"paused","CAMPAIGN_STATUS_BUDGET_EXCEED":"paused","ADVERTISER_AUDIT_DENY":"error","CAMPAIGN_STATUS_ADVERTISER_AUDIT_DENY":"error","NOT_DELETE":"active","ADVERTISER_AUDIT":"pending","CAMPAIGN_STATUS_ADVERTISER_AUDIT":"pending","REAUDIT":"pending","ALL":"active"}',
|
||||
3
|
||||
);
|
||||
|
||||
PRINT 'Channel config seeded successfully';
|
||||
GO
|
||||
257
Gateway/Migrations/007_ProviderStatusMap.sql
Normal file
257
Gateway/Migrations/007_ProviderStatusMap.sql
Normal file
@@ -0,0 +1,257 @@
|
||||
-- ============================================================
|
||||
-- Provider Status Mapping Reference Table
|
||||
-- ============================================================
|
||||
-- Maps provider-specific campaign statuses to platform statuses.
|
||||
-- Runtime normalization is config-driven (appsettings.json),
|
||||
-- but this table serves as:
|
||||
-- 1. Canonical reference / documentation
|
||||
-- 2. Admin-editable override (future phase)
|
||||
-- 3. Audit trail for mapping changes
|
||||
--
|
||||
-- Platform statuses: draft, staged, pending, active, paused,
|
||||
-- completed, cancelled, error
|
||||
-- ============================================================
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'tbProviderStatusMap')
|
||||
BEGIN
|
||||
CREATE TABLE dbo.tbProviderStatusMap (
|
||||
psmId INT IDENTITY(1,1) PRIMARY KEY,
|
||||
psmChannelType VARCHAR(50) NOT NULL, -- google_ads, meta, tiktok
|
||||
psmProviderStatus VARCHAR(100) NOT NULL, -- raw provider value (ENABLED, DELIVERY_OK, etc.)
|
||||
psmPlatformStatus VARCHAR(20) NOT NULL, -- normalized platform value
|
||||
psmDescription NVARCHAR(200) NULL, -- human-readable explanation
|
||||
psmIsActive BIT NOT NULL DEFAULT 1,
|
||||
psmCreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
|
||||
psmUpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
|
||||
|
||||
CONSTRAINT UQ_ProviderStatusMap_Channel_Status
|
||||
UNIQUE (psmChannelType, psmProviderStatus),
|
||||
|
||||
CONSTRAINT CK_ProviderStatusMap_PlatformStatus
|
||||
CHECK (psmPlatformStatus IN ('draft','staged','pending','active','paused','completed','cancelled','error'))
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_ProviderStatusMap_Channel
|
||||
ON dbo.tbProviderStatusMap (psmChannelType)
|
||||
INCLUDE (psmProviderStatus, psmPlatformStatus)
|
||||
WHERE psmIsActive = 1;
|
||||
END
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: Google Ads
|
||||
-- ============================================================
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (VALUES
|
||||
('google_ads', 'ENABLED', 'active', 'Campaign is serving ads'),
|
||||
('google_ads', 'Enabled', 'active', 'Campaign is serving ads (camelCase variant)'),
|
||||
('google_ads', 'PAUSED', 'paused', 'Campaign is paused by advertiser'),
|
||||
('google_ads', 'Paused', 'paused', 'Campaign is paused (camelCase variant)'),
|
||||
('google_ads', 'REMOVED', 'cancelled', 'Campaign has been removed'),
|
||||
('google_ads', 'Removed', 'cancelled', 'Campaign has been removed (camelCase variant)'),
|
||||
('google_ads', 'UNKNOWN', 'error', 'Unknown status from Google Ads API'),
|
||||
('google_ads', 'UNSPECIFIED', 'error', 'Unspecified status from Google Ads API')
|
||||
) AS src (channelType, providerStatus, platformStatus, description)
|
||||
ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description);
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: Meta (Facebook / Instagram)
|
||||
-- ============================================================
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (VALUES
|
||||
('meta', 'ACTIVE', 'active', 'Campaign is delivering'),
|
||||
('meta', 'PAUSED', 'paused', 'Campaign paused by advertiser'),
|
||||
('meta', 'DELETED', 'cancelled', 'Campaign deleted'),
|
||||
('meta', 'ARCHIVED', 'completed', 'Campaign archived after completion'),
|
||||
('meta', 'IN_PROCESS', 'pending', 'Campaign is being processed'),
|
||||
('meta', 'WITH_ISSUES', 'error', 'Campaign has delivery issues'),
|
||||
('meta', 'CAMPAIGN_PAUSED', 'paused', 'Parent campaign is paused'),
|
||||
('meta', 'ADSET_PAUSED', 'paused', 'Ad set level pause'),
|
||||
('meta', 'DISAPPROVED', 'error', 'Ad/campaign disapproved by review'),
|
||||
('meta', 'PREAPPROVED', 'pending', 'Preapproved, awaiting final review'),
|
||||
('meta', 'PENDING_REVIEW', 'pending', 'Awaiting Meta ad review'),
|
||||
('meta', 'PENDING_BILLING_INFO','error', 'Billing information required')
|
||||
) AS src (channelType, providerStatus, platformStatus, description)
|
||||
ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description);
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: TikTok
|
||||
-- ============================================================
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (VALUES
|
||||
('tiktok', 'ENABLE', 'active', 'Campaign is active and delivering'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_ENABLE', 'active', 'Campaign enabled (prefixed variant)'),
|
||||
('tiktok', 'DISABLE', 'paused', 'Campaign disabled by advertiser'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_DISABLE', 'paused', 'Campaign disabled (prefixed variant)'),
|
||||
('tiktok', 'DELETE', 'cancelled', 'Campaign deleted'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_DELETE', 'cancelled', 'Campaign deleted (prefixed variant)'),
|
||||
('tiktok', 'BUDGET_EXCEED', 'paused', 'Budget limit exceeded'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_BUDGET_EXCEED', 'paused', 'Budget exceeded (prefixed variant)'),
|
||||
('tiktok', 'ADVERTISER_AUDIT_DENY', 'error', 'Advertiser account audit denied'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_ADVERTISER_AUDIT_DENY','error', 'Audit denied (prefixed variant)'),
|
||||
('tiktok', 'NOT_DELETE', 'active', 'Campaign exists and is not deleted'),
|
||||
('tiktok', 'ADVERTISER_AUDIT', 'pending', 'Advertiser account under audit'),
|
||||
('tiktok', 'CAMPAIGN_STATUS_ADVERTISER_AUDIT', 'pending', 'Under audit (prefixed variant)'),
|
||||
('tiktok', 'REAUDIT', 'pending', 'Campaign under re-audit'),
|
||||
('tiktok', 'ALL', 'active', 'TikTok ALL filter status (treat as active)')
|
||||
) AS src (channelType, providerStatus, platformStatus, description)
|
||||
ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description);
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: Common / Internal (platform-generated statuses)
|
||||
-- ============================================================
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (VALUES
|
||||
('_common', 'submitted', 'active', 'Successfully dispatched to provider'),
|
||||
('_common', 'pending_review', 'pending', 'Awaiting provider review'),
|
||||
('_common', 'stub_provider', 'pending', 'Stub provider — no real dispatch yet'),
|
||||
('_common', 'approved', 'active', 'Provider approved the campaign'),
|
||||
('_common', 'rejected', 'error', 'Provider rejected the campaign'),
|
||||
('_common', 'suspended', 'paused', 'Campaign suspended by provider'),
|
||||
('_common', 'budget_depleted', 'paused', 'Budget fully consumed'),
|
||||
('_common', 'expired', 'completed', 'Campaign reached its end date'),
|
||||
('_common', 'archived', 'completed', 'Campaign archived'),
|
||||
('_common', 'deleted', 'cancelled', 'Campaign deleted'),
|
||||
('_common', 'in_process', 'pending', 'Campaign is being processed'),
|
||||
('_common', 'in_review', 'pending', 'Campaign is under review'),
|
||||
('_common', 'learning', 'active', 'Campaign in learning/optimization phase'),
|
||||
('_common', 'limited', 'active', 'Campaign serving but limited (budget, targeting)')
|
||||
) AS src (channelType, providerStatus, platformStatus, description)
|
||||
ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description);
|
||||
GO
|
||||
|
||||
-- ============================================================
|
||||
-- Stored Procedure: spProviderStatusMap
|
||||
-- ============================================================
|
||||
-- Actions: list, get, upsert, delete
|
||||
-- Follows standard JSON request/response pattern.
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE dbo.spProviderStatusMap
|
||||
@Action VARCHAR(20),
|
||||
@Rqst NVARCHAR(MAX) = '{}',
|
||||
@Resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
-- ── LIST ──
|
||||
IF @Action = 'list'
|
||||
BEGIN
|
||||
DECLARE @filterChannel VARCHAR(50) = JSON_VALUE(@Rqst, '$.channelType');
|
||||
|
||||
SET @Resp = (
|
||||
SELECT
|
||||
psmId AS id,
|
||||
psmChannelType AS channelType,
|
||||
psmProviderStatus AS providerStatus,
|
||||
psmPlatformStatus AS platformStatus,
|
||||
psmDescription AS [description],
|
||||
psmIsActive AS isActive
|
||||
FROM dbo.tbProviderStatusMap
|
||||
WHERE psmIsActive = 1
|
||||
AND (@filterChannel IS NULL OR psmChannelType = @filterChannel)
|
||||
ORDER BY psmChannelType, psmProviderStatus
|
||||
FOR JSON PATH, ROOT('data')
|
||||
);
|
||||
|
||||
IF @Resp IS NULL SET @Resp = '{"data":[]}';
|
||||
SET @Resp = '{"ok":true,' + SUBSTRING(@Resp, 2, LEN(@Resp));
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── GET ──
|
||||
IF @Action = 'get'
|
||||
BEGIN
|
||||
DECLARE @getId INT = JSON_VALUE(@Rqst, '$.id');
|
||||
|
||||
SET @Resp = (
|
||||
SELECT
|
||||
psmId AS id,
|
||||
psmChannelType AS channelType,
|
||||
psmProviderStatus AS providerStatus,
|
||||
psmPlatformStatus AS platformStatus,
|
||||
psmDescription AS [description],
|
||||
psmIsActive AS isActive
|
||||
FROM dbo.tbProviderStatusMap
|
||||
WHERE psmId = @getId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
|
||||
IF @Resp IS NULL
|
||||
BEGIN
|
||||
SET @Resp = '{"ok":false,"error":"Mapping not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @Resp = '{"ok":true,"data":' + @Resp + '}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── UPSERT ──
|
||||
IF @Action = 'upsert'
|
||||
BEGIN
|
||||
DECLARE @uChannelType VARCHAR(50) = JSON_VALUE(@Rqst, '$.channelType');
|
||||
DECLARE @uProviderStatus VARCHAR(100) = JSON_VALUE(@Rqst, '$.providerStatus');
|
||||
DECLARE @uPlatformStatus VARCHAR(20) = JSON_VALUE(@Rqst, '$.platformStatus');
|
||||
DECLARE @uDescription NVARCHAR(200) = JSON_VALUE(@Rqst, '$.description');
|
||||
|
||||
IF @uChannelType IS NULL OR @uProviderStatus IS NULL OR @uPlatformStatus IS NULL
|
||||
BEGIN
|
||||
SET @Resp = '{"ok":false,"error":"channelType, providerStatus, and platformStatus are required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF @uPlatformStatus NOT IN ('draft','staged','pending','active','paused','completed','cancelled','error')
|
||||
BEGIN
|
||||
SET @Resp = '{"ok":false,"error":"Invalid platformStatus. Must be: draft, staged, pending, active, paused, completed, cancelled, error"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
MERGE dbo.tbProviderStatusMap AS tgt
|
||||
USING (SELECT @uChannelType, @uProviderStatus) AS src (ct, ps)
|
||||
ON tgt.psmChannelType = src.ct AND tgt.psmProviderStatus = src.ps
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET
|
||||
psmPlatformStatus = @uPlatformStatus,
|
||||
psmDescription = COALESCE(@uDescription, psmDescription),
|
||||
psmIsActive = 1,
|
||||
psmUpdatedAt = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription)
|
||||
VALUES (@uChannelType, @uProviderStatus, @uPlatformStatus, @uDescription);
|
||||
|
||||
SET @Resp = '{"ok":true,"message":"Mapping saved"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- ── DELETE (soft) ──
|
||||
IF @Action = 'delete'
|
||||
BEGIN
|
||||
DECLARE @dId INT = JSON_VALUE(@Rqst, '$.id');
|
||||
|
||||
UPDATE dbo.tbProviderStatusMap
|
||||
SET psmIsActive = 0, psmUpdatedAt = SYSUTCDATETIME()
|
||||
WHERE psmId = @dId;
|
||||
|
||||
SET @Resp = '{"ok":true,"message":"Mapping deactivated"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @Resp = '{"ok":false,"error":"Unknown action: ' + ISNULL(@Action,'null') + '"}';
|
||||
END
|
||||
GO
|
||||
174
Gateway/Migrations/SecurityHardening.sql
Normal file
174
Gateway/Migrations/SecurityHardening.sql
Normal file
@@ -0,0 +1,174 @@
|
||||
-- ════════════════════════════════════════════════════════════════
|
||||
-- SECURITY HARDENING: Stored Procedure Ownership Enforcement
|
||||
-- ════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- PURPOSE: Add WHERE clientId checks to all stored procedures
|
||||
-- that accept initiativeId, channelCampaignId, or wizardId.
|
||||
--
|
||||
-- The Gateway now passes clientId in all JSON requests.
|
||||
-- These proc changes enforce ownership at the database level
|
||||
-- as a SECOND layer of defense (the Gateway guard is the first).
|
||||
--
|
||||
-- APPLY: Run against your AdPlatform SQL Server database.
|
||||
-- TEST FIRST in dev/staging before production.
|
||||
-- ════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- PATTERN: Inside each proc's @Action handler, add:
|
||||
--
|
||||
-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId');
|
||||
--
|
||||
-- -- Then in every SELECT/UPDATE/DELETE that references an initiative:
|
||||
-- WHERE i.initiativeId = @initiativeId
|
||||
-- AND i.clientId = @clientId -- ← ADD THIS
|
||||
--
|
||||
-- -- If the WHERE filters out the row, return "not found"
|
||||
-- -- (same response as non-existent ID — prevents enumeration)
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT '=== Security Hardening Migration ==='
|
||||
PRINT ''
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 1. spInitiative — get, update, updateStatus, delete
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Hardening spInitiative...'
|
||||
|
||||
-- Example pattern for the "get" action:
|
||||
-- (Apply this pattern to get, update, updateStatus, delete actions)
|
||||
--
|
||||
-- Current:
|
||||
-- SELECT ... FROM tbInitiative WHERE initiativeId = @initiativeId
|
||||
--
|
||||
-- Hardened:
|
||||
-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId')
|
||||
-- SELECT ... FROM tbInitiative
|
||||
-- WHERE initiativeId = @initiativeId
|
||||
-- AND (@clientId IS NULL OR clientId = @clientId)
|
||||
--
|
||||
-- The @clientId IS NULL fallback allows internal/system calls
|
||||
-- (like InitiativeLaunchService) that don't pass clientId to still work.
|
||||
|
||||
-- IMPORTANT: Apply to each action in spInitiative:
|
||||
-- 'get' → WHERE initiativeId = @id AND (@clientId IS NULL OR clientId = @clientId)
|
||||
-- 'update' → same
|
||||
-- 'updateStatus' → same
|
||||
-- 'delete' → same
|
||||
-- 'list' → already scoped by clientId (verify it uses = not LIKE)
|
||||
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 2. spChannelCampaign — get, sync
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Hardening spChannelCampaign...'
|
||||
|
||||
-- Channel campaigns link to initiatives, so ownership requires a JOIN:
|
||||
--
|
||||
-- Current:
|
||||
-- SELECT cc.* FROM tbChannelCampaign cc WHERE cc.channelCampaignId = @id
|
||||
--
|
||||
-- Hardened:
|
||||
-- SELECT cc.*
|
||||
-- FROM tbChannelCampaign cc
|
||||
-- JOIN tbInitiative i ON cc.initiativeId = i.initiativeId
|
||||
-- WHERE cc.channelCampaignId = @id
|
||||
-- AND (@clientId IS NULL OR i.clientId = @clientId)
|
||||
--
|
||||
-- For 'sync' action: This is now admin-only in the Gateway,
|
||||
-- but add the JOIN anyway for defense in depth.
|
||||
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 3. spCampaignWizard — get, updateStep, setStep, submit, updateStatus, delete
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Hardening spCampaignWizard...'
|
||||
|
||||
-- Current:
|
||||
-- SELECT ... FROM tbCampaignWizard WHERE wizardId = @wizardId
|
||||
--
|
||||
-- Hardened:
|
||||
-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId')
|
||||
-- SELECT ... FROM tbCampaignWizard
|
||||
-- WHERE wizardId = @wizardId
|
||||
-- AND (@clientId IS NULL OR clientId = @clientId)
|
||||
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 4. spAllocation — all actions
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Hardening spAllocation...'
|
||||
|
||||
-- Allocations link to initiatives:
|
||||
--
|
||||
-- Hardened:
|
||||
-- JOIN tbInitiative i ON a.initiativeId = i.initiativeId
|
||||
-- WHERE a.initiativeId = @initiativeId
|
||||
-- AND (@clientId IS NULL OR i.clientId = @clientId)
|
||||
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 5. Status transition validation at DB level (optional extra layer)
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Adding status transition function...'
|
||||
|
||||
-- Create a function the procs can call to validate transitions:
|
||||
IF OBJECT_ID('dbo.fnIsValidStatusTransition', 'FN') IS NOT NULL
|
||||
DROP FUNCTION dbo.fnIsValidStatusTransition
|
||||
GO
|
||||
|
||||
CREATE FUNCTION dbo.fnIsValidStatusTransition(
|
||||
@currentStatus VARCHAR(20),
|
||||
@requestedStatus VARCHAR(20),
|
||||
@isSystem BIT = 0 -- 1 = system/admin (broader transitions allowed)
|
||||
)
|
||||
RETURNS BIT
|
||||
AS
|
||||
BEGIN
|
||||
-- System can do anything
|
||||
IF @isSystem = 1 RETURN 1
|
||||
|
||||
-- Client-allowed transitions
|
||||
IF @currentStatus = 'active' AND @requestedStatus = 'paused' RETURN 1
|
||||
IF @currentStatus = 'paused' AND @requestedStatus = 'active' RETURN 1
|
||||
IF @currentStatus IN ('draft','staged','pending','active','paused')
|
||||
AND @requestedStatus = 'cancelled' RETURN 1
|
||||
|
||||
RETURN 0
|
||||
END
|
||||
GO
|
||||
|
||||
-- ──────────────────────────────────────────────────
|
||||
-- 6. spGoogleAccount — validate
|
||||
-- ──────────────────────────────────────────────────
|
||||
|
||||
PRINT 'Verifying spGoogleAccount...'
|
||||
|
||||
-- The validate action should verify that the customerId
|
||||
-- belongs to the requesting client. Current implementation
|
||||
-- may not check this — verify and add:
|
||||
--
|
||||
-- WHERE a.customerId = @customerId
|
||||
-- AND a.clientId = @clientId
|
||||
|
||||
GO
|
||||
|
||||
PRINT ''
|
||||
PRINT '=== Migration complete ==='
|
||||
PRINT 'NOTE: This is a TEMPLATE. Review each stored procedure and apply'
|
||||
PRINT 'the ownership WHERE clauses to match your exact table/column names.'
|
||||
PRINT ''
|
||||
PRINT 'After applying, test:'
|
||||
PRINT ' 1. Normal user can only see their own initiatives/wizards'
|
||||
PRINT ' 2. User A cannot access User B resources by guessing IDs'
|
||||
PRINT ' 3. LaunchService (no clientId) can still read initiatives'
|
||||
PRINT ' 4. Admin role can sync channel status'
|
||||
GO
|
||||
137
Gateway/Models/ForecastModels.cs
Normal file
137
Gateway/Models/ForecastModels.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
namespace Gateway.Models;
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Request: Client → Gateway
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class ChannelForecastRequest
|
||||
{
|
||||
/// <summary>Advertising objective: awareness, traffic, leads, sales</summary>
|
||||
public string Objective { get; set; } = "traffic";
|
||||
|
||||
/// <summary>Business category from wizard Step 1</summary>
|
||||
public string? BusinessCategory { get; set; }
|
||||
|
||||
/// <summary>Keywords from URL analysis (Step 1)</summary>
|
||||
public List<string> Keywords { get; set; } = new();
|
||||
|
||||
/// <summary>Geo targeting from audience step</summary>
|
||||
public ForecastGeoTargeting? GeoTargeting { get; set; }
|
||||
|
||||
/// <summary>Audience parameters from Step 2</summary>
|
||||
public ForecastAudience? Audience { get; set; }
|
||||
|
||||
/// <summary>Monthly budget in whole dollars</summary>
|
||||
public decimal MonthlyBudget { get; set; }
|
||||
|
||||
/// <summary>Channels to estimate (defaults to all selected)</summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForecastGeoTargeting
|
||||
{
|
||||
public List<string>? ZipCodes { get; set; }
|
||||
public double? RadiusMiles { get; set; }
|
||||
public List<long>? GeoTargetIds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForecastAudience
|
||||
{
|
||||
public int? AgeMin { get; set; }
|
||||
public int? AgeMax { get; set; }
|
||||
public List<string>? Genders { get; set; }
|
||||
public List<string>? Interests { get; set; }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Response: Gateway → Client (normalized)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class ChannelForecastResponse
|
||||
{
|
||||
public bool Ok { get; set; } = true;
|
||||
public string Objective { get; set; } = string.Empty;
|
||||
public decimal TotalBudget { get; set; }
|
||||
public List<ChannelEstimate> Channels { get; set; } = new();
|
||||
public ForecastRecommendation? Recommendation { get; set; }
|
||||
public ForecastMeta Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ChannelEstimate
|
||||
{
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public int AllocationPercent { get; set; }
|
||||
public decimal AllocatedBudget { get; set; }
|
||||
public ChannelEstimateMetrics Estimates { get; set; } = new();
|
||||
public double EfficiencyScore { get; set; }
|
||||
public string StrengthLabel { get; set; } = string.Empty;
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "none";
|
||||
}
|
||||
|
||||
public sealed class ChannelEstimateMetrics
|
||||
{
|
||||
public double Impressions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForecastRecommendation
|
||||
{
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public List<string> Highlights { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ForecastMeta
|
||||
{
|
||||
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string ForecastPeriod { get; set; } = "30 days";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Objective-weighted scoring
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class MetricWeights
|
||||
{
|
||||
public double Reach { get; }
|
||||
public double Impressions { get; }
|
||||
public double Cpm { get; }
|
||||
public double Clicks { get; }
|
||||
public double Cpc { get; }
|
||||
public double Ctr { get; }
|
||||
public double Conversions { get; }
|
||||
public double Cpa { get; }
|
||||
|
||||
public MetricWeights(double reach, double impressions, double cpm,
|
||||
double clicks, double cpc, double ctr, double conversions, double cpa)
|
||||
{
|
||||
Reach = reach; Impressions = impressions; Cpm = cpm;
|
||||
Clicks = clicks; Cpc = cpc; Ctr = ctr;
|
||||
Conversions = conversions; Cpa = cpa;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ObjectiveWeights
|
||||
{
|
||||
public static readonly Dictionary<string, MetricWeights> Weights = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// reach imp cpm clicks cpc ctr conv cpa
|
||||
["awareness"] = new MetricWeights(0.35, 0.25, 0.20, 0.05, 0.05, 0.05, 0.00, 0.00),
|
||||
["traffic"] = new MetricWeights(0.05, 0.10, 0.10, 0.30, 0.30, 0.15, 0.00, 0.00),
|
||||
["leads"] = new MetricWeights(0.05, 0.05, 0.05, 0.15, 0.15, 0.10, 0.25, 0.20),
|
||||
["sales"] = new MetricWeights(0.05, 0.05, 0.05, 0.10, 0.10, 0.10, 0.30, 0.25),
|
||||
};
|
||||
|
||||
/// <summary>Fallback: balanced weights if objective not recognized</summary>
|
||||
public static readonly MetricWeights Default =
|
||||
new(0.10, 0.10, 0.10, 0.20, 0.20, 0.10, 0.10, 0.10);
|
||||
|
||||
public static MetricWeights For(string objective)
|
||||
=> Weights.TryGetValue(objective, out var w) ? w : Default;
|
||||
}
|
||||
109
Gateway/Models/MultiChannelConfig.cs
Normal file
109
Gateway/Models/MultiChannelConfig.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Gateway.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a single advertising channel provider.
|
||||
/// Populated from database (tbChannelConfig) via ChannelConfigService.
|
||||
/// Drives Gateway routing, wizard behavior, and validation.
|
||||
/// </summary>
|
||||
public sealed class ProviderConfig
|
||||
{
|
||||
/// <summary>Channel type key (e.g., "google_ads", "meta", "tiktok").</summary>
|
||||
public string ChannelType { get; set; } = "";
|
||||
|
||||
/// <summary>Display name for UI.</summary>
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
/// <summary>Short description shown in channel selection.</summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Whether this channel is currently available for new campaigns.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Provider service endpoint URL (null = stub/disabled).</summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>Internal API key for provider service authentication.</summary>
|
||||
public string? InternalKey { get; set; }
|
||||
|
||||
/// <summary>Whether this is a stub container (test mode).</summary>
|
||||
public bool IsStub { get; set; }
|
||||
|
||||
/// <summary>Minimum daily budget in USD for this channel.</summary>
|
||||
public decimal MinDailyBudget { get; set; }
|
||||
|
||||
/// <summary>Minimum monthly budget in USD for this channel.</summary>
|
||||
public decimal MinMonthlyBudget { get; set; }
|
||||
|
||||
/// <summary>Supported unified objectives (awareness, traffic, conversions, leads, sales).</summary>
|
||||
public List<string> SupportedObjectives { get; set; } = new();
|
||||
|
||||
/// <summary>Supported creative formats (text, image, video, carousel, etc.).</summary>
|
||||
public List<string> SupportedCreativeFormats { get; set; } = new();
|
||||
|
||||
/// <summary>Estimated approval time in hours.</summary>
|
||||
public int ApprovalEstimateHours { get; set; }
|
||||
|
||||
/// <summary>How often to refresh metrics from this provider (minutes).</summary>
|
||||
public int MetricsRefreshIntervalMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>Icon identifier for UI (e.g., "google", "meta", "tiktok").</summary>
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>Brand color hex for UI (e.g., "#4285F4").</summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>Auth method used by provider (oauth2, api_key, mcc).</summary>
|
||||
public string? AuthMethod { get; set; }
|
||||
|
||||
/// <summary>Key Vault secret name for provider credentials.</summary>
|
||||
public string? KeyVaultSecretName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps provider-specific status strings to platform statuses.
|
||||
/// Keys are raw provider values (case-insensitive), values are platform statuses
|
||||
/// (draft, staged, pending, active, paused, completed, cancelled, error).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> StatusMappings { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global allocation and multi-channel settings.
|
||||
/// Loaded from appsettings.json "MultiChannel:Allocation" section (simple scalars only).
|
||||
/// </summary>
|
||||
public sealed class AllocationSettings
|
||||
{
|
||||
public decimal MinMultiChannelMonthlyBudget { get; set; } = 500.00m;
|
||||
public int MaxChannelsPerInitiative { get; set; } = 5;
|
||||
public string DefaultAllocationStrategy { get; set; } = "template";
|
||||
public int PerformanceEvalIntervalDays { get; set; } = 7;
|
||||
public int PerformanceLookbackDays { get; set; } = 14;
|
||||
public int PerformanceLearningPeriodDays { get; set; } = 14;
|
||||
public decimal MaxAllocationShiftPct { get; set; } = 15.00m;
|
||||
public decimal MinChannelAllocationPct { get; set; } = 10.00m;
|
||||
public decimal MaxChannelAllocationPct { get; set; } = 80.00m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root multi-channel configuration.
|
||||
/// Channels are populated from DB via ChannelConfigService at startup.
|
||||
/// Allocation settings loaded from appsettings.json (simple scalars).
|
||||
/// </summary>
|
||||
public sealed class MultiChannelConfig
|
||||
{
|
||||
/// <summary>Per-provider configurations, keyed by channel type.</summary>
|
||||
public Dictionary<string, ProviderConfig> Channels { get; set; } = new();
|
||||
|
||||
/// <summary>Global allocation settings.</summary>
|
||||
public AllocationSettings Allocation { get; set; } = new();
|
||||
|
||||
/// <summary>Get only enabled providers.</summary>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<ProviderConfig> EnabledChannels =>
|
||||
Channels.Values.Where(c => c.Enabled);
|
||||
|
||||
/// <summary>Look up a provider config by channel type.</summary>
|
||||
public ProviderConfig? GetChannel(string channelType) =>
|
||||
Channels.TryGetValue(channelType, out var config) ? config : null;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Azure.Storage.Blobs;
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Gateway.ProviderClients;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
@@ -20,7 +22,18 @@ builder.Services.AddSwaggerGen();
|
||||
|
||||
// Data & business services
|
||||
builder.Services.AddScoped<SqlService>();
|
||||
builder.Services.AddScoped<ExecutionService>();
|
||||
|
||||
// Channel configuration (loaded from DB at startup, not appsettings)
|
||||
builder.Services.AddSingleton<ChannelConfigService>();
|
||||
|
||||
// For consumers injecting MultiChannelConfig directly (e.g. ExecutionService)
|
||||
builder.Services.AddScoped<MultiChannelConfig>(sp =>
|
||||
sp.GetRequiredService<ChannelConfigService>().Current);
|
||||
|
||||
// For consumers injecting IOptions<MultiChannelConfig> (e.g. InitiativeController, InitiativeLaunchService)
|
||||
builder.Services.AddSingleton<Microsoft.Extensions.Options.IOptions<MultiChannelConfig>>(sp =>
|
||||
Microsoft.Extensions.Options.Options.Create(
|
||||
sp.GetRequiredService<ChannelConfigService>().Current));
|
||||
|
||||
// Authentication context (scoped - one per request)
|
||||
builder.Services.AddScoped<ClientContext>();
|
||||
@@ -38,8 +51,86 @@ builder.Services.AddHttpClient<GoogleProviderClient>(client =>
|
||||
// HTTP client factory for ExecutionService
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// --------------------
|
||||
// Blob Storage (for Creative images)
|
||||
// --------------------
|
||||
var blobConnectionString = builder.Configuration["BlobStorage:ConnectionString"]
|
||||
?? Environment.GetEnvironmentVariable("BLOB_STORAGE_CONNECTION_STRING");
|
||||
|
||||
if (!string.IsNullOrEmpty(blobConnectionString))
|
||||
{
|
||||
builder.Services.AddSingleton(new BlobServiceClient(blobConnectionString));
|
||||
Console.WriteLine("[Gateway] Blob storage configured");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Register null so DI can resolve ImageStorageService
|
||||
builder.Services.AddSingleton<BlobServiceClient>(sp => null!);
|
||||
Console.WriteLine("[Gateway] Blob storage not configured - Creative images will use source URLs");
|
||||
}
|
||||
|
||||
// ImageStorageService (works with or without blob storage configured)
|
||||
builder.Services.AddScoped<ImageStorageService>();
|
||||
|
||||
// ExecutionService (depends on ImageStorageService)
|
||||
builder.Services.AddScoped<ExecutionService>();
|
||||
|
||||
// Metric sync orchestration (pulls from providers, writes to DB, triggers evaluation)
|
||||
builder.Services.AddScoped<MetricSyncService>();
|
||||
|
||||
// Initiative launch orchestration service
|
||||
builder.Services.AddScoped<InitiativeLaunchService>();
|
||||
|
||||
// Authorization guard (ownership, roles, status transitions)
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<AuthorizationGuard>();
|
||||
|
||||
// Provider status normalization
|
||||
builder.Services.AddSingleton<ProviderStatusNormalizer>();
|
||||
|
||||
// Forecast service for channel performance estimates (local fallback)
|
||||
builder.Services.AddSingleton<ForecastService>();
|
||||
|
||||
// IntelligenceApi client — routes forecast requests to the category-aware engine container
|
||||
// Falls back to ForecastService if INTELLIGENCE_API_URL is not configured
|
||||
builder.Services.AddSingleton<IntelligenceApiClient>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Load channel config from database (before serving requests)
|
||||
// ────────────────────────────────────────────────
|
||||
try
|
||||
{
|
||||
var channelConfigSvc = app.Services.GetRequiredService<ChannelConfigService>();
|
||||
await channelConfigSvc.LoadAsync();
|
||||
Console.WriteLine("[Gateway] Channel config loaded from database");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Gateway] ⚠️ Channel config DB load failed — using defaults: {ex.Message}");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// SECURITY: Startup environment checks
|
||||
// ────────────────────────────────────────────────
|
||||
var env = app.Environment;
|
||||
var allowDevBypass = builder.Configuration.GetValue<bool>("Auth:AllowDevBypass");
|
||||
|
||||
if (allowDevBypass && !env.IsDevelopment())
|
||||
{
|
||||
Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ ⚠️ WARNING: Auth:AllowDevBypass=true in NON-DEV env! ║");
|
||||
Console.WriteLine("║ This allows X-Dev-ClientId header to bypass auth. ║");
|
||||
Console.WriteLine("║ Remove this setting in production! ║");
|
||||
Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
|
||||
}
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
Console.WriteLine("[Gateway] ⚠️ Development mode — dev bypass headers accepted");
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// Middleware pipeline
|
||||
// --------------------
|
||||
@@ -49,12 +140,31 @@ app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// Health check endpoint (before auth & logging)
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
app.MapGet("/health", (ChannelConfigService channelSvc, IConfiguration config) =>
|
||||
{
|
||||
ok = true,
|
||||
service = "Gateway",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
var blobConfigured = !string.IsNullOrEmpty(config["BlobStorage:ConnectionString"]) ||
|
||||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BLOB_STORAGE_CONNECTION_STRING"));
|
||||
|
||||
var mcConfig = channelSvc.Current;
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "Gateway",
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
config = new
|
||||
{
|
||||
blobStorageConfigured = blobConfigured,
|
||||
blobContainer = config["BlobStorage:ContainerName"] ?? "creative-images",
|
||||
enabledChannels = mcConfig.EnabledChannels.Select(c => new
|
||||
{
|
||||
c.ChannelType,
|
||||
c.DisplayName,
|
||||
c.IsStub
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Root endpoint
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
|
||||
396
Gateway/Security/AuthorizationGuard.cs
Normal file
396
Gateway/Security/AuthorizationGuard.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized authorization guard for resource ownership, role checks,
|
||||
/// and status transition enforcement.
|
||||
///
|
||||
/// DEFENSE IN DEPTH:
|
||||
/// Layer 1: Middleware authenticates session → populates ClientContext
|
||||
/// Layer 2: This guard validates resource ownership before operations
|
||||
/// Layer 3: Stored procs SHOULD also have WHERE clientId = @clientId
|
||||
///
|
||||
/// All public methods return (bool Allowed, string? Error) to keep
|
||||
/// controller code clean and consistent.
|
||||
/// </summary>
|
||||
public sealed class AuthorizationGuard
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ILogger<AuthorizationGuard> _log;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public AuthorizationGuard(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
ILogger<AuthorizationGuard> log,
|
||||
IConfiguration config,
|
||||
IHttpContextAccessor http)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_log = log;
|
||||
_config = config;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// SERVICE KEY CHECK
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate an internal service-to-service call via X-Service-Key header.
|
||||
/// Used by provider containers and background services that cannot carry
|
||||
/// a CIAM session token. Configure via INTERNAL_SERVICE_KEY env var.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) RequireServiceKey()
|
||||
{
|
||||
var expected = _config["INTERNAL_SERVICE_KEY"];
|
||||
if (string.IsNullOrWhiteSpace(expected))
|
||||
{
|
||||
_log.LogWarning("[AuthZ] INTERNAL_SERVICE_KEY not configured — service key check denied");
|
||||
return (false, "Service key not configured");
|
||||
}
|
||||
|
||||
var provided = _http.HttpContext?.Request.Headers["X-Service-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(provided) || provided != expected)
|
||||
{
|
||||
_log.LogWarning("[AuthZ] Invalid or missing X-Service-Key");
|
||||
return (false, "Valid service key required");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// BASIC AUTH CHECKS
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>Require authenticated session with a valid ClientId.</summary>
|
||||
public (bool Ok, string? Error) RequireAuth()
|
||||
{
|
||||
if (!_client.IsAuthenticated)
|
||||
return (false, "Authentication required");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>Require specific role(s). Case-insensitive.</summary>
|
||||
public (bool Ok, string? Error) RequireRole(params string[] allowedRoles)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return (ok, err);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_client.Role))
|
||||
return (false, "No role assigned");
|
||||
|
||||
if (!allowedRoles.Any(r => string.Equals(_client.Role, r, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Role denied | ClientId={ClientId} Role={Role} Required={Required}",
|
||||
_client.ClientId, _client.Role, string.Join(",", allowedRoles));
|
||||
return (false, "Insufficient permissions");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>Require admin role.</summary>
|
||||
public (bool Ok, string? Error) RequireAdmin()
|
||||
=> RequireRole("admin");
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// INITIATIVE OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify initiative belongs to the authenticated client.
|
||||
/// Returns the initiative JSON on success (avoids double-fetch).
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyInitiativeOwnerAsync(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Initiative, "get",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Initiative not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Initiative not found");
|
||||
|
||||
// Extract clientId from response — check both clean and prefixed shapes
|
||||
var initiative = root.TryGetProperty("initiative", out var initEl) ? initEl : root;
|
||||
var ownerClientId =
|
||||
initiative.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() :
|
||||
initiative.TryGetProperty("iniClientId", out var iniCidProp) ? iniCidProp.GetString() :
|
||||
null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerClientId))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Initiative {Id} has no clientId — ownership check inconclusive, denying",
|
||||
initiativeId);
|
||||
return OwnershipResult.Denied("Initiative ownership could not be verified");
|
||||
}
|
||||
|
||||
if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] IDOR attempt | InitiativeId={InitiativeId} Owner={Owner} Requester={Requester}",
|
||||
initiativeId, ownerClientId, _client.ClientId);
|
||||
return OwnershipResult.Denied("Initiative not found"); // Don't reveal existence
|
||||
}
|
||||
|
||||
// Extract current status for transition validation — check both shapes
|
||||
var status =
|
||||
initiative.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
initiative.TryGetProperty("iniStatus", out var iniStProp) ? iniStProp.GetString() :
|
||||
null;
|
||||
|
||||
return OwnershipResult.Allowed(resp, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Ownership check failed for initiative {Id}", initiativeId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// CHANNEL CAMPAIGN OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify channel campaign belongs to the authenticated client.
|
||||
/// Follows channelCampaign → initiative → client ownership chain.
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyChannelOwnerAsync(long channelCampaignId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelCampaign, "get",
|
||||
JsonSerializer.Serialize(new { channelCampaignId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
// Get initiativeId, then check initiative ownership
|
||||
var campaign = root.TryGetProperty("channelCampaign", out var ccEl) ? ccEl : root;
|
||||
var initiativeId =
|
||||
campaign.TryGetProperty("initiativeId", out var initIdProp) ? initIdProp.GetInt64() :
|
||||
campaign.TryGetProperty("chcInitiativeId", out var chcInitProp) ? chcInitProp.GetInt64() :
|
||||
0;
|
||||
|
||||
if (initiativeId <= 0)
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
// Delegate to initiative ownership check
|
||||
return await VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Channel ownership check failed for {Id}", channelCampaignId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// WIZARD OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify wizard belongs to the authenticated client.
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyWizardOwnerAsync(string wizardId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
"dbo.spCampaignWizard", "get",
|
||||
JsonSerializer.Serialize(new { wizardId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
|
||||
var wizard = root.TryGetProperty("wizard", out var wzEl) ? wzEl : root;
|
||||
var ownerClientId =
|
||||
wizard.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() :
|
||||
wizard.TryGetProperty("wizClientId", out var wzCidProp) ? wzCidProp.GetString() :
|
||||
null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerClientId))
|
||||
{
|
||||
_log.LogWarning("[AuthZ] Wizard {Id} has no clientId", wizardId);
|
||||
return OwnershipResult.Denied("Wizard ownership could not be verified");
|
||||
}
|
||||
|
||||
if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] IDOR attempt | WizardId={WizardId} Owner={Owner} Requester={Requester}",
|
||||
wizardId, ownerClientId, _client.ClientId);
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
}
|
||||
|
||||
var status =
|
||||
wizard.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
wizard.TryGetProperty("wizStatus", out var wzStProp) ? wzStProp.GetString() :
|
||||
null;
|
||||
return OwnershipResult.Allowed(resp, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Wizard ownership check failed for {Id}", wizardId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// STATUS TRANSITION VALIDATION
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a status transition is allowed for client-initiated actions.
|
||||
/// Internal/system transitions (from launch service, provider callbacks) bypass this.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) ValidateClientStatusTransition(
|
||||
string? currentStatus, string requestedStatus, string resourceType = "initiative")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requestedStatus))
|
||||
return (false, "Status is required");
|
||||
|
||||
// Normalize
|
||||
var from = (currentStatus ?? "").ToLowerInvariant();
|
||||
var to = requestedStatus.ToLowerInvariant();
|
||||
|
||||
// Client-allowed transitions (restrictive)
|
||||
var allowed = IsClientTransitionAllowed(from, to);
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Invalid status transition | {ResourceType} {From} → {To} by ClientId={ClientId}",
|
||||
resourceType, from, to, _client.ClientId);
|
||||
return (false, $"Cannot change {resourceType} from '{from}' to '{to}'");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whitelist of client-allowed transitions.
|
||||
/// Everything else requires admin or system action.
|
||||
/// </summary>
|
||||
private static bool IsClientTransitionAllowed(string from, string to)
|
||||
{
|
||||
return (from, to) switch
|
||||
{
|
||||
// Pausing: only active campaigns can be paused
|
||||
("active", "paused") => true,
|
||||
|
||||
// Resuming: only paused campaigns can be resumed
|
||||
("paused", "active") => true,
|
||||
|
||||
// Cancelling: clients can cancel from most pre-completion states
|
||||
("draft", "cancelled") => true,
|
||||
("staged", "cancelled") => true,
|
||||
("pending", "cancelled") => true,
|
||||
("active", "cancelled") => true,
|
||||
("paused", "cancelled") => true,
|
||||
|
||||
// Everything else is denied at the client level
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// BUDGET VALIDATION
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate budget against channel minimums.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) ValidateBudget(
|
||||
decimal totalBudget, string? budgetPeriod, MultiChannelConfig config)
|
||||
{
|
||||
if (totalBudget <= 0)
|
||||
return (false, "Budget must be greater than zero");
|
||||
|
||||
// Convert to monthly for comparison
|
||||
var monthlyBudget = (budgetPeriod?.ToLowerInvariant()) switch
|
||||
{
|
||||
"daily" => totalBudget * 30.4m,
|
||||
"weekly" => totalBudget * 4.33m,
|
||||
_ => totalBudget
|
||||
};
|
||||
|
||||
// Check against lowest channel minimum
|
||||
var minBudget = config.EnabledChannels
|
||||
.Select(c => c.MinMonthlyBudget)
|
||||
.DefaultIfEmpty(150m)
|
||||
.Min();
|
||||
|
||||
if (monthlyBudget < minBudget)
|
||||
return (false, $"Monthly budget must be at least ${minBudget:F0}");
|
||||
|
||||
// Cap at reasonable maximum (safety valve)
|
||||
if (monthlyBudget > 1_000_000m)
|
||||
return (false, "Budget exceeds maximum allowed. Contact support for high-spend campaigns.");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Result type
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class OwnershipResult
|
||||
{
|
||||
public bool IsAllowed { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>The raw JSON response from the ownership lookup (avoids re-fetching).</summary>
|
||||
public string? EntityJson { get; init; }
|
||||
|
||||
/// <summary>Current status of the entity (for transition validation).</summary>
|
||||
public string? CurrentStatus { get; init; }
|
||||
|
||||
public static OwnershipResult Allowed(string? entityJson = null, string? status = null)
|
||||
=> new() { IsAllowed = true, EntityJson = entityJson, CurrentStatus = status };
|
||||
|
||||
public static OwnershipResult Denied(string error)
|
||||
=> new() { IsAllowed = false, Error = error };
|
||||
}
|
||||
@@ -223,7 +223,7 @@ public sealed class ClientAuthMiddleware
|
||||
_logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId);
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
|
||||
var resp = await sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -239,12 +239,13 @@ public sealed class ClientAuthMiddleware
|
||||
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
|
||||
|
||||
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
|
||||
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
|
||||
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
|
||||
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
clientContext.IsDevBypass = false;
|
||||
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
|
||||
clientContext.ClientCategory = data.TryGetProperty("clientCategory", out var ccat) ? ccat.GetString() : null;
|
||||
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
|
||||
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
clientContext.IsDevBypass = false;
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
@@ -1,60 +1,37 @@
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Holds authenticated client information for the current request.
|
||||
/// Populated by ClientAuthMiddleware.
|
||||
/// Holds authenticated identity information for the current request.
|
||||
/// Populated by MultiProviderAuthMiddleware.
|
||||
/// </summary>
|
||||
public sealed class ClientContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Session ID from session-based auth.
|
||||
/// </summary>
|
||||
public string? SessionId { get; set; }
|
||||
public string? SessionId { get; set; }
|
||||
public string? ClientId { get; set; } // OID (JWT) or platform client ID (session)
|
||||
public string? TenantId { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public string? ClientCategory { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public bool IsDevBypass { get; set; }
|
||||
public string? AuthProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authenticated client ID (from session, JWT sub claim, or dev header).
|
||||
/// This identifies the client/organization in our platform.
|
||||
/// Raw Entra Object ID (oid claim) — always set for Microsoft tokens.
|
||||
/// Used for identity and activity logging. Distinct from ClientId which may fall
|
||||
/// back to sub for tokens where oid isn't surfaced as a named claim.
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
public string? EntraOid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant ID for the ad platform (e.g., Google Ads customer ID).
|
||||
/// May be derived from ClientId mapping or passed in request.
|
||||
/// True when the token was issued by the standard Entra (staff) tenant.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
public bool IsStaff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name from token or session (if available).
|
||||
/// </summary>
|
||||
public string? ClientName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User ID from session (if using session auth).
|
||||
/// </summary>
|
||||
public string? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Email from token or session (if available).
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User role from session (admin, user, readonly).
|
||||
/// </summary>
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this request was authenticated via dev bypass (vs real auth).
|
||||
/// </summary>
|
||||
public bool IsDevBypass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authentication provider used (microsoft, google, etc.)
|
||||
/// </summary>
|
||||
public string? AuthProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if we have a valid ClientId.
|
||||
/// </summary>
|
||||
/// <summary>True if we have a valid ClientId.</summary>
|
||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
|
||||
|
||||
/// <summary>True if this is an admin session (IsStaff + Role set).</summary>
|
||||
public bool IsAdmin => IsStaff && !string.IsNullOrWhiteSpace(Role);
|
||||
}
|
||||
|
||||
@@ -248,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;
|
||||
}
|
||||
|
||||
299
Gateway/Services/ChannelConfigService.cs
Normal file
299
Gateway/Services/ChannelConfigService.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Loads channel provider configuration from the database (tbChannelConfig)
|
||||
/// instead of appsettings.json. Caches in memory and provides a populated
|
||||
/// MultiChannelConfig instance for DI consumers.
|
||||
///
|
||||
/// Why: Complex nested JSON in appsettings.json was causing startup crashes
|
||||
/// with the .NET configuration parser. Database-driven config is also easier
|
||||
/// to update without redeployment.
|
||||
///
|
||||
/// Usage:
|
||||
/// - Called once at startup to populate the singleton MultiChannelConfig
|
||||
/// - Admin endpoints can call RefreshAsync() to reload after DB changes
|
||||
/// </summary>
|
||||
public sealed class ChannelConfigService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<ChannelConfigService> _log;
|
||||
private MultiChannelConfig _cached;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public ChannelConfigService(
|
||||
IServiceProvider sp,
|
||||
IConfiguration cfg,
|
||||
ILogger<ChannelConfigService> log)
|
||||
{
|
||||
_sp = sp;
|
||||
_cfg = cfg;
|
||||
_log = log;
|
||||
_cached = BuildDefaults();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current in-memory config. Always non-null (falls back to defaults).
|
||||
/// </summary>
|
||||
public MultiChannelConfig Current
|
||||
{
|
||||
get { lock (_lock) { return _cached; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load channel config from the database.
|
||||
/// Call at startup and whenever admin updates channel config.
|
||||
/// </summary>
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var sql = scope.ServiceProvider.GetRequiredService<SqlService>();
|
||||
|
||||
var resp = await sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelConfig, "list", "{}", ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] DB returned empty — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] DB returned ok=false — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("channels", out var channelsEl) ||
|
||||
channelsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_log.LogWarning("[ChannelConfig] No channels array in response — using defaults");
|
||||
return;
|
||||
}
|
||||
|
||||
var channels = new Dictionary<string, ProviderConfig>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var ch in channelsEl.EnumerateArray())
|
||||
{
|
||||
var config = ParseChannel(ch);
|
||||
if (config != null)
|
||||
channels[config.ChannelType] = config;
|
||||
}
|
||||
|
||||
// Build new MultiChannelConfig with DB channels + appsettings allocation
|
||||
var newConfig = new MultiChannelConfig
|
||||
{
|
||||
Channels = channels,
|
||||
Allocation = LoadAllocationFromConfig()
|
||||
};
|
||||
|
||||
lock (_lock) { _cached = newConfig; }
|
||||
|
||||
_log.LogInformation(
|
||||
"[ChannelConfig] Loaded {Count} channels from DB: {Types}",
|
||||
channels.Count,
|
||||
string.Join(", ", channels.Keys));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[ChannelConfig] Failed to load from DB — using defaults");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reload config from DB (for admin refresh endpoints).</summary>
|
||||
public Task RefreshAsync(CancellationToken ct = default) => LoadAsync(ct);
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Parsing
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private static ProviderConfig? ParseChannel(JsonElement ch)
|
||||
{
|
||||
var channelType = Str(ch, "channelType");
|
||||
var displayName = Str(ch, "displayName");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(channelType) || string.IsNullOrWhiteSpace(displayName))
|
||||
return null;
|
||||
|
||||
return new ProviderConfig
|
||||
{
|
||||
ChannelType = channelType,
|
||||
DisplayName = displayName,
|
||||
Description = Str(ch, "description"),
|
||||
Icon = Str(ch, "icon"),
|
||||
Color = Str(ch, "color"),
|
||||
Enabled = Bool(ch, "enabled", true),
|
||||
IsStub = Bool(ch, "isStub", true),
|
||||
Endpoint = Str(ch, "endpoint"),
|
||||
InternalKey = Str(ch, "internalKey"),
|
||||
MinDailyBudget = Dec(ch, "minDailyBudget", 5m),
|
||||
MinMonthlyBudget = Dec(ch, "minMonthlyBudget", 150m),
|
||||
SupportedObjectives = StringList(ch, "supportedObjectives"),
|
||||
SupportedCreativeFormats = StringList(ch, "supportedCreativeFormats"),
|
||||
ApprovalEstimateHours = Int(ch, "approvalEstimateHours", 24),
|
||||
MetricsRefreshIntervalMinutes = Int(ch, "metricsRefreshIntervalMinutes", 60),
|
||||
AuthMethod = Str(ch, "authMethod"),
|
||||
KeyVaultSecretName = Str(ch, "keyVaultSecretName"),
|
||||
StatusMappings = StringDict(ch, "statusMappings")
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Allocation (stays in appsettings — simple scalars)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private AllocationSettings LoadAllocationFromConfig()
|
||||
{
|
||||
var section = _cfg.GetSection("MultiChannel:Allocation");
|
||||
if (!section.Exists())
|
||||
return new AllocationSettings();
|
||||
|
||||
return new AllocationSettings
|
||||
{
|
||||
MinMultiChannelMonthlyBudget = section.GetValue("MinMultiChannelMonthlyBudget", 500.00m),
|
||||
MaxChannelsPerInitiative = section.GetValue("MaxChannelsPerInitiative", 5),
|
||||
DefaultAllocationStrategy = section.GetValue("DefaultAllocationStrategy", "template") ?? "template",
|
||||
PerformanceEvalIntervalDays = section.GetValue("PerformanceEvalIntervalDays", 7),
|
||||
PerformanceLookbackDays = section.GetValue("PerformanceLookbackDays", 14),
|
||||
PerformanceLearningPeriodDays = section.GetValue("PerformanceLearningPeriodDays", 14),
|
||||
MaxAllocationShiftPct = section.GetValue("MaxAllocationShiftPct", 15.00m),
|
||||
MinChannelAllocationPct = section.GetValue("MinChannelAllocationPct", 10.00m),
|
||||
MaxChannelAllocationPct = section.GetValue("MaxChannelAllocationPct", 80.00m)
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Defaults (used until DB load completes or if DB is unavailable)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private MultiChannelConfig BuildDefaults()
|
||||
{
|
||||
return new MultiChannelConfig
|
||||
{
|
||||
Channels = new Dictionary<string, ProviderConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["google_ads"] = new()
|
||||
{
|
||||
ChannelType = "google_ads",
|
||||
DisplayName = "Google Ads",
|
||||
Description = "Search, Display, Shopping & Performance Max across Google properties",
|
||||
Icon = "google",
|
||||
Color = "#4285F4",
|
||||
Enabled = true,
|
||||
IsStub = false,
|
||||
MinDailyBudget = 10m,
|
||||
MinMonthlyBudget = 300m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "text", "image", "responsive", "video" },
|
||||
ApprovalEstimateHours = 24,
|
||||
AuthMethod = "mcc"
|
||||
},
|
||||
["meta"] = new()
|
||||
{
|
||||
ChannelType = "meta",
|
||||
DisplayName = "Meta Ads",
|
||||
Description = "Facebook, Instagram, Messenger & Threads advertising",
|
||||
Icon = "meta",
|
||||
Color = "#1877F2",
|
||||
Enabled = true,
|
||||
IsStub = true,
|
||||
MinDailyBudget = 5m,
|
||||
MinMonthlyBudget = 250m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "image", "video", "carousel", "stories" },
|
||||
ApprovalEstimateHours = 48,
|
||||
AuthMethod = "oauth2"
|
||||
},
|
||||
["tiktok"] = new()
|
||||
{
|
||||
ChannelType = "tiktok",
|
||||
DisplayName = "TikTok Ads",
|
||||
Description = "In-feed video ads across TikTok and partner apps",
|
||||
Icon = "tiktok",
|
||||
Color = "#000000",
|
||||
Enabled = true,
|
||||
IsStub = true,
|
||||
MinDailyBudget = 20m,
|
||||
MinMonthlyBudget = 200m,
|
||||
SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" },
|
||||
SupportedCreativeFormats = new() { "video", "image", "spark_ads" },
|
||||
ApprovalEstimateHours = 24,
|
||||
AuthMethod = "oauth2"
|
||||
}
|
||||
},
|
||||
Allocation = new AllocationSettings()
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// JSON helpers
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
private static string? Str(JsonElement el, string prop)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String)
|
||||
return v.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Bool(JsonElement el, string prop, bool def = false)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v))
|
||||
{
|
||||
if (v.ValueKind == JsonValueKind.True) return true;
|
||||
if (v.ValueKind == JsonValueKind.False) return false;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
private static int Int(JsonElement el, string prop, int def = 0)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number)
|
||||
return v.GetInt32();
|
||||
return def;
|
||||
}
|
||||
|
||||
private static decimal Dec(JsonElement el, string prop, decimal def = 0m)
|
||||
{
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number)
|
||||
return v.GetDecimal();
|
||||
return def;
|
||||
}
|
||||
|
||||
private static List<string> StringList(JsonElement el, string prop)
|
||||
{
|
||||
var list = new List<string>();
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in v.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
list.Add(item.GetString()!);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> StringDict(JsonElement el, string prop)
|
||||
{
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var p in v.EnumerateObject())
|
||||
{
|
||||
if (p.Value.ValueKind == JsonValueKind.String)
|
||||
dict[p.Name] = p.Value.GetString()!;
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public sealed class ExecutionService
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ImageStorageService _imageStorage;
|
||||
private readonly ILogger<ExecutionService> _logger;
|
||||
|
||||
// Operations that don't require a linked account (health checks, etc.)
|
||||
@@ -19,17 +20,31 @@ public sealed class ExecutionService
|
||||
"Ping", "TestPing", "ListAccessibleCustomers"
|
||||
};
|
||||
|
||||
// Providers that require Google Ads account validation
|
||||
private static readonly HashSet<string> GoogleAccountProviders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"google"
|
||||
};
|
||||
|
||||
// Creative operations that return images and need blob storage processing
|
||||
private static readonly HashSet<string> CreativeImageOperations = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CreateDraft", "GetImages"
|
||||
};
|
||||
|
||||
public ExecutionService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ClientContext client,
|
||||
ImageStorageService imageStorage,
|
||||
ILogger<ExecutionService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_client = client;
|
||||
_imageStorage = imageStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,33 +61,55 @@ public sealed class ExecutionService
|
||||
var service = reqJson.TryGetProperty("service", out var sv) ? sv.GetString() ?? "system" : "system";
|
||||
var action = reqJson.TryGetProperty("action", out var av) ? av.GetString() ?? "ping" : "ping";
|
||||
|
||||
// Legacy support: if "operation" is provided, use it as action
|
||||
string? operation = action;
|
||||
// Operation: explicit "operation" field takes priority, then falls back to "action"
|
||||
string operation = action;
|
||||
if (reqJson.TryGetProperty("operation", out var opProp) && opProp.ValueKind == JsonValueKind.String)
|
||||
operation = opProp.GetString();
|
||||
operation = opProp.GetString() ?? action;
|
||||
|
||||
// TenantId priority: 1) request body, 2) ClientContext, 3) null
|
||||
// TenantId priority: 1) request body, 2) ClientContext (header), 3) default MCC, 4) null
|
||||
string? tenantId = null;
|
||||
if (reqJson.TryGetProperty("tenantId", out var tid) && tid.ValueKind == JsonValueKind.String)
|
||||
tenantId = tid.GetString();
|
||||
tenantId ??= _client.TenantId;
|
||||
|
||||
// Agency model fallback: use default MCC customer ID if no tenant specified
|
||||
// This ensures real API calls work even before per-client subaccounts exist
|
||||
bool tenantIsSystemDefault = false;
|
||||
if (string.IsNullOrWhiteSpace(tenantId) && GoogleAccountProviders.Contains(provider))
|
||||
{
|
||||
tenantId = _cfg["GoogleAds:DefaultLoginCustomerId"]
|
||||
?? _cfg["GOOGLE_DEFAULT_CUSTOMER_ID"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_DEFAULT_CUSTOMER_ID");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantIsSystemDefault = true;
|
||||
_logger.LogInformation("[Execution] Using default MCC customer ID as tenantId | RequestId={RequestId}", requestId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Action={Action} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, action, _client.IsDevBypass);
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Operation={Operation} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, operation, _client.IsDevBypass);
|
||||
|
||||
// ================================================================
|
||||
// AGENCY MODEL: Validate account and get loginCustomerId
|
||||
// AGENCY MODEL: Validate Google account (only for Google provider)
|
||||
// Skip validation if tenantId is the system-configured MCC default
|
||||
// (admin pre-configured, not user-supplied)
|
||||
// ================================================================
|
||||
string? loginCustomerId = null;
|
||||
string? validatedClientName = null;
|
||||
|
||||
// Only validate if operation requires a linked account
|
||||
bool requiresAccount = !string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId);
|
||||
// Only validate if provider requires it AND operation requires a linked account
|
||||
// AND tenantId is user-provided (not the system MCC default)
|
||||
bool requiresGoogleAccount =
|
||||
GoogleAccountProviders.Contains(provider) &&
|
||||
!string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId) &&
|
||||
!tenantIsSystemDefault;
|
||||
|
||||
if (requiresAccount)
|
||||
if (requiresGoogleAccount)
|
||||
{
|
||||
var validation = await ValidateGoogleAccountAsync(tenantId!, ct);
|
||||
|
||||
@@ -106,7 +143,7 @@ public sealed class ExecutionService
|
||||
requestId, tenantId, loginCustomerId, validatedClientName);
|
||||
}
|
||||
|
||||
// Log start (now includes clientId and routing info)
|
||||
// Log start (includes routing info)
|
||||
int? logId = null;
|
||||
var startRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
@@ -116,7 +153,7 @@ public sealed class ExecutionService
|
||||
tenantId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
operation,
|
||||
loginCustomerId,
|
||||
sessionId = _client.SessionId,
|
||||
userId = _client.UserId,
|
||||
@@ -131,8 +168,8 @@ public sealed class ExecutionService
|
||||
logId = e.GetInt32();
|
||||
}
|
||||
|
||||
// Inject/override fields in request before forwarding to provider
|
||||
var enrichedRequest = EnrichRequest(reqJson, requestId, tenantId, loginCustomerId);
|
||||
// Build enriched request for provider
|
||||
var enrichedRequest = BuildProviderRequest(reqJson, requestId, operation, tenantId, loginCustomerId);
|
||||
|
||||
// Forward to provider (URL based on provider type)
|
||||
var sw = Stopwatch.StartNew();
|
||||
@@ -143,6 +180,11 @@ public sealed class ExecutionService
|
||||
var providerUrl = GetProviderUrl(provider);
|
||||
var key = GetProviderKey(provider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
{
|
||||
throw new InvalidOperationException($"No provider URL configured for '{provider}'. Check environment variables.");
|
||||
}
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
@@ -155,17 +197,45 @@ public sealed class ExecutionService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId}", requestId);
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId} Provider={Provider}", requestId, provider);
|
||||
providerStatus = 500;
|
||||
providerResp = JsonSerializer.Serialize(new { ok = false, requestId, error = ex.Message });
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, providerStatus, sw.ElapsedMilliseconds);
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Provider={Provider} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, provider, providerStatus, sw.ElapsedMilliseconds);
|
||||
|
||||
// Log finish (includes clientId and routing info for correlation)
|
||||
// ================================================================
|
||||
// CREATIVE IMAGE PROCESSING: Store images in blob storage
|
||||
// ================================================================
|
||||
if (provider.Equals("creative", StringComparison.OrdinalIgnoreCase) &&
|
||||
CreativeImageOperations.Contains(operation) &&
|
||||
providerStatus >= 200 && providerStatus < 300 &&
|
||||
_imageStorage.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[Execution] Processing Creative images | RequestId={RequestId} ClientId={ClientId}",
|
||||
requestId, clientId);
|
||||
|
||||
providerResp = await _imageStorage.ProcessCreativeDraftAsync(
|
||||
clientId ?? "unknown",
|
||||
providerResp,
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"[Execution] Image storage failed, returning original response | RequestId={RequestId}",
|
||||
requestId);
|
||||
// Continue with original response - non-fatal error
|
||||
}
|
||||
}
|
||||
|
||||
// Log finish (includes routing info for correlation)
|
||||
var finishRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
action = "finish",
|
||||
@@ -174,10 +244,10 @@ public sealed class ExecutionService
|
||||
clientId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
operation,
|
||||
providerStatus,
|
||||
elapsedMs = sw.ElapsedMilliseconds,
|
||||
resp = JsonDocument.Parse(providerResp).RootElement
|
||||
resp = SafeParseJson(providerResp)
|
||||
});
|
||||
|
||||
_ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct);
|
||||
@@ -187,6 +257,52 @@ public sealed class ExecutionService
|
||||
return wrappedResponse;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Provider request building
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Build a clean request object for the provider container.
|
||||
/// Ensures "operation" is always set explicitly so providers can dispatch on it.
|
||||
/// Includes session context so providers know who initiated the request.
|
||||
/// </summary>
|
||||
private string BuildProviderRequest(JsonElement original, string requestId, string operation,
|
||||
string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
var request = new Dictionary<string, object?>
|
||||
{
|
||||
["requestId"] = requestId,
|
||||
["operation"] = operation,
|
||||
["tenantId"] = tenantId,
|
||||
["loginCustomerId"] = loginCustomerId,
|
||||
["session"] = new
|
||||
{
|
||||
sessionId = _client.SessionId,
|
||||
clientId = _client.ClientId,
|
||||
userId = _client.UserId,
|
||||
isDevBypass = _client.IsDevBypass
|
||||
}
|
||||
};
|
||||
|
||||
// Copy payload if present (provider-specific data)
|
||||
if (original.TryGetProperty("payload", out var payload))
|
||||
{
|
||||
request["payload"] = payload;
|
||||
}
|
||||
|
||||
// Copy service/action for providers that use them
|
||||
if (original.TryGetProperty("service", out var svc))
|
||||
request["service"] = svc.GetString();
|
||||
if (original.TryGetProperty("action", out var act))
|
||||
request["action"] = act.GetString();
|
||||
|
||||
return JsonSerializer.Serialize(request);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Account validation (Google-specific)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a Google Ads customer ID is linked in the database.
|
||||
/// Returns loginCustomerId if account is found.
|
||||
@@ -258,36 +374,19 @@ public sealed class ExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider.
|
||||
/// </summary>
|
||||
private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(original.GetRawText());
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(doc.RootElement.GetRawText())
|
||||
?? new Dictionary<string, JsonElement>();
|
||||
|
||||
// Add/override requestId
|
||||
dict["requestId"] = JsonDocument.Parse($"\"{requestId}\"").RootElement;
|
||||
|
||||
// Add tenantId if we have one
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
dict["tenantId"] = JsonDocument.Parse($"\"{tenantId}\"").RootElement;
|
||||
}
|
||||
|
||||
// Add loginCustomerId (manager account) if we have one
|
||||
if (!string.IsNullOrWhiteSpace(loginCustomerId))
|
||||
{
|
||||
dict["loginCustomerId"] = JsonDocument.Parse($"\"{loginCustomerId}\"").RootElement;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict);
|
||||
}
|
||||
// ================================================================
|
||||
// Response wrapping
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Wrap provider response with Gateway metadata.
|
||||
/// </summary>
|
||||
private static object SafeParseJson(string raw)
|
||||
{
|
||||
try { return JsonDocument.Parse(raw).RootElement; }
|
||||
catch { return raw[..Math.Min(raw.Length, 500)]; }
|
||||
}
|
||||
|
||||
private static string WrapResponse(string providerResp, int status, long elapsedMs, string requestId, string? clientId)
|
||||
{
|
||||
try
|
||||
@@ -319,9 +418,10 @@ public sealed class ExecutionService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of account validation.
|
||||
/// </summary>
|
||||
// ================================================================
|
||||
// Validation result
|
||||
// ================================================================
|
||||
|
||||
private sealed class AccountValidation
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
@@ -345,6 +445,10 @@ public sealed class ExecutionService
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Provider routing
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Get provider URL based on provider type.
|
||||
/// </summary>
|
||||
@@ -353,9 +457,12 @@ public sealed class ExecutionService
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"creative" => _cfg["CREATIVE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"intelligence" => _cfg["INTELLIGENCE_API_URL"]?.TrimEnd('/') ?? "",
|
||||
"msads" => _cfg["MSADS_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
_ => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? ""
|
||||
_ => "" // No default fallback ? unknown providers fail explicitly
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,9 +474,12 @@ public sealed class ExecutionService
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
|
||||
"creative" => _cfg["CREATIVE_INTERNAL_KEY"] ?? "",
|
||||
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "",
|
||||
"intelligence" => _cfg["INTELLIGENCE_INTERNAL_KEY"] ?? "",
|
||||
"msads" => _cfg["MSADS_INTERNAL_KEY"] ?? "",
|
||||
_ => _cfg["GOOGLE_INTERNAL_KEY"] ?? ""
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
478
Gateway/Services/ForecastService.cs
Normal file
478
Gateway/Services/ForecastService.cs
Normal file
@@ -0,0 +1,478 @@
|
||||
using Gateway.Models;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fans out to provider services for forecast estimates, normalizes the responses,
|
||||
/// scores them by objective, and derives recommended allocation percentages.
|
||||
///
|
||||
/// Called by ForecastController for the wizard budget step.
|
||||
/// Same capability can serve admin seed workflow later.
|
||||
/// </summary>
|
||||
public sealed class ForecastService
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<ForecastService> _logger;
|
||||
|
||||
private const int MIN_ALLOCATION = 15;
|
||||
private const int MAX_ALLOCATION = 85;
|
||||
|
||||
public ForecastService(IHttpClientFactory http, IConfiguration cfg, ILogger<ForecastService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate forecast estimates across requested channels and return
|
||||
/// normalized comparison with recommended allocation.
|
||||
/// </summary>
|
||||
public async Task<ChannelForecastResponse> ForecastAsync(ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var channels = request.Channels ?? new List<string> { "google_ads" };
|
||||
var weights = ObjectiveWeights.For(request.Objective);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Forecast] Starting | Objective={Obj} Budget={Budget} Channels={Ch}",
|
||||
request.Objective, request.MonthlyBudget, string.Join(",", channels));
|
||||
|
||||
// ── Fan out to providers in parallel ──
|
||||
var tasks = new Dictionary<string, Task<ProviderForecastResult>>();
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
tasks[channel] = channel switch
|
||||
{
|
||||
"google_ads" => FetchGoogleForecastAsync(request, ct),
|
||||
"meta" => FetchMetaForecastAsync(request, ct),
|
||||
"tiktok" => Task.FromResult(TemplateForecast("tiktok", request.MonthlyBudget, channels.Count)),
|
||||
_ => Task.FromResult(TemplateForecast(channel, request.MonthlyBudget, channels.Count))
|
||||
};
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks.Values);
|
||||
|
||||
// ── Collect results ──
|
||||
var results = new Dictionary<string, ProviderForecastResult>();
|
||||
foreach (var (channel, task) in tasks)
|
||||
{
|
||||
results[channel] = task.Result;
|
||||
}
|
||||
|
||||
// ── Score and derive allocation ──
|
||||
var scored = ScoreChannels(results, weights);
|
||||
var allocations = DeriveAllocations(scored);
|
||||
|
||||
// ── Build response ──
|
||||
var channelEstimates = new List<ChannelEstimate>();
|
||||
foreach (var (channel, result) in results)
|
||||
{
|
||||
var pct = allocations[channel];
|
||||
var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2);
|
||||
|
||||
channelEstimates.Add(new ChannelEstimate
|
||||
{
|
||||
Provider = channel,
|
||||
AllocationPercent = pct,
|
||||
AllocatedBudget = allocated,
|
||||
Estimates = new ChannelEstimateMetrics
|
||||
{
|
||||
Impressions = result.Impressions,
|
||||
Reach = result.Reach,
|
||||
Clicks = result.Clicks,
|
||||
Conversions = result.Conversions,
|
||||
AvgCpc = result.AvgCpc,
|
||||
AvgCpm = result.AvgCpm,
|
||||
EstimatedCpa = result.EstimatedCpa,
|
||||
Ctr = result.Ctr
|
||||
},
|
||||
EfficiencyScore = Math.Round(scored[channel], 3),
|
||||
StrengthLabel = GetStrengthLabel(channel, request.Objective),
|
||||
Confidence = result.Confidence,
|
||||
DataSource = result.DataSource
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by allocation descending
|
||||
channelEstimates.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent));
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation("[Forecast] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds);
|
||||
|
||||
return new ChannelForecastResponse
|
||||
{
|
||||
Ok = true,
|
||||
Objective = request.Objective,
|
||||
TotalBudget = request.MonthlyBudget,
|
||||
Channels = channelEstimates,
|
||||
Recommendation = BuildRecommendation(channelEstimates, request.Objective),
|
||||
Metadata = new ForecastMeta
|
||||
{
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
ForecastPeriod = "30 days"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Provider calls
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private async Task<ProviderForecastResult> FetchGoogleForecastAsync(
|
||||
ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerUrl = _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "";
|
||||
var key = _cfg["GOOGLE_INTERNAL_KEY"] ?? "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
return EmulatedGoogleFallback(request);
|
||||
|
||||
// Build provider request matching existing ProviderRequest pattern
|
||||
var payload = new
|
||||
{
|
||||
keywords = request.Keywords,
|
||||
geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List<long>(),
|
||||
monthlyBudget = request.MonthlyBudget,
|
||||
currencyCode = "USD",
|
||||
forecastDays = 30
|
||||
};
|
||||
|
||||
var providerRequest = new
|
||||
{
|
||||
operation = "KeywordForecast",
|
||||
requestId = Guid.NewGuid().ToString("N"),
|
||||
payload
|
||||
};
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(providerRequest, _jsonOpts),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var resp = await httpClient.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[Forecast] Google provider returned {Status}", (int)resp.StatusCode);
|
||||
return EmulatedGoogleFallback(request);
|
||||
}
|
||||
|
||||
// Parse provider response: { ok, data: { provider, monthly, metrics, ... } }
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
var data = root.TryGetProperty("data", out var d) ? d : root;
|
||||
|
||||
var monthly = data.GetProperty("monthly");
|
||||
var metrics = data.GetProperty("metrics");
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "google_ads",
|
||||
Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0,
|
||||
Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0,
|
||||
Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0,
|
||||
Reach = null, // Google Search doesn't provide reach
|
||||
AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0,
|
||||
AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0,
|
||||
Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0,
|
||||
EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null
|
||||
? cpa.GetDecimal() : null,
|
||||
Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low",
|
||||
DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Forecast] Google provider call failed");
|
||||
return EmulatedGoogleFallback(request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProviderForecastResult> FetchMetaForecastAsync(
|
||||
ChannelForecastRequest request, CancellationToken ct)
|
||||
{
|
||||
// TODO Phase 2: Call MetaApi /internal/execute with DeliveryEstimate operation
|
||||
// For now, return realistic emulated Meta estimates
|
||||
await Task.CompletedTask;
|
||||
|
||||
var budget = request.MonthlyBudget;
|
||||
var rng = new Random((int)(budget * 77));
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
// Meta: strong reach/impressions, moderate clicks, lower CPC than Google
|
||||
var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0); // $12.50 – $20.50
|
||||
var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0;
|
||||
var reach = impressions * 0.42; // ~2.4 frequency
|
||||
var clickRate = 0.012 + (rng.NextDouble() * 0.008); // 1.2% – 2.0% CTR
|
||||
var clicks = impressions * clickRate;
|
||||
var convRate = 0.025 + (rng.NextDouble() * 0.015);
|
||||
var conversions = clicks * convRate;
|
||||
var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0;
|
||||
var cpa = conversions > 0 ? budget / (decimal)conversions : (decimal?)null;
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "meta",
|
||||
Impressions = Math.Round(impressions),
|
||||
Clicks = Math.Round(clicks),
|
||||
Conversions = Math.Round(conversions, 1),
|
||||
Reach = Math.Round(reach),
|
||||
AvgCpc = Math.Round(avgCpc, 2),
|
||||
AvgCpm = Math.Round(cpm, 2),
|
||||
Ctr = Math.Round(clickRate, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Template-only fallback for channels without API forecasting (e.g., TikTok)</summary>
|
||||
private static ProviderForecastResult TemplateForecast(string provider, decimal totalBudget, int channelCount)
|
||||
{
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = provider,
|
||||
Confidence = "none",
|
||||
DataSource = "template"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Client-side Google emulation when provider is unreachable</summary>
|
||||
private ProviderForecastResult EmulatedGoogleFallback(ChannelForecastRequest request)
|
||||
{
|
||||
var budget = request.MonthlyBudget;
|
||||
var kwCount = Math.Max(request.Keywords.Count, 1);
|
||||
var rng = new Random((int)(budget * 100) + kwCount);
|
||||
var v = 0.85 + (rng.NextDouble() * 0.30);
|
||||
|
||||
var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20);
|
||||
var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0;
|
||||
var impressions = clicks / 0.045;
|
||||
var conversions = clicks * 0.035;
|
||||
var ctr = impressions > 0 ? clicks / impressions : 0;
|
||||
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
|
||||
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
|
||||
|
||||
return new ProviderForecastResult
|
||||
{
|
||||
Provider = "google_ads",
|
||||
Impressions = Math.Round(impressions),
|
||||
Clicks = Math.Round(clicks),
|
||||
Conversions = Math.Round(conversions, 1),
|
||||
AvgCpc = Math.Round(baseCpc, 2),
|
||||
AvgCpm = Math.Round((decimal)cpm, 2),
|
||||
Ctr = Math.Round(ctr, 4),
|
||||
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
|
||||
Confidence = "low",
|
||||
DataSource = "emulated"
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Scoring: objective-weighted efficiency
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static Dictionary<string, double> ScoreChannels(
|
||||
Dictionary<string, ProviderForecastResult> results, MetricWeights w)
|
||||
{
|
||||
// Only score channels that have real estimates
|
||||
var scoreable = results
|
||||
.Where(r => r.Value.DataSource != "template")
|
||||
.ToDictionary(r => r.Key, r => r.Value);
|
||||
|
||||
if (scoreable.Count == 0)
|
||||
return results.ToDictionary(r => r.Key, _ => 1.0);
|
||||
|
||||
// For each "more is better" metric: normalize to 0–1 (best = 1.0)
|
||||
// For each "less is better" metric: invert (lowest = 1.0)
|
||||
//double Norm(Func<ProviderForecastResult, double> selector, bool invert = false)
|
||||
//{
|
||||
// Not used directly — we normalize per-channel below
|
||||
// return 0;
|
||||
// }
|
||||
|
||||
var scores = new Dictionary<string, double>();
|
||||
|
||||
// Find max/min across scoreable channels for normalization
|
||||
var maxImp = scoreable.Values.Max(r => r.Impressions);
|
||||
var maxReach = scoreable.Values.Max(r => r.Reach ?? 0);
|
||||
var maxClicks = scoreable.Values.Max(r => r.Clicks);
|
||||
var maxConv = scoreable.Values.Max(r => r.Conversions);
|
||||
var maxCtr = scoreable.Values.Max(r => r.Ctr);
|
||||
|
||||
var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min();
|
||||
var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min();
|
||||
var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0).Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min();
|
||||
|
||||
foreach (var (channel, r) in scoreable)
|
||||
{
|
||||
double score = 0;
|
||||
|
||||
// "More is better" — value / max
|
||||
score += w.Impressions * SafeDiv(r.Impressions, maxImp);
|
||||
score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1);
|
||||
score += w.Clicks * SafeDiv(r.Clicks, maxClicks);
|
||||
score += w.Conversions * SafeDiv(r.Conversions, maxConv);
|
||||
score += w.Ctr * SafeDiv(r.Ctr, maxCtr);
|
||||
|
||||
// "Less is better" — min / value
|
||||
score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0);
|
||||
score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0);
|
||||
score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0);
|
||||
|
||||
scores[channel] = score;
|
||||
}
|
||||
|
||||
// Template-only channels get the average score
|
||||
var avgScore = scores.Values.Average();
|
||||
foreach (var channel in results.Keys.Except(scoreable.Keys))
|
||||
{
|
||||
scores[channel] = avgScore * 0.5; // Slight penalty for no data
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> DeriveAllocations(Dictionary<string, double> scores)
|
||||
{
|
||||
var total = scores.Values.Sum();
|
||||
if (total == 0)
|
||||
{
|
||||
// Even split
|
||||
var even = 100 / scores.Count;
|
||||
return scores.ToDictionary(s => s.Key, _ => even);
|
||||
}
|
||||
|
||||
// Proportional split
|
||||
var raw = scores.ToDictionary(s => s.Key, s => (int)Math.Round(s.Value / total * 100));
|
||||
|
||||
// Apply floor/ceiling constraints
|
||||
foreach (var key in raw.Keys.ToList())
|
||||
{
|
||||
raw[key] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[key]));
|
||||
}
|
||||
|
||||
// Normalize to exactly 100%
|
||||
var sum = raw.Values.Sum();
|
||||
if (sum != 100 && raw.Count > 0)
|
||||
{
|
||||
var diff = 100 - sum;
|
||||
// Add/subtract difference from the highest-scored channel
|
||||
var topChannel = raw.OrderByDescending(r => r.Value).First().Key;
|
||||
raw[topChannel] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[topChannel] + diff));
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static double SafeDiv(double numerator, double denominator)
|
||||
=> denominator > 0 ? numerator / denominator : 0;
|
||||
|
||||
private static string GetStrengthLabel(string channel, string objective) => channel switch
|
||||
{
|
||||
"google_ads" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for search visibility",
|
||||
"traffic" => "Strong for search intent clicks",
|
||||
"leads" => "Strong for high-intent leads",
|
||||
"sales" => "Strong for purchase intent",
|
||||
_ => "Search & intent targeting"
|
||||
},
|
||||
"meta" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for reach & discovery",
|
||||
"traffic" => "Strong for social traffic",
|
||||
"leads" => "Strong for lead gen forms",
|
||||
"sales" => "Strong for retargeting & social proof",
|
||||
_ => "Social reach & engagement"
|
||||
},
|
||||
"tiktok" => objective switch
|
||||
{
|
||||
"awareness" => "Strong for viral reach",
|
||||
_ => "Video-first engagement"
|
||||
},
|
||||
_ => "Advertising channel"
|
||||
};
|
||||
|
||||
private static ForecastRecommendation BuildRecommendation(
|
||||
List<ChannelEstimate> channels, string objective)
|
||||
{
|
||||
if (channels.Count < 2)
|
||||
return new ForecastRecommendation
|
||||
{
|
||||
Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.",
|
||||
Highlights = new List<string>()
|
||||
};
|
||||
|
||||
var top = channels[0];
|
||||
var second = channels[1];
|
||||
var highlights = new List<string>();
|
||||
|
||||
// Compare key metrics
|
||||
if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0)
|
||||
{
|
||||
var clickRatio = top.Estimates.Clicks / second.Estimates.Clicks;
|
||||
if (clickRatio > 1.3)
|
||||
highlights.Add($"{ChannelDisplayName(top.Provider)}: ~{clickRatio:F0}x more clicks per dollar");
|
||||
}
|
||||
|
||||
if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0)
|
||||
{
|
||||
var impRatio = second.Estimates.Impressions / top.Estimates.Impressions;
|
||||
if (impRatio > 1.5)
|
||||
highlights.Add($"{ChannelDisplayName(second.Provider)}: ~{impRatio:F0}x more impressions per dollar");
|
||||
}
|
||||
|
||||
if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0)
|
||||
{
|
||||
highlights.Add($"CPA range: ${Math.Min(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0}–${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels");
|
||||
}
|
||||
|
||||
return new ForecastRecommendation
|
||||
{
|
||||
Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " +
|
||||
$"between {ChannelDisplayName(top.Provider)} and {ChannelDisplayName(second.Provider)}, " +
|
||||
$"optimized for {objective}.",
|
||||
Highlights = highlights
|
||||
};
|
||||
}
|
||||
|
||||
private static string ChannelDisplayName(string provider) => provider switch
|
||||
{
|
||||
"google_ads" => "Google",
|
||||
"meta" => "Meta",
|
||||
"tiktok" => "TikTok",
|
||||
_ => provider
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Internal result from a single provider call</summary>
|
||||
private sealed class ProviderForecastResult
|
||||
{
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public double Impressions { get; set; }
|
||||
public double? Reach { get; set; }
|
||||
public double Clicks { get; set; }
|
||||
public double Conversions { get; set; }
|
||||
public decimal AvgCpc { get; set; }
|
||||
public decimal AvgCpm { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
public decimal? EstimatedCpa { get; set; }
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "none";
|
||||
}
|
||||
}
|
||||
353
Gateway/Services/ImageStorageService.cs
Normal file
353
Gateway/Services/ImageStorageService.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles downloading images from source URLs and storing them in Azure Blob Storage.
|
||||
/// Used when processing Creative drafts to ensure all image URLs are permanent.
|
||||
///
|
||||
/// Blob structure: {clientId}/drafts/{draftId}/{orientation}.{ext}
|
||||
/// Example: client-42/drafts/a1b2c3d4e5f6/landscape.jpg
|
||||
///
|
||||
/// This structure enables:
|
||||
/// - Client isolation (easy to list/delete all client assets)
|
||||
/// - Draft organization (images grouped per draft)
|
||||
/// - Future expansion (campaigns, versions, etc.)
|
||||
/// - Per-client access control via SAS tokens if needed
|
||||
/// </summary>
|
||||
public class ImageStorageService
|
||||
{
|
||||
private readonly BlobServiceClient _blobClient;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<ImageStorageService> _logger;
|
||||
private readonly string _containerName;
|
||||
private readonly string _blobBaseUrl;
|
||||
private readonly bool _isConfigured;
|
||||
|
||||
public ImageStorageService(
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<ImageStorageService> logger,
|
||||
IConfiguration config,
|
||||
BlobServiceClient blobClient)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
_blobClient = blobClient;
|
||||
_containerName = config["BlobStorage:ContainerName"] ?? "creative-images";
|
||||
_blobBaseUrl = config["BlobStorage:BaseUrl"] ?? "https://usimadpcreatives.blob.core.windows.net";
|
||||
_isConfigured = blobClient != null;
|
||||
|
||||
if (!_isConfigured)
|
||||
{
|
||||
_logger.LogWarning("[ImageStorage] Blob storage not configured - images will use source URLs");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("[ImageStorage] Blob storage configured: {BaseUrl}/{Container}", _blobBaseUrl, _containerName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether blob storage is configured and available.
|
||||
/// </summary>
|
||||
public bool IsConfigured => _isConfigured;
|
||||
|
||||
/// <summary>
|
||||
/// Process a Creative draft response, downloading and storing images in blob storage.
|
||||
/// Returns the modified JSON with blob URLs replacing source URLs.
|
||||
/// </summary>
|
||||
public async Task<string> ProcessCreativeDraftAsync(
|
||||
string clientId,
|
||||
string providerResponseJson,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured)
|
||||
{
|
||||
_logger.LogDebug("[ImageStorage] Skipping image processing - not configured");
|
||||
return providerResponseJson;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(providerResponseJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check if this is a successful response with data
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
return providerResponseJson;
|
||||
|
||||
if (!root.TryGetProperty("data", out var dataProp))
|
||||
return providerResponseJson;
|
||||
|
||||
// Check if data has images array
|
||||
if (!dataProp.TryGetProperty("images", out var imagesProp) ||
|
||||
imagesProp.ValueKind != JsonValueKind.Array ||
|
||||
imagesProp.GetArrayLength() == 0)
|
||||
{
|
||||
_logger.LogDebug("[ImageStorage] No images in draft response");
|
||||
return providerResponseJson;
|
||||
}
|
||||
|
||||
// Get draftId
|
||||
var draftId = dataProp.TryGetProperty("draftId", out var draftIdProp)
|
||||
? draftIdProp.GetString() ?? Guid.NewGuid().ToString("N")[..12]
|
||||
: Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
_logger.LogInformation(
|
||||
"[ImageStorage] Processing {Count} images for client {ClientId} draft {DraftId}",
|
||||
imagesProp.GetArrayLength(), clientId, draftId);
|
||||
|
||||
// Ensure container exists
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: ct);
|
||||
|
||||
// Process each image and collect results
|
||||
var processedImages = new List<Dictionary<string, object?>>();
|
||||
|
||||
foreach (var image in imagesProp.EnumerateArray())
|
||||
{
|
||||
var processedImage = await ProcessSingleImageAsync(
|
||||
containerClient, clientId, draftId, image, ct);
|
||||
processedImages.Add(processedImage);
|
||||
}
|
||||
|
||||
// Rebuild the response with updated image URLs
|
||||
return RebuildResponseWithProcessedImages(doc, processedImages);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[ImageStorage] Failed to process draft images, returning original response");
|
||||
return providerResponseJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a single image: download and upload to blob storage.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, object?>> ProcessSingleImageAsync(
|
||||
BlobContainerClient container,
|
||||
string clientId,
|
||||
string draftId,
|
||||
JsonElement image,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Extract image properties
|
||||
var imageId = image.TryGetProperty("imageId", out var idProp) ? idProp.GetString() : null;
|
||||
var sourceUrl = image.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null;
|
||||
var downloadUrl = image.TryGetProperty("downloadUrl", out var dlProp) ? dlProp.GetString() : null;
|
||||
var orientation = image.TryGetProperty("orientation", out var orProp) ? orProp.GetString() ?? "unknown" : "unknown";
|
||||
var source = image.TryGetProperty("source", out var srcProp) ? srcProp.GetString() : "unknown";
|
||||
var width = image.TryGetProperty("width", out var wProp) ? wProp.GetInt32() : 0;
|
||||
var height = image.TryGetProperty("height", out var hProp) ? hProp.GetInt32() : 0;
|
||||
var altText = image.TryGetProperty("altText", out var altProp) ? altProp.GetString() : null;
|
||||
var attribution = image.TryGetProperty("attribution", out var attrProp) ? attrProp.GetString() : null;
|
||||
|
||||
// Build result with original properties
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["imageId"] = imageId,
|
||||
["url"] = sourceUrl, // Will be replaced with blob URL on success
|
||||
["source"] = source,
|
||||
["orientation"] = orientation,
|
||||
["width"] = width,
|
||||
["height"] = height,
|
||||
["altText"] = altText,
|
||||
["attribution"] = attribution,
|
||||
["downloadUrl"] = downloadUrl,
|
||||
["blobStored"] = false // Track whether we stored it
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(sourceUrl))
|
||||
{
|
||||
_logger.LogWarning("[ImageStorage] Image has no URL, skipping");
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Download image bytes (prefer download URL for higher quality)
|
||||
var fetchUrl = !string.IsNullOrEmpty(downloadUrl) ? downloadUrl : sourceUrl;
|
||||
|
||||
var httpClient = _httpFactory.CreateClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
_logger.LogDebug("[ImageStorage] Downloading from {Url}", fetchUrl);
|
||||
|
||||
using var response = await httpClient.GetAsync(fetchUrl, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/jpeg";
|
||||
var extension = GetExtensionFromContentType(contentType);
|
||||
|
||||
// Build blob path: {clientId}/drafts/{draftId}/{orientation}.{ext}
|
||||
var blobName = $"{clientId}/drafts/{draftId}/{orientation}.{extension}";
|
||||
var blobClient = container.GetBlobClient(blobName);
|
||||
|
||||
// Upload with proper content type and caching headers
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
|
||||
var uploadOptions = new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders
|
||||
{
|
||||
ContentType = contentType,
|
||||
CacheControl = "public, max-age=31536000" // 1 year cache
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = source ?? "unknown",
|
||||
["originalUrl"] = sourceUrl,
|
||||
["orientation"] = orientation,
|
||||
["width"] = width.ToString(),
|
||||
["height"] = height.ToString(),
|
||||
["clientId"] = clientId,
|
||||
["draftId"] = draftId
|
||||
}
|
||||
};
|
||||
|
||||
await blobClient.UploadAsync(stream, uploadOptions, ct);
|
||||
|
||||
// Build permanent blob URL
|
||||
var blobUrl = $"{_blobBaseUrl}/{_containerName}/{blobName}";
|
||||
|
||||
result["url"] = blobUrl;
|
||||
result["blobStored"] = true;
|
||||
result["originalUrl"] = sourceUrl; // Keep original for reference
|
||||
|
||||
_logger.LogInformation("[ImageStorage] Stored {Orientation} image: {BlobUrl}", orientation, blobUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[ImageStorage] Failed to store {Orientation} image, keeping original URL", orientation);
|
||||
// Keep original URL as fallback
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild the provider response JSON with processed image data.
|
||||
/// </summary>
|
||||
private static string RebuildResponseWithProcessedImages(
|
||||
JsonDocument original,
|
||||
List<Dictionary<string, object?>> processedImages)
|
||||
{
|
||||
var root = original.RootElement;
|
||||
|
||||
// Build new response maintaining structure
|
||||
var response = new Dictionary<string, object?>
|
||||
{
|
||||
["ok"] = root.GetProperty("ok").GetBoolean(),
|
||||
["requestId"] = root.TryGetProperty("requestId", out var rid) ? rid.GetString() : null
|
||||
};
|
||||
|
||||
// Rebuild data object with processed images
|
||||
if (root.TryGetProperty("data", out var dataProp))
|
||||
{
|
||||
var data = new Dictionary<string, object?>();
|
||||
|
||||
// Copy all data properties except images
|
||||
foreach (var prop in dataProp.EnumerateObject())
|
||||
{
|
||||
if (prop.Name == "images")
|
||||
continue;
|
||||
|
||||
data[prop.Name] = JsonElementToObject(prop.Value);
|
||||
}
|
||||
|
||||
// Add processed images
|
||||
data["images"] = processedImages;
|
||||
|
||||
response["data"] = data;
|
||||
}
|
||||
|
||||
// Copy error if present
|
||||
if (root.TryGetProperty("error", out var errorProp))
|
||||
{
|
||||
response["error"] = JsonElementToObject(errorProp);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert JsonElement to object for serialization.
|
||||
/// </summary>
|
||||
private static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => element.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
|
||||
JsonValueKind.Array => element.EnumerateArray()
|
||||
.Select(JsonElementToObject).ToList(),
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all images for a specific draft.
|
||||
/// </summary>
|
||||
public async Task DeleteDraftImagesAsync(string clientId, string draftId, CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured) return;
|
||||
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
|
||||
var prefix = $"{clientId}/drafts/{draftId}/";
|
||||
await foreach (var blob in containerClient.GetBlobsAsync(
|
||||
traits: BlobTraits.None,
|
||||
states: BlobStates.None,
|
||||
prefix: prefix,
|
||||
cancellationToken: ct))
|
||||
{
|
||||
await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct);
|
||||
_logger.LogInformation("[ImageStorage] Deleted blob {Name}", blob.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all images for a client.
|
||||
/// </summary>
|
||||
public async Task DeleteClientImagesAsync(string clientId, CancellationToken ct)
|
||||
{
|
||||
if (!_isConfigured) return;
|
||||
|
||||
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
|
||||
|
||||
var prefix = $"{clientId}/";
|
||||
var count = 0;
|
||||
|
||||
await foreach (var blob in containerClient.GetBlobsAsync(
|
||||
traits: BlobTraits.None,
|
||||
states: BlobStates.None,
|
||||
prefix: prefix,
|
||||
cancellationToken: ct))
|
||||
{
|
||||
await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct);
|
||||
count++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("[ImageStorage] Deleted {Count} blobs for client {ClientId}", count, clientId);
|
||||
}
|
||||
|
||||
private static string GetExtensionFromContentType(string contentType) => contentType switch
|
||||
{
|
||||
"image/png" => "png",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
"image/svg+xml" => "svg",
|
||||
_ => "jpg"
|
||||
};
|
||||
}
|
||||
553
Gateway/Services/InitiativeLaunchService.cs
Normal file
553
Gateway/Services/InitiativeLaunchService.cs
Normal file
@@ -0,0 +1,553 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates launching an initiative by dispatching each channel campaign
|
||||
/// to its provider service (GoogleApi, Meta, TikTok, etc.).
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Load initiative + channel campaigns from DB (single call, channels nested)
|
||||
/// 2. Validate initiative belongs to requesting client
|
||||
/// 3. For each channel in "pending" status:
|
||||
/// a. Resolve provider config (endpoint, stub status)
|
||||
/// b. If real provider → dispatch via ExecutionService
|
||||
/// c. If stub/unconfigured → simulate "pending_review"
|
||||
/// d. Sync result back to DB via spChannelCampaign
|
||||
/// 4. Update initiative status based on aggregate results
|
||||
/// </summary>
|
||||
public sealed class InitiativeLaunchService
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ExecutionService _execution;
|
||||
private readonly MultiChannelConfig _config;
|
||||
private readonly ProviderStatusNormalizer _statusNorm;
|
||||
private readonly IConfiguration _appConfig;
|
||||
private readonly ILogger<InitiativeLaunchService> _log;
|
||||
|
||||
public InitiativeLaunchService(
|
||||
SqlService sql,
|
||||
ExecutionService execution,
|
||||
IOptions<MultiChannelConfig> config,
|
||||
ProviderStatusNormalizer statusNorm,
|
||||
IConfiguration appConfig,
|
||||
ILogger<InitiativeLaunchService> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_execution = execution;
|
||||
_config = config.Value;
|
||||
_statusNorm = statusNorm;
|
||||
_appConfig = appConfig;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launch all pending channel campaigns for an initiative.
|
||||
/// Returns a per-channel result summary.
|
||||
/// </summary>
|
||||
public async Task<LaunchResult> LaunchAsync(
|
||||
long initiativeId,
|
||||
string clientId,
|
||||
string? userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_log.LogInformation("[Launch] Starting initiative {InitiativeId} for client {ClientId}",
|
||||
initiativeId, clientId);
|
||||
|
||||
var result = new LaunchResult { InitiativeId = initiativeId };
|
||||
|
||||
// 1. Get initiative + nested channels in a single call
|
||||
// Pass clientId for ownership validation
|
||||
var initResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Initiative, "get",
|
||||
JsonSerializer.Serialize(new { initiativeId, clientId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(initResp))
|
||||
{
|
||||
result.Error = "Initiative not found";
|
||||
return result;
|
||||
}
|
||||
|
||||
using var initDoc = JsonDocument.Parse(initResp);
|
||||
var initRoot = initDoc.RootElement;
|
||||
|
||||
if (initRoot.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
{
|
||||
result.Error = initRoot.TryGetProperty("error", out var errProp)
|
||||
? errProp.GetString() ?? "Initiative not found"
|
||||
: "Initiative not found";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract initiative fields we need for dispatch (check both clean and prefixed shapes)
|
||||
var initiative = initRoot.TryGetProperty("initiative", out var initEl) ? initEl : initRoot;
|
||||
var initiativeName = TryStr(initiative, "name", "iniName") ?? "Campaign";
|
||||
var objective = TryStr(initiative, "objective", "iniObjective") ?? "traffic";
|
||||
var totalBudget = TryDec(initiative, "totalBudget", "iniBudget");
|
||||
var budgetPeriod = TryStr(initiative, "budgetPeriod", "iniBudgetPeriod") ?? "monthly";
|
||||
var businessCategory = TryStr(initiative, "businessCategory", "iniBusinessCategory");
|
||||
|
||||
// 2. Extract channels from the initiative response (already nested by spInitiative 'get')
|
||||
// No separate DB call needed — channels come back with clean field names
|
||||
JsonElement campaignsArray;
|
||||
if (initiative.TryGetProperty("channels", out var channelsEl) && channelsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
campaignsArray = channelsEl;
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.LogWarning("[Launch] No channels array in initiative response. Keys: {Keys}",
|
||||
string.Join(", ", EnumerateKeys(initiative)));
|
||||
result.Error = "No channel campaigns found for this initiative";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (campaignsArray.GetArrayLength() == 0)
|
||||
{
|
||||
result.Error = "Initiative has no channel campaigns to launch";
|
||||
return result;
|
||||
}
|
||||
|
||||
_log.LogInformation("[Launch] Found {Count} channel campaigns for initiative {InitiativeId}",
|
||||
campaignsArray.GetArrayLength(), initiativeId);
|
||||
|
||||
// 3. Dispatch each channel campaign
|
||||
foreach (var camp in campaignsArray.EnumerateArray())
|
||||
{
|
||||
var channelResult = new ChannelLaunchResult();
|
||||
|
||||
// Fields come back with clean names from spInitiative 'get':
|
||||
// channelCampaignId, channelType, allocatedBudget, allocationPct,
|
||||
// externalCampaignId, externalAccountId, providerPayload, status, providerStatus
|
||||
var ccId = TryLong(camp, "channelCampaignId", "chcId");
|
||||
var channelType = TryStr(camp, "channelType", "chcChannelType") ?? "unknown";
|
||||
var status = TryStr(camp, "status", "chcStatus") ?? "pending";
|
||||
var allocationPct = TryDec(camp, "allocationPct", "chcAllocationPct");
|
||||
if (allocationPct == 0m) allocationPct = 100m;
|
||||
|
||||
channelResult.ChannelCampaignId = ccId;
|
||||
channelResult.ChannelType = channelType ?? "unknown";
|
||||
|
||||
// Skip already-launched channels
|
||||
if (status != "pending" && status != "draft" && status != "staged")
|
||||
{
|
||||
channelResult.Status = status ?? "unknown";
|
||||
channelResult.Message = "Already dispatched";
|
||||
channelResult.Skipped = true;
|
||||
result.Channels.Add(channelResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate this channel's budget
|
||||
var channelBudget = totalBudget * allocationPct / 100m;
|
||||
|
||||
// Look up provider config
|
||||
var providerConfig = _config.GetChannel(channelType ?? "");
|
||||
|
||||
if (providerConfig == null || !providerConfig.Enabled)
|
||||
{
|
||||
channelResult.Status = "error";
|
||||
channelResult.Message = $"Channel '{channelType}' is not enabled";
|
||||
result.Channels.Add(channelResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Determine if a real provider is available:
|
||||
// Check both MultiChannel config Endpoint AND the PROVIDER_URL env var
|
||||
// that ExecutionService uses for routing.
|
||||
var hasRealProvider = !providerConfig.IsStub && IsProviderUrlConfigured(channelType!);
|
||||
|
||||
if (!hasRealProvider)
|
||||
{
|
||||
// Stub provider - simulate submission
|
||||
channelResult = await DispatchStubAsync(ccId, channelType!, providerConfig, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Real provider - dispatch through ExecutionService
|
||||
channelResult = await DispatchRealAsync(
|
||||
ccId, channelType!, providerConfig,
|
||||
initiativeName, objective, channelBudget, budgetPeriod,
|
||||
businessCategory, clientId, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Launch] Dispatch failed for channel {Channel} on initiative {InitiativeId}",
|
||||
channelType, initiativeId);
|
||||
channelResult.Status = "error";
|
||||
channelResult.Message = $"Dispatch error: {ex.Message}";
|
||||
}
|
||||
|
||||
result.Channels.Add(channelResult);
|
||||
}
|
||||
|
||||
// 4. Update initiative status based on results
|
||||
var anySuccess = result.Channels.Any(c =>
|
||||
c.Status == "active" || c.Status == "pending" || c.Status == "submitted");
|
||||
var allFailed = result.Channels.All(c => c.Status == "error");
|
||||
|
||||
if (anySuccess)
|
||||
{
|
||||
await UpdateInitiativeStatus(initiativeId, "active", ct);
|
||||
result.InitiativeStatus = "active";
|
||||
}
|
||||
else if (allFailed)
|
||||
{
|
||||
result.InitiativeStatus = "error";
|
||||
result.Error = "All channel dispatches failed";
|
||||
}
|
||||
|
||||
result.Ok = anySuccess;
|
||||
|
||||
_log.LogInformation(
|
||||
"[Launch] Completed initiative {InitiativeId} | Channels={ChannelCount} Success={SuccessCount} Failed={FailCount}",
|
||||
initiativeId, result.Channels.Count,
|
||||
result.Channels.Count(c => c.Status != "error"),
|
||||
result.Channels.Count(c => c.Status == "error"));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch to a real provider service via ExecutionService.
|
||||
/// Builds a GoogleApi-compatible request with proper payload structure.
|
||||
/// </summary>
|
||||
private async Task<ChannelLaunchResult> DispatchRealAsync(
|
||||
long channelCampaignId,
|
||||
string channelType,
|
||||
ProviderConfig config,
|
||||
string? campaignName,
|
||||
string? objective,
|
||||
decimal budget,
|
||||
string? budgetPeriod,
|
||||
string? businessCategory,
|
||||
string clientId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = new ChannelLaunchResult
|
||||
{
|
||||
ChannelCampaignId = channelCampaignId,
|
||||
ChannelType = channelType,
|
||||
};
|
||||
|
||||
// Convert budget: initiative stores dollars, GoogleApi expects daily micros
|
||||
var dailyBudget = ConvertToDailyBudget(budget, budgetPeriod);
|
||||
var budgetMicros = (long)(dailyBudget * 1_000_000m);
|
||||
|
||||
// Map objective to campaign type
|
||||
var campaignType = MapObjectiveToCampaignType(objective);
|
||||
|
||||
// Build execution request with proper payload structure
|
||||
// ExecutionService.BuildProviderRequest copies the "payload" field through
|
||||
var providerName = MapChannelToProvider(channelType);
|
||||
var execRequest = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = providerName,
|
||||
service = "campaign",
|
||||
action = "create",
|
||||
operation = "CreateCampaign",
|
||||
payload = new
|
||||
{
|
||||
name = campaignName ?? "Campaign",
|
||||
type = campaignType,
|
||||
budgetMicros = budgetMicros,
|
||||
biddingStrategy = MapObjectiveToBiddingStrategy(objective),
|
||||
}
|
||||
});
|
||||
|
||||
_log.LogInformation(
|
||||
"[Launch] Dispatching {Channel} to provider {Provider} | Budget=${Budget}/mo → {DailyBudget}/day → {BudgetMicros} micros | Type={CampaignType}",
|
||||
channelType, providerName, budget, dailyBudget, budgetMicros, campaignType);
|
||||
|
||||
// Call ExecutionService (handles routing, auth, logging)
|
||||
var execDoc = JsonDocument.Parse(execRequest);
|
||||
var respJson = await _execution.ExecuteAsync(execDoc.RootElement, ct);
|
||||
|
||||
// Parse wrapped response:
|
||||
// { ok, status, result: { ok, data: { campaignResourceName, externalId, ... } } }
|
||||
using var respDoc = JsonDocument.Parse(respJson);
|
||||
var respRoot = respDoc.RootElement;
|
||||
|
||||
// Check wrapper ok
|
||||
var wrapperOk = respRoot.TryGetProperty("ok", out var wrapOkEl) && wrapOkEl.GetBoolean();
|
||||
|
||||
// Navigate into result.data for the actual response
|
||||
string? externalId = null;
|
||||
bool providerOk = false;
|
||||
|
||||
if (respRoot.TryGetProperty("result", out var resultEl))
|
||||
{
|
||||
providerOk = resultEl.TryGetProperty("ok", out var provOkEl) && provOkEl.GetBoolean();
|
||||
|
||||
if (resultEl.TryGetProperty("data", out var dataEl))
|
||||
{
|
||||
// Real API returns campaignResourceName
|
||||
if (dataEl.TryGetProperty("campaignResourceName", out var crnEl))
|
||||
externalId = crnEl.GetString();
|
||||
// Emulated returns externalId
|
||||
else if (dataEl.TryGetProperty("externalId", out var extEl))
|
||||
externalId = extEl.GetString();
|
||||
}
|
||||
|
||||
// Also check for flat error at result level
|
||||
if (!providerOk && resultEl.TryGetProperty("error", out var errEl))
|
||||
{
|
||||
var errorMsg = errEl.ValueKind == JsonValueKind.Object
|
||||
? (errEl.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : errEl.GetRawText())
|
||||
: errEl.GetString();
|
||||
result.Status = "error";
|
||||
result.Message = $"Provider error: {errorMsg}";
|
||||
await SyncChannelCampaign(channelCampaignId, null, "error", errorMsg, ct);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapperOk && providerOk)
|
||||
{
|
||||
var platformStatus = _statusNorm.Normalize(channelType, "submitted");
|
||||
result.Status = platformStatus;
|
||||
result.ExternalCampaignId = externalId;
|
||||
result.Message = $"Successfully dispatched to {config.DisplayName}";
|
||||
|
||||
_log.LogInformation(
|
||||
"[Launch] {Channel} dispatched successfully | ExternalId={ExternalId} PlatformStatus={Status}",
|
||||
channelType, externalId, platformStatus);
|
||||
|
||||
// Sync back to DB
|
||||
await SyncChannelCampaign(channelCampaignId, externalId, platformStatus, "submitted", ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = "Provider returned error";
|
||||
// Try to extract error message
|
||||
if (respRoot.TryGetProperty("result", out var resEl) &&
|
||||
resEl.TryGetProperty("error", out var errObj))
|
||||
{
|
||||
error = errObj.ValueKind == JsonValueKind.Object
|
||||
? (errObj.TryGetProperty("message", out var m) ? m.GetString() : errObj.GetRawText())
|
||||
: errObj.GetString();
|
||||
}
|
||||
|
||||
result.Status = "error";
|
||||
result.Message = $"Provider error: {error}";
|
||||
|
||||
_log.LogWarning("[Launch] {Channel} dispatch failed | Error={Error}", channelType, error);
|
||||
|
||||
await SyncChannelCampaign(channelCampaignId, null, "error", error, ct);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulate dispatch for stub/unconfigured providers.
|
||||
/// Marks the channel as "pending_review" since there's no real provider to call.
|
||||
/// </summary>
|
||||
private async Task<ChannelLaunchResult> DispatchStubAsync(
|
||||
long channelCampaignId,
|
||||
string channelType,
|
||||
ProviderConfig config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_log.LogInformation("[Launch] Stub dispatch for {Channel} (no real provider)", channelType);
|
||||
|
||||
// Simulate a short delay for realism
|
||||
await Task.Delay(100, ct);
|
||||
|
||||
var result = new ChannelLaunchResult
|
||||
{
|
||||
ChannelCampaignId = channelCampaignId,
|
||||
ChannelType = channelType,
|
||||
Status = _statusNorm.Normalize(channelType, "pending_review"),
|
||||
Message = $"{config.DisplayName} campaign queued for review (provider coming soon)",
|
||||
IsStub = true,
|
||||
};
|
||||
|
||||
// Sync to DB: chcStatus = normalized, chcProviderStatus = raw
|
||||
await SyncChannelCampaign(channelCampaignId, null, result.Status, "stub_provider", ct);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Update channel campaign status in DB.</summary>
|
||||
private async Task SyncChannelCampaign(
|
||||
long channelCampaignId,
|
||||
string? externalCampaignId,
|
||||
string status,
|
||||
string? providerStatus,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelCampaign, "sync",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
channelCampaignId,
|
||||
externalCampaignId = externalCampaignId,
|
||||
status = status,
|
||||
providerStatus = providerStatus,
|
||||
}), ct: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Launch] Failed to sync channel campaign {Id}", channelCampaignId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Update initiative status in DB.</summary>
|
||||
private async Task UpdateInitiativeStatus(long initiativeId, string status, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Initiative, "updateStatus",
|
||||
JsonSerializer.Serialize(new { initiativeId, status }), ct: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Launch] Failed to update initiative status {Id}", initiativeId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Map channel type to execution provider name.</summary>
|
||||
private static string MapChannelToProvider(string channelType)
|
||||
{
|
||||
return channelType switch
|
||||
{
|
||||
"google_ads" => "google",
|
||||
"meta" => "meta",
|
||||
"tiktok" => "tiktok",
|
||||
_ => channelType
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a real provider URL is configured for this channel type.
|
||||
/// Uses the same env var pattern as ExecutionService for routing.
|
||||
/// </summary>
|
||||
private bool IsProviderUrlConfigured(string channelType)
|
||||
{
|
||||
var envVarName = channelType switch
|
||||
{
|
||||
"google_ads" => "GOOGLE_PROVIDER_URL",
|
||||
"meta" => "META_PROVIDER_URL",
|
||||
"tiktok" => "TIKTOK_PROVIDER_URL",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (envVarName == null) return false;
|
||||
|
||||
var url = _appConfig[envVarName];
|
||||
return !string.IsNullOrWhiteSpace(url);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert initiative budget (dollars per period) to daily budget.
|
||||
/// Google Ads API operates on daily budgets.
|
||||
/// </summary>
|
||||
private static decimal ConvertToDailyBudget(decimal budget, string? budgetPeriod)
|
||||
{
|
||||
return (budgetPeriod?.ToLowerInvariant()) switch
|
||||
{
|
||||
"daily" => budget,
|
||||
"weekly" => budget / 7m,
|
||||
"monthly" => budget / 30.4m, // Google's standard month divisor
|
||||
_ => budget / 30.4m // Default to monthly
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map platform objective to Google Ads campaign type.
|
||||
/// This determines the advertising channel (Search, Display, etc.)
|
||||
/// </summary>
|
||||
private static string MapObjectiveToCampaignType(string? objective)
|
||||
{
|
||||
return (objective?.ToLowerInvariant()) switch
|
||||
{
|
||||
"awareness" => "Display", // Brand awareness → Display network
|
||||
"traffic" => "Search", // Website traffic → Search ads
|
||||
"leads" => "Search", // Lead generation → Search ads
|
||||
"conversions" => "Search", // Conversions → Search ads
|
||||
"sales" => "PerformanceMax", // Sales → Performance Max
|
||||
"engagement" => "Display", // Engagement → Display network
|
||||
_ => "Search" // Default to Search
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map platform objective to a bidding strategy.
|
||||
/// </summary>
|
||||
private static string MapObjectiveToBiddingStrategy(string? objective)
|
||||
{
|
||||
return (objective?.ToLowerInvariant()) switch
|
||||
{
|
||||
"awareness" => "MaximizeClicks", // Broad reach
|
||||
"traffic" => "MaximizeClicks", // Drive traffic
|
||||
"leads" => "MaximizeConversions", // Optimize for leads
|
||||
"conversions" => "MaximizeConversions", // Optimize for conversions
|
||||
"sales" => "MaximizeConversions", // Optimize for sales
|
||||
_ => "MaximizeClicks" // Safe default
|
||||
};
|
||||
}
|
||||
|
||||
// ── JSON field helpers (try both clean and prefixed names) ──
|
||||
|
||||
private static string? TryStr(JsonElement el, string key1, string key2)
|
||||
{
|
||||
if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.String) return p1.GetString();
|
||||
if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.String) return p2.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static decimal TryDec(JsonElement el, string key1, string key2)
|
||||
{
|
||||
if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.Number) return p1.GetDecimal();
|
||||
if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.Number) return p2.GetDecimal();
|
||||
return 0m;
|
||||
}
|
||||
|
||||
private static long TryLong(JsonElement el, string key1, string key2)
|
||||
{
|
||||
if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.Number) return p1.GetInt64();
|
||||
if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.Number) return p2.GetInt64();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateKeys(JsonElement el)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Object)
|
||||
foreach (var prop in el.EnumerateObject())
|
||||
yield return prop.Name;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Result DTOs
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
public sealed class LaunchResult
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public long InitiativeId { get; set; }
|
||||
public string? InitiativeStatus { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public List<ChannelLaunchResult> Channels { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ChannelLaunchResult
|
||||
{
|
||||
public long ChannelCampaignId { get; set; }
|
||||
public string ChannelType { get; set; } = "";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? Message { get; set; }
|
||||
public string? ExternalCampaignId { get; set; }
|
||||
public bool IsStub { get; set; }
|
||||
public bool Skipped { get; set; }
|
||||
}
|
||||
214
Gateway/Services/IntelligenceApiClient.cs
Normal file
214
Gateway/Services/IntelligenceApiClient.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using Gateway.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for IntelligenceApi — the spend distribution engine container.
|
||||
///
|
||||
/// The Gateway injects clientCategory from ClientContext and provider config
|
||||
/// before forwarding requests. The client portal never calls IntelligenceApi
|
||||
/// directly; all routing goes through the Gateway.
|
||||
///
|
||||
/// FALLBACK: If IntelligenceApi is unreachable, ForecastController falls back
|
||||
/// to the local ForecastService (identical to the General engine output).
|
||||
/// This means a container restart or deployment never breaks the wizard.
|
||||
/// </summary>
|
||||
public sealed class IntelligenceApiClient
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<IntelligenceApiClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts =
|
||||
new(JsonSerializerDefaults.Web);
|
||||
|
||||
public IntelligenceApiClient(
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ILogger<IntelligenceApiClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward raw census data for a ZCTA to the Intelligence container for
|
||||
/// market analysis derivation (age chips, income tiers, insight strings).
|
||||
/// Returns the raw JSON response string, or null if the container is
|
||||
/// unreachable — caller falls back to returning raw census data.
|
||||
/// </summary>
|
||||
public async Task<string?> GetDemographicAnalysisAsync(
|
||||
string zcta,
|
||||
JsonElement censusData,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = _cfg["INTELLIGENCE_API_URL"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping demographics analysis");
|
||||
return null;
|
||||
}
|
||||
|
||||
var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
zcta,
|
||||
census = censusData
|
||||
}, _jsonOpts);
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"{baseUrl.TrimEnd('/')}/api/demographics/analyze");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(internalKey))
|
||||
msg.Headers.Add("X-Internal-Key", internalKey);
|
||||
|
||||
msg.Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
_logger.LogInformation("[IntelligenceApiClient] Demographics analysis | ZCTA={Zcta}", zcta);
|
||||
|
||||
using var resp = await client.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Demographics non-success {Status}", (int)resp.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Demographics analysis timed out | ZCTA={Zcta}", zcta);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[IntelligenceApiClient] Demographics analysis failed | ZCTA={Zcta}", zcta);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward a channel forecast request to IntelligenceApi with
|
||||
/// clientCategory injected. Returns null if the service is unreachable
|
||||
/// or returns an error — caller should fall back to ForecastService.
|
||||
/// </summary>
|
||||
public async Task<ChannelForecastResponse?> GetSpendDistributionAsync(
|
||||
ChannelForecastRequest request,
|
||||
string? clientCategory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = _cfg["INTELLIGENCE_API_URL"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
||||
|
||||
// Build the IntelligenceApi request — forward everything from the
|
||||
// original wizard request, plus inject clientCategory and provider config
|
||||
var intelligenceRequest = new
|
||||
{
|
||||
clientCategory = clientCategory ?? "General",
|
||||
objective = request.Objective,
|
||||
businessCategory = request.BusinessCategory,
|
||||
keywords = request.Keywords,
|
||||
geoTargeting = request.GeoTargeting,
|
||||
audience = request.Audience,
|
||||
monthlyBudget = request.MonthlyBudget,
|
||||
channels = request.Channels,
|
||||
|
||||
// Forward provider URLs so the engine can call providers directly
|
||||
providerUrls = new Dictionary<string, string>
|
||||
{
|
||||
["google_ads"] = _cfg["GOOGLE_PROVIDER_URL"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL") ?? ""
|
||||
},
|
||||
internalKeys = new Dictionary<string, string>
|
||||
{
|
||||
["google_ads"] = _cfg["GOOGLE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY") ?? ""
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"{baseUrl.TrimEnd('/')}/api/spend-distribution");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(internalKey))
|
||||
msg.Headers.Add("X-Internal-Key", internalKey);
|
||||
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(intelligenceRequest, _jsonOpts),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
_logger.LogInformation(
|
||||
"[IntelligenceApiClient] Calling engine | Category={Category} Budget={Budget}",
|
||||
clientCategory, request.MonthlyBudget);
|
||||
|
||||
using var resp = await client.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[IntelligenceApiClient] Non-success {Status}: {Body}",
|
||||
(int)resp.StatusCode, body[..Math.Min(body.Length, 200)]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map IntelligenceApi response shape → Gateway ChannelForecastResponse
|
||||
// The shapes are intentionally aligned so this is a straight deserialize.
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Engine returned ok=false");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-serialize then deserialize into the Gateway model
|
||||
// (avoids a hard dependency on IntelligenceApi model types in Gateway)
|
||||
var result = JsonSerializer.Deserialize<ChannelForecastResponse>(body, _jsonOpts);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[IntelligenceApiClient] OK | Engine={Engine} Channels={N}",
|
||||
root.TryGetProperty("metadata", out var meta)
|
||||
&& meta.TryGetProperty("engine", out var eng)
|
||||
? eng.GetString() : "?",
|
||||
result?.Channels?.Count ?? 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning("[IntelligenceApiClient] Request timed out");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[IntelligenceApiClient] Request failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
339
Gateway/Services/MetricSyncService.cs
Normal file
339
Gateway/Services/MetricSyncService.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates pulling campaign performance metrics from providers
|
||||
/// and writing them into the database via spPerformanceMetric.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Get active channel campaigns (from spChannelCampaign listByClient)
|
||||
/// 2. For each channel campaign with an external campaign ID:
|
||||
/// - Call the appropriate provider's reporting endpoint
|
||||
/// - Transform provider response into standard metric format
|
||||
/// - Upsert into tbPerformanceMetric via spPerformanceMetric.upsertBatch
|
||||
/// 3. After metrics are synced, trigger recommendation evaluation
|
||||
///
|
||||
/// Called by:
|
||||
/// - Admin endpoint (manual trigger)
|
||||
/// - Background polling (future: Azure Functions timer trigger)
|
||||
/// </summary>
|
||||
public sealed class MetricSyncService
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<MetricSyncService> _logger;
|
||||
|
||||
public MetricSyncService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ILogger<MetricSyncService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync metrics for a specific client's active campaigns.
|
||||
/// </summary>
|
||||
public async Task<SyncResult> SyncClientMetricsAsync(
|
||||
string clientId, string? startDate, string? endDate, CancellationToken ct)
|
||||
{
|
||||
var result = new SyncResult { ClientId = clientId };
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Get active channel campaigns for this client
|
||||
var listResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelCampaign, "listByClient",
|
||||
JsonSerializer.Serialize(new { clientId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(listResp))
|
||||
{
|
||||
result.Error = "Failed to retrieve channel campaigns";
|
||||
return result;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(listResp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
||||
{
|
||||
result.Error = "Channel campaign query returned error";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract campaigns array
|
||||
JsonElement campaigns;
|
||||
if (root.TryGetProperty("channelCampaigns", out campaigns) ||
|
||||
root.TryGetProperty("channels", out campaigns))
|
||||
{
|
||||
// ok
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Error = "No channel campaigns found in response";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (campaigns.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
result.Error = "Channel campaigns is not an array";
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Sync each active channel campaign
|
||||
foreach (var cc in campaigns.EnumerateArray())
|
||||
{
|
||||
var chcId = cc.TryGetProperty("channelCampaignId", out var chcIdProp) ? chcIdProp.GetInt64() :
|
||||
cc.TryGetProperty("chcId", out var chcProp) ? chcProp.GetInt64() : 0;
|
||||
var channelType = cc.TryGetProperty("channelType", out var ctProp) ? ctProp.GetString() :
|
||||
cc.TryGetProperty("chcChannelType", out var chcCtProp) ? chcCtProp.GetString() : null;
|
||||
var status = cc.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
cc.TryGetProperty("chcStatus", out var chcStProp) ? chcStProp.GetString() : null;
|
||||
|
||||
if (chcId == 0 || string.IsNullOrWhiteSpace(channelType)) continue;
|
||||
if (status != "active") continue;
|
||||
|
||||
result.CampaignsProcessed++;
|
||||
|
||||
try
|
||||
{
|
||||
var provider = MapChannelToProvider(channelType);
|
||||
var providerUrl = GetProviderUrl(provider);
|
||||
var providerKey = GetProviderKey(provider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerUrl))
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] No URL for provider {Provider}, skipping chcId={ChcId}",
|
||||
provider, chcId);
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get external campaign ID
|
||||
// providerPayload from the channel campaign contains the external mapping
|
||||
var externalCampaignId = cc.TryGetProperty("externalCampaignId", out var extIdProp)
|
||||
? extIdProp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(externalCampaignId))
|
||||
{
|
||||
// Try to extract from providerPayload JSON
|
||||
if (cc.TryGetProperty("providerPayload", out var ppProp) &&
|
||||
ppProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ppDoc = JsonDocument.Parse(ppProp.GetString()!);
|
||||
externalCampaignId = ppDoc.RootElement.TryGetProperty("externalId", out var eidProp)
|
||||
? eidProp.GetString() : null;
|
||||
}
|
||||
catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(externalCampaignId))
|
||||
{
|
||||
_logger.LogDebug("[MetricSync] No externalCampaignId for chcId={ChcId}, skipping", chcId);
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Call provider reporting endpoint
|
||||
var reportPayload = new
|
||||
{
|
||||
operation = "GetCampaignReport",
|
||||
tenantId = GetTenantId(cc),
|
||||
requestId = Guid.NewGuid().ToString("N"),
|
||||
payload = new
|
||||
{
|
||||
campaignId = externalCampaignId,
|
||||
startDate = startDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd"),
|
||||
endDate = endDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd")
|
||||
}
|
||||
};
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", providerKey);
|
||||
msg.Content = new StringContent(
|
||||
JsonSerializer.Serialize(reportPayload),
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var resp = await httpClient.SendAsync(msg, ct);
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] Provider returned {Status} for chcId={ChcId}",
|
||||
resp.StatusCode, chcId);
|
||||
result.Errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse provider response and extract daily rows
|
||||
using var respDoc = JsonDocument.Parse(respBody);
|
||||
var respRoot = respDoc.RootElement;
|
||||
|
||||
JsonElement data;
|
||||
if (respRoot.TryGetProperty("data", out data) ||
|
||||
respRoot.TryGetProperty("Data", out data))
|
||||
{
|
||||
// ok
|
||||
}
|
||||
else
|
||||
{
|
||||
data = respRoot;
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("rows", out var rowsEl) ||
|
||||
rowsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_logger.LogWarning("[MetricSync] No rows in provider response for chcId={ChcId}", chcId);
|
||||
result.Errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform rows into upsertBatch format
|
||||
var metrics = new List<object>();
|
||||
foreach (var row in rowsEl.EnumerateArray())
|
||||
{
|
||||
var metricDate = row.TryGetProperty("date", out var dProp) ? dProp.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(metricDate)) continue;
|
||||
|
||||
metrics.Add(new
|
||||
{
|
||||
channelCampaignId = chcId,
|
||||
metricDate,
|
||||
impressions = GetLong(row, "impressions"),
|
||||
clicks = GetLong(row, "clicks"),
|
||||
spend = GetDecimal(row, "spend") ?? (GetLong(row, "costMicros") / 1_000_000.0m),
|
||||
conversions = GetDecimal(row, "conversions") ?? 0,
|
||||
conversionValue = GetDecimal(row, "conversionValue") ?? 0,
|
||||
sourceAttribution = "provider"
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.Count == 0) continue;
|
||||
|
||||
// Upsert into database
|
||||
var upsertResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.PerformanceMetric, "upsertBatch",
|
||||
JsonSerializer.Serialize(new { metrics }), ct: ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[MetricSync] Synced {Count} rows for chcId={ChcId} channel={Channel}",
|
||||
metrics.Count, chcId, channelType);
|
||||
|
||||
result.MetricsWritten += metrics.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Error syncing chcId={ChcId}", chcId);
|
||||
result.Errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Trigger recommendation evaluation for this client
|
||||
if (result.MetricsWritten > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var evalResp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Recommendation, "evaluate",
|
||||
JsonSerializer.Serialize(new { clientId }), ct: ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evalResp))
|
||||
{
|
||||
using var evalDoc = JsonDocument.Parse(evalResp);
|
||||
if (evalDoc.RootElement.TryGetProperty("generated", out var genProp))
|
||||
result.RecommendationsGenerated = genProp.GetInt32();
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[MetricSync] Evaluation complete for client {ClientId} | Recommendations={Recommendations}",
|
||||
clientId, result.RecommendationsGenerated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Evaluation failed for client {ClientId}", clientId);
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MetricSync] Sync failed for client {ClientId}", clientId);
|
||||
result.Error = ex.Message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
private static string MapChannelToProvider(string channelType) =>
|
||||
channelType.ToLowerInvariant() switch
|
||||
{
|
||||
"google_ads" or "google" => "google",
|
||||
"meta" or "facebook" => "meta",
|
||||
"tiktok" => "tiktok",
|
||||
_ => channelType
|
||||
};
|
||||
|
||||
private string GetProviderUrl(string provider) =>
|
||||
provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private string GetProviderKey(string provider) =>
|
||||
provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
|
||||
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
|
||||
"tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private static string? GetTenantId(JsonElement cc)
|
||||
{
|
||||
if (cc.TryGetProperty("externalAccountId", out var eaProp)) return eaProp.GetString();
|
||||
if (cc.TryGetProperty("chcExternalAccountId", out var chcEaProp)) return chcEaProp.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long GetLong(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
|
||||
private static decimal? GetDecimal(JsonElement el, string prop)
|
||||
{
|
||||
if (!el.TryGetProperty(prop, out var p)) return null;
|
||||
return p.ValueKind == JsonValueKind.Number ? p.GetDecimal() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of a metric sync operation.</summary>
|
||||
public sealed class SyncResult
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public int CampaignsProcessed { get; set; }
|
||||
public int MetricsWritten { get; set; }
|
||||
public int RecommendationsGenerated { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
public int Errors { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
160
Gateway/Services/ProviderStatusNormalizer.cs
Normal file
160
Gateway/Services/ProviderStatusNormalizer.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using Gateway.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes provider-specific campaign statuses into platform-standard statuses.
|
||||
///
|
||||
/// Each advertising channel (Google Ads, Meta, TikTok, etc.) reports campaign state
|
||||
/// using its own vocabulary. This service translates those into the platform's
|
||||
/// unified status set: draft, staged, pending, active, paused, completed, cancelled, error.
|
||||
///
|
||||
/// Mapping priority:
|
||||
/// 1. Channel-specific mapping from config (e.g. google_ads → ENABLED → active)
|
||||
/// 2. Common/internal mappings (e.g. submitted → active, pending_review → pending)
|
||||
/// 3. Pass-through if the raw status is already a valid platform status
|
||||
/// 4. "error" fallback with a warning log for truly unknown statuses
|
||||
/// </summary>
|
||||
public sealed class ProviderStatusNormalizer
|
||||
{
|
||||
private readonly MultiChannelConfig _config;
|
||||
private readonly ILogger<ProviderStatusNormalizer> _log;
|
||||
|
||||
/// <summary>The canonical set of platform-level statuses.</summary>
|
||||
private static readonly HashSet<string> PlatformStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"draft", "staged", "pending", "active", "paused", "completed", "cancelled", "error"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Internal/transitional statuses used during launch orchestration.
|
||||
/// These are not provider-specific but arise from the platform's own workflow.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> CommonMappings = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Launch service assigns these during dispatch
|
||||
["submitted"] = "active",
|
||||
["pending_review"] = "pending",
|
||||
["stub_provider"] = "pending",
|
||||
|
||||
// Webhook / callback transitional states
|
||||
["approved"] = "active",
|
||||
["rejected"] = "error",
|
||||
["suspended"] = "paused",
|
||||
["budget_depleted"] = "paused",
|
||||
["expired"] = "completed",
|
||||
["archived"] = "completed",
|
||||
["deleted"] = "cancelled",
|
||||
["in_process"] = "pending",
|
||||
["in_review"] = "pending",
|
||||
["learning"] = "active", // Meta "learning phase"
|
||||
["limited"] = "active", // Google "limited by budget" etc.
|
||||
};
|
||||
|
||||
public ProviderStatusNormalizer(
|
||||
IOptions<MultiChannelConfig> config,
|
||||
ILogger<ProviderStatusNormalizer> log)
|
||||
{
|
||||
_config = config.Value;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize a raw provider status into a platform status.
|
||||
/// </summary>
|
||||
/// <param name="channelType">Channel identifier (e.g. "google_ads", "meta", "tiktok").</param>
|
||||
/// <param name="rawProviderStatus">The status string as returned by the provider.</param>
|
||||
/// <returns>A valid platform status string.</returns>
|
||||
public string Normalize(string? channelType, string? rawProviderStatus)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawProviderStatus))
|
||||
return "error";
|
||||
|
||||
var raw = rawProviderStatus.Trim();
|
||||
|
||||
// 1. Try channel-specific mapping from config
|
||||
if (!string.IsNullOrWhiteSpace(channelType))
|
||||
{
|
||||
var provider = _config.GetChannel(channelType);
|
||||
if (provider?.StatusMappings != null &&
|
||||
provider.StatusMappings.TryGetValue(raw, out var mapped))
|
||||
{
|
||||
_log.LogDebug("[StatusNorm] {Channel}/{RawStatus} → {PlatformStatus} (config)",
|
||||
channelType, raw, mapped);
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try common/internal mappings
|
||||
if (CommonMappings.TryGetValue(raw, out var common))
|
||||
{
|
||||
_log.LogDebug("[StatusNorm] {RawStatus} → {PlatformStatus} (common)",
|
||||
raw, common);
|
||||
return common;
|
||||
}
|
||||
|
||||
// 3. If the raw value is already a valid platform status, pass through
|
||||
if (PlatformStatuses.Contains(raw))
|
||||
{
|
||||
_log.LogDebug("[StatusNorm] {RawStatus} → pass-through (already platform status)", raw);
|
||||
return raw.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// 4. Unknown — log warning and return "error"
|
||||
_log.LogWarning(
|
||||
"[StatusNorm] Unknown provider status: channel={Channel}, raw={RawStatus}. Defaulting to 'error'. " +
|
||||
"Add a mapping in MultiChannel.Channels[].StatusMappings or CommonMappings.",
|
||||
channelType ?? "(none)", raw);
|
||||
|
||||
return "error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the platform status for a sync operation.
|
||||
/// If an explicit platform status is provided, validate and use it.
|
||||
/// Otherwise, normalize the provider status.
|
||||
/// </summary>
|
||||
/// <param name="channelType">Channel identifier.</param>
|
||||
/// <param name="explicitStatus">Explicitly provided platform status (optional).</param>
|
||||
/// <param name="rawProviderStatus">Raw provider status (optional).</param>
|
||||
/// <returns>A valid platform status string.</returns>
|
||||
public string Resolve(string? channelType, string? explicitStatus, string? rawProviderStatus)
|
||||
{
|
||||
// If an explicit platform status was given, validate it
|
||||
if (!string.IsNullOrWhiteSpace(explicitStatus))
|
||||
{
|
||||
if (PlatformStatuses.Contains(explicitStatus))
|
||||
return explicitStatus.ToLowerInvariant();
|
||||
|
||||
_log.LogWarning("[StatusNorm] Invalid explicit status '{Status}', normalizing as provider status instead.",
|
||||
explicitStatus);
|
||||
// Fall through to normalization
|
||||
}
|
||||
|
||||
return Normalize(channelType, rawProviderStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all configured mappings for a channel (for diagnostics / admin display).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GetMappings(string channelType)
|
||||
{
|
||||
var result = new Dictionary<string, string>(CommonMappings, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var provider = _config.GetChannel(channelType);
|
||||
if (provider?.StatusMappings != null)
|
||||
{
|
||||
foreach (var kv in provider.StatusMappings)
|
||||
result[kv.Key] = kv.Value; // Channel-specific overrides common
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether a string is a valid platform status.
|
||||
/// </summary>
|
||||
public static bool IsValidPlatformStatus(string? status) =>
|
||||
!string.IsNullOrWhiteSpace(status) && PlatformStatuses.Contains(status);
|
||||
}
|
||||
@@ -9,10 +9,38 @@
|
||||
|
||||
"Auth": {
|
||||
"AllowDevBypass": false,
|
||||
|
||||
"Microsoft": {
|
||||
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
|
||||
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e",
|
||||
"StaffTenantId": "0be4c23a-6941-4bdb-b397-a4faf88de4b3",
|
||||
"StaffClientId": "b0f29246-91e7-4615-96db-5de9b6f8da2e"
|
||||
},
|
||||
|
||||
"EntraId": {
|
||||
"Instance": "https://login.microsoftonline.com/",
|
||||
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
|
||||
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
|
||||
}
|
||||
},
|
||||
|
||||
"BlobStorage": {
|
||||
"ConnectionString": "",
|
||||
"ContainerName": "creative-images",
|
||||
"BaseUrl": "https://usimadpcreatives.blob.core.windows.net"
|
||||
},
|
||||
|
||||
"MultiChannel": {
|
||||
"Allocation": {
|
||||
"MinMultiChannelMonthlyBudget": 500.00,
|
||||
"MaxChannelsPerInitiative": 5,
|
||||
"DefaultAllocationStrategy": "template",
|
||||
"PerformanceEvalIntervalDays": 7,
|
||||
"PerformanceLookbackDays": 14,
|
||||
"PerformanceLearningPeriodDays": 14,
|
||||
"MaxAllocationShiftPct": 15.00,
|
||||
"MinChannelAllocationPct": 10.00,
|
||||
"MaxChannelAllocationPct": 80.00
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user