Initial import into Gitea
This commit is contained in:
38
Management/Controllers/Admin/AdminCampaignsController.cs
Normal file
38
Management/Controllers/Admin/AdminCampaignsController.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
188
Management/Controllers/Admin/AdminClientDocumentsController.cs
Normal file
188
Management/Controllers/Admin/AdminClientDocumentsController.cs
Normal 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" });
|
||||
}
|
||||
}
|
||||
84
Management/Controllers/Admin/AdminClientUsersController.cs
Normal file
84
Management/Controllers/Admin/AdminClientUsersController.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
50
Management/Controllers/Admin/AdminHelpController.cs
Normal file
50
Management/Controllers/Admin/AdminHelpController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
112
Management/Controllers/Admin/AdminMetricSyncController.cs
Normal file
112
Management/Controllers/Admin/AdminMetricSyncController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
84
Management/Controllers/Admin/AdminModifiersController.cs
Normal file
84
Management/Controllers/Admin/AdminModifiersController.cs
Normal 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; }
|
||||
}
|
||||
120
Management/Controllers/Admin/AdminObjectiveMappingController.cs
Normal file
120
Management/Controllers/Admin/AdminObjectiveMappingController.cs
Normal 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; }
|
||||
}
|
||||
179
Management/Controllers/Admin/AdminRecommendationsController.cs
Normal file
179
Management/Controllers/Admin/AdminRecommendationsController.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
220
Management/Controllers/Admin/AdminTemplateConfigController.cs
Normal file
220
Management/Controllers/Admin/AdminTemplateConfigController.cs
Normal 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; }
|
||||
}
|
||||
115
Management/Controllers/Admin/AdminTemplatesController.cs
Normal file
115
Management/Controllers/Admin/AdminTemplatesController.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
177
Management/Controllers/Admin/DocumentController.cs
Normal file
177
Management/Controllers/Admin/DocumentController.cs
Normal 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" });
|
||||
}
|
||||
}
|
||||
67
Management/Controllers/AdminReportingController.cs
Normal file
67
Management/Controllers/AdminReportingController.cs
Normal 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);
|
||||
}
|
||||
67
Management/Controllers/HelpController.cs
Normal file
67
Management/Controllers/HelpController.cs
Normal 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>"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
119
Management/Security/ActivityLoggingMiddleware.cs
Normal file
119
Management/Security/ActivityLoggingMiddleware.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
36
Management/Services/GraphService.cs
Normal file
36
Management/Services/GraphService.cs
Normal 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;
|
||||
}
|
||||
150
Management/Services/RegistrationClient.cs
Normal file
150
Management/Services/RegistrationClient.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user