Initial import into Gitea

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

View File

@@ -44,10 +44,18 @@ public sealed class AuthController : ControllerBase
_log.LogWarning("[Session] Authenticated: ClientId={ClientId}, Email={Email}",
_client.ClientId, _client.Email);
// Gateway handles CIAM client sessions only.
// Staff apps authenticate directly to Management API via JWT Bearer — never via Gateway.
if (_client.IsStaff)
{
_log.LogWarning("[Session] Staff token rejected — use JWT Bearer directly to Management API");
return StatusCode(403, new { ok = false, error = "Staff authentication does not use Gateway sessions" });
}
var rqst = JsonSerializer.Serialize(new
{
provider = _client.AuthProvider ?? "EntraExternalId",
subject = _client.ClientId,
subject = _client.ClientId,
email = _client.Email,
displayName = _client.ClientName,
clientId = request?.PreferredClientId,
@@ -56,13 +64,15 @@ public sealed class AuthController : ControllerBase
sessionDurationHours = request?.SessionDurationHours ?? 24
});
_log.LogWarning("[Session] Calling spSession with: {Rqst}", rqst);
_log.LogWarning("[Session] Calling proc with: {Rqst}", rqst);
_log.LogWarning("[Session] Using proc=dbo.spClientSession");
try
{
var resp = await _sql.ExecProcAsync("dbo.spSession", "createFromIdentity", rqst, ct: ct);
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "createFromIdentity", rqst, ct: ct);
_log.LogWarning("[Session] spSession response: {Resp}", resp ?? "(null)");
_log.LogWarning("[Session] Proc response: {Resp}", resp ?? "(null)");
if (string.IsNullOrWhiteSpace(resp))
{
@@ -118,7 +128,7 @@ public sealed class AuthController : ControllerBase
var rqst = JsonSerializer.Serialize(new
{
provider = _client.AuthProvider ?? "EntraExternalId",
subject = _client.ClientId,
subject = _client.ClientId,
email = _client.Email,
displayName = _client.ClientName,
companyName = request.CompanyName,
@@ -173,10 +183,11 @@ public sealed class AuthController : ControllerBase
}
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var signoffProc = "dbo.spClientSession"; // Gateway handles client sessions only
try
{
await _sql.ExecProcAsync("dbo.spSession", "signoff", rqst, ct: ct);
await _sql.ExecProcAsync(signoffProc, "signoff", rqst, ct: ct);
return Ok(new { ok = true, message = "Signed out successfully" });
}
catch (Exception ex)
@@ -204,10 +215,11 @@ public sealed class AuthController : ControllerBase
sessionToken = token,
sessionDurationHours = request?.SessionDurationHours ?? 24
});
var refreshProc = "dbo.spClientSession"; // Gateway handles client sessions only
try
{
var resp = await _sql.ExecProcAsync("dbo.spSession", "refresh", rqst, ct: ct);
var resp = await _sql.ExecProcAsync(refreshProc, "refresh", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
{
@@ -251,7 +263,7 @@ public sealed class AuthController : ControllerBase
try
{
var resp = await _sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: ct);
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
{
@@ -304,7 +316,7 @@ public sealed class AuthController : ControllerBase
try
{
var resp = await _sql.ExecProcAsync("dbo.spSession", "switchClient", rqst, ct: ct);
var resp = await _sql.ExecProcAsync("dbo.spClientSession", "switchClient", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
{
@@ -338,11 +350,18 @@ public sealed class AuthController : ControllerBase
if (!string.IsNullOrWhiteSpace(token))
return token;
// Check Authorization header (for session tokens, not JWTs)
// Check Authorization header — accept both "Session <token>" and "Bearer <token>".
// NOTE: Bearer here is a session token (not an Entra JWT) because the middleware
// only routes to these controller actions after session validation succeeds.
// The JWT-only endpoint (/api/auth/session) never calls ExtractSessionToken().
var auth = Request.Headers.Authorization.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(auth))
{
return auth.Substring(8).Trim();
if (auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase))
return auth.Substring(8).Trim();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return auth.Substring(7).Trim();
}
return null;

View File

@@ -0,0 +1,185 @@
using Gateway.Data;
using Gateway.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Gateway.Controllers;
/// <summary>
/// Campaign intelligence endpoints: health overview, budget pacing,
/// and post-campaign analysis.
///
/// SECURITY MODEL:
/// - Every endpoint requires authenticated session (via middleware)
/// - Initiative endpoints verify ownership before data access
/// - Client-level endpoints scoped via injected ClientContext
/// - ClientId is always injected server-side, never from request body
/// </summary>
[ApiController]
[Route("api/intelligence")]
public sealed class CampaignIntelligenceController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly AuthorizationGuard _guard;
private readonly ILogger<CampaignIntelligenceController> _log;
public CampaignIntelligenceController(
SqlService sql,
ClientContext client,
AuthorizationGuard guard,
ILogger<CampaignIntelligenceController> log)
{
_sql = sql;
_client = client;
_guard = guard;
_log = log;
}
// ────────────────────────────────────────────────
// Campaign Health Overview
// ────────────────────────────────────────────────
/// <summary>
/// Get health overview for all active initiatives.
/// Returns green/yellow/red status per channel campaign based on active recommendations.
/// </summary>
[HttpGet("health")]
public async Task<IActionResult> Health(CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
return await Exec(SqlNames.Procs.CampaignIntelligence, "health",
JsonSerializer.Serialize(new { clientId = _client.ClientId }), ct);
}
// ────────────────────────────────────────────────
// Budget Pacing
// ────────────────────────────────────────────────
/// <summary>
/// Get budget pacing analysis for an initiative.
/// Shows actual vs expected spend velocity with projections.
/// </summary>
[HttpGet("{initiativeId:long}/pacing")]
public async Task<IActionResult> Pacing(long initiativeId, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.CampaignIntelligence, "pacing",
JsonSerializer.Serialize(new { initiativeId }), ct);
}
// ────────────────────────────────────────────────
// Post-Campaign Report
// ────────────────────────────────────────────────
/// <summary>
/// Comprehensive post-campaign analysis.
/// Cross-platform comparison with daily trends, efficiency metrics,
/// and recommendation history.
/// </summary>
[HttpGet("{initiativeId:long}/report")]
public async Task<IActionResult> PostCampaignReport(long initiativeId, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.CampaignIntelligence, "postCampaign",
JsonSerializer.Serialize(new { initiativeId }), ct);
}
// ────────────────────────────────────────────────
// Metric Snapshots (internal / polling service)
// ────────────────────────────────────────────────
/// <summary>
/// Record an intraday metric snapshot for pacing analysis.
/// Called by the background polling service between daily aggregations.
/// Admin-only endpoint.
/// </summary>
[HttpPost("snapshot")]
public async Task<IActionResult> Snapshot([FromBody] SnapshotRequest request, CancellationToken ct)
{
var (ok, err) = _guard.RequireServiceKey();
if (!ok) return StatusCode(403, new { ok = false, error = err });
return await Exec(SqlNames.Procs.CampaignIntelligence, "snapshot",
JsonSerializer.Serialize(new
{
channelCampaignId = request.ChannelCampaignId,
date = request.Date,
impressions = request.Impressions,
clicks = request.Clicks,
spend = request.Spend,
conversions = request.Conversions
}), ct);
}
/// <summary>
/// Batch insert intraday snapshots.
/// Admin-only endpoint.
/// </summary>
[HttpPost("snapshot/batch")]
public async Task<IActionResult> SnapshotBatch([FromBody] SnapshotBatchRequest request, CancellationToken ct)
{
var (ok, err) = _guard.RequireServiceKey();
if (!ok) return StatusCode(403, new { ok = false, error = err });
return await Exec(SqlNames.Procs.CampaignIntelligence, "snapshotBatch",
JsonSerializer.Serialize(new { snapshots = request.Snapshots }), ct);
}
// ────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
return NotFound(JsonSerializer.Deserialize<object>(resp));
return BadRequest(JsonSerializer.Deserialize<object>(resp));
}
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "CampaignIntelligence {Action} error", action);
return StatusCode(500, new { ok = false, error = "Service error" });
}
}
}
// ── DTOs ──
public sealed class SnapshotRequest
{
public long? ChannelCampaignId { get; set; }
public string? Date { get; set; }
public long? Impressions { get; set; }
public long? Clicks { get; set; }
public decimal? Spend { get; set; }
public decimal? Conversions { get; set; }
}
public sealed class SnapshotBatchRequest
{
public object[]? Snapshots { get; set; }
}

View File

@@ -0,0 +1,212 @@
using Gateway.Data;
using Gateway.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using System.Data;
using System.Text.Json;
namespace Gateway.Controllers;
/// <summary>
/// Client-facing document endpoints.
/// All operations are scoped to the authenticated client — clientId is always
/// injected from ClientContext, never trusted from the request body.
///
/// POST /api/documents/list - List client's own documents
/// POST /api/documents - Upload a document (multipart)
/// GET /api/documents/{id}/download - Download (enforces client ownership)
/// DELETE /api/documents/{id} - Soft delete (enforces client ownership)
/// </summary>
[ApiController]
[Route("api/documents")]
public sealed class ClientDocumentController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly IConfiguration _config;
private readonly AuthorizationGuard _guard;
private readonly ILogger<ClientDocumentController> _log;
public ClientDocumentController(
SqlService sql,
ClientContext client,
IConfiguration config,
AuthorizationGuard guard,
ILogger<ClientDocumentController> log)
{
_sql = sql;
_client = client;
_config = config;
_guard = guard;
_log = log;
}
// ── POST /api/documents/list ─────────────────────────────────────────────
[HttpPost("list")]
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
try
{
var rqst = JsonSerializer.Serialize(new
{
scope = "client",
clientId = _client.ClientId // always from session, never from body
});
var result = await _sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Client document list failed");
return StatusCode(500, new { ok = false, error = "Document service error" });
}
}
// ── POST /api/documents ──────────────────────────────────────────────────
[HttpPost]
[RequestSizeLimit(52_428_800)]
public async Task<IActionResult> Upload(
IFormFile file,
[FromForm] string category,
[FromForm] string? description = null,
CancellationToken ct = default)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
if (file == null || file.Length == 0)
return BadRequest(new { ok = false, error = "No file provided" });
try
{
byte[] fileBytes;
using (var ms = new MemoryStream())
{
await file.CopyToAsync(ms, ct);
fileBytes = ms.ToArray();
}
var rqst = JsonSerializer.Serialize(new
{
docFileName = file.FileName,
docMimeType = file.ContentType,
docFileSize = file.Length,
docCategory = category,
docDescription = description,
docUploadedBy = _client.Email,
docScope = "client",
docCltId = _client.ClientId // injected from session
});
_log.LogInformation("[ClientDocs] Upload {FileName} | Client={ClientId}",
file.FileName, _client.ClientId);
var result = await ExecUploadAsync(rqst, fileBytes, ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Client document upload failed: {FileName}", file?.FileName);
return StatusCode(500, new { ok = false, error = "Upload failed" });
}
}
// ── GET /api/documents/{id}/download ─────────────────────────────────────
[HttpGet("{id:long}/download")]
public async Task<IActionResult> Download(long id, CancellationToken ct = default)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
try
{
var cs = _config.GetConnectionString("Sql")
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = 60
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value =
JsonSerializer.Serialize(new { docId = id, clientId = _client.ClientId }) });
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
return NotFound(new { ok = false, error = "Document not found" });
var fileName = reader.GetString(reader.GetOrdinal("docFileName"));
var mimeType = reader.GetString(reader.GetOrdinal("docMimeType"));
var content = (byte[])reader["docContent"];
return File(content, mimeType, fileName);
}
catch (Exception ex)
{
_log.LogError(ex, "Client document download failed: docId={DocId}", id);
return StatusCode(500, new { ok = false, error = "Download failed" });
}
}
// ── DELETE /api/documents/{id} ───────────────────────────────────────────
[HttpDelete("{id:long}")]
public async Task<IActionResult> Delete(long id, CancellationToken ct = default)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
try
{
_log.LogInformation("[ClientDocs] Delete docId={DocId} | Client={ClientId}", id, _client.ClientId);
// Pass clientId so the SP enforces ownership before deleting
var rqst = JsonSerializer.Serialize(new { docId = id, clientId = _client.ClientId });
var result = await _sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Client document delete failed: docId={DocId}", id);
return StatusCode(500, new { ok = false, error = "Delete failed" });
}
}
// ─── Upload helper: binary passed separately from JSON rqst ──────────────
private async Task<string> ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct)
{
var cs = _config.GetConnectionString("Sql")
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = 60
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst });
cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent });
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
{
Direction = ParameterDirection.Output
};
cmd.Parameters.Add(pResp);
await cmd.ExecuteNonQueryAsync(ct);
return pResp.Value as string
?? JsonSerializer.Serialize(new { ok = false, error = "No response from database" });
}
}

View File

@@ -0,0 +1,179 @@
using Gateway.Data;
using Gateway.Security;
using Gateway.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Gateway.Controllers;
/// <summary>
/// Census demographic data endpoints for the campaign wizard.
///
/// GET /api/demographics/{zcta} — fetch census data from DB, forward to
/// Intelligence container for derived
/// recommendations. Falls back to raw census
/// data if Intelligence is unreachable.
/// POST /api/demographics/list — multiple ZCTAs (raw data only)
/// POST /api/demographics/search — find ZCTAs by criteria (raw data only)
/// </summary>
[ApiController]
[Route("api/demographics")]
public sealed class DemographicsController : ControllerBase
{
private readonly SqlService _sql;
private readonly AuthorizationGuard _guard;
private readonly IntelligenceApiClient _intelligence;
private readonly ILogger<DemographicsController> _log;
public DemographicsController(
SqlService sql,
AuthorizationGuard guard,
IntelligenceApiClient intelligence,
ILogger<DemographicsController> log)
{
_sql = sql;
_guard = guard;
_intelligence = intelligence;
_log = log;
}
/// <summary>
/// Fetch raw census data for a ZCTA, then forward to Intelligence container
/// for derived audience recommendations (age chips, income tiers, insights).
/// Falls back to raw census data only if Intelligence is unreachable.
/// </summary>
[HttpGet("{zcta}")]
public async Task<IActionResult> Get(string zcta, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
if (string.IsNullOrWhiteSpace(zcta) || zcta.Length != 5 || !zcta.All(char.IsDigit))
return BadRequest(new { ok = false, error = "Valid 5-digit ZIP code is required" });
try
{
// 1. Fetch raw census data from DB
var censusJson = await _sql.ExecProcAsync(
SqlNames.Procs.Demographics, "get",
JsonSerializer.Serialize(new { zcta }),
ct: ct);
if (string.IsNullOrWhiteSpace(censusJson))
return NotFound(new { ok = false, error = "ZIP code not found" });
using var doc = JsonDocument.Parse(censusJson);
var censusRoot = doc.RootElement;
if (censusRoot.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
return NotFound(new { ok = false, error = "ZIP code not found" });
// 2. Forward to Intelligence container for market analysis derivation
var analysis = await _intelligence.GetDemographicAnalysisAsync(zcta, censusRoot, ct);
if (analysis != null)
{
_log.LogInformation("[Demographics] Analysis by Intelligence container | ZCTA={Zcta}", zcta);
return Content(analysis, "application/json");
}
// 3. Fallback: return raw census data
_log.LogInformation("[Demographics] Intelligence unavailable — raw census | ZCTA={Zcta}", zcta);
return Content(censusJson, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Demographics get error for ZCTA {Zcta}", zcta);
return StatusCode(500, new { ok = false, error = "Demographics service error" });
}
}
/// <summary>Get demographics for multiple ZCTAs (raw census data).</summary>
[HttpPost("list")]
public async Task<IActionResult> List([FromBody] ZctaListRequest? request, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
if (request?.Zctas == null || request.Zctas.Length == 0)
return BadRequest(new { ok = false, error = "zctas array is required" });
try
{
var rqst = JsonSerializer.Serialize(new
{
zctas = request.Zctas,
page = request.Page ?? 1,
pageSize = request.PageSize ?? 50
});
var resp = await _sql.ExecProcAsync(SqlNames.Procs.Demographics, "list", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Demographics service unavailable" });
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Demographics list error");
return StatusCode(500, new { ok = false, error = "Demographics service error" });
}
}
/// <summary>Search ZCTAs by demographic criteria (raw census data).</summary>
[HttpPost("search")]
public async Task<IActionResult> Search([FromBody] DemographicSearchRequest? request, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
try
{
var rqst = JsonSerializer.Serialize(new
{
zctaPrefix = request?.ZctaPrefix,
minIncome = request?.MinIncome,
maxIncome = request?.MaxIncome,
minPopulation = request?.MinPopulation,
minBachelorPct = request?.MinBachelorPct,
minAge25to34Pct = request?.MinAge25to34Pct,
minHomeValue = request?.MinHomeValue,
page = request?.Page ?? 1,
pageSize = request?.PageSize ?? 50
});
var resp = await _sql.ExecProcAsync(SqlNames.Procs.Demographics, "search", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Demographics service unavailable" });
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Demographics search error");
return StatusCode(500, new { ok = false, error = "Demographics service error" });
}
}
}
// ── DTOs ──
public sealed class ZctaListRequest
{
public string[]? Zctas { get; set; }
public int? Page { get; set; }
public int? PageSize { get; set; }
}
public sealed class DemographicSearchRequest
{
public string? ZctaPrefix { get; set; }
public int? MinIncome { get; set; }
public int? MaxIncome { get; set; }
public int? MinPopulation { get; set; }
public decimal? MinBachelorPct { get; set; }
public decimal? MinAge25to34Pct { get; set; }
public int? MinHomeValue { get; set; }
public int? Page { get; set; }
public int? PageSize { get; set; }
}

View File

@@ -1,3 +1,4 @@
using Gateway.Security;
using Gateway.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
@@ -9,17 +10,25 @@ namespace Gateway.Controllers;
public sealed class ExecutionController : ControllerBase
{
private readonly ExecutionService _svc;
public ExecutionController(ExecutionService svc) => _svc = svc;
private readonly ClientContext _client;
public ExecutionController(ExecutionService svc, ClientContext client)
{
_svc = svc;
_client = client;
}
[HttpPost("request")]
public async Task<IActionResult> Execute([FromBody] JsonElement body)
{
// SECURITY: Require authenticated session
if (!_client.IsAuthenticated)
return Unauthorized(new { ok = false, error = "Authentication required" });
if (body.ValueKind == JsonValueKind.Undefined || body.ValueKind == JsonValueKind.Null)
return BadRequest(new { ok = false, error = "Missing request body" });
var resp = await _svc.ExecuteAsync(body, HttpContext.RequestAborted);
// resp is JsonElement / JsonDocument / string json — you decide.
return Content(resp, "application/json");
}
}

View File

@@ -0,0 +1,96 @@
using Gateway.Models;
using Gateway.Security;
using Gateway.Services;
using Microsoft.AspNetCore.Mvc;
namespace Gateway.Controllers;
/// <summary>
/// Channel forecast endpoint for the campaign wizard.
///
/// Routes to IntelligenceApi (category-aware engine container) when configured.
/// Falls back to local ForecastService (General/rules-based) if IntelligenceApi
/// is unreachable — ensuring the wizard never breaks during deployments.
///
/// ROUTING LOGIC:
/// 1. Try IntelligenceApi — passes clientCategory so the engine router
/// can select the correct model (General, Franchisee, Franchisor, etc.)
/// 2. If unreachable / error → fall back to local ForecastService
///
/// SECURITY: Requires authenticated client session.
/// </summary>
[ApiController]
[Route("api/forecast")]
public sealed class ForecastController : ControllerBase
{
private readonly ForecastService _forecastService;
private readonly IntelligenceApiClient _intelligenceClient;
private readonly ClientContext _client;
private readonly AuthorizationGuard _guard;
private readonly ILogger<ForecastController> _log;
public ForecastController(
ForecastService forecastService,
IntelligenceApiClient intelligenceClient,
ClientContext client,
AuthorizationGuard guard,
ILogger<ForecastController> log)
{
_forecastService = forecastService;
_intelligenceClient = intelligenceClient;
_client = client;
_guard = guard;
_log = log;
}
/// <summary>
/// Generate channel performance estimates for given targeting + budget.
/// Called by the wizard AllocationStep when budget changes.
///
/// POST /api/forecast/channel-estimate
/// </summary>
[HttpPost("channel-estimate")]
public async Task<IActionResult> ChannelEstimate(
[FromBody] ChannelForecastRequest? request,
CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
if (request == null)
return BadRequest(new { ok = false, error = "Request body is required" });
if (request.MonthlyBudget <= 0)
return BadRequest(new { ok = false, error = "monthlyBudget must be greater than zero" });
if (request.Keywords.Count == 0)
return BadRequest(new { ok = false, error = "At least one keyword is required" });
_log.LogInformation(
"[Forecast] Request | Category={Category} Budget={Budget} Objective={Obj}",
_client.ClientCategory, request.MonthlyBudget, request.Objective);
try
{
// ── 1. Try IntelligenceApi (category-aware) ──
var result = await _intelligenceClient.GetSpendDistributionAsync(
request, _client.ClientCategory, ct);
if (result != null)
{
_log.LogInformation("[Forecast] Served by IntelligenceApi");
return Ok(result);
}
// ── 2. Fallback: local ForecastService (General engine equivalent) ──
_log.LogInformation("[Forecast] IntelligenceApi unavailable — using local ForecastService");
var fallback = await _forecastService.ForecastAsync(request, ct);
return Ok(fallback);
}
catch (Exception ex)
{
_log.LogError(ex, "[Forecast] Error");
return StatusCode(500, new { ok = false, error = "Forecast service error" });
}
}
}

View File

@@ -0,0 +1,502 @@
using Gateway.Data;
using Gateway.Models;
using Gateway.Security;
using Gateway.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json;
namespace Gateway.Controllers;
/// <summary>
/// Multi-channel initiative endpoints.
///
/// SECURITY MODEL:
/// - Every endpoint requires authenticated session (via middleware)
/// - Every resource-specific endpoint validates ownership (initiative → client)
/// - Status changes are restricted to valid client-initiated transitions
/// - Sync endpoint is restricted to admin role
/// - Budget values are validated server-side against channel minimums
/// - ClientId is injected server-side, never trusted from request body
/// </summary>
[ApiController]
[Route("api/initiative")]
public sealed class InitiativeController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly AuthorizationGuard _guard;
private readonly MultiChannelConfig _config;
private readonly InitiativeLaunchService _launch;
private readonly ProviderStatusNormalizer _statusNorm;
private readonly ILogger<InitiativeController> _log;
public InitiativeController(
SqlService sql,
ClientContext client,
AuthorizationGuard guard,
IOptions<MultiChannelConfig> config,
InitiativeLaunchService launch,
ProviderStatusNormalizer statusNorm,
ILogger<InitiativeController> log)
{
_sql = sql;
_client = client;
_guard = guard;
_config = config.Value;
_launch = launch;
_statusNorm = statusNorm;
_log = log;
}
// ────────────────────────────────────────────────
// Initiative CRUD
// ────────────────────────────────────────────────
/// <summary>Create a new initiative with channel allocations.</summary>
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateInitiativeRequest request, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
if (request.TotalBudget.HasValue)
{
var (budgetOk, budgetErr) = _guard.ValidateBudget(
request.TotalBudget.Value, request.BudgetPeriod, _config);
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
}
var rqst = JsonSerializer.Serialize(new
{
clientId = _client.ClientId,
userId = _client.UserId,
name = request.Name,
objective = request.Objective,
totalBudget = request.TotalBudget,
budgetPeriod = request.BudgetPeriod ?? "monthly",
startDate = request.StartDate,
endDate = request.EndDate,
allocationStrategy = request.AllocationStrategy ?? "manual",
businessCategory = request.BusinessCategory,
wizardId = request.WizardId,
channels = request.Channels
});
return await Exec(SqlNames.Procs.Initiative, "create", rqst, ct);
}
/// <summary>
/// Stage an initiative for confirmation with server-calculated billing.
/// </summary>
[HttpPost("stage")]
public async Task<IActionResult> Stage([FromBody] CreateInitiativeRequest request, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
if (request.InitiativeId.HasValue && request.InitiativeId > 0)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(request.InitiativeId.Value, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
}
if (request.TotalBudget.HasValue)
{
var (budgetOk, budgetErr) = _guard.ValidateBudget(
request.TotalBudget.Value, request.BudgetPeriod, _config);
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
}
var rqst = JsonSerializer.Serialize(new
{
clientId = _client.ClientId,
userId = _client.UserId,
name = request.Name,
objective = request.Objective,
totalBudget = request.TotalBudget,
budgetPeriod = request.BudgetPeriod ?? "monthly",
startDate = request.StartDate,
endDate = request.EndDate,
allocationStrategy = request.AllocationStrategy ?? "manual",
businessCategory = request.BusinessCategory,
wizardId = request.WizardId,
initiativeId = request.InitiativeId,
channels = request.Channels
});
return await Exec(SqlNames.Procs.InitiativeStage, "stage", rqst, ct);
}
/// <summary>Get billing for a staged initiative.</summary>
[HttpGet("{initiativeId:long}/billing")]
public async Task<IActionResult> GetBilling(long initiativeId, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.InitiativeStage, "getBilling",
JsonSerializer.Serialize(new { initiativeId }), ct);
}
/// <summary>Get initiative by ID (ownership verified).</summary>
[HttpGet("{initiativeId:long}")]
public async Task<IActionResult> Get(long initiativeId, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
if (!string.IsNullOrWhiteSpace(ownership.EntityJson))
return Content(ownership.EntityJson, "application/json");
return await Exec(SqlNames.Procs.Initiative, "get",
JsonSerializer.Serialize(new { initiativeId }), ct);
}
/// <summary>List initiatives for current client (always scoped).</summary>
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? status, [FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
return await Exec(SqlNames.Procs.Initiative, "list",
JsonSerializer.Serialize(new { clientId = _client.ClientId, status, page, pageSize }), ct);
}
/// <summary>Update initiative metadata (ownership verified, status stripped).</summary>
[HttpPut("{initiativeId:long}")]
public async Task<IActionResult> Update(long initiativeId, [FromBody] UpdateInitiativeRequest request, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
if (request.TotalBudget.HasValue)
{
var (budgetOk, budgetErr) = _guard.ValidateBudget(request.TotalBudget.Value, null, _config);
if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr });
}
return await Exec(SqlNames.Procs.Initiative, "update",
JsonSerializer.Serialize(new
{
initiativeId,
clientId = _client.ClientId,
name = request.Name,
totalBudget = request.TotalBudget,
startDate = request.StartDate,
endDate = request.EndDate,
businessCategory = request.BusinessCategory
}), ct);
}
/// <summary>
/// Update status with transition enforcement.
/// Clients: active↔paused, *→cancelled only. Admins: any transition.
/// </summary>
[HttpPatch("{initiativeId:long}/status")]
public async Task<IActionResult> UpdateStatus(long initiativeId, [FromBody] UpdateInitiativeStatusRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.Status))
return BadRequest(new { ok = false, error = "status is required" });
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
var isAdmin = string.Equals(_client.Role, "admin", StringComparison.OrdinalIgnoreCase);
if (!isAdmin)
{
var (transOk, transErr) = _guard.ValidateClientStatusTransition(
ownership.CurrentStatus, request.Status, "initiative");
if (!transOk) return BadRequest(new { ok = false, error = transErr });
}
return await Exec(SqlNames.Procs.Initiative, "updateStatus",
JsonSerializer.Serialize(new { initiativeId, clientId = _client.ClientId, status = request.Status }), ct);
}
/// <summary>Soft-delete (cannot delete active — cancel first).</summary>
[HttpDelete("{initiativeId:long}")]
public async Task<IActionResult> Delete(long initiativeId, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
if (ownership.CurrentStatus == "active")
return BadRequest(new { ok = false, error = "Cannot delete an active initiative. Cancel it first." });
return await Exec(SqlNames.Procs.Initiative, "delete",
JsonSerializer.Serialize(new { initiativeId, clientId = _client.ClientId }), ct);
}
// ────────────────────────────────────────────────
// Launch / Dispatch
// ────────────────────────────────────────────────
/// <summary>Launch a staged initiative (ownership + status verified).</summary>
[HttpPost("{initiativeId:long}/launch")]
public async Task<IActionResult> Launch(long initiativeId, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
if (ownership.CurrentStatus != "staged")
return BadRequest(new { ok = false, error = $"Initiative must be staged before launching (current: {ownership.CurrentStatus})" });
_log.LogInformation("[Initiative] Launch {InitiativeId} by {UserId}", initiativeId, _client.UserId);
var result = await _launch.LaunchAsync(initiativeId, _client.ClientId ?? "", _client.UserId, ct);
if (!result.Ok && result.Error != null)
{
_log.LogWarning("[Initiative] Launch failed {InitiativeId}: {Error}", initiativeId, result.Error);
return BadRequest(result);
}
return Ok(result);
}
// ────────────────────────────────────────────────
// Channel Campaigns (all ownership-verified)
// ────────────────────────────────────────────────
[HttpGet("{initiativeId:long}/channels")]
public async Task<IActionResult> ListChannels(long initiativeId, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.ChannelCampaign, "list", JsonSerializer.Serialize(new { initiativeId }), ct);
}
[HttpGet("channel/{channelCampaignId:long}")]
public async Task<IActionResult> GetChannel(long channelCampaignId, CancellationToken ct)
{
var ownership = await _guard.VerifyChannelOwnerAsync(channelCampaignId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.ChannelCampaign, "get", JsonSerializer.Serialize(new { channelCampaignId }), ct);
}
/// <summary>Sync channel status — called by provider containers.</summary>
[HttpPatch("channel/{channelCampaignId:long}/sync")]
public async Task<IActionResult> SyncChannel(long channelCampaignId, [FromBody] SyncChannelRequest request, CancellationToken ct)
{
var (ok, err) = _guard.RequireServiceKey();
if (!ok) return StatusCode(403, new { ok = false, error = err });
var normalizedStatus = _statusNorm.Resolve(request.ChannelType, request.Status, request.ProviderStatus);
_log.LogInformation("[Sync] Channel {Id} | {Provider} → {Status} | By={User}",
channelCampaignId, request.ProviderStatus, normalizedStatus, _client.UserId);
return await Exec(SqlNames.Procs.ChannelCampaign, "sync",
JsonSerializer.Serialize(new
{
channelCampaignId,
externalCampaignId = request.ExternalCampaignId,
externalAccountId = request.ExternalAccountId,
status = normalizedStatus,
providerStatus = request.ProviderStatus
}), ct);
}
// ────────────────────────────────────────────────
// Budget Allocation (all ownership-verified)
// ────────────────────────────────────────────────
[HttpGet("{initiativeId:long}/allocation")]
public async Task<IActionResult> GetAllocation(long initiativeId, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.Allocation, "get", JsonSerializer.Serialize(new { initiativeId }), ct);
}
[HttpPut("{initiativeId:long}/allocation")]
public async Task<IActionResult> UpdateAllocation(long initiativeId, [FromBody] UpdateAllocationRequest request, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.Allocation, "update",
JsonSerializer.Serialize(new { initiativeId, userId = _client.UserId, allocations = request.Allocations, reason = request.Reason }), ct);
}
[HttpGet("{initiativeId:long}/allocation/recommend")]
public async Task<IActionResult> GetRecommendation(long initiativeId, [FromQuery] string? businessCategory, [FromQuery] string? objective, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.Allocation, "recommend", JsonSerializer.Serialize(new { initiativeId, businessCategory, objective }), ct);
}
[HttpPost("{initiativeId:long}/allocation/apply")]
public async Task<IActionResult> ApplyAllocation(long initiativeId, [FromBody] ApplyAllocationRequest request, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.Allocation, "apply",
JsonSerializer.Serialize(new { initiativeId, source = request.Source, allocations = request.Allocations, reason = request.Reason }), ct);
}
[HttpGet("{initiativeId:long}/allocation/history")]
public async Task<IActionResult> GetAllocationHistory(long initiativeId, [FromQuery] int? limit, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.Allocation, "history", JsonSerializer.Serialize(new { initiativeId, limit }), ct);
}
// ────────────────────────────────────────────────
// Channel Config (read-only)
// ────────────────────────────────────────────────
[HttpGet("channels/available")]
public async Task<IActionResult> GetAvailableChannels(CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
var mappingsResp = await _sql.ExecProcAsync(SqlNames.Procs.ObjectiveMapping, "list", "{}", ct: ct);
return Ok(new
{
ok = true,
channels = _config.EnabledChannels.Select(c => new
{
c.ChannelType, c.DisplayName, c.Description, c.Icon, c.Color,
c.MinDailyBudget, c.MinMonthlyBudget, c.SupportedObjectives,
c.SupportedCreativeFormats, c.ApprovalEstimateHours, c.IsStub
}),
allocation = new
{
_config.Allocation.MinMultiChannelMonthlyBudget,
_config.Allocation.MaxChannelsPerInitiative,
_config.Allocation.DefaultAllocationStrategy,
_config.Allocation.MinChannelAllocationPct,
_config.Allocation.MaxChannelAllocationPct
},
objectiveMappings = JsonSerializer.Deserialize<object>(mappingsResp ?? "{}")
});
}
/// <summary>Status mappings — available to authenticated clients.</summary>
[HttpGet("channels/status-mappings")]
public IActionResult GetStatusMappings([FromQuery] string? channelType)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
if (!string.IsNullOrWhiteSpace(channelType))
return Ok(new { ok = true, channelType, mappings = _statusNorm.GetMappings(channelType) });
return Ok(new
{
ok = true,
channels = _config.Channels.Values.Select(c => new { c.ChannelType, c.DisplayName, c.Enabled, mappings = _statusNorm.GetMappings(c.ChannelType) })
});
}
[HttpGet("templates")]
public async Task<IActionResult> GetTemplates([FromQuery] string? businessCategory, [FromQuery] string? objective, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
return await Exec(SqlNames.Procs.Allocation, "getTemplates", JsonSerializer.Serialize(new { businessCategory, objective }), ct);
}
// ────────────────────────────────────────────────
// Performance Metrics (ownership-verified)
// ────────────────────────────────────────────────
[HttpGet("{initiativeId:long}/metrics")]
public async Task<IActionResult> MetricsSummary(long initiativeId, [FromQuery] string? fromDate, [FromQuery] string? toDate, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.PerformanceMetric, "summary", JsonSerializer.Serialize(new { initiativeId, fromDate, toDate }), ct);
}
[HttpGet("{initiativeId:long}/metrics/compare")]
public async Task<IActionResult> MetricsCompare(long initiativeId, [FromQuery] int? lookbackDays, CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.PerformanceMetric, "compare", JsonSerializer.Serialize(new { initiativeId, lookbackDays }), ct);
}
// ────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
return NotFound(JsonSerializer.Deserialize<object>(resp));
return BadRequest(JsonSerializer.Deserialize<object>(resp));
}
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Initiative {Action} error", action);
return StatusCode(500, new { ok = false, error = "Service error" });
}
}
}
// ── DTOs ──
public sealed class CreateInitiativeRequest
{
public long? InitiativeId { get; set; }
public string? Name { get; set; }
public string? Objective { get; set; }
public decimal? TotalBudget { get; set; }
public string? BudgetPeriod { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public string? AllocationStrategy { get; set; }
public string? BusinessCategory { get; set; }
public string? WizardId { get; set; }
public object[]? Channels { get; set; }
}
public sealed class UpdateInitiativeRequest
{
public string? Name { get; set; }
public decimal? TotalBudget { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public string? BusinessCategory { get; set; }
}
public sealed class UpdateInitiativeStatusRequest { public string? Status { get; set; } }
public sealed class SyncChannelRequest
{
public string? ExternalCampaignId { get; set; }
public string? ExternalAccountId { get; set; }
public string? Status { get; set; }
public string? ProviderStatus { get; set; }
public string? ChannelType { get; set; }
}
public sealed class UpdateAllocationRequest { public object[]? Allocations { get; set; } public string? Reason { get; set; } }
public sealed class ApplyAllocationRequest { public string? Source { get; set; } public object[]? Allocations { get; set; } public string? Reason { get; set; } }

View File

@@ -0,0 +1,62 @@
using Gateway.Security;
using Gateway.Services;
using Microsoft.AspNetCore.Mvc;
namespace Gateway.Controllers;
/// <summary>
/// Metric sync trigger — called by Management API or Azure Functions timer.
/// Pulls campaign performance data from provider containers and writes
/// it into the database, then triggers recommendation evaluation.
/// Secured by internal service key (X-Service-Key header).
/// </summary>
[ApiController]
[Route("api/sync")]
public sealed class MetricSyncController : ControllerBase
{
private readonly MetricSyncService _sync;
private readonly AuthorizationGuard _guard;
private readonly ILogger<MetricSyncController> _log;
public MetricSyncController(
MetricSyncService sync,
AuthorizationGuard guard,
ILogger<MetricSyncController> log)
{
_sync = sync;
_guard = guard;
_log = log;
}
/// <summary>
/// Sync metrics for a specific client.
/// Pulls from all active channel campaign providers, writes to DB,
/// then triggers recommendation evaluation.
/// </summary>
[HttpPost("metrics/{clientId}")]
public async Task<IActionResult> SyncClient(
string clientId,
[FromQuery] string? startDate,
[FromQuery] string? endDate,
CancellationToken ct)
{
var (ok, err) = _guard.RequireServiceKey();
if (!ok) return StatusCode(403, new { ok = false, error = err });
_log.LogInformation("[MetricSync] Manual sync triggered for client {ClientId}", clientId);
var result = await _sync.SyncClientMetricsAsync(clientId, startDate, endDate, ct);
return Ok(new
{
ok = result.Success,
clientId = result.ClientId,
campaignsProcessed = result.CampaignsProcessed,
metricsWritten = result.MetricsWritten,
recommendationsGenerated = result.RecommendationsGenerated,
skipped = result.Skipped,
errors = result.Errors,
error = result.Error
});
}
}

View File

@@ -0,0 +1,153 @@
using Gateway.Data;
using Gateway.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Gateway.Controllers;
/// <summary>
/// Client-facing recommendation endpoints.
///
/// Clients can view, dismiss, and resolve recommendations for their
/// own campaigns. All endpoints are scoped to the authenticated client.
///
/// Admin operations (rule CRUD, evaluate, cleanup) live in the
/// Management API at /api/admin/recommendations.
/// </summary>
[ApiController]
[Route("api/recommendations")]
public sealed class RecommendationController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly AuthorizationGuard _guard;
private readonly ILogger<RecommendationController> _log;
public RecommendationController(
SqlService sql,
ClientContext client,
AuthorizationGuard guard,
ILogger<RecommendationController> log)
{
_sql = sql;
_client = client;
_guard = guard;
_log = log;
}
// ────────────────────────────────────────────────
// Client-Facing: List Recommendations
// ────────────────────────────────────────────────
/// <summary>
/// Get active recommendations for the authenticated client's dashboard.
/// Returns recommendations sorted by severity (critical first).
/// </summary>
[HttpGet]
public async Task<IActionResult> ListByClient(
[FromQuery] string? status,
[FromQuery] int? limit,
CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
return await Exec(SqlNames.Procs.Recommendation, "listByClient",
JsonSerializer.Serialize(new
{
clientId = _client.ClientId,
status = status ?? "active",
limit = limit ?? 50
}), ct);
}
/// <summary>
/// Get recommendations for a specific initiative (ownership verified).
/// </summary>
[HttpGet("initiative/{initiativeId:long}")]
public async Task<IActionResult> ListByInitiative(
long initiativeId,
[FromQuery] string? status,
CancellationToken ct)
{
var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
return await Exec(SqlNames.Procs.Recommendation, "listByInitiative",
JsonSerializer.Serialize(new
{
initiativeId,
status = status ?? "active"
}), ct);
}
// ────────────────────────────────────────────────
// Client-Facing: Manage Recommendations
// ────────────────────────────────────────────────
/// <summary>
/// Dismiss a recommendation (user explicitly ignores it).
/// </summary>
[HttpPost("{recommendationId:long}/dismiss")]
public async Task<IActionResult> Dismiss(long recommendationId, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
// Ownership check: verify the recommendation belongs to this client
// The SP itself filters by recId, but we pass userId for audit trail
return await Exec(SqlNames.Procs.Recommendation, "dismiss",
JsonSerializer.Serialize(new
{
recommendationId,
userId = _client.UserId
}), ct);
}
/// <summary>
/// Resolve a recommendation (action was taken to address it).
/// </summary>
[HttpPost("{recommendationId:long}/resolve")]
public async Task<IActionResult> Resolve(long recommendationId, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
return await Exec(SqlNames.Procs.Recommendation, "resolve",
JsonSerializer.Serialize(new { recommendationId }), ct);
}
// ────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────
private async Task<IActionResult> Exec(string proc, string action, string rqst, CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
return NotFound(JsonSerializer.Deserialize<object>(resp));
return BadRequest(JsonSerializer.Deserialize<object>(resp));
}
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Recommendation {Action} error", action);
return StatusCode(500, new { ok = false, error = "Service error" });
}
}
}

View File

@@ -0,0 +1,301 @@
using Gateway.Data;
using Gateway.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Gateway.Controllers;
/// <summary>
/// Campaign wizard endpoints.
///
/// SECURITY: Every wizard operation validates ownership (wizard → client).
/// ClientId is always injected server-side.
/// </summary>
[ApiController]
[Route("api/wizard")]
public sealed class WizardController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly AuthorizationGuard _guard;
private readonly ILogger<WizardController> _log;
public WizardController(SqlService sql, ClientContext client, AuthorizationGuard guard, ILogger<WizardController> log)
{
_sql = sql;
_client = client;
_guard = guard;
_log = log;
}
/// <summary>
/// Get active categories + objectives for wizard Step 1.
/// Client-authenticated (not admin). Read-only.
/// Calls spAdminTemplateConfig with action 'public.config'.
/// </summary>
[HttpGet("config")]
public async Task<IActionResult> GetConfig(CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
try
{
var resp = await _sql.ExecProcAsync(
SqlNames.Procs.TemplateConfig,
"public.config",
"{}",
ct: ct
);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Config service unavailable" });
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard config error");
return StatusCode(500, new { ok = false, error = "Config service error" });
}
}
/// <summary>Create a new wizard (no ownership check — creates for current client).</summary>
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateWizardRequest? request, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
var rqst = JsonSerializer.Serialize(new
{
clientId = _client.ClientId, // ← SERVER-SIDE
userId = _client.UserId,
name = request?.Name,
url = request?.Url
});
return await ExecAndReturn("create", rqst, ct);
}
/// <summary>Get wizard by ID (ownership verified).</summary>
[HttpGet("{wizardId}")]
public async Task<IActionResult> Get(string wizardId, CancellationToken ct)
{
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
// Return already-fetched entity
if (!string.IsNullOrWhiteSpace(ownership.EntityJson))
return Content(ownership.EntityJson, "application/json");
var rqst = JsonSerializer.Serialize(new { wizardId });
return await ExecAndReturn("get", rqst, ct);
}
/// <summary>List wizards for current client (always scoped).</summary>
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? status, [FromQuery] int? limit, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
var rqst = JsonSerializer.Serialize(new
{
clientId = _client.ClientId, // ← scoped to authenticated client
status,
limit
});
return await ExecAndReturn("listByClient", rqst, ct);
}
/// <summary>Update step data (ownership verified, steps 1-4 only).</summary>
[HttpPut("{wizardId}/step/{step:int}")]
public async Task<IActionResult> UpdateStep(string wizardId, int step, [FromBody] UpdateStepRequest? request, CancellationToken ct)
{
if (step < 1 || step > 5)
return BadRequest(new { ok = false, error = "Step must be 1-5" });
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
var rqst = JsonSerializer.Serialize(new
{
wizardId,
step,
data = request?.Data,
name = request?.Name
});
return await ExecAndReturn("updateStep", rqst, ct);
}
/// <summary>Navigate to step (ownership verified).</summary>
[HttpPatch("{wizardId}/step/{step:int}")]
public async Task<IActionResult> SetStep(string wizardId, int step, CancellationToken ct)
{
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
var rqst = JsonSerializer.Serialize(new { wizardId, step });
return await ExecAndReturn("setStep", rqst, ct);
}
/// <summary>Get wizard summary for review (ownership verified).</summary>
[HttpGet("{wizardId}/summary")]
public async Task<IActionResult> GetSummary(string wizardId, CancellationToken ct)
{
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
var rqst = JsonSerializer.Serialize(new { wizardId });
return await ExecAndReturn("getSummary", rqst, ct);
}
/// <summary>Submit wizard (ownership verified).</summary>
[HttpPost("{wizardId}/submit")]
public async Task<IActionResult> Submit(string wizardId, [FromBody] SubmitWizardRequest? request, CancellationToken ct)
{
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
var rqst = JsonSerializer.Serialize(new
{
wizardId,
campaignId = (string?)null,
network = request?.Network ?? "google"
});
return await ExecAndReturn("submit", rqst, ct);
}
/// <summary>Update wizard status (ownership verified, transition rules applied).</summary>
[HttpPatch("{wizardId}/status")]
public async Task<IActionResult> UpdateStatus(string wizardId, [FromBody] UpdateStatusRequest? request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.Status))
return BadRequest(new { ok = false, error = "status is required" });
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
// Wizard status transitions: only allow cancel from draft
var current = ownership.CurrentStatus ?? "draft";
var requested = request.Status.ToLowerInvariant();
var isAdmin = string.Equals(_client.Role, "admin", StringComparison.OrdinalIgnoreCase);
if (!isAdmin && requested != "cancelled")
return BadRequest(new { ok = false, error = $"Cannot change wizard status to '{requested}'" });
var rqst = JsonSerializer.Serialize(new { wizardId, status = request.Status });
return await ExecAndReturn("updateStatus", rqst, ct);
}
/// <summary>Delete wizard (ownership verified).</summary>
[HttpDelete("{wizardId}")]
public async Task<IActionResult> Delete(string wizardId, [FromQuery] bool force = false, CancellationToken ct = default)
{
var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct);
if (!ownership.IsAllowed)
return NotFound(new { ok = false, error = ownership.Error });
var rqst = JsonSerializer.Serialize(new { wizardId, force });
return await ExecAndReturn("delete", rqst, ct);
}
/// <summary>
/// Get audience-adjusted channel mix recommendation.
/// Calls spAllocationRecommend with audience factors.
/// </summary>
[HttpPost("recommend")]
public async Task<IActionResult> Recommend([FromBody] RecommendRequest? request, CancellationToken ct)
{
var (ok, err) = _guard.RequireAuth();
if (!ok) return Unauthorized(new { ok = false, error = err });
if (string.IsNullOrWhiteSpace(request?.BusinessCategory) || string.IsNullOrWhiteSpace(request?.Objective))
return BadRequest(new { ok = false, error = "businessCategory and objective are required" });
try
{
var rqst = JsonSerializer.Serialize(new
{
businessCategory = request.BusinessCategory,
objective = request.Objective,
ageSkew = request.AgeSkew,
marketScope = request.MarketScope
});
var resp = await _sql.ExecProcAsync(
SqlNames.Procs.AllocationRecommend,
"recommend",
rqst,
ct: ct
);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Recommendation service unavailable" });
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Recommend error");
return StatusCode(500, new { ok = false, error = "Recommendation service error" });
}
}
// ────────────────────────────────────────────────
// Helper
// ────────────────────────────────────────────────
private async Task<IActionResult> ExecAndReturn(string action, string rqst, CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync("dbo.spCampaignWizard", action, rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Wizard service unavailable" });
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error";
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
return NotFound(JsonSerializer.Deserialize<object>(resp));
return BadRequest(JsonSerializer.Deserialize<object>(resp));
}
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard {Action} error", action);
return StatusCode(500, new { ok = false, error = "Wizard service error" });
}
}
}
// ── DTOs ──
public sealed class CreateWizardRequest { public string? Name { get; set; } public string? Url { get; set; } }
public sealed class UpdateStepRequest { public object? Data { get; set; } public string? Name { get; set; } }
public sealed class SubmitWizardRequest { public string? Network { get; set; } }
public sealed class UpdateStatusRequest { public string? Status { get; set; } }
public sealed class RecommendRequest
{
public string? BusinessCategory { get; set; }
public string? Objective { get; set; }
public string? AgeSkew { get; set; }
public string? MarketScope { get; set; }
}