Files
AdPlatform-Server/Gateway/Controllers/CampaignIntelligenceController.cs
2026-03-14 13:50:09 -07:00

186 lines
7.6 KiB
C#

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; }
}