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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user