Initial import into Gitea
This commit is contained in:
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user