342 lines
15 KiB
C#
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);
|
|
}
|