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(mappingsResp ?? "{}") }); } /// Status mappings — available to authenticated clients. [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 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 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 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 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(resp)); return BadRequest(JsonSerializer.Deserialize(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; } }