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(resp)); return BadRequest(JsonSerializer.Deserialize(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; } }