Add project files.

This commit is contained in:
Grae Jones
2026-02-03 15:04:37 -08:00
parent a4838b594d
commit 8e7e03702e
65 changed files with 6227 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for client (organization) management.
/// Requires Admin role.
///
/// 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
/// </summary>
[ApiController]
[Route("api/admin/clients")]
public sealed class AdminClientsController : AdminControllerBase
{
public AdminClientsController(SqlService sql, ClientContext client, ILogger<AdminClientsController> log)
: base(sql, client, log) { }
/// <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)
{
if (string.IsNullOrWhiteSpace(request?.ClientName))
return Task.FromResult(ValidationError("clientName is required"));
Logger.LogWarning("[Admin] CreateClient | Name={Name} | By={User}", request.ClientName, Client.Email);
return CallProc("spAdminClients", "create", new { clientName = request.ClientName.Trim() }, ct);
}
/// <summary>
/// Update client.
/// </summary>
[HttpPut("{clientId}")]
public Task<IActionResult> Update(string clientId, [FromBody] UpdateClientRequest request, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateClient | Id={Id} | By={User}", clientId, Client.Email);
return CallProc("spAdminClients", "update", new
{
clientId,
clientName = request?.ClientName?.Trim(),
status = request?.Status
}, ct);
}
/// <summary>
/// Deactivate client (soft delete).
/// </summary>
[HttpDelete("{clientId}")]
public Task<IActionResult> Delete(string clientId, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteClient | Id={Id} | By={User}", clientId, Client.Email);
return CallProc("spAdminClients", "delete", new { clientId }, ct);
}
}
// DTOs
public sealed class CreateClientRequest
{
public string? ClientName { get; set; }
}
public sealed class UpdateClientRequest
{
public string? ClientName { get; set; }
public string? Status { get; set; }
}

View File

@@ -0,0 +1,58 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Base class for admin controllers with shared functionality.
/// </summary>
public abstract class AdminControllerBase : ControllerBase
{
protected readonly SqlService Sql;
protected readonly ClientContext Client;
protected readonly ILogger Logger;
protected AdminControllerBase(SqlService sql, ClientContext client, ILogger logger)
{
Sql = sql;
Client = client;
Logger = logger;
}
/// <summary>
/// Execute stored procedure and return appropriate IActionResult.
/// </summary>
protected async Task<IActionResult> CallProc(string proc, string action, object rqst, CancellationToken ct)
{
try
{
var json = JsonSerializer.Serialize(rqst);
var resp = await Sql.ExecProcAsync($"dbo.{proc}", action, json, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
return Content(resp, "application/json");
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Operation failed";
return BadRequest(new { ok = false, error });
}
catch (Exception ex)
{
Logger.LogError(ex, "[Admin] {Proc}.{Action} error", proc, action);
return StatusCode(500, new { ok = false, error = "Operation failed", detail = ex.Message });
}
}
/// <summary>
/// Return BadRequest for validation failures.
/// </summary>
protected IActionResult ValidationError(string error)
=> BadRequest(new { ok = false, error });
}

View File

@@ -0,0 +1,65 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for session management.
/// Requires Admin role.
///
/// ENDPOINTS:
/// GET /api/admin/sessions - List sessions
/// POST /api/admin/sessions/{id}/revoke - Revoke session
/// POST /api/admin/users/{id}/revoke-sessions - Revoke all user sessions
/// POST /api/admin/sessions/cleanup - Cleanup expired sessions
/// </summary>
[ApiController]
[Route("api/admin/sessions")]
public sealed class AdminSessionsController : AdminControllerBase
{
public AdminSessionsController(SqlService sql, ClientContext client, ILogger<AdminSessionsController> log)
: base(sql, client, log) { }
/// <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);
/// <summary>
/// Revoke a session.
/// </summary>
[HttpPost("{sessionId}/revoke")]
public Task<IActionResult> Revoke(string sessionId, CancellationToken ct)
{
Logger.LogWarning("[Admin] RevokeSession | SessionId={SessionId} | By={User}", sessionId, Client.Email);
return CallProc("spAdminSessions", "revoke", new { sessionId }, ct);
}
/// <summary>
/// Revoke all sessions for a user.
/// </summary>
[HttpPost("~/api/admin/users/{userId}/revoke-sessions")]
public Task<IActionResult> RevokeAllForUser(string userId, CancellationToken ct)
{
Logger.LogWarning("[Admin] RevokeAllSessions | UserId={UserId} | By={User}", userId, Client.Email);
return CallProc("spAdminSessions", "revokeAllForUser", new { userId }, ct);
}
/// <summary>
/// Cleanup expired sessions.
/// </summary>
[HttpPost("cleanup")]
public Task<IActionResult> Cleanup([FromQuery] int daysOld = 30, CancellationToken ct = default)
{
Logger.LogWarning("[Admin] CleanupSessions | DaysOld={DaysOld} | By={User}", daysOld, Client.Email);
return CallProc("spAdminSessions", "cleanup", new { daysOld }, ct);
}
}

View File

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

View File

@@ -0,0 +1,76 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers;
/// <summary>
/// Monitoring endpoints for system health and stats.
/// Requires Admin session.
///
/// ENDPOINTS:
/// GET /api/monitoring/health - System health overview
/// GET /api/monitoring/stats - Detailed statistics
/// </summary>
[ApiController]
[Route("api/monitoring")]
public sealed class MonitoringController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly ILogger<MonitoringController> _log;
public MonitoringController(SqlService sql, ClientContext client, ILogger<MonitoringController> log)
{
_sql = sql;
_client = client;
_log = log;
}
/// <summary>
/// System health overview.
/// </summary>
[HttpGet("health")]
public async Task<IActionResult> Health(CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync("dbo.spMonitoring", "health", "{}", 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] Health error");
return StatusCode(500, new { ok = false, error = "Health check failed", detail = ex.Message });
}
}
/// <summary>
/// Detailed system statistics.
/// </summary>
[HttpGet("stats")]
public async Task<IActionResult> Stats([FromQuery] int hours = 24, CancellationToken ct = default)
{
var rqst = JsonSerializer.Serialize(new { hours });
try
{
var resp = await _sql.ExecProcAsync("dbo.spMonitoring", "stats", rqst, 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] Stats error");
return StatusCode(500, new { ok = false, error = "Stats failed", detail = ex.Message });
}
}
}

View File

@@ -0,0 +1,114 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers;
/// <summary>
/// Onboarding endpoints for new user/client registration.
/// Requires JWT authentication (user may not have session yet).
///
/// ENDPOINTS:
/// GET /api/onboarding/status - Check registration status
/// POST /api/onboarding/register - Register new organization
/// </summary>
[ApiController]
[Route("api/onboarding")]
public sealed class OnboardingController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly ILogger<OnboardingController> _log;
public OnboardingController(SqlService sql, ClientContext client, ILogger<OnboardingController> log)
{
_sql = sql;
_client = client;
_log = log;
}
/// <summary>
/// Check registration status for authenticated user.
/// </summary>
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken ct)
{
if (!_client.IsAuthenticated)
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
var rqst = JsonSerializer.Serialize(new
{
provider = "EntraExternalId",
subject = _client.ClientId,
email = _client.Email
});
try
{
var resp = await _sql.ExecProcAsync("dbo.spOnboarding", "status", rqst, 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, "[Onboarding] Status error");
return StatusCode(500, new { ok = false, error = "Status check failed", detail = ex.Message });
}
}
/// <summary>
/// Register a new organization.
/// </summary>
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken ct)
{
if (!_client.IsAuthenticated)
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
if (string.IsNullOrWhiteSpace(request?.ClientName))
return BadRequest(new { ok = false, error = "clientName is required" });
_log.LogWarning("[Onboarding] Register | Subject={Subject} ClientName={ClientName}",
_client.ClientId, request.ClientName);
var rqst = JsonSerializer.Serialize(new
{
provider = "EntraExternalId",
subject = _client.ClientId,
email = _client.Email,
displayName = _client.ClientName,
clientName = request.ClientName.Trim()
});
try
{
var resp = await _sql.ExecProcAsync("dbo.spOnboarding", "register", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Registration service unavailable" });
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
return Content(resp, "application/json");
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Registration failed";
return BadRequest(new { ok = false, error });
}
catch (Exception ex)
{
_log.LogError(ex, "[Onboarding] Register error");
return StatusCode(500, new { ok = false, error = "Registration failed", detail = ex.Message });
}
}
}
public sealed class RegisterRequest
{
public string? ClientName { get; set; }
}

View File

@@ -0,0 +1,37 @@
using Management.Data;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers;
/// <summary>
/// Test endpoints (anonymous, no auth required).
/// </summary>
[ApiController]
[Route("api/test")]
public class TestController : ControllerBase
{
private readonly SqlService _sql;
public TestController(SqlService sql)
{
_sql = sql;
}
/// <summary>
/// Database connectivity test.
/// </summary>
[HttpGet("ping")]
public async Task<IActionResult> Ping(CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync("dbo.spTemplate", "ping",
"""{ "clientId":"00000000-0000-0000-0000-000000000001" }""", ct: ct);
return Content(resp, "application/json");
}
catch (Exception ex)
{
return StatusCode(500, new { ok = false, error = "Database connection failed", detail = ex.Message });
}
}
}

View File

@@ -0,0 +1,33 @@
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();
}
}
}