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; }
|
||||
}
|
||||
Reference in New Issue
Block a user