using Management.Data; using Management.Security; using Management.Services; using Microsoft.AspNetCore.Mvc; using System.Text; using System.Text.Json; namespace Management.Controllers.Admin; /// /// Admin endpoints for client lifecycle management. /// Auth enforced at middleware level (/api/admin/* → Session + Admin role). /// All operations pass JSON to stored procs (@action/@rqst/@resp). /// /// APPROVAL FLOW (POST /api/admin/clients): /// 1. Fetch full applicant from Registration Function — OID comes from server, never browser /// 2. spClientManagement 'create' → tbClient + tbClientUser + tbClientUserRole (atomic) /// 3. POST JSON to each provider container → sub-account creation /// 4. spClientManagement 'recordAdAccount' per provider result /// 5. spNotification 'queue' → welcome + provisioning alert queued for async send /// 6. Registration 'complete' → mark registration closed /// /// ENDPOINTS: /// GET /api/admin/clients - List clients /// GET /api/admin/clients/{clientId} - Get client detail /// POST /api/admin/clients - Approve from registration /// PUT /api/admin/clients/{clientId} - Update profile /// POST /api/admin/clients/{clientId}/suspend - Suspend /// POST /api/admin/clients/{clientId}/cancel - Cancel /// POST /api/admin/clients/{clientId}/reactivate - Reactivate /// GET /api/admin/clients/{clientId}/defaults - Wizard pre-fill /// /// REGISTRATION PROXY: /// GET /api/registration/pending - List pending applicants /// GET /api/registration/{id} - Get single applicant /// POST /api/registration/{id}/reject - Reject applicant /// [ApiController] [Route("api/admin/clients")] public sealed class AdminClientsController : AdminControllerBase { private readonly RegistrationClient _registration; private readonly IHttpClientFactory _http; private readonly IConfiguration _cfg; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; public AdminClientsController( SqlService sql, ClientContext client, RegistrationClient registration, IHttpClientFactory http, IConfiguration cfg, ILogger log) : base(sql, client, log) { _registration = registration; _http = http; _cfg = cfg; } private const string Proc = "spClientManagement"; // ── CRUD + Lifecycle ────────────────────────────────────────────────── [HttpPost("list")] public Task List([FromBody] JsonElement body, CancellationToken ct) => CallProc(Proc, "list", body.ToString(), ct); [HttpGet("{clientId}")] public Task Get(string clientId, CancellationToken ct) => CallProc(Proc, "get", new { clientId }, ct); /// /// Approve a registration. Only registrationId is needed from the browser — /// all data including the CIAM OID is fetched from the Registration Function server-side. /// [HttpPost] public async Task Approve([FromBody] JsonElement body, CancellationToken ct) { var registrationId = Str(body, "registrationId"); if (string.IsNullOrWhiteSpace(registrationId)) return ValidationError("registrationId is required"); // ── 1. Fetch full applicant — OID comes from server record, not browser ── var regDoc = await _registration.GetByIdAsync(registrationId, ct); if (regDoc == null) return StatusCode(502, new { ok = false, error = "Registration service unavailable" }); if (!regDoc.RootElement.TryGetProperty("applicant", out var app)) return NotFound(new { ok = false, error = "Registration not found" }); var entraSub = Str(app, "entraSubjectId"); var regEmail = Str(app, "contactEmail"); var regName = Str(app, "contactName"); var bizName = Str(app, "businessName"); var websiteUrl = Str(app, "websiteUrl"); var bizCategory = Str(app, "businessCategory"); var cltCategory = Str(app, "clientCategory") ?? "General"; var contactPhone= Str(app, "contactPhone"); if (string.IsNullOrWhiteSpace(entraSub)) return BadRequest(new { ok = false, error = "Applicant has no verified identity — they must sign in through the registration portal before approval." }); if (string.IsNullOrWhiteSpace(bizName)) return ValidationError("Registration has no business name"); Logger.LogInformation("[Approve] '{Name}' regId={RegId} OID={OID} by {User}", bizName, registrationId, entraSub, Client.Email); // ── 2. Create tbClient + tbClientUser + tbClientUserRole (atomic in proc) ── var createResp = await Sql.ExecProcAsync("dbo.spClientManagement", "create", Json(new { registrantEntraSub = entraSub, registrantEmail = regEmail, registrantName = regName, name = bizName.Trim(), websiteUrl, businessCategory = bizCategory, contactName = regName, contactEmail = regEmail, contactPhone, clientCategory = cltCategory, approvedByEmail = Client.Email, registrationRef = registrationId }), ct: ct); using var createDoc = JsonDocument.Parse(createResp); var cr = createDoc.RootElement; if (!Bol(cr, "ok")) return BadRequest(new { ok = false, error = Str(cr, "error") ?? "DB create failed" }); var clientId = Str(cr, "clientId")!; var wasCreated = Bol(cr, "created"); // ── 3 + 4. Provider sub-accounts ───────────────────────────────── var providerResults = new List(); if (wasCreated) { foreach (var provider in new[] { "google", "meta", "tiktok" }) { var url = _cfg[$"{provider.ToUpper()}_PROVIDER_URL"]?.TrimEnd('/'); var intKey = _cfg[$"{provider.ToUpper()}_INTERNAL_KEY"]; if (string.IsNullOrWhiteSpace(url)) { providerResults.Add(new { provider, status = "Skipped", error = "Not configured" }); continue; } try { using var http = _http.CreateClient(); http.Timeout = TimeSpan.FromSeconds(30); if (!string.IsNullOrWhiteSpace(intKey)) http.DefaultRequestHeaders.Add("X-Internal-Key", intKey); var provResp = await http.PostAsync( $"{url}/api/accounts/create", new StringContent( Json(new { clientId, clientName = bizName, contactEmail = regEmail }), Encoding.UTF8, "application/json"), ct); var provBody = await provResp.Content.ReadAsStringAsync(ct); using var provDoc = JsonDocument.Parse(provBody); var pv = provDoc.RootElement; if (provResp.IsSuccessStatusCode && Bol(pv, "ok")) { var extId = Str(pv, "externalAccountId"); var loginId = Str(pv, "loginAccountId"); await Sql.ExecProcAsync("dbo.spClientManagement", "recordAdAccount", Json(new { clientId, network = provider, externalAccountId = extId, loginAccountId = loginId, status = "Active" }), ct: ct); providerResults.Add(new { provider, status = "Succeeded", externalAccountId = extId }); Logger.LogInformation("[Approve] {Provider} account created: {ExtId}", provider, extId); } else { var err = Str(pv, "error") ?? $"HTTP {(int)provResp.StatusCode}"; providerResults.Add(new { provider, status = "Failed", error = err }); Logger.LogWarning("[Approve] {Provider} failed: {Error}", provider, err); } } catch (Exception ex) { providerResults.Add(new { provider, status = "Failed", error = ex.Message }); Logger.LogError(ex, "[Approve] {Provider} threw", provider); } } } // ── 5. Queue notifications ──────────────────────────────────────── if (wasCreated && !string.IsNullOrWhiteSpace(regEmail)) { await Sql.ExecProcAsync("dbo.spNotification", "queue", Json(new { type = "approval_welcome", toEmail = regEmail, toName = regName, subject = $"Your AdPlatform account is approved — {bizName}", bodyJson = Json(new { clientId, clientName = bizName, providerResults }) }), ct: ct); var failures = providerResults .Where(r => { using var d = JsonDocument.Parse(Json(r)); return Str(d.RootElement, "status") == "Failed"; }) .ToList(); if (failures.Any() && !string.IsNullOrWhiteSpace(Client.Email)) { await Sql.ExecProcAsync("dbo.spNotification", "queue", Json(new { type = "provisioning_alert", toEmail = Client.Email, toName = Client.Email, subject = $"[AdPlatform] Provisioning issue — {bizName}", bodyJson = Json(new { clientId, clientName = bizName, failures }) }), ct: ct); } } // ── 6. Mark registration complete ───────────────────────────────── try { await _registration.CompleteAsync(registrationId, clientId, ct); } catch (Exception ex) { Logger.LogWarning(ex, "[Approve] Failed to mark registration {RegId} complete (non-fatal)", registrationId); } return Ok(new { ok = true, clientId, name = bizName, status = "Active", created = wasCreated, providerResults }); } [HttpPut("{clientId}")] public Task Update(string clientId, [FromBody] JsonElement body, CancellationToken ct) { Logger.LogInformation("[Admin] Updating client {Id} by {User}", clientId, Client.Email); return CallProc(Proc, "updateProfile", new { clientId, name = Str(body, "name"), websiteUrl = Str(body, "websiteUrl"), description = Str(body, "description"), contactName = Str(body, "contactName"), contactEmail = Str(body, "contactEmail"), contactPhone = Str(body, "contactPhone"), notes = Str(body, "notes"), category = Str(body, "clientCategory") }, ct); } [HttpPost("{clientId}/suspend")] public Task Suspend(string clientId, [FromBody] JsonElement body, CancellationToken ct) { Logger.LogWarning("[Admin] Suspending {Id} by {User}", clientId, Client.Email); return CallProc(Proc, "updateStatus", new { clientId, newStatus = "Suspended", reason = Str(body, "reason"), changedByEmail = Client.Email }, ct); } [HttpPost("{clientId}/cancel")] public Task Cancel(string clientId, [FromBody] JsonElement body, CancellationToken ct) { Logger.LogWarning("[Admin] Cancelling {Id} by {User}", clientId, Client.Email); return CallProc(Proc, "updateStatus", new { clientId, newStatus = "Cancelled", reason = Str(body, "reason"), changedByEmail = Client.Email }, ct); } [HttpPost("{clientId}/reactivate")] public Task Reactivate(string clientId, CancellationToken ct) { Logger.LogInformation("[Admin] Reactivating {Id} by {User}", clientId, Client.Email); return CallProc(Proc, "updateStatus", new { clientId, newStatus = "Active", changedByEmail = Client.Email }, ct); } [HttpGet("{clientId}/defaults")] public Task Defaults(string clientId, CancellationToken ct) => CallProc(Proc, "defaults", new { clientId }, ct); // ── Registration Proxy ──────────────────────────────────────────────── [HttpGet("/api/registration/pending")] public async Task GetPending(CancellationToken ct) { var result = await _registration.GetPendingAsync(ct); if (result == null) return StatusCode(502, new { ok = false, error = "Registration service unavailable" }); return Content(result.RootElement.GetRawText(), "application/json"); } [HttpGet("/api/registration/{registrationId}")] public async Task GetById(string registrationId, CancellationToken ct) { var result = await _registration.GetByIdAsync(registrationId, ct); if (result == null) return StatusCode(502, new { ok = false, error = "Registration service unavailable" }); return Content(result.RootElement.GetRawText(), "application/json"); } [HttpPost("/api/registration/{registrationId}/reject")] public async Task Reject(string registrationId, [FromBody] JsonElement body, CancellationToken ct) { Logger.LogInformation("[Admin] Rejecting {Id} by {User}", registrationId, Client.Email); var result = await _registration.RejectAsync(registrationId, Str(body, "reason"), ct); if (result == null) return StatusCode(502, new { ok = false, error = "Registration service unavailable" }); return Content(result.RootElement.GetRawText(), "application/json"); } // ── Helpers ─────────────────────────────────────────────────────────── private static string? Str(JsonElement el, string key) => el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; private static bool Bol(JsonElement el, string key) => el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.True; private static string Json(object o) => JsonSerializer.Serialize(o, JsonOpts); }