Initial import into Gitea

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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