Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -0,0 +1,38 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for campaign (initiative) management.
/// Lists initiatives across all clients with their channel campaign details.
/// Requires Admin role.
///
/// ENDPOINTS:
/// GET /api/admin/campaigns - List all initiatives with channels
/// GET /api/admin/campaigns/{id} - Get initiative detail with channels
/// </summary>
[ApiController]
[Route("api/admin/campaigns")]
public sealed class AdminCampaignsController : AdminControllerBase
{
public AdminCampaignsController(SqlService sql, ClientContext client, ILogger<AdminCampaignsController> log)
: base(sql, client, log) { }
/// <summary>
/// List all initiatives across all clients, with nested channel campaigns.
/// Optional filters: status, clientId, dateFrom, dateTo.
/// </summary>
[HttpPost("list")]
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
=> CallProc("spAdminCampaigns", "list", body.ToString(), ct);
/// <summary>
/// Get initiative by ID with full channel campaign details.
/// </summary>
[HttpGet("{initiativeId:long}")]
public Task<IActionResult> Get(long initiativeId, CancellationToken ct)
=> CallProc("spAdminCampaigns", "get", new { initiativeId }, ct);
}

View File

@@ -0,0 +1,31 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Client activity log — queries tbAccessLog (populated by Gateway's AccessLogMiddleware).
///
/// ENDPOINTS:
/// POST /api/admin/client-activity/list - Paginated activity for a specific client
/// POST /api/admin/client-activity/summary - Request counts + last-seen per client
/// </summary>
[ApiController]
[Route("api/admin/client-activity")]
public sealed class AdminClientActivityController : AdminControllerBase
{
private const string Proc = "spClientActivity";
public AdminClientActivityController(SqlService sql, ClientContext client, ILogger<AdminClientActivityController> log)
: base(sql, client, log) { }
[HttpPost("list")]
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
=> CallProc(Proc, "list", body.ToString(), ct);
[HttpPost("summary")]
public Task<IActionResult> Summary([FromBody] JsonElement body, CancellationToken ct)
=> CallProc(Proc, "summary", body.ToString(), ct);
}

View File

@@ -0,0 +1,188 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using System.Data;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Client document management (admin view).
/// Admins can list, upload, download, and delete documents for any client.
/// All documents created here have scope='client' and a required clientId.
///
/// POST /api/admin/client-documents/list - List docs for a client
/// POST /api/admin/client-documents/upload - Upload doc for a client
/// GET /api/admin/client-documents/{id}/download
/// DELETE /api/admin/client-documents/{id}
/// </summary>
[ApiController]
[Route("api/admin/client-documents")]
public sealed class AdminClientDocumentsController : AdminControllerBase
{
private readonly IConfiguration _config;
public AdminClientDocumentsController(SqlService sql, ClientContext client, IConfiguration config, ILogger<AdminClientDocumentsController> log)
: base(sql, client, log)
{
_config = config;
}
// ── POST /api/admin/client-documents/list ────────────────────────────────
[HttpPost("list")]
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
{
try
{
var clientId = body.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
if (string.IsNullOrWhiteSpace(clientId))
return BadRequest(new { ok = false, error = "clientId is required" });
var rqst = JsonSerializer.Serialize(new { scope = "client", clientId });
var result = await Sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
Logger.LogError(ex, "Client document list failed");
return StatusCode(500, new { ok = false, message = ex.Message });
}
}
// ── POST /api/admin/client-documents/upload ──────────────────────────────
[HttpPost("upload")]
[RequestSizeLimit(52_428_800)]
public async Task<IActionResult> Upload(
IFormFile file,
[FromForm] string clientId,
[FromForm] string category,
[FromForm] string? description = null,
CancellationToken ct = default)
{
if (file == null || file.Length == 0)
return BadRequest(new { ok = false, message = "No file provided" });
if (string.IsNullOrWhiteSpace(clientId))
return BadRequest(new { ok = false, message = "clientId is required" });
try
{
byte[] fileBytes;
using (var ms = new MemoryStream())
{
await file.CopyToAsync(ms, ct);
fileBytes = ms.ToArray();
}
var rqst = JsonSerializer.Serialize(new
{
docFileName = file.FileName,
docMimeType = file.ContentType,
docFileSize = file.Length,
docCategory = category,
docDescription = description,
docUploadedBy = Client.Email,
docScope = "client",
docCltId = clientId
});
Logger.LogInformation("[ClientDocs] Upload {FileName} for client {ClientId} by {User}",
file.FileName, clientId, Client.Email);
var result = await ExecUploadAsync(rqst, fileBytes, ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
Logger.LogError(ex, "Client document upload failed: {FileName}", file?.FileName);
return StatusCode(500, new { ok = false, message = ex.Message });
}
}
// ── GET /api/admin/client-documents/{id}/download ────────────────────────
[HttpGet("{id:long}/download")]
public async Task<IActionResult> Download(long id, CancellationToken ct = default)
{
try
{
var cs = _config.GetConnectionString("Sql")
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = 60
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = JsonSerializer.Serialize(new { docId = id }) });
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
return NotFound(new { ok = false, message = "Document not found" });
var fileName = reader.GetString(reader.GetOrdinal("docFileName"));
var mimeType = reader.GetString(reader.GetOrdinal("docMimeType"));
var content = (byte[])reader["docContent"];
return File(content, mimeType, fileName);
}
catch (Exception ex)
{
Logger.LogError(ex, "Client document download failed: docId={DocId}", id);
return StatusCode(500, new { ok = false, message = ex.Message });
}
}
// ── DELETE /api/admin/client-documents/{id} ──────────────────────────────
[HttpDelete("{id:long}")]
public async Task<IActionResult> Delete(long id, CancellationToken ct = default)
{
try
{
Logger.LogInformation("[ClientDocs] Delete docId={DocId} by {User}", id, Client.Email);
var rqst = JsonSerializer.Serialize(new { docId = id });
var result = await Sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
Logger.LogError(ex, "Client document delete failed: docId={DocId}", id);
return StatusCode(500, new { ok = false, message = ex.Message });
}
}
// ─── Upload helper ────────────────────────────────────────────────────────
private async Task<string> ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct)
{
var cs = _config.GetConnectionString("Sql")
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = 60
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst });
cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent });
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
{
Direction = ParameterDirection.Output
};
cmd.Parameters.Add(pResp);
await cmd.ExecuteNonQueryAsync(ct);
return pResp.Value as string
?? JsonSerializer.Serialize(new { ok = false, message = "No response from database" });
}
}

View File

@@ -0,0 +1,84 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Client-planet user management — tbClientUser / tbClientUserRole.
/// No admin-plane concepts here whatsoever.
///
/// ENDPOINTS:
/// GET /api/admin/client-users - List client users
/// GET /api/admin/client-users/{userId} - Get single user
/// PUT /api/admin/client-users/{userId} - Update display name / status
/// DELETE /api/admin/client-users/{userId} - Deactivate
/// POST /api/admin/client-users/{userId}/link - Link to a client
/// DELETE /api/admin/client-users/{userId}/link/{clientId} - Unlink from a client
/// </summary>
[ApiController]
[Route("api/admin/client-users")]
public sealed class AdminClientUsersController : AdminControllerBase
{
private const string Proc = "spClientUsers";
public AdminClientUsersController(SqlService sql, ClientContext client, ILogger<AdminClientUsersController> log)
: base(sql, client, log) { }
[HttpPost("list")]
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
=> CallProc(Proc, "list", body.ToString(), ct);
[HttpGet("{userId}")]
public Task<IActionResult> Get(string userId, CancellationToken ct)
=> CallProc(Proc, "get", new { userId }, ct);
[HttpPut("{userId}")]
public Task<IActionResult> Update(string userId, [FromBody] JsonElement body, CancellationToken ct)
{
Logger.LogInformation("[ClientUsers] Update {Id} by {User}", userId, Client.Email);
return CallProc(Proc, "update", new
{
userId,
displayName = Str(body, "displayName"),
status = Str(body, "status")
}, ct);
}
[HttpDelete("{userId}")]
public Task<IActionResult> Deactivate(string userId, CancellationToken ct)
{
Logger.LogWarning("[ClientUsers] Deactivate {Id} by {User}", userId, Client.Email);
return CallProc(Proc, "deactivate", new { userId }, ct);
}
[HttpPost("{userId}/link")]
public Task<IActionResult> LinkToClient(string userId, [FromBody] JsonElement body, CancellationToken ct)
{
var clientId = Str(body, "clientId");
if (string.IsNullOrWhiteSpace(clientId))
return Task.FromResult(ValidationError("clientId is required"));
Logger.LogInformation("[ClientUsers] Link user {UserId} → client {ClientId} by {User}",
userId, clientId, Client.Email);
return CallProc(Proc, "linkToClient", new
{
userId,
clientId,
role = Str(body, "role") ?? "User"
}, ct);
}
[HttpDelete("{userId}/link/{clientId}")]
public Task<IActionResult> UnlinkFromClient(string userId, string clientId, CancellationToken ct)
{
Logger.LogInformation("[ClientUsers] Unlink user {UserId} from client {ClientId} by {User}",
userId, clientId, Client.Email);
return CallProc(Proc, "unlinkFromClient", new { userId, clientId }, ct);
}
private static string? Str(JsonElement el, string key) =>
el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
}

View File

@@ -1,92 +1,341 @@
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 (organization) management.
/// Requires Admin role.
///
/// 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/{id} - Get client
/// POST /api/admin/clients - Create client
/// PUT /api/admin/clients/{id} - Update client
/// DELETE /api/admin/clients/{id} - Deactivate client
/// 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
{
public AdminClientsController(SqlService sql, ClientContext client, ILogger<AdminClientsController> log)
: base(sql, client, log) { }
private readonly RegistrationClient _registration;
private readonly IHttpClientFactory _http;
private readonly IConfiguration _cfg;
/// <summary>
/// List all clients with optional filtering.
/// </summary>
[HttpGet]
public Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken ct = default)
=> CallProc("spAdminClients", "list", new { status, page, pageSize }, ct);
/// <summary>
/// Get client by ID.
/// </summary>
[HttpGet("{clientId}")]
public Task<IActionResult> Get(string clientId, CancellationToken ct)
=> CallProc("spAdminClients", "get", new { clientId }, ct);
/// <summary>
/// Create a new client.
/// </summary>
[HttpPost]
public Task<IActionResult> Create([FromBody] CreateClientRequest request, CancellationToken ct)
private static readonly JsonSerializerOptions JsonOpts = new()
{
if (string.IsNullOrWhiteSpace(request?.ClientName))
return Task.FromResult(ValidationError("clientName is required"));
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
Logger.LogWarning("[Admin] CreateClient | Name={Name} | By={User}", request.ClientName, Client.Email);
return CallProc("spAdminClients", "create", new { clientName = request.ClientName.Trim() }, ct);
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>
/// Update client.
/// 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>
[HttpPut("{clientId}")]
public Task<IActionResult> Update(string clientId, [FromBody] UpdateClientRequest request, CancellationToken ct)
[HttpPost]
public async Task<IActionResult> Approve([FromBody] JsonElement body, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateClient | Id={Id} | By={User}", clientId, Client.Email);
return CallProc("spAdminClients", "update", new
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,
clientName = request?.ClientName?.Trim(),
status = request?.Status
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);
}
/// <summary>
/// Deactivate client (soft delete).
/// </summary>
[HttpDelete("{clientId}")]
public Task<IActionResult> Delete(string clientId, CancellationToken ct)
[HttpPost("{clientId}/suspend")]
public Task<IActionResult> Suspend(string clientId, [FromBody] JsonElement body, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteClient | Id={Id} | By={User}", clientId, Client.Email);
return CallProc("spAdminClients", "delete", new { clientId }, 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);
}
}
// DTOs
public sealed class CreateClientRequest
{
public string? ClientName { get; set; }
}
[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);
}
public sealed class UpdateClientRequest
{
public string? ClientName { get; set; }
public string? Status { get; set; }
[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);
}

View File

@@ -24,11 +24,16 @@ public abstract class AdminControllerBase : ControllerBase
/// <summary>
/// Execute stored procedure and return appropriate IActionResult.
/// </summary>
protected async Task<IActionResult> CallProc(string proc, string action, object rqst, CancellationToken ct)
protected Task<IActionResult> CallProc(string proc, string action, string rqst, CancellationToken ct)
=> CallProcInternal(proc, action, rqst, ct);
protected Task<IActionResult> CallProc(string proc, string action, object rqst, CancellationToken ct)
=> CallProcInternal(proc, action, JsonSerializer.Serialize(rqst), ct);
private async Task<IActionResult> CallProcInternal(string proc, string action, string json, CancellationToken ct)
{
try
{
var json = JsonSerializer.Serialize(rqst);
var resp = await Sql.ExecProcAsync($"dbo.{proc}", action, json, ct: ct);
if (string.IsNullOrWhiteSpace(resp))

View File

@@ -0,0 +1,50 @@
using Management.Data;
using Management.Security;
using Management.Controllers.Admin;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin CRUD for help content — requires admin session.
/// </summary>
[ApiController]
[Route("api/admin/help")]
public class AdminHelpController : AdminControllerBase
{
public AdminHelpController(SqlService sql, ClientContext client, ILogger<AdminHelpController> logger)
: base(sql, client, logger) { }
/// <summary>
/// GET /api/admin/help
/// List all help content entries (active and inactive).
/// </summary>
[HttpPost("list")]
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
{
return await CallProc("spHelp", "list", body.ToString(), ct);
}
/// <summary>
/// POST /api/admin/help
/// Create or update a help entry by helpKey (upsert).
/// Body: { helpKey, title, body, isActive }
/// </summary>
[HttpPost]
public async Task<IActionResult> Upsert([FromBody] JsonElement payload, CancellationToken ct)
{
return await CallProc("spHelp", "upsert", payload, ct);
}
/// <summary>
/// DELETE /api/admin/help/{key}
/// Delete a help entry by key.
/// </summary>
[HttpDelete("{key}")]
public async Task<IActionResult> Delete(string key, CancellationToken ct)
{
return await CallProc("spHelp", "delete",
new { helpKey = key, adminId = Client.UserId }, ct);
}
}

View File

@@ -0,0 +1,112 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoint for manually triggering metric sync.
///
/// ENDPOINTS:
/// POST /api/admin/sync/metrics/{clientId} - Sync a specific client
/// POST /api/admin/sync/metrics - Sync all active clients
///
/// Proxies to Gateway /api/sync/metrics/* using an internal service key.
/// Gateway owns provider connectivity; Management owns the trigger surface.
/// Configure via GATEWAY_URL + INTERNAL_SERVICE_KEY env vars.
/// </summary>
[ApiController]
[Route("api/admin/sync")]
public sealed class AdminMetricSyncController : AdminControllerBase
{
private readonly IHttpClientFactory _http;
private readonly IConfiguration _config;
public AdminMetricSyncController(
SqlService sql,
ClientContext client,
ILogger<AdminMetricSyncController> log,
IHttpClientFactory http,
IConfiguration config)
: base(sql, client, log)
{
_http = http;
_config = config;
}
/// <summary>
/// Trigger metric sync for a specific client.
/// </summary>
[HttpPost("metrics/{clientId}")]
public async Task<IActionResult> SyncClient(
string clientId,
[FromQuery] string? startDate,
[FromQuery] string? endDate,
CancellationToken ct)
{
Logger.LogInformation("[Admin] MetricSync triggered for client {ClientId} | By={User}",
clientId, Client.Email);
return await ProxyToGateway($"metrics/{Uri.EscapeDataString(clientId)}", startDate, endDate, ct);
}
/// <summary>
/// Trigger metric sync for all active clients.
/// </summary>
[HttpPost("metrics")]
public async Task<IActionResult> SyncAll(
[FromQuery] string? startDate,
[FromQuery] string? endDate,
CancellationToken ct)
{
Logger.LogInformation("[Admin] MetricSync ALL clients triggered | By={User}", Client.Email);
return await ProxyToGateway("metrics/all", startDate, endDate, ct);
}
// ────────────────────────────────────────────────
// Proxy helper
// ────────────────────────────────────────────────
private async Task<IActionResult> ProxyToGateway(
string path, string? startDate, string? endDate, CancellationToken ct)
{
var gatewayUrl = _config["GATEWAY_URL"]?.TrimEnd('/');
var serviceKey = _config["INTERNAL_SERVICE_KEY"];
if (string.IsNullOrWhiteSpace(gatewayUrl))
return StatusCode(500, new { ok = false, error = "GATEWAY_URL not configured" });
if (string.IsNullOrWhiteSpace(serviceKey))
return StatusCode(500, new { ok = false, error = "INTERNAL_SERVICE_KEY not configured" });
var qs = new List<string>();
if (!string.IsNullOrWhiteSpace(startDate)) qs.Add($"startDate={Uri.EscapeDataString(startDate)}");
if (!string.IsNullOrWhiteSpace(endDate)) qs.Add($"endDate={Uri.EscapeDataString(endDate)}");
var url = $"{gatewayUrl}/api/sync/{path}";
if (qs.Count > 0) url += "?" + string.Join("&", qs);
try
{
var client = _http.CreateClient();
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Add("X-Service-Key", serviceKey);
using var resp = await client.SendAsync(req, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
Logger.LogInformation("[Admin] MetricSync Gateway response {Status} | Path={Path}",
(int)resp.StatusCode, path);
return resp.IsSuccessStatusCode
? Content(body, "application/json")
: StatusCode((int)resp.StatusCode, new { ok = false, error = $"Gateway returned {(int)resp.StatusCode}", detail = body });
}
catch (Exception ex)
{
Logger.LogError(ex, "[Admin] MetricSync proxy error | Path={Path}", path);
return StatusCode(500, new { ok = false, error = "Sync trigger failed", detail = ex.Message });
}
}
}

View File

@@ -0,0 +1,84 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for audience-based allocation modifiers.
/// Table: dbo.tbAllocationModifier
/// Proc: dbo.spAllocationRecommend (list / update actions)
///
/// ENDPOINTS:
/// GET /api/admin/modifiers - List all modifiers
/// PUT /api/admin/modifiers/{id} - Update a modifier
/// POST /api/admin/modifiers/preview - Preview recommendation with factors
/// </summary>
[ApiController]
[Route("api/admin/modifiers")]
public sealed class AdminModifiersController : AdminControllerBase
{
public AdminModifiersController(SqlService sql, ClientContext client, ILogger<AdminModifiersController> log)
: base(sql, client, log) { }
[HttpGet]
public Task<IActionResult> List(CancellationToken ct)
=> CallProc("spAllocationRecommend", "list", new { }, ct);
[HttpPut("{id:int}")]
public Task<IActionResult> Update(int id, [FromBody] UpdateModifierRequest request, CancellationToken ct)
{
if (request?.PctAdjustment is < -50 or > 50)
return Task.FromResult(ValidationError("pctAdjustment must be between -50 and 50"));
Logger.LogWarning("[Admin] UpdateModifier | Id={Id} | By={User}", id, Client.Email);
return CallProc("spAllocationRecommend", "update", new
{
id,
pctAdjustment = request?.PctAdjustment,
minBudgetAdj = request?.MinBudgetAdj,
rationale = request?.Rationale?.Trim(),
isActive = request?.IsActive
}, ct);
}
/// <summary>
/// Preview a recommendation with given factors — same proc, recommend action.
/// Lets admins test how modifiers affect channel mix without going through the wizard.
/// </summary>
[HttpPost("preview")]
public Task<IActionResult> Preview([FromBody] PreviewRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.BusinessCategory))
return Task.FromResult(ValidationError("businessCategory is required"));
if (string.IsNullOrWhiteSpace(request?.Objective))
return Task.FromResult(ValidationError("objective is required"));
return CallProc("spAllocationRecommend", "recommend", new
{
businessCategory = request.BusinessCategory.Trim(),
objective = request.Objective.Trim(),
ageSkew = request.AgeSkew?.Trim(),
marketScope = request.MarketScope?.Trim()
}, ct);
}
}
// DTOs
public sealed class UpdateModifierRequest
{
public int? PctAdjustment { get; set; }
public int? MinBudgetAdj { get; set; }
public string? Rationale { get; set; }
public bool? IsActive { get; set; }
}
public sealed class PreviewRequest
{
public string? BusinessCategory { get; set; }
public string? Objective { get; set; }
public string? AgeSkew { get; set; }
public string? MarketScope { get; set; }
}

View File

@@ -0,0 +1,120 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for objective-to-channel mapping management.
/// Table: dbo.tbObjectiveMapping
/// Maps platform objectives to provider-specific objectives with capability flags.
///
/// ENDPOINTS:
/// GET /api/admin/objectives - List mappings (filterable)
/// GET /api/admin/objectives/{id} - Get mapping
/// POST /api/admin/objectives - Create mapping
/// PUT /api/admin/objectives/{id} - Update mapping
/// DELETE /api/admin/objectives/{id} - Delete mapping
/// </summary>
[ApiController]
[Route("api/admin/objectives")]
public sealed class AdminObjectiveMappingController : AdminControllerBase
{
public AdminObjectiveMappingController(SqlService sql, ClientContext client, ILogger<AdminObjectiveMappingController> log)
: base(sql, client, log) { }
[HttpPost("list")]
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
=> CallProc("spAdminObjectiveMapping", "list", body.ToString(), ct);
[HttpGet("{mappingId:int}")]
public Task<IActionResult> Get(int mappingId, CancellationToken ct)
=> CallProc("spAdminObjectiveMapping", "get", new { mappingId }, ct);
[HttpPost]
public Task<IActionResult> Create([FromBody] CreateMappingRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.ChannelType))
return Task.FromResult(ValidationError("channelType is required"));
if (string.IsNullOrWhiteSpace(request?.PlatformObjective))
return Task.FromResult(ValidationError("platformObjective is required"));
if (string.IsNullOrWhiteSpace(request?.ProviderObjective))
return Task.FromResult(ValidationError("providerObjective is required"));
if (string.IsNullOrWhiteSpace(request?.ProviderObjectiveLabel))
return Task.FromResult(ValidationError("providerObjectiveLabel is required"));
Logger.LogWarning("[Admin] CreateObjectiveMapping | {Objective} → {Channel}/{Provider} | By={User}",
request.PlatformObjective, request.ChannelType, request.ProviderObjective, Client.Email);
return CallProc("spAdminObjectiveMapping", "create", new
{
channelType = request.ChannelType.Trim(),
platformObjective = request.PlatformObjective.Trim(),
providerObjective = request.ProviderObjective.Trim(),
providerObjectiveLabel = request.ProviderObjectiveLabel.Trim(),
supportsObjectiveChange = request.SupportsObjectiveChange ?? false,
supportsBudgetChange = request.SupportsBudgetChange ?? true,
supportsTargetingChange = request.SupportsTargetingChange ?? true,
supportsStatusToggle = request.SupportsStatusToggle ?? true,
notes = request.Notes?.Trim()
}, ct);
}
[HttpPut("{mappingId:int}")]
public Task<IActionResult> Update(int mappingId, [FromBody] UpdateMappingRequest request, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateObjectiveMapping | Id={Id} | By={User}", mappingId, Client.Email);
return CallProc("spAdminObjectiveMapping", "update", new
{
mappingId,
channelType = request?.ChannelType?.Trim(),
platformObjective = request?.PlatformObjective?.Trim(),
providerObjective = request?.ProviderObjective?.Trim(),
providerObjectiveLabel = request?.ProviderObjectiveLabel?.Trim(),
supportsObjectiveChange = request?.SupportsObjectiveChange,
supportsBudgetChange = request?.SupportsBudgetChange,
supportsTargetingChange = request?.SupportsTargetingChange,
supportsStatusToggle = request?.SupportsStatusToggle,
notes = request?.Notes?.Trim(),
isActive = request?.IsActive
}, ct);
}
[HttpDelete("{mappingId:int}")]
public Task<IActionResult> Delete(int mappingId, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteObjectiveMapping | Id={Id} | By={User}", mappingId, Client.Email);
return CallProc("spAdminObjectiveMapping", "delete", new { mappingId }, ct);
}
}
// DTOs
public sealed class CreateMappingRequest
{
public string? ChannelType { get; set; }
public string? PlatformObjective { get; set; }
public string? ProviderObjective { get; set; }
public string? ProviderObjectiveLabel { get; set; }
public bool? SupportsObjectiveChange { get; set; }
public bool? SupportsBudgetChange { get; set; }
public bool? SupportsTargetingChange { get; set; }
public bool? SupportsStatusToggle { get; set; }
public string? Notes { get; set; }
}
public sealed class UpdateMappingRequest
{
public string? ChannelType { get; set; }
public string? PlatformObjective { get; set; }
public string? ProviderObjective { get; set; }
public string? ProviderObjectiveLabel { get; set; }
public bool? SupportsObjectiveChange { get; set; }
public bool? SupportsBudgetChange { get; set; }
public bool? SupportsTargetingChange { get; set; }
public bool? SupportsStatusToggle { get; set; }
public string? Notes { get; set; }
public bool? IsActive { get; set; }
}

View File

@@ -0,0 +1,179 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for the recommendation engine.
///
/// ENDPOINTS:
/// GET /api/admin/recommendations/rules - List all rules
/// GET /api/admin/recommendations/rules/{id} - Get rule
/// POST /api/admin/recommendations/rules - Create rule
/// PUT /api/admin/recommendations/rules/{id} - Update rule
/// DELETE /api/admin/recommendations/rules/{id} - Delete rule
/// POST /api/admin/recommendations/evaluate - Trigger evaluation
/// POST /api/admin/recommendations/cleanup - Cleanup old records
///
/// Client-facing endpoints (list, dismiss, resolve) remain on Gateway
/// at /api/recommendations — scoped to the authenticated CIAM session.
/// </summary>
[ApiController]
[Route("api/admin/recommendations")]
public sealed class AdminRecommendationsController : AdminControllerBase
{
public AdminRecommendationsController(
SqlService sql, ClientContext client, ILogger<AdminRecommendationsController> log)
: base(sql, client, log) { }
// ────────────────────────────────────────────────
// Rule Management
// ────────────────────────────────────────────────
[HttpGet("rules")]
public Task<IActionResult> ListRules(
[FromQuery] string? category,
[FromQuery] string? channel,
CancellationToken ct)
=> CallProc("spRecommendation", "rules.list", new { category, channel }, ct);
[HttpGet("rules/{ruleId:int}")]
public Task<IActionResult> GetRule(int ruleId, CancellationToken ct)
=> CallProc("spRecommendation", "rules.get", new { ruleId }, ct);
[HttpPost("rules")]
public Task<IActionResult> CreateRule([FromBody] RuleRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.Code))
return Task.FromResult(ValidationError("code is required"));
if (string.IsNullOrWhiteSpace(request.Name))
return Task.FromResult(ValidationError("name is required"));
Logger.LogInformation("[Admin] CreateRecommendationRule {Code} | By={User}", request.Code, Client.Email);
return CallProc("spRecommendation", "rules.create", new
{
request.Code,
request.Name,
request.Category,
request.Metric,
request.Operator,
request.Threshold,
request.ThresholdType,
request.Severity,
request.Channel,
request.Objective,
request.Message,
request.AdminNotes,
request.LookbackDays,
request.MinDataDays,
request.CooldownHours,
request.SortOrder
}, ct);
}
[HttpPut("rules/{ruleId:int}")]
public Task<IActionResult> UpdateRule(int ruleId, [FromBody] RuleRequest request, CancellationToken ct)
{
Logger.LogInformation("[Admin] UpdateRecommendationRule {Id} | By={User}", ruleId, Client.Email);
return CallProc("spRecommendation", "rules.update", new
{
ruleId,
request.Name,
request.Category,
request.Metric,
request.Operator,
request.Threshold,
request.ThresholdType,
request.Severity,
request.Channel,
request.Objective,
request.Message,
request.AdminNotes,
request.LookbackDays,
request.MinDataDays,
request.CooldownHours,
request.IsActive,
request.SortOrder
}, ct);
}
[HttpDelete("rules/{ruleId:int}")]
public Task<IActionResult> DeleteRule(int ruleId, CancellationToken ct)
{
Logger.LogInformation("[Admin] DeleteRecommendationRule {Id} | By={User}", ruleId, Client.Email);
return CallProc("spRecommendation", "rules.delete", new { ruleId }, ct);
}
// ────────────────────────────────────────────────
// Evaluation Engine
// ────────────────────────────────────────────────
/// <summary>
/// Trigger rule evaluation for a campaign, initiative, client, or all active campaigns.
/// </summary>
[HttpPost("evaluate")]
public Task<IActionResult> Evaluate([FromBody] EvaluateRequest? request, CancellationToken ct)
{
Logger.LogInformation("[Admin] Evaluate | ClientId={Client} InitiativeId={Init} | By={User}",
request?.ClientId, request?.InitiativeId, Client.Email);
return CallProc("spRecommendation", "evaluate", new
{
channelCampaignId = request?.ChannelCampaignId,
initiativeId = request?.InitiativeId,
clientId = request?.ClientId
}, ct);
}
/// <summary>
/// Cleanup expired and old recommendations.
/// </summary>
[HttpPost("cleanup")]
public Task<IActionResult> Cleanup([FromBody] CleanupRequest? request, CancellationToken ct)
{
Logger.LogInformation("[Admin] RecommendationCleanup daysToKeep={Days} | By={User}",
request?.DaysToKeep ?? 90, Client.Email);
return CallProc("spRecommendation", "cleanup",
new { daysToKeep = request?.DaysToKeep ?? 90 }, ct);
}
}
// ── Request DTOs ──
public sealed class RuleRequest
{
public string? Code { get; set; }
public string? Name { get; set; }
public string? Category { get; set; }
public string? Metric { get; set; }
public string? Operator { get; set; }
public decimal? Threshold { get; set; }
public string? ThresholdType { get; set; }
public string? Severity { get; set; }
public string? Channel { get; set; }
public string? Objective { get; set; }
public string? Message { get; set; }
public string? AdminNotes { get; set; }
public int? LookbackDays { get; set; }
public int? MinDataDays { get; set; }
public int? CooldownHours { get; set; }
public bool? IsActive { get; set; }
public int? SortOrder { get; set; }
}
public sealed class EvaluateRequest
{
public long? ChannelCampaignId { get; set; }
public long? InitiativeId { get; set; }
public string? ClientId { get; set; }
}
public sealed class CleanupRequest
{
public int? DaysToKeep { get; set; }
}

View File

@@ -1,6 +1,7 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
@@ -24,14 +25,9 @@ public sealed class AdminSessionsController : AdminControllerBase
/// <summary>
/// List sessions with optional filtering.
/// </summary>
[HttpGet]
public Task<IActionResult> List(
[FromQuery] string? clientId,
[FromQuery] string? userId,
[FromQuery] bool activeOnly = true,
[FromQuery] int limit = 100,
CancellationToken ct = default)
=> CallProc("spAdminSessions", "list", new { clientId, userId, activeOnly, limit }, ct);
[HttpPost("list")]
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
=> CallProc("spAdminSessions", "list", body.ToString(), ct);
/// <summary>
/// Revoke a session.
@@ -57,9 +53,10 @@ public sealed class AdminSessionsController : AdminControllerBase
/// Cleanup expired sessions.
/// </summary>
[HttpPost("cleanup")]
public Task<IActionResult> Cleanup([FromQuery] int daysOld = 30, CancellationToken ct = default)
public Task<IActionResult> Cleanup([FromBody] JsonElement body, CancellationToken ct)
{
var daysOld = body.TryGetProperty("daysOld", out var d) ? d.GetInt32() : 30;
Logger.LogWarning("[Admin] CleanupSessions | DaysOld={DaysOld} | By={User}", daysOld, Client.Email);
return CallProc("spAdminSessions", "cleanup", new { daysOld }, ct);
return CallProc("spAdminSessions", "cleanup", body.ToString(), ct);
}
}

View File

@@ -0,0 +1,220 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for template configuration metadata —
/// business categories and objective display properties.
///
/// Both are managed through a single stored procedure
/// (spAdminTemplateConfig) with action-based routing.
///
/// CHANNEL ENDPOINTS:
/// GET /api/admin/template-config/channels - List channels (with mapping/template counts)
/// GET /api/admin/template-config/channels/{id} - Get channel
/// POST /api/admin/template-config/channels - Create channel
/// PUT /api/admin/template-config/channels/{id} - Update channel (code rename cascades to mappings + templates)
/// DELETE /api/admin/template-config/channels/{id} - Delete channel (blocked if references exist)
///
/// CATEGORY ENDPOINTS:
/// GET /api/admin/template-config/categories - List categories (with template counts)
/// GET /api/admin/template-config/categories/{id} - Get category
/// POST /api/admin/template-config/categories - Create category
/// PUT /api/admin/template-config/categories/{id} - Update category (rename cascades to templates)
/// DELETE /api/admin/template-config/categories/{id} - Delete category (blocked if templates exist)
///
/// OBJECTIVE ENDPOINTS:
/// GET /api/admin/template-config/objectives - List objectives (with template counts + mapping status)
/// GET /api/admin/template-config/objectives/{id} - Get objective
/// PUT /api/admin/template-config/objectives/{id} - Update objective display properties (color, sort)
///
/// NOTE: Objectives are a controlled set — they cannot be created or deleted
/// because they must stay in sync with tbObjectiveMapping and spInitiative
/// validation. Only display properties (color, sortOrder, isActive) are editable.
/// </summary>
[ApiController]
[Route("api/admin/template-config")]
public sealed class AdminTemplateConfigController : AdminControllerBase
{
public AdminTemplateConfigController(SqlService sql, ClientContext client, ILogger<AdminTemplateConfigController> log)
: base(sql, client, log) { }
// ═══════════════════════════════════════════════════════════
// CATEGORIES
// ═══════════════════════════════════════════════════════════
[HttpGet("categories")]
public Task<IActionResult> ListCategories(CancellationToken ct = default)
=> CallProc("spAdminTemplateConfig", "categories.list", new { }, ct);
[HttpGet("categories/{categoryId:int}")]
public Task<IActionResult> GetCategory(int categoryId, CancellationToken ct)
=> CallProc("spAdminTemplateConfig", "categories.get", new { categoryId }, ct);
[HttpPost("categories")]
public Task<IActionResult> CreateCategory([FromBody] CreateCategoryRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.Name))
return Task.FromResult(ValidationError("name is required"));
Logger.LogWarning("[Admin] CreateCategory | Name={Name} | By={User}",
request.Name, Client.Email);
return CallProc("spAdminTemplateConfig", "categories.create", new
{
name = request.Name.Trim(),
icon = request.Icon?.Trim(),
sortOrder = request.SortOrder
}, ct);
}
[HttpPut("categories/{categoryId:int}")]
public Task<IActionResult> UpdateCategory(int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateCategory | Id={Id} | By={User}", categoryId, Client.Email);
return CallProc("spAdminTemplateConfig", "categories.update", new
{
categoryId,
name = request?.Name?.Trim(),
icon = request?.Icon?.Trim(),
sortOrder = request?.SortOrder,
isActive = request?.IsActive
}, ct);
}
[HttpDelete("categories/{categoryId:int}")]
public Task<IActionResult> DeleteCategory(int categoryId, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteCategory | Id={Id} | By={User}", categoryId, Client.Email);
return CallProc("spAdminTemplateConfig", "categories.delete", new { categoryId }, ct);
}
// ═══════════════════════════════════════════════════════════
// OBJECTIVES
// ═══════════════════════════════════════════════════════════
[HttpGet("objectives")]
public Task<IActionResult> ListObjectives(CancellationToken ct = default)
=> CallProc("spAdminTemplateConfig", "objectives.list", new { }, ct);
[HttpGet("objectives/{objectiveId:int}")]
public Task<IActionResult> GetObjective(int objectiveId, CancellationToken ct)
=> CallProc("spAdminTemplateConfig", "objectives.get", new { objectiveId }, ct);
[HttpPut("objectives/{objectiveId:int}")]
public Task<IActionResult> UpdateObjective(int objectiveId, [FromBody] UpdateObjectiveRequest request, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateObjective | Id={Id} | By={User}", objectiveId, Client.Email);
return CallProc("spAdminTemplateConfig", "objectives.update", new
{
objectiveId,
color = request?.Color?.Trim(),
sortOrder = request?.SortOrder,
isActive = request?.IsActive
}, ct);
}
// ═══════════════════════════════════════════════════════════
// CHANNELS
// ═══════════════════════════════════════════════════════════
[HttpGet("channels")]
public Task<IActionResult> ListChannels(CancellationToken ct = default)
=> CallProc("spAdminTemplateConfig", "channels.list", new { }, ct);
[HttpGet("channels/{channelId:int}")]
public Task<IActionResult> GetChannel(int channelId, CancellationToken ct)
=> CallProc("spAdminTemplateConfig", "channels.get", new { channelId }, ct);
[HttpPost("channels")]
public Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.Code))
return Task.FromResult(ValidationError("code is required"));
if (string.IsNullOrWhiteSpace(request?.Label))
return Task.FromResult(ValidationError("label is required"));
Logger.LogWarning("[Admin] CreateChannel | Code={Code} | By={User}",
request.Code, Client.Email);
return CallProc("spAdminTemplateConfig", "channels.create", new
{
code = request.Code.Trim().ToLowerInvariant(),
label = request.Label.Trim(),
color = request.Color?.Trim(),
icon = request.Icon?.Trim(),
sortOrder = request.SortOrder
}, ct);
}
[HttpPut("channels/{channelId:int}")]
public Task<IActionResult> UpdateChannel(int channelId, [FromBody] UpdateChannelRequest request, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateChannel | Id={Id} | By={User}", channelId, Client.Email);
return CallProc("spAdminTemplateConfig", "channels.update", new
{
channelId,
code = request?.Code?.Trim()?.ToLowerInvariant(),
label = request?.Label?.Trim(),
color = request?.Color?.Trim(),
icon = request?.Icon?.Trim(),
sortOrder = request?.SortOrder,
isActive = request?.IsActive
}, ct);
}
[HttpDelete("channels/{channelId:int}")]
public Task<IActionResult> DeleteChannel(int channelId, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteChannel | Id={Id} | By={User}", channelId, Client.Email);
return CallProc("spAdminTemplateConfig", "channels.delete", new { channelId }, ct);
}
}
// ─── DTOs ───────────────────────────────────────────────────
public sealed class CreateCategoryRequest
{
public string? Name { get; set; }
public string? Icon { get; set; }
public int? SortOrder { get; set; }
}
public sealed class UpdateCategoryRequest
{
public string? Name { get; set; }
public string? Icon { get; set; }
public int? SortOrder { get; set; }
public bool? IsActive { get; set; }
}
public sealed class UpdateObjectiveRequest
{
public string? Color { get; set; }
public int? SortOrder { get; set; }
public bool? IsActive { get; set; }
}
public sealed class CreateChannelRequest
{
public string? Code { get; set; }
public string? Label { get; set; }
public string? Color { get; set; }
public string? Icon { get; set; }
public int? SortOrder { get; set; }
}
public sealed class UpdateChannelRequest
{
public string? Code { get; set; }
public string? Label { get; set; }
public string? Color { get; set; }
public string? Icon { get; set; }
public int? SortOrder { get; set; }
public bool? IsActive { get; set; }
}

View File

@@ -0,0 +1,115 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for allocation template management.
/// Table: dbo.tbAllocationTemplate
///
/// ENDPOINTS:
/// GET /api/admin/templates - List templates (filterable)
/// GET /api/admin/templates/{id} - Get template
/// POST /api/admin/templates - Create template
/// PUT /api/admin/templates/{id} - Update template
/// DELETE /api/admin/templates/{id} - Delete template
/// GET /api/admin/templates/categories - Distinct business categories
/// </summary>
[ApiController]
[Route("api/admin/templates")]
public sealed class AdminTemplatesController : AdminControllerBase
{
public AdminTemplatesController(SqlService sql, ClientContext client, ILogger<AdminTemplatesController> log)
: base(sql, client, log) { }
[HttpPost("list")]
public Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
=> CallProc("spAdminTemplates", "list", body.ToString(), ct);
[HttpGet("{templateId:int}")]
public Task<IActionResult> Get(int templateId, CancellationToken ct)
=> CallProc("spAdminTemplates", "get", new { templateId }, ct);
[HttpPost]
public Task<IActionResult> Create([FromBody] CreateTemplateRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.ChannelType))
return Task.FromResult(ValidationError("channelType is required"));
if (string.IsNullOrWhiteSpace(request?.BusinessCategory))
return Task.FromResult(ValidationError("businessCategory is required"));
if (string.IsNullOrWhiteSpace(request?.Objective))
return Task.FromResult(ValidationError("objective is required"));
if (request.RecommendedPct is null or < 0 or > 100)
return Task.FromResult(ValidationError("recommendedPct must be between 0 and 100"));
Logger.LogWarning("[Admin] CreateTemplate | {Channel}/{Category}/{Objective} = {Pct}% | By={User}",
request.ChannelType, request.BusinessCategory, request.Objective, request.RecommendedPct, Client.Email);
return CallProc("spAdminTemplates", "create", new
{
channelType = request.ChannelType.Trim(),
businessCategory = request.BusinessCategory.Trim(),
objective = request.Objective.Trim(),
recommendedPct = request.RecommendedPct,
minBudgetRequired = request.MinBudgetRequired ?? 0m,
rationale = request.Rationale?.Trim()
}, ct);
}
[HttpPut("{templateId:int}")]
public Task<IActionResult> Update(int templateId, [FromBody] UpdateTemplateRequest request, CancellationToken ct)
{
if (request?.RecommendedPct is < 0 or > 100)
return Task.FromResult(ValidationError("recommendedPct must be between 0 and 100"));
Logger.LogWarning("[Admin] UpdateTemplate | Id={Id} | By={User}", templateId, Client.Email);
return CallProc("spAdminTemplates", "update", new
{
templateId,
channelType = request?.ChannelType?.Trim(),
businessCategory = request?.BusinessCategory?.Trim(),
objective = request?.Objective?.Trim(),
recommendedPct = request?.RecommendedPct,
minBudgetRequired = request?.MinBudgetRequired,
rationale = request?.Rationale?.Trim(),
isActive = request?.IsActive
}, ct);
}
[HttpDelete("{templateId:int}")]
public Task<IActionResult> Delete(int templateId, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteTemplate | Id={Id} | By={User}", templateId, Client.Email);
return CallProc("spAdminTemplates", "delete", new { templateId }, ct);
}
[HttpGet("categories")]
public Task<IActionResult> Categories(CancellationToken ct)
=> CallProc("spAdminTemplates", "categories", new { }, ct);
}
// DTOs
public sealed class CreateTemplateRequest
{
public string? ChannelType { get; set; }
public string? BusinessCategory { get; set; }
public string? Objective { get; set; }
public decimal? RecommendedPct { get; set; }
public decimal? MinBudgetRequired { get; set; }
public string? Rationale { get; set; }
}
public sealed class UpdateTemplateRequest
{
public string? ChannelType { get; set; }
public string? BusinessCategory { get; set; }
public string? Objective { get; set; }
public decimal? RecommendedPct { get; set; }
public decimal? MinBudgetRequired { get; set; }
public string? Rationale { get; set; }
public bool? IsActive { get; set; }
}

View File

@@ -1,140 +0,0 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for user management.
/// Requires Admin role.
///
/// ENDPOINTS:
/// GET /api/admin/users - List users
/// GET /api/admin/users/{id} - Get user
/// POST /api/admin/users - Create user
/// PUT /api/admin/users/{id} - Update user
/// DELETE /api/admin/users/{id} - Deactivate user
/// POST /api/admin/users/{id}/clients - Link user to client
/// DELETE /api/admin/users/{id}/clients/{cltId} - Unlink user from client
/// </summary>
[ApiController]
[Route("api/admin/users")]
public sealed class AdminUsersController : AdminControllerBase
{
public AdminUsersController(SqlService sql, ClientContext client, ILogger<AdminUsersController> log)
: base(sql, client, log) { }
/// <summary>
/// List users with optional filtering.
/// </summary>
[HttpGet]
public Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] string? clientId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken ct = default)
=> CallProc("spAdminUsers", "list", new { status, clientId, page, pageSize }, ct);
/// <summary>
/// Get user by ID.
/// </summary>
[HttpGet("{userId}")]
public Task<IActionResult> Get(string userId, CancellationToken ct)
=> CallProc("spAdminUsers", "get", new { userId }, ct);
/// <summary>
/// Create a new user.
/// </summary>
[HttpPost]
public Task<IActionResult> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.Email))
return Task.FromResult(ValidationError("email is required"));
Logger.LogWarning("[Admin] CreateUser | Email={Email} | By={User}", request.Email, Client.Email);
return CallProc("spAdminUsers", "create", new
{
email = request.Email.Trim(),
displayName = request.DisplayName?.Trim(),
clientId = request.ClientId,
role = request.Role ?? "User"
}, ct);
}
/// <summary>
/// Update user.
/// </summary>
[HttpPut("{userId}")]
public Task<IActionResult> Update(string userId, [FromBody] UpdateUserRequest request, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateUser | Id={Id} | By={User}", userId, Client.Email);
return CallProc("spAdminUsers", "update", new
{
userId,
displayName = request?.DisplayName?.Trim(),
status = request?.Status
}, ct);
}
/// <summary>
/// Deactivate user (soft delete).
/// </summary>
[HttpDelete("{userId}")]
public Task<IActionResult> Delete(string userId, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteUser | Id={Id} | By={User}", userId, Client.Email);
return CallProc("spAdminUsers", "delete", new { userId }, ct);
}
/// <summary>
/// Link user to client with role.
/// </summary>
[HttpPost("{userId}/clients")]
public Task<IActionResult> LinkToClient(string userId, [FromBody] LinkUserRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.ClientId))
return Task.FromResult(ValidationError("clientId is required"));
Logger.LogWarning("[Admin] LinkUser | UserId={UserId} ClientId={ClientId} | By={User}",
userId, request.ClientId, Client.Email);
return CallProc("spAdminUsers", "linkToClient", new
{
userId,
clientId = request.ClientId,
role = request.Role ?? "User"
}, ct);
}
/// <summary>
/// Unlink user from client.
/// </summary>
[HttpDelete("{userId}/clients/{clientId}")]
public Task<IActionResult> UnlinkFromClient(string userId, string clientId, CancellationToken ct)
{
Logger.LogWarning("[Admin] UnlinkUser | UserId={UserId} ClientId={ClientId} | By={User}",
userId, clientId, Client.Email);
return CallProc("spAdminUsers", "unlinkFromClient", new { userId, clientId }, ct);
}
}
// DTOs
public sealed class CreateUserRequest
{
public string? Email { get; set; }
public string? DisplayName { get; set; }
public string? ClientId { get; set; }
public string? Role { get; set; }
}
public sealed class UpdateUserRequest
{
public string? DisplayName { get; set; }
public string? Status { get; set; }
}
public sealed class LinkUserRequest
{
public string? ClientId { get; set; }
public string? Role { get; set; }
}

View File

@@ -0,0 +1,177 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using System.Data;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Internal document management — scope='internal' only.
/// Client-scoped documents are managed through the Gateway.
///
/// POST /api/documents/list - List internal documents
/// POST /api/documents - Upload internal document
/// GET /api/documents/{id}/download
/// DELETE /api/documents/{id}
/// </summary>
[ApiController]
[Route("api/documents")]
public sealed class DocumentController : AdminControllerBase
{
private readonly IConfiguration _config;
public DocumentController(SqlService sql, ClientContext client, IConfiguration config, ILogger<DocumentController> log)
: base(sql, client, log)
{
_config = config;
}
// ── POST /api/documents/list ─────────────────────────────────────────────
[HttpPost("list")]
public async Task<IActionResult> List([FromBody] JsonElement body, CancellationToken ct)
{
try
{
// Always internal scope for Management API
var rqst = JsonSerializer.Serialize(new { scope = "internal" });
var result = await Sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
Logger.LogError(ex, "Document list failed");
return StatusCode(500, new { ok = false, message = ex.Message });
}
}
// ── POST /api/documents ──────────────────────────────────────────────────
[HttpPost]
[RequestSizeLimit(52_428_800)]
public async Task<IActionResult> Upload(
IFormFile file,
[FromForm] string category,
[FromForm] string? description = null,
CancellationToken ct = default)
{
if (file == null || file.Length == 0)
return BadRequest(new { ok = false, message = "No file provided" });
try
{
byte[] fileBytes;
using (var ms = new MemoryStream())
{
await file.CopyToAsync(ms, ct);
fileBytes = ms.ToArray();
}
var rqst = JsonSerializer.Serialize(new
{
docFileName = file.FileName,
docMimeType = file.ContentType,
docFileSize = file.Length,
docCategory = category,
docDescription = description,
docUploadedBy = Client.Email,
docScope = "internal", // Management API = internal only
docCltId = (string?)null
});
var result = await ExecUploadAsync(rqst, fileBytes, ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
Logger.LogError(ex, "Document upload failed: {FileName}", file?.FileName);
return StatusCode(500, new { ok = false, message = ex.Message });
}
}
// ── GET /api/documents/{id}/download ─────────────────────────────────────
[HttpGet("{id:long}/download")]
public async Task<IActionResult> Download(long id, CancellationToken ct = default)
{
try
{
var cs = _config.GetConnectionString("Sql")
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = 60
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = JsonSerializer.Serialize(new { docId = id }) });
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
return NotFound(new { ok = false, message = "Document not found" });
var fileName = reader.GetString(reader.GetOrdinal("docFileName"));
var mimeType = reader.GetString(reader.GetOrdinal("docMimeType"));
var content = (byte[])reader["docContent"];
return File(content, mimeType, fileName);
}
catch (Exception ex)
{
Logger.LogError(ex, "Document download failed: docId={DocId}", id);
return StatusCode(500, new { ok = false, message = ex.Message });
}
}
// ── DELETE /api/documents/{id} ───────────────────────────────────────────
[HttpDelete("{id:long}")]
public async Task<IActionResult> Delete(long id, CancellationToken ct = default)
{
try
{
Logger.LogInformation("[Documents] Delete docId={DocId} by {User}", id, Client.Email);
var rqst = JsonSerializer.Serialize(new { docId = id });
var result = await Sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct);
return Content(result, "application/json");
}
catch (Exception ex)
{
Logger.LogError(ex, "Document delete failed: docId={DocId}", id);
return StatusCode(500, new { ok = false, message = ex.Message });
}
}
// ─── Upload helper: binary @filecontent passed separately ────────────────
private async Task<string> ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct)
{
var cs = _config.GetConnectionString("Sql")
?? throw new InvalidOperationException("Missing ConnectionStrings:Sql");
await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand("dbo.usp_Document", conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = 60
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst });
cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent });
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
{
Direction = ParameterDirection.Output
};
cmd.Parameters.Add(pResp);
await cmd.ExecuteNonQueryAsync(ct);
return pResp.Value as string
?? JsonSerializer.Serialize(new { ok = false, message = "No response from database" });
}
}

View File

@@ -0,0 +1,67 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for campaign performance reporting and intelligence.
/// Provides normalized metrics across all providers (Google, Meta, TikTok).
/// Requires Admin role.
///
/// ENDPOINTS:
/// POST /api/admin/reporting/summary - KPI summary across all campaigns
/// POST /api/admin/reporting/campaigns - Per-campaign performance metrics
/// GET /api/admin/reporting/campaigns/{id} - Detailed metrics for one initiative
/// POST /api/admin/reporting/insights - Optimization recommendations
/// POST /api/admin/reporting/analysis - Post-campaign analysis data
/// </summary>
[ApiController]
[Route("api/admin/reporting")]
public sealed class AdminReportingController : AdminControllerBase
{
public AdminReportingController(SqlService sql, ClientContext client, ILogger<AdminReportingController> log)
: base(sql, client, log) { }
/// <summary>
/// KPI summary: totals for spend, impressions, clicks, conversions, CTR, CPC, ROAS.
/// Body: { dateFrom?, dateTo?, clientId? }
/// </summary>
[HttpPost("summary")]
public Task<IActionResult> Summary([FromBody] JsonElement body, CancellationToken ct)
=> CallProc("spAdminReporting", "summary", body.ToString(), ct);
/// <summary>
/// Per-campaign performance list with channel breakdowns.
/// Body: { status?, clientId?, dateFrom?, dateTo?, sortBy?, sortDir?, page?, pageSize? }
/// </summary>
[HttpPost("campaigns")]
public Task<IActionResult> Campaigns([FromBody] JsonElement body, CancellationToken ct)
=> CallProc("spAdminReporting", "campaigns", body.ToString(), ct);
/// <summary>
/// Detailed metrics for a single initiative with daily time-series
/// and per-channel breakdowns.
/// </summary>
[HttpGet("campaigns/{initiativeId:long}")]
public Task<IActionResult> CampaignDetail(long initiativeId, CancellationToken ct)
=> CallProc("spAdminReporting", "detail", new { initiativeId }, ct);
/// <summary>
/// Optimization insights and recommendations.
/// Body: { severity?, clientId? }
/// </summary>
[HttpPost("insights")]
public Task<IActionResult> Insights([FromBody] JsonElement body, CancellationToken ct)
=> CallProc("spAdminReporting", "insights", body.ToString(), ct);
/// <summary>
/// Post-campaign analysis: completed campaigns with ROI, cost-efficiency,
/// and channel-level performance comparisons.
/// Body: { clientId?, dateFrom?, dateTo? }
/// </summary>
[HttpPost("analysis")]
public Task<IActionResult> Analysis([FromBody] JsonElement body, CancellationToken ct)
=> CallProc("spAdminReporting", "analysis", body.ToString(), ct);
}

View File

@@ -0,0 +1,67 @@
using Management.Data;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers;
/// <summary>
/// Public help content endpoint — anonymous, no auth required.
/// Returns 200 with default message when key not found, so client
/// never needs to handle a 404.
/// </summary>
[ApiController]
[Route("api/help")]
public class HelpController : ControllerBase
{
private readonly SqlService _sql;
private readonly ILogger<HelpController> _logger;
public HelpController(SqlService sql, ILogger<HelpController> logger)
{
_sql = sql;
_logger = logger;
}
/// <summary>
/// GET /api/help/{key}
/// Returns active help content for the given key, or a friendly
/// default if no content has been authored yet.
/// </summary>
[HttpGet("{key}")]
public async Task<IActionResult> GetHelp(string key, CancellationToken ct)
{
try
{
var rqst = JsonSerializer.Serialize(new { helpKey = key });
var resp = await _sql.ExecProcAsync("dbo.spHelp", "get", rqst, ct: ct);
if (!string.IsNullOrWhiteSpace(resp))
{
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var ok) && ok.GetBoolean())
return Content(resp, "application/json");
}
// Key not found — return 200 with friendly default so clients
// don't need special 404 handling
return Ok(new
{
ok = true,
title = "Help",
body = "<p>No information available for this topic yet.</p>"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving help content for key: {Key}", key);
return Ok(new
{
ok = true,
title = "Help",
body = "<p>No information available for this topic yet.</p>"
});
}
}
}

View File

@@ -21,10 +21,13 @@ public sealed class MonitoringController : ControllerBase
private readonly ClientContext _client;
private readonly ILogger<MonitoringController> _log;
public MonitoringController(SqlService sql, ClientContext client, ILogger<MonitoringController> log)
private readonly Management.Services.GraphService _graph;
public MonitoringController(SqlService sql, ClientContext client, Management.Services.GraphService graph, ILogger<MonitoringController> log)
{
_sql = sql;
_client = client;
_graph = graph;
_log = log;
}
@@ -53,10 +56,10 @@ public sealed class MonitoringController : ControllerBase
/// <summary>
/// Detailed system statistics.
/// </summary>
[HttpGet("stats")]
public async Task<IActionResult> Stats([FromQuery] int hours = 24, CancellationToken ct = default)
[HttpPost("stats")]
public async Task<IActionResult> Stats([FromBody] JsonElement body, CancellationToken ct)
{
var rqst = JsonSerializer.Serialize(new { hours });
var rqst = body.ToString();
try
{
@@ -73,4 +76,47 @@ public sealed class MonitoringController : ControllerBase
return StatusCode(500, new { ok = false, error = "Stats failed", detail = ex.Message });
}
}
/// <summary>
/// Staff user list — distinct staff who have ever performed an action,
/// derived directly from tbAdminActivity.
/// </summary>
[HttpGet("staff")]
public async Task<IActionResult> Staff(CancellationToken ct)
{
try
{
var json = await _sql.ExecProcAsync("dbo.spAdminActivity", "distinct-staff", "{}", ct: ct);
return Content(json, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "[Monitoring] Staff list error");
return StatusCode(500, new { ok = false, error = "Staff list failed", detail = ex.Message });
}
}
/// <summary>
/// Admin activity log — all mutating requests by staff members.
/// Accessible to Staff.Admin and Staff.Tech.
/// Body: { oid?, dateFrom?, dateTo?, page?, pageSize? }
/// </summary>
[HttpPost("activity")]
public async Task<IActionResult> Activity([FromBody] JsonElement body, CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync("dbo.spAdminActivity", "list", body.ToString(), ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "[Monitoring] Activity log error");
return StatusCode(500, new { ok = false, error = "Activity log failed", detail = ex.Message });
}
}
}

View File

@@ -1,33 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -9,11 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.18.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.4.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.4.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Microsoft.Graph" Version="5.103.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.16.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,5 +1,6 @@
using Management.Data;
using Management.Security;
using Management.Services;
var builder = WebApplication.CreateBuilder(args);
@@ -19,6 +20,12 @@ builder.Services.AddScoped<SqlService>();
builder.Services.AddScoped<ClientContext>();
builder.Services.AddHttpClient();
// Registration Function client (typed HttpClient)
builder.Services.AddHttpClient<RegistrationClient>();
// Graph API service — app-only credentials for org tenant user listing
builder.Services.AddSingleton<GraphService>();
var app = builder.Build();
// Middleware pipeline
@@ -47,7 +54,10 @@ app.MapGet("/", () => Results.Ok(new
{
clients = new[] { "GET/POST /api/admin/clients", "GET/PUT/DELETE /api/admin/clients/{id}" },
users = new[] { "GET/POST /api/admin/users", "GET/PUT/DELETE /api/admin/users/{id}" },
sessions = new[] { "GET /api/admin/sessions", "POST /api/admin/sessions/{id}/revoke" }
sessions = new[] { "GET /api/admin/sessions", "POST /api/admin/sessions/{id}/revoke" },
templates = new[] { "GET/POST /api/admin/templates", "GET/PUT/DELETE /api/admin/templates/{id}", "GET /api/admin/templates/categories" },
objectives = new[] { "GET/POST /api/admin/objectives", "GET/PUT/DELETE /api/admin/objectives/{id}" },
reporting = new[] { "GET /api/admin/reporting/summary", "GET /api/admin/reporting/campaigns", "GET /api/admin/reporting/campaigns/{id}", "GET /api/admin/reporting/insights", "GET /api/admin/reporting/analysis" }
}
}
}));
@@ -55,6 +65,9 @@ app.MapGet("/", () => Results.Ok(new
// Authentication middleware
app.UseMiddleware<ClientAuthMiddleware>();
// Activity logging — fires after auth so ClientContext is populated
app.UseMiddleware<ActivityLoggingMiddleware>();
app.UseAuthorization();
app.MapControllers();

View File

@@ -1,181 +0,0 @@
-- ============================================================
-- spAdminClients: Client (organization) management
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spAdminClients]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: create
------------------------------------------------------------------------
IF @action = 'create'
BEGIN
DECLARE @cName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
IF @cName IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientName is required"}';
RETURN;
END
IF EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltName = @cName)
BEGIN
SET @resp = N'{"ok":false,"error":"Client name already exists"}';
RETURN;
END
DECLARE @cId UNIQUEIDENTIFIER = NEWID();
INSERT INTO dbo.tbClient (cltId, cltName, cltStatus)
VALUES (@cId, @cName, 'Active');
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
@cId AS clientId,
@cName AS clientName,
'Active' AS status
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: get
------------------------------------------------------------------------
IF @action = 'get'
BEGIN
DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
IF @gId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientId is required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @gId)
BEGIN
SET @resp = N'{"ok":false,"error":"Client not found"}';
RETURN;
END
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
c.cltId AS clientId,
c.cltName AS clientName,
c.cltStatus AS status,
c.cltCreatedUtc AS createdAt,
(SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount,
(SELECT COUNT(*) FROM dbo.tbAdAccount WHERE accCltId = c.cltId) AS accountCount
FROM dbo.tbClient c WHERE c.cltId = @gId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: list
------------------------------------------------------------------------
IF @action = 'list'
BEGIN
DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1);
DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50);
DECLARE @clients NVARCHAR(MAX);
SELECT @clients = (
SELECT
c.cltId AS clientId,
c.cltName AS clientName,
c.cltStatus AS status,
c.cltCreatedUtc AS createdAt,
(SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount
FROM dbo.tbClient c
WHERE @lStatus IS NULL OR c.cltStatus = @lStatus
ORDER BY c.cltName
OFFSET (@lPage - 1) * @lPageSize ROWS
FETCH NEXT @lPageSize ROWS ONLY
FOR JSON PATH
);
DECLARE @lTotal INT;
SELECT @lTotal = COUNT(*) FROM dbo.tbClient WHERE @lStatus IS NULL OR cltStatus = @lStatus;
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
JSON_QUERY(ISNULL(@clients, '[]')) AS clients,
@lTotal AS totalCount,
@lPage AS page,
@lPageSize AS pageSize
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: update
------------------------------------------------------------------------
IF @action = 'update'
BEGIN
DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @uName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
IF @uId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientId is required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @uId)
BEGIN
SET @resp = N'{"ok":false,"error":"Client not found"}';
RETURN;
END
UPDATE dbo.tbClient
SET cltName = ISNULL(@uName, cltName),
cltStatus = ISNULL(@uStatus, cltStatus)
WHERE cltId = @uId;
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
cltId AS clientId,
cltName AS clientName,
cltStatus AS status
FROM dbo.tbClient WHERE cltId = @uId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: delete (soft delete)
------------------------------------------------------------------------
IF @action = 'delete'
BEGIN
DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
IF @dId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientId is required"}';
RETURN;
END
UPDATE dbo.tbClient SET cltStatus = 'Inactive' WHERE cltId = @dId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -1,111 +0,0 @@
-- ============================================================
-- spAdminSessions: Session management
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spAdminSessions]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: list
------------------------------------------------------------------------
IF @action = 'list'
BEGIN
DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @lUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
DECLARE @lActiveOnly BIT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.activeOnly') AS BIT), 1);
DECLARE @lLimit INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.limit') AS INT), 100);
DECLARE @sessions NVARCHAR(MAX);
SELECT @sessions = (
SELECT TOP (@lLimit)
s.sesId AS sessionId,
u.usrId AS userId,
u.usrEmail AS userEmail,
u.usrDisplayName AS displayName,
c.cltId AS clientId,
c.cltName AS clientName,
s.sesCreatedUtc AS createdAt,
s.sesExpiresUtc AS expiresAt,
s.sesLastActivityUtc AS lastActivity,
s.sesIpAddress AS ipAddress,
s.sesIsRevoked AS isRevoked
FROM dbo.tbSession s
JOIN dbo.tbUser u ON u.usrId = s.sesUsrId
JOIN dbo.tbClient c ON c.cltId = s.sesCltId
WHERE (@lClientId IS NULL OR c.cltId = @lClientId)
AND (@lUserId IS NULL OR u.usrId = @lUserId)
AND (@lActiveOnly = 0 OR (s.sesIsRevoked = 0 AND s.sesExpiresUtc > SYSUTCDATETIME()))
ORDER BY s.sesLastActivityUtc DESC
FOR JSON PATH
);
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
JSON_QUERY(ISNULL(@sessions, '[]')) AS sessions
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: revoke
------------------------------------------------------------------------
IF @action = 'revoke'
BEGIN
DECLARE @rSessionId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.sessionId'));
IF @rSessionId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"sessionId is required"}';
RETURN;
END
UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesId = @rSessionId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: revokeAllForUser
------------------------------------------------------------------------
IF @action = 'revokeAllForUser'
BEGIN
DECLARE @raUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
IF @raUserId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId is required"}';
RETURN;
END
UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesUsrId = @raUserId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: cleanup
------------------------------------------------------------------------
IF @action = 'cleanup'
BEGIN
DECLARE @daysOld INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.daysOld') AS INT), 30);
DELETE FROM dbo.tbSession
WHERE sesExpiresUtc < DATEADD(DAY, -@daysOld, SYSUTCDATETIME());
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsDeleted FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -1,288 +0,0 @@
-- ============================================================
-- spAdminUsers: User management
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spAdminUsers]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: create
------------------------------------------------------------------------
IF @action = 'create'
BEGIN
DECLARE @cEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
DECLARE @cDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
DECLARE @cClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @cRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User');
IF @cEmail IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"email is required"}';
RETURN;
END
IF EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrEmail = @cEmail)
BEGIN
SET @resp = N'{"ok":false,"error":"User with this email already exists"}';
RETURN;
END
IF @cClientId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @cClientId)
BEGIN
SET @resp = N'{"ok":false,"error":"Client not found"}';
RETURN;
END
DECLARE @cUserId UNIQUEIDENTIFIER = NEWID();
DECLARE @cEntraSub NVARCHAR(100) = 'pending-' + CAST(@cUserId AS NVARCHAR(50));
INSERT INTO dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus)
VALUES (@cUserId, @cEntraSub, 'Pending', @cEntraSub, @cEmail, @cDisplayName, 'Active');
IF @cClientId IS NOT NULL
BEGIN
INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
VALUES (@cUserId, @cClientId, @cRole);
END
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
@cUserId AS userId,
@cEmail AS email,
@cDisplayName AS displayName,
@cClientId AS clientId,
@cRole AS [role]
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: get
------------------------------------------------------------------------
IF @action = 'get'
BEGIN
DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
IF @gId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId is required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @gId)
BEGIN
SET @resp = N'{"ok":false,"error":"User not found"}';
RETURN;
END
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
u.usrId AS userId,
u.usrEmail AS email,
u.usrDisplayName AS displayName,
u.usrStatus AS status,
u.usrCreatedUtc AS createdAt,
(
SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role]
FROM dbo.tbUserClientRole r
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId
WHERE r.ucrUsrId = u.usrId
FOR JSON PATH
) AS clients
FROM dbo.tbUser u WHERE u.usrId = @gId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: list
------------------------------------------------------------------------
IF @action = 'list'
BEGIN
DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1);
DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50);
DECLARE @users NVARCHAR(MAX);
SELECT @users = (
SELECT
u.usrId AS userId,
u.usrEmail AS email,
u.usrDisplayName AS displayName,
u.usrStatus AS status,
u.usrCreatedUtc AS createdAt,
(
SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role]
FROM dbo.tbUserClientRole r
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId
WHERE r.ucrUsrId = u.usrId
FOR JSON PATH
) AS clients
FROM dbo.tbUser u
WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus)
AND (@lClientId IS NULL OR EXISTS (
SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId
))
ORDER BY u.usrEmail
OFFSET (@lPage - 1) * @lPageSize ROWS
FETCH NEXT @lPageSize ROWS ONLY
FOR JSON PATH
);
DECLARE @lTotal INT;
SELECT @lTotal = COUNT(*)
FROM dbo.tbUser u
WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus)
AND (@lClientId IS NULL OR EXISTS (
SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId
));
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
JSON_QUERY(ISNULL(@users, '[]')) AS users,
@lTotal AS totalCount,
@lPage AS page,
@lPageSize AS pageSize
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: update
------------------------------------------------------------------------
IF @action = 'update'
BEGIN
DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
DECLARE @uDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
IF @uId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId is required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @uId)
BEGIN
SET @resp = N'{"ok":false,"error":"User not found"}';
RETURN;
END
UPDATE dbo.tbUser
SET usrDisplayName = ISNULL(@uDisplayName, usrDisplayName),
usrStatus = ISNULL(@uStatus, usrStatus)
WHERE usrId = @uId;
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
usrId AS userId,
usrEmail AS email,
usrDisplayName AS displayName,
usrStatus AS status
FROM dbo.tbUser WHERE usrId = @uId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: delete (soft delete)
------------------------------------------------------------------------
IF @action = 'delete'
BEGIN
DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
IF @dId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId is required"}';
RETURN;
END
UPDATE dbo.tbUser SET usrStatus = 'Inactive' WHERE usrId = @dId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: linkToClient
------------------------------------------------------------------------
IF @action = 'linkToClient'
BEGIN
DECLARE @luUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
DECLARE @luClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @luRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User');
IF @luUserId IS NULL OR @luClientId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId and clientId are required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @luUserId)
BEGIN
SET @resp = N'{"ok":false,"error":"User not found"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @luClientId)
BEGIN
SET @resp = N'{"ok":false,"error":"Client not found"}';
RETURN;
END
IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId)
BEGIN
UPDATE dbo.tbUserClientRole
SET ucrRole = @luRole
WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'updated' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
VALUES (@luUserId, @luClientId, @luRole);
SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'created' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: unlinkFromClient
------------------------------------------------------------------------
IF @action = 'unlinkFromClient'
BEGIN
DECLARE @ruUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
DECLARE @ruClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
IF @ruUserId IS NULL OR @ruClientId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId and clientId are required"}';
RETURN;
END
DELETE FROM dbo.tbUserClientRole
WHERE ucrUsrId = @ruUserId AND ucrCltId = @ruClientId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -1,106 +0,0 @@
-- ============================================================
-- spMonitoring: System health and statistics
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spMonitoring]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: health
-- System health overview
------------------------------------------------------------------------
IF @action = 'health'
BEGIN
DECLARE @clientCount INT, @userCount INT, @sessionCount INT, @logCount24h INT;
SELECT @clientCount = COUNT(*) FROM dbo.tbClient WHERE cltStatus = 'Active';
SELECT @userCount = COUNT(*) FROM dbo.tbUser WHERE usrStatus = 'Active';
SELECT @sessionCount = COUNT(*) FROM dbo.tbSession WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME();
-- Check if tbAdpApiLog exists (may not be in all installations)
IF OBJECT_ID('dbo.tbAdpApiLog', 'U') IS NOT NULL
EXEC sp_executesql N'SELECT @cnt = COUNT(*) FROM dbo.tbAdpApiLog WHERE createdUtc > DATEADD(HOUR, -24, SYSUTCDATETIME())',
N'@cnt INT OUTPUT', @cnt = @logCount24h OUTPUT;
ELSE
SET @logCount24h = 0;
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
@clientCount AS activeClients,
@userCount AS activeUsers,
@sessionCount AS activeSessions,
@logCount24h AS apiCalls24h,
SYSUTCDATETIME() AS serverTimeUtc
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: stats
-- Detailed statistics
------------------------------------------------------------------------
IF @action = 'stats'
BEGIN
DECLARE @hours INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.hours') AS INT), 24);
-- Clients by status
DECLARE @clientsByStatus NVARCHAR(MAX);
SELECT @clientsByStatus = (
SELECT cltStatus AS status, COUNT(*) AS [count]
FROM dbo.tbClient
GROUP BY cltStatus
FOR JSON PATH
);
-- Users by status
DECLARE @usersByStatus NVARCHAR(MAX);
SELECT @usersByStatus = (
SELECT usrStatus AS status, COUNT(*) AS [count]
FROM dbo.tbUser
GROUP BY usrStatus
FOR JSON PATH
);
-- Sessions stats
DECLARE @activeSessions INT, @expiredSessions INT, @revokedSessions INT;
SELECT @activeSessions = COUNT(*) FROM dbo.tbSession
WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME();
SELECT @expiredSessions = COUNT(*) FROM dbo.tbSession
WHERE sesIsRevoked = 0 AND sesExpiresUtc <= SYSUTCDATETIME();
SELECT @revokedSessions = COUNT(*) FROM dbo.tbSession
WHERE sesIsRevoked = 1;
-- Recent registrations (last 7 days)
DECLARE @recentClients INT, @recentUsers INT;
SELECT @recentClients = COUNT(*) FROM dbo.tbClient
WHERE cltCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME());
SELECT @recentUsers = COUNT(*) FROM dbo.tbUser
WHERE usrCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME());
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
JSON_QUERY(ISNULL(@clientsByStatus, '[]')) AS clientsByStatus,
JSON_QUERY(ISNULL(@usersByStatus, '[]')) AS usersByStatus,
@activeSessions AS activeSessions,
@expiredSessions AS expiredSessions,
@revokedSessions AS revokedSessions,
@recentClients AS newClientsLast7Days,
@recentUsers AS newUsersLast7Days,
SYSUTCDATETIME() AS serverTimeUtc
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -1,151 +0,0 @@
-- ============================================================
-- spOnboarding: User/Client registration
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spOnboarding]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: status
-- Check if user is registered and has client access
------------------------------------------------------------------------
IF @action = 'status'
BEGIN
DECLARE @sSubject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), '');
DECLARE @sEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
DECLARE @sUserId UNIQUEIDENTIFIER;
DECLARE @sUserEmail NVARCHAR(256);
SELECT @sUserId = usrId, @sUserEmail = usrEmail
FROM dbo.tbUser
WHERE usrEntraSub = @sSubject;
-- User doesn't exist
IF @sUserId IS NULL
BEGIN
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
CAST(0 AS BIT) AS isRegistered,
@sEmail AS email
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
-- Check for client access
DECLARE @clients NVARCHAR(MAX);
SELECT @clients = (
SELECT
c.cltId AS clientId,
c.cltName AS clientName,
r.ucrRole AS [role]
FROM dbo.tbUserClientRole r
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId AND c.cltStatus = 'Active'
WHERE r.ucrUsrId = @sUserId
FOR JSON PATH
);
IF @clients IS NULL OR @clients = '[]'
BEGIN
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
CAST(0 AS BIT) AS isRegistered,
@sUserId AS userId,
@sUserEmail AS email
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
CAST(1 AS BIT) AS isRegistered,
@sUserId AS userId,
@sUserEmail AS email,
JSON_QUERY(@clients) AS clients
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: register
-- Creates client + links user as Admin
------------------------------------------------------------------------
IF @action = 'register'
BEGIN
DECLARE @provider VARCHAR(30) = NULLIF(JSON_VALUE(@j, '$.provider'), '');
DECLARE @subject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), '');
DECLARE @email NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
DECLARE @displayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
DECLARE @clientName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
-- Validation
IF @provider IS NULL OR @subject IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"provider and subject are required"}';
RETURN;
END
IF @clientName IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientName is required"}';
RETURN;
END
-- Find or create user
DECLARE @userId UNIQUEIDENTIFIER;
SELECT @userId = usrId
FROM dbo.tbUser
WHERE usrEntraSub = @subject;
IF @userId IS NULL
BEGIN
SET @userId = NEWID();
INSERT dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus)
VALUES (@userId, @subject, @provider, @subject, @email, @displayName, 'Active');
END
-- Check if user already has client access
IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @userId)
BEGIN
SET @resp = N'{"ok":false,"error":"User is already registered"}';
RETURN;
END
-- Create client
DECLARE @clientId UNIQUEIDENTIFIER = NEWID();
INSERT dbo.tbClient (cltId, cltName, cltStatus)
VALUES (@clientId, @clientName, 'Active');
-- Link user as Admin
INSERT dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
VALUES (@userId, @clientId, 'Admin');
-- Return success
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
@userId AS userId,
@clientId AS clientId,
@clientName AS clientName,
'Admin' AS [role]
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -0,0 +1,119 @@
using Management.Data;
using Management.Security;
using System.Text.Json;
namespace Management.Security;
/// <summary>
/// Logs all mutating (non-GET) requests to tbAdminActivity when a staff JWT is present.
///
/// Enables request body buffering so a compact filter summary can be extracted
/// after the controller has run. Fire-and-forget — never delays the response.
/// Registered after ClientAuthMiddleware so ClientContext is populated.
/// </summary>
public sealed class ActivityLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ActivityLoggingMiddleware> _logger;
private static readonly string[] _skipPrefixes =
{
"/api/help", "/api/test", "/swagger", "/health"
};
// Body keys treated as filter context worth surfacing in the activity log.
// Pagination keys (page, pageSize) are intentionally excluded.
private static readonly string[] _filterKeys =
[
"search", "status", "clientId", "category", "network",
"dateFrom", "dateTo", "action", "ruleId", "initiativeId",
];
public ActivityLoggingMiddleware(RequestDelegate next, ILogger<ActivityLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
{
var method = context.Request.Method;
var isRead = HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
// Buffer request body before calling next so we can re-read it after
if (!isRead)
context.Request.EnableBuffering();
await _next(context);
if (isRead) return;
var oid = clientContext.ClientId;
if (string.IsNullOrWhiteSpace(oid) || clientContext.IsDevBypass) return;
var path = context.Request.Path.Value ?? "";
if (_skipPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return;
var statusCode = context.Response.StatusCode;
var filterSummary = await ReadFilterSummaryAsync(context.Request);
_ = Task.Run(async () =>
{
try
{
var rqst = JsonSerializer.Serialize(new
{
oid,
email = clientContext.Email,
displayName = clientContext.ClientName,
method,
path,
statusCode,
filter = filterSummary, // null for bodies with no recognised filter keys
});
await sql.ExecProcAsync("dbo.spAdminActivity", "log", rqst);
}
catch (Exception ex)
{
_logger.LogWarning("[Activity] Log failed: {Message}", ex.Message);
}
});
}
private async Task<string?> ReadFilterSummaryAsync(HttpRequest request)
{
if (request.ContentLength is null or 0) return null;
if (request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) != true) return null;
try
{
request.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(body)) return null;
using var doc = JsonDocument.Parse(body);
var parts = new List<string>();
foreach (var key in _filterKeys)
{
if (!doc.RootElement.TryGetProperty(key, out var val)) continue;
if (val.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) continue;
var str = val.ValueKind == JsonValueKind.String
? val.GetString()
: val.ToString();
if (!string.IsNullOrWhiteSpace(str))
parts.Add($"{key}={str}");
}
return parts.Count > 0 ? string.Join(" ", parts) : null;
}
catch
{
return null;
}
}
}

View File

@@ -12,10 +12,18 @@ namespace Management.Security;
/// Authentication middleware for Management API.
///
/// Auth paths:
/// - /api/onboarding/* → JWT (user may not have session yet)
/// - /api/admin/* → Session + Admin role
/// - /api/monitoring/* → Session + Admin role
/// - /api/test/* → Anonymous
/// - /api/onboarding/* → JWT (Entra, any staff role)
/// - /api/monitoring/* → JWT (Entra, Staff.Admin or Staff.Tech role)
/// - /api/admin/* → JWT (Entra, Staff.Admin role only)
/// - /api/registration/*→ JWT (Entra, Staff.Admin role only)
/// - /api/staff/* → JWT (Entra, Staff.Admin role only)
/// - /api/test/* → Anonymous
/// - /api/documents/* → JWT (Entra, Staff.Admin or Staff.Tech role)
/// - /api/help/* → Anonymous
///
/// App Role values (defined in Entra portal on the staff app registration):
/// Staff.Admin → full platform access
/// Staff.Tech → monitoring/health only
/// </summary>
public sealed class ClientAuthMiddleware
{
@@ -28,9 +36,10 @@ public sealed class ClientAuthMiddleware
"/", "/health"
};
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" };
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test", "/api/help" };
private static readonly string[] _jwtOnlyPrefixes = { "/api/onboarding" };
private static readonly string[] _adminRequiredPrefixes = { "/api/monitoring", "/api/admin" };
private static readonly string[] _staffRequiredPrefixes = { "/api/monitoring", "/api/documents" }; // Admin or Tech
private static readonly string[] _adminRequiredPrefixes = { "/api/admin", "/api/registration", "/api/staff" }; // Admin only
private static ConfigurationManager<OpenIdConnectConfiguration>? _oidcConfigManager;
private static readonly object _oidcLock = new();
@@ -75,10 +84,36 @@ public sealed class ClientAuthMiddleware
return;
}
// Admin-required paths
// Staff-required paths (Admin or Tech role) — monitoring/health
// Try session auth (client session tokens for CIAM users).
// then fall back to direct JWT for service-to-service calls.
if (IsStaffRequiredPath(path))
{
var staffAuthed = await TrySessionAuthAsync(context, clientContext, sql)
|| await TryJwtAuthAsync(context, clientContext);
if (staffAuthed)
{
if (!clientContext.IsStaff)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Staff access required" });
return;
}
await _next(context);
return;
}
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid staff authentication required" });
return;
}
// Admin-required paths (Admin role only)
if (IsAdminRequiredPath(path))
{
if (await TrySessionAuthAsync(context, clientContext, sql))
var adminAuthed = await TrySessionAuthAsync(context, clientContext, sql)
|| await TryJwtAuthAsync(context, clientContext);
if (adminAuthed)
{
if (!clientContext.IsAdmin)
{
@@ -86,13 +121,12 @@ public sealed class ClientAuthMiddleware
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Admin access required" });
return;
}
await _next(context);
return;
}
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin session required" });
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin authentication required" });
return;
}
@@ -113,6 +147,9 @@ public sealed class ClientAuthMiddleware
private static bool IsJwtOnlyPath(string path) =>
_jwtOnlyPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
private static bool IsStaffRequiredPath(string path) =>
_staffRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
private static bool IsAdminRequiredPath(string path) =>
_adminRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
@@ -164,8 +201,9 @@ public sealed class ClientAuthMiddleware
try
{
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var validateProc = "dbo.spClientSession"; // Staff use JWT Bearer; only client sessions exist
var resp = await sql.ExecProcAsync(validateProc, "validate", rqst, ct: context.RequestAborted);
if (string.IsNullOrWhiteSpace(resp))
return false;
@@ -178,12 +216,20 @@ public sealed class ClientAuthMiddleware
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
clientContext.PlatformClientId = clientContext.ClientId;
// Admin sessions return adminId; client sessions return clientId — handle both
var clientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
if (string.IsNullOrWhiteSpace(clientId))
clientId = data.TryGetProperty("adminId", out var aid) ? aid.GetString() : null;
clientContext.ClientId = clientId;
clientContext.PlatformClientId = clientId;
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
// IsStaff is computed from Role in ClientContext — no assignment needed
return clientContext.IsAuthenticated;
}
@@ -207,9 +253,9 @@ public sealed class ClientAuthMiddleware
if (string.IsNullOrWhiteSpace(token))
return false;
var tenantId = _config["Auth:EntraId:TenantId"];
var clientId = _config["Auth:EntraId:ClientId"];
var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/";
var tenantId = _config["Auth:Staff:TenantId"];
var clientId = _config["Auth:Staff:ClientId"];
var instance = _config["Auth:Staff:Instance"] ?? "https://usimclients.ciamlogin.com/";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
return false;
@@ -217,28 +263,44 @@ public sealed class ClientAuthMiddleware
try
{
var handler = new JwtSecurityTokenHandler();
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
// Disable default claim type remapping so JWT claim names (roles, oid, etc.)
// are preserved as-is. Without this, "roles" is remapped to ClaimTypes.Role
// and FindAll("roles") returns empty — causing IsAdmin to be false.
handler.InboundClaimTypeMap.Clear();
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
var metadataAddress = $"{authority}/.well-known/openid-configuration";
var mgr = GetOrCreateConfigManager(metadataAddress);
var mgr = GetOrCreateConfigManager(metadataAddress);
var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted);
var validationParams = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
ValidateAudience = true,
ValidAudiences = new[] { clientId, $"api://{clientId}" },
ValidateLifetime = true,
ValidateIssuer = true,
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
ValidateAudience = true,
ValidAudiences = new[] { clientId, $"api://{clientId}" },
ValidateLifetime = true,
IssuerSigningKeys = openIdConfig.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(5)
ClockSkew = TimeSpan.FromMinutes(5)
};
var principal = handler.ValidateToken(token, validationParams, out _);
clientContext.ClientId = principal.FindFirstValue("oid") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
clientContext.Email = principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue(ClaimTypes.Email);
clientContext.ClientName = principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name);
// Map Entra App Role values to internal role names.
// Users with no recognized role get null — middleware rejects them.
var roles = principal.FindAll("roles").Select(c => c.Value).ToList();
var role = roles.Contains("Staff.Admin") ? "Admin"
: roles.Contains("Staff.Tech") ? "Tech"
: null; // no valid role assigned — reject
clientContext.ClientId = principal.FindFirstValue("oid")
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
clientContext.Email = principal.FindFirstValue("preferred_username")
?? principal.FindFirstValue(ClaimTypes.Email);
clientContext.ClientName = principal.FindFirstValue("name")
?? principal.FindFirstValue(ClaimTypes.Name);
clientContext.Role = role;
return clientContext.IsAuthenticated;
}

View File

@@ -16,5 +16,17 @@ public sealed class ClientContext
public bool IsDevBypass { get; set; }
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
public bool IsAdmin => string.Equals(Role, "Admin", StringComparison.OrdinalIgnoreCase);
/// <summary>Full platform access.</summary>
/// <summary>Full admin access — SuperAdmin or Admin role.</summary>
public bool IsAdmin =>
string.Equals(Role, "SuperAdmin", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Role, "Admin", StringComparison.OrdinalIgnoreCase);
/// <summary>Health monitoring and Tech Client access only.</summary>
public bool IsTech =>
string.Equals(Role, "Tech", StringComparison.OrdinalIgnoreCase);
/// <summary>Any authenticated staff member (SuperAdmin, Admin or Tech).</summary>
public bool IsStaff => IsAdmin || IsTech;
}

View File

@@ -0,0 +1,36 @@
using Azure.Identity;
using Microsoft.Graph;
namespace Management.Services;
/// <summary>
/// Wraps a Microsoft.Graph client authenticated with app-only (client credentials)
/// credentials against the org tenant.
///
/// Registered as a singleton in Program.cs — one GraphServiceClient per process.
/// </summary>
public sealed class GraphService
{
private readonly GraphServiceClient _client;
private readonly ILogger<GraphService> _log;
public GraphService(IConfiguration config, ILogger<GraphService> log)
{
_log = log;
var tenantId = config["Graph:TenantId"] ?? "";
var clientId = config["Graph:ClientId"] ?? "";
var clientSecret = config["Graph:ClientSecret"] ?? "";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
_log.LogWarning("[Graph] One or more Graph config values are missing (TenantId, ClientId, ClientSecret). " +
"GET /api/admin/access/users will return an error until these are set.");
}
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
_client = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
}
public GraphServiceClient Client => _client;
}

View File

@@ -0,0 +1,150 @@
using System.Text.Json;
namespace Management.Services;
/// <summary>
/// HTTP client for calling the Registration Azure Function.
///
/// Configuration (appsettings.json):
/// "Registration": {
/// "BaseUrl": "https://your-function-app.azurewebsites.net/api",
/// "FunctionKey": "your-function-key-here"
/// }
///
/// Registered in DI as a typed HttpClient.
/// </summary>
public class RegistrationClient
{
private readonly HttpClient _http;
private readonly ILogger<RegistrationClient> _log;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public RegistrationClient(HttpClient http, IConfiguration config, ILogger<RegistrationClient> log)
{
_http = http;
_log = log;
var baseUrl = config["Registration:BaseUrl"];
var functionKey = config["Registration:FunctionKey"];
if (!string.IsNullOrWhiteSpace(baseUrl))
{
try
{
_http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
}
catch (UriFormatException ex)
{
log.LogWarning(ex, "[RegistrationClient] Invalid BaseUrl: {BaseUrl}", baseUrl);
}
}
else
{
log.LogWarning("[RegistrationClient] Registration:BaseUrl not configured — registration proxy disabled");
}
// Function key sent as query param (Azure Functions default auth)
if (!string.IsNullOrWhiteSpace(functionKey))
{
// Store key for per-request query string injection
_functionKey = functionKey;
}
_log.LogInformation("[RegistrationClient] Configured. BaseUrl={BaseUrl} KeyPresent={HasKey}",
_http.BaseAddress, !string.IsNullOrWhiteSpace(functionKey));
}
private readonly string? _functionKey;
// ── API Methods ──
public async Task<JsonDocument?> GetPendingAsync(CancellationToken ct)
{
return await GetAsync("registration/pending", ct);
}
public async Task<JsonDocument?> GetByIdAsync(string registrationId, CancellationToken ct)
{
return await GetAsync($"registration/item/{registrationId}", ct);
}
public async Task<JsonDocument?> RejectAsync(string registrationId, string? reason, CancellationToken ct)
{
return await PostAsync($"registration/action/{registrationId}/reject", new { reason }, ct);
}
public async Task<JsonDocument?> CompleteAsync(string registrationId, string? platformClientId, CancellationToken ct)
{
return await PostAsync($"registration/action/{registrationId}/complete", new { platformClientId }, ct);
}
// ── Internal HTTP helpers ──
private async Task<JsonDocument?> GetAsync(string path, CancellationToken ct)
{
try
{
var url = AppendKey(path);
_log.LogInformation("[RegistrationClient] GET {Path}", path);
var response = await _http.GetAsync(url, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_log.LogWarning("[RegistrationClient] GET {Path} → {Status}: {Body}",
path, (int)response.StatusCode, body[..Math.Min(200, body.Length)]);
return null;
}
return JsonDocument.Parse(body);
}
catch (Exception ex)
{
_log.LogError(ex, "[RegistrationClient] GET {Path} failed", path);
return null;
}
}
private async Task<JsonDocument?> PostAsync(string path, object? payload, CancellationToken ct)
{
try
{
var url = AppendKey(path);
var json = payload != null ? JsonSerializer.Serialize(payload, JsonOpts) : "{}";
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
_log.LogInformation("[RegistrationClient] POST {Path}", path);
var response = await _http.PostAsync(url, content, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_log.LogWarning("[RegistrationClient] POST {Path} → {Status}: {Body}",
path, (int)response.StatusCode, body[..Math.Min(200, body.Length)]);
}
return JsonDocument.Parse(body);
}
catch (Exception ex)
{
_log.LogError(ex, "[RegistrationClient] POST {Path} failed", path);
return null;
}
}
private string AppendKey(string path)
{
if (string.IsNullOrWhiteSpace(_functionKey))
return path;
var separator = path.Contains('?') ? '&' : '?';
return $"{path}{separator}code={_functionKey}";
}
}

View File

@@ -1,13 +0,0 @@
namespace Management
{
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
}

View File

@@ -8,10 +8,44 @@
"AllowedHosts": "*",
"Auth": {
"AllowDevBypass": false,
"EntraId": {
"Instance": "https://login.microsoftonline.com/",
/*
* STAFF IDENTITY Entra External ID (dev) / Entra org tenant (prod)
*
* PRODUCTION MIGRATION: update these three environment variables only.
* No code changes required.
*
* Auth__Staff__Instance https://login.microsoftonline.com/
* Auth__Staff__TenantId new company org tenant ID
* Auth__Staff__ClientId staff app registration in org tenant
*
* DEV: CIAM tenant used as placeholder (staff/client login looks identical).
* The API-level audience isolation is real regardless of tenant.
*/
"Staff": {
"Instance": "https://usimclients.ciamlogin.com/",
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
"ClientId": "STAFF_APP_CLIENT_ID"
}
},
/*
* GRAPH API app-only credentials for reading Entra org tenant users.
* Used by AdminAccessController to list platform access users.
*
* TenantId and ClientId refer to the org tenant (thematrixpoint),
* NOT the CIAM tenant. ClientSecret must be injected via env var:
* Graph__ClientSecret = <secret> (Azure Container Apps env var)
*
* PREREQUISITES (one-time Entra portal steps):
* 1. App registration: AdPlatform Staff (b0f29246-...)
* 2. API permissions Microsoft Graph Application User.Read.All
* 3. Grant admin consent
* 4. Create a client secret copy value set Graph__ClientSecret env var
*/
"Graph": {
"TenantId": "0be4c23a-6941-4bdb-b397-a4faf88de4b3",
"ClientId": "b0f29246-91e7-4615-96db-5de9b6f8da2e",
"ClientSecret": ""
}
}