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;
///
/// 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
///
[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 _log;
public InitiativeController(
SqlService sql,
ClientContext client,
AuthorizationGuard guard,
IOptions config,
InitiativeLaunchService launch,
ProviderStatusNormalizer statusNorm,
ILogger log)
{
_sql = sql;
_client = client;
_guard = guard;
_config = config.Value;
_launch = launch;
_statusNorm = statusNorm;
_log = log;
}
// ────────────────────────────────────────────────
// Initiative CRUD
// ────────────────────────────────────────────────
/// Create a new initiative with channel allocations.
[HttpPost]
public async Task 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);
}
///
/// Stage an initiative for confirmation with server-calculated billing.
///
[HttpPost("stage")]
public async Task 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);
}
/// Get billing for a staged initiative.
[HttpGet("{initiativeId:long}/billing")]
public async Task 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);
}
/// Get initiative by ID (ownership verified).
[HttpGet("{initiativeId:long}")]
public async Task 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);
}
/// List initiatives for current client (always scoped).
[HttpGet]
public async Task 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);
}
/// Update initiative metadata (ownership verified, status stripped).
[HttpPut("{initiativeId:long}")]
public async Task 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);
}
///
/// Update status with transition enforcement.
/// Clients: active↔paused, *→cancelled only. Admins: any transition.
///
[HttpPatch("{initiativeId:long}/status")]
public async Task 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);
}
/// Soft-delete (cannot delete active — cancel first).
[HttpDelete("{initiativeId:long}")]
public async Task 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
// ────────────────────────────────────────────────
/// Launch a staged initiative (ownership + status verified).
[HttpPost("{initiativeId:long}/launch")]
public async Task 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 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 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);
}
/// Sync channel status — called by provider containers.
[HttpPatch("channel/{channelCampaignId:long}/sync")]
public async Task 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 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 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 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 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 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 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