Initial import into Gitea
This commit is contained in:
301
Gateway/Controllers/WizardController.cs
Normal file
301
Gateway/Controllers/WizardController.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign wizard endpoints.
|
||||
///
|
||||
/// SECURITY: Every wizard operation validates ownership (wizard → client).
|
||||
/// ClientId is always injected server-side.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/wizard")]
|
||||
public sealed class WizardController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly AuthorizationGuard _guard;
|
||||
private readonly ILogger<WizardController> _log;
|
||||
|
||||
public WizardController(SqlService sql, ClientContext client, AuthorizationGuard guard, ILogger<WizardController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_guard = guard;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get active categories + objectives for wizard Step 1.
|
||||
/// Client-authenticated (not admin). Read-only.
|
||||
/// Calls spAdminTemplateConfig with action 'public.config'.
|
||||
/// </summary>
|
||||
[HttpGet("config")]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Create a new wizard (no ownership check — creates for current client).</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Get wizard by ID (ownership verified).</summary>
|
||||
[HttpGet("{wizardId}")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>List wizards for current client (always scoped).</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Update step data (ownership verified, steps 1-4 only).</summary>
|
||||
[HttpPut("{wizardId}/step/{step:int}")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Navigate to step (ownership verified).</summary>
|
||||
[HttpPatch("{wizardId}/step/{step:int}")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Get wizard summary for review (ownership verified).</summary>
|
||||
[HttpGet("{wizardId}/summary")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Submit wizard (ownership verified).</summary>
|
||||
[HttpPost("{wizardId}/submit")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Update wizard status (ownership verified, transition rules applied).</summary>
|
||||
[HttpPatch("{wizardId}/status")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Delete wizard (ownership verified).</summary>
|
||||
[HttpDelete("{wizardId}")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get audience-adjusted channel mix recommendation.
|
||||
/// Calls spAllocationRecommend with audience factors.
|
||||
/// </summary>
|
||||
[HttpPost("recommend")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<object>(resp));
|
||||
return BadRequest(JsonSerializer.Deserialize<object>(resp));
|
||||
}
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Wizard {Action} error", action);
|
||||
return StatusCode(500, new { ok = false, error = "Wizard service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ──
|
||||
|
||||
public sealed class CreateWizardRequest { public string? Name { get; set; } public string? Url { get; set; } }
|
||||
public sealed class UpdateStepRequest { public object? Data { get; set; } public string? Name { get; set; } }
|
||||
public sealed class SubmitWizardRequest { public string? Network { get; set; } }
|
||||
public sealed class UpdateStatusRequest { public string? Status { get; set; } }
|
||||
public sealed class RecommendRequest
|
||||
{
|
||||
public string? BusinessCategory { get; set; }
|
||||
public string? Objective { get; set; }
|
||||
public string? AgeSkew { get; set; }
|
||||
public string? MarketScope { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user