Files
AdPlatform-Server/Management/Controllers/Admin/AdminClientsController.cs
2026-03-14 13:50:09 -07:00

342 lines
15 KiB
C#

using Management.Data;
using Management.Security;
using Management.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// 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
/// </summary>
[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<AdminClientsController> log)
: base(sql, client, log)
{
_registration = registration;
_http = http;
_cfg = cfg;
}
private const string Proc = "spClientManagement";
// ── CRUD + Lifecycle ──────────────────────────────────────────────────
[HttpPost("list")]
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
=> CallProc(Proc, "list", body.ToString(), ct);
[HttpGet("{clientId}")]
public Task<IActionResult> Get(string clientId, CancellationToken ct)
=> CallProc(Proc, "get", new { clientId }, ct);
/// <summary>
/// Approve a registration. Only registrationId is needed from the browser —
/// all data including the CIAM OID is fetched from the Registration Function server-side.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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<object>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Defaults(string clientId, CancellationToken ct)
=> CallProc(Proc, "defaults", new { clientId }, ct);
// ── Registration Proxy ────────────────────────────────────────────────
[HttpGet("/api/registration/pending")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}