using Gateway.Data;
using Gateway.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Gateway.Controllers;
///
/// Campaign wizard endpoints.
///
/// SECURITY: Every wizard operation validates ownership (wizard → client).
/// ClientId is always injected server-side.
///
[ApiController]
[Route("api/wizard")]
public sealed class WizardController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly AuthorizationGuard _guard;
private readonly ILogger _log;
public WizardController(SqlService sql, ClientContext client, AuthorizationGuard guard, ILogger log)
{
_sql = sql;
_client = client;
_guard = guard;
_log = log;
}
///
/// Get active categories + objectives for wizard Step 1.
/// Client-authenticated (not admin). Read-only.
/// Calls spAdminTemplateConfig with action 'public.config'.
///
[HttpGet("config")]
public async Task 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" });
}
}
/// Create a new wizard (no ownership check — creates for current client).
[HttpPost]
public async Task 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);
}
/// Get wizard by ID (ownership verified).
[HttpGet("{wizardId}")]
public async Task 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);
}
/// List wizards for current client (always scoped).
[HttpGet]
public async Task 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);
}
/// Update step data (ownership verified, steps 1-4 only).
[HttpPut("{wizardId}/step/{step:int}")]
public async Task 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);
}
/// Navigate to step (ownership verified).
[HttpPatch("{wizardId}/step/{step:int}")]
public async Task 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);
}
/// Get wizard summary for review (ownership verified).
[HttpGet("{wizardId}/summary")]
public async Task 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);
}
/// Submit wizard (ownership verified).
[HttpPost("{wizardId}/submit")]
public async Task 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);
}
/// Update wizard status (ownership verified, transition rules applied).
[HttpPatch("{wizardId}/status")]
public async Task 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);
}
/// Delete wizard (ownership verified).
[HttpDelete("{wizardId}")]
public async Task 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);
}
///
/// Get audience-adjusted channel mix recommendation.
/// Calls spAllocationRecommend with audience factors.
///
[HttpPost("recommend")]
public async Task 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 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