Initial commit: JDE Scoping Tool migration project

Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
This commit is contained in:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,26 @@
using System.Security.Claims;
using JdeScoping.Api.Extensions;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Mvc;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Base controller providing access to current user context
/// </summary>
[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
/// <summary>
/// Gets the current authenticated user from claims.
/// Returns null if not authenticated.
/// </summary>
protected UserInfo? CurrentUser => User.Identity?.IsAuthenticated == true
? User.ToUserInfo()
: null;
/// <summary>
/// Gets the current username from claims.
/// </summary>
protected string? CurrentUserName => User.FindFirstValue(ClaimTypes.Name);
}
@@ -0,0 +1,100 @@
using System.Security.Claims;
using JdeScoping.Api.Extensions;
using JdeScoping.Api.Models;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Authentication endpoints for Blazor WASM client
/// </summary>
[Route("api/auth")]
[ApiController]
public class AuthController : ApiControllerBase
{
private readonly IAuthService _authService;
private readonly ILogger<AuthController> _logger;
public AuthController(
IAuthService authService,
ILogger<AuthController> logger)
{
_authService = authService;
_logger = logger;
}
/// <summary>
/// Authenticates a user and creates a session cookie
/// </summary>
/// <param name="request">Login credentials</param>
/// <param name="ct">Cancellation token</param>
/// <returns>User info on success, 401 on failure</returns>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<UserInfo>> Login(
[FromBody] LoginRequest request,
CancellationToken ct)
{
var result = await _authService.AuthenticateAsync(
request.Username, request.Password, ct);
if (!result.Success)
{
_logger.LogWarning("Failed login attempt for user {Username}", request.Username);
return Unauthorized(new { message = result.ErrorMessage });
}
// Sign out existing session
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Create claims identity from user info
var identity = ClaimsExtensions.FromUserInfo(result.User!);
var principal = new ClaimsPrincipal(identity);
// Sign in with non-persistent cookie
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties { IsPersistent = false });
_logger.LogInformation("User {Username} logged in successfully", request.Username);
return Ok(result.User);
}
/// <summary>
/// Logs out the current user
/// </summary>
/// <returns>200 OK on success</returns>
[HttpPost("logout")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> Logout()
{
var username = CurrentUserName;
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
_logger.LogInformation("User {Username} logged out", username);
return Ok();
}
/// <summary>
/// Gets the current authenticated user's information
/// </summary>
/// <returns>User info on success, 401 if not authenticated</returns>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public ActionResult<UserInfo> GetCurrentUser()
{
return Ok(CurrentUser);
}
}
@@ -0,0 +1,34 @@
using JdeScoping.Core.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Handles file upload/download operations for Excel templates.
/// </summary>
[Authorize]
[ApiController]
[Route("api/fileio")]
public partial class FileIOController : ApiControllerBase
{
private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private readonly ILotFinderRepository _repository;
private readonly IExcelParserService _parserService;
private readonly IExcelTemplateService _templateService;
private readonly ILogger<FileIOController> _logger;
public FileIOController(
ILotFinderRepository repository,
IExcelParserService parserService,
IExcelTemplateService templateService,
ILogger<FileIOController> logger)
{
_repository = repository;
_parserService = parserService;
_templateService = templateService;
_logger = logger;
}
}
@@ -0,0 +1,75 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Component lot file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing component lot/item pairs and returns the matched lots
/// </summary>
[HttpPost("componentlots/upload")]
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<LotViewModel>>> UploadComponentLots(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var lotViewModels = _parserService.ParseComponentLots(stream);
var lots = await _repository.LookupLotsAsync(lotViewModels, ct);
var viewModels = lots
.Select(l => l.ToViewModel())
.DistinctBy(l => new { l.LotNumber, l.ItemNumber })
.OrderBy(l => l.LotNumber)
.ToArray();
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded component lots file");
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current component lot data
/// </summary>
[HttpPost("componentlots/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadComponentLots([FromBody] List<LotViewModel>? lotNumbers)
{
var sourceData = (lotNumbers ?? [])
.Select(l => new object?[] { l.LotNumber, l.ItemNumber })
.ToArray();
var headers = new[] { "Component Lot Number", "Component Item Number" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "component_lot_template.xlsx");
}
}
@@ -0,0 +1,74 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Item file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing item numbers and returns the matched items
/// </summary>
[HttpPost("items/upload")]
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<ItemViewModel>>> UploadItems(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var itemNumbers = _parserService.ParseItems(stream);
var items = await _repository.LookupItemsAsync(itemNumbers, ct);
var viewModels = items
.Select(i => i.ToViewModel())
.DistinctBy(i => new { i.ItemNumber, i.Description })
.ToArray();
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded items file");
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current item data
/// </summary>
[HttpPost("items/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadItems([FromBody] List<ItemViewModel>? items)
{
var sourceData = (items ?? [])
.Select(i => new object?[] { i.ItemNumber })
.ToArray();
var headers = new[] { "Item Number" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "item_number_template.xlsx");
}
}
@@ -0,0 +1,66 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Part operations file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing part operations and returns the parsed data
/// </summary>
[HttpPost("partoperations/upload")]
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status200OK)]
public ActionResult<FileUploadResult<PartOperationViewModel>> UploadPartOperations(IFormFile? file)
{
if (file is null)
{
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var partOperations = _parserService.ParsePartOperations(stream);
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = true,
Data = partOperations.ToArray()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded part operations file");
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current part operation data
/// </summary>
[HttpPost("partoperations/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadPartOperations([FromBody] List<PartOperationViewModel>? partOperations)
{
var sourceData = (partOperations ?? [])
.Select(po => new object?[] { po.ItemNumber, po.OperationNumber, po.MisNumber, po.MisRevision })
.ToArray();
var headers = new[] { "Item Number", "Operation Number", "MIS Number", "MIS Revision" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "item_operations_mis_template.xlsx");
}
}
@@ -0,0 +1,71 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Work order file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing work order numbers and returns the matched work orders
/// </summary>
[HttpPost("workorders/upload")]
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var workOrderNumbers = _parserService.ParseWorkOrders(stream);
var workOrders = await _repository.LookupWorkordersAsync(workOrderNumbers, ct);
var viewModels = workOrders
.Select(wo => wo.ToViewModel())
.DistinctBy(wo => new { wo.WorkOrderNumber, wo.ItemNumber })
.OrderBy(wo => wo.WorkOrderNumber)
.ToArray();
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded work order file");
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current work order data
/// </summary>
[HttpPost("workorders/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadWorkOrders([FromBody] List<long>? workOrders)
{
var data = _templateService.GenerateSingleColumn(workOrders ?? [], "Work Order Number");
return File(data, ContentType, "work_order_template.xlsx");
}
}
@@ -0,0 +1,93 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Lookup/autocomplete endpoints (no authorization required)
/// </summary>
[Route("api/lookup")]
[ApiController]
public class LookupController : ApiControllerBase
{
private readonly ILotFinderRepository _repository;
public LookupController(ILotFinderRepository repository)
{
_repository = repository;
}
/// <summary>
/// Searches for items matching the query
/// </summary>
/// <param name="q">Search query for item number or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("items")]
[ProducesResponseType(typeof(IEnumerable<ItemViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ItemViewModel>>> FindItems(
[FromQuery] string q,
CancellationToken ct)
{
var items = await _repository.SearchItemsAsync(q ?? string.Empty, ct);
var viewModels = items
.OrderBy(i => i.ItemNumber)
.Select(i => i.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for profit centers matching the query
/// </summary>
/// <param name="q">Search query for profit center code or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("profit-centers")]
[ProducesResponseType(typeof(IEnumerable<ProfitCenterViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ProfitCenterViewModel>>> FindProfitCenters(
[FromQuery] string q,
CancellationToken ct)
{
var centers = await _repository.SearchProfitCentersAsync(q ?? string.Empty, ct);
var viewModels = centers
.OrderBy(pc => pc.Code)
.Select(pc => pc.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for work centers matching the query
/// </summary>
/// <param name="q">Search query for work center code or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("work-centers")]
[ProducesResponseType(typeof(IEnumerable<WorkCenterViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<WorkCenterViewModel>>> FindWorkCenters(
[FromQuery] string q,
CancellationToken ct)
{
var centers = await _repository.SearchWorkCentersAsync(q ?? string.Empty, ct);
var viewModels = centers
.OrderBy(wc => wc.Code)
.Select(wc => wc.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for operators (JDE users) matching the query
/// </summary>
/// <param name="q">Search query for operator name or ID</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("operators")]
[ProducesResponseType(typeof(IEnumerable<JdeUserViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<JdeUserViewModel>>> FindOperators(
[FromQuery] string q,
CancellationToken ct)
{
var users = await _repository.SearchUsersAsync(q ?? string.Empty, ct);
var viewModels = users
.OrderBy(u => u.FullName)
.Select(u => u.ToViewModel());
return Ok(viewModels);
}
}
@@ -0,0 +1,163 @@
using JdeScoping.Api.Hubs;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Search management controller
/// </summary>
[Route("api/search")]
[ApiController]
[Authorize]
public class SearchController : ApiControllerBase
{
private readonly ILotFinderRepository _repository;
private readonly IHubContext<StatusHub> _hubContext;
private readonly ILogger<SearchController> _logger;
public SearchController(
ILotFinderRepository repository,
IHubContext<StatusHub> hubContext,
ILogger<SearchController> logger)
{
_repository = repository;
_hubContext = hubContext;
_logger = logger;
}
/// <summary>
/// Gets all searches for the current user
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetSearches(CancellationToken ct)
{
var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
var viewModels = searches
.OrderByDescending(s => s.StartDt)
.Select(s => new SearchViewModel(s));
return Ok(viewModels);
}
/// <summary>
/// Gets all queued searches
/// </summary>
[HttpGet("queue")]
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetQueuedSearches(CancellationToken ct)
{
var searches = await _repository.GetQueuedSearchesAsync(ct);
var viewModels = searches.Select(s => new SearchViewModel(s));
return Ok(viewModels);
}
/// <summary>
/// Gets a single search by ID
/// </summary>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SearchViewModel>> GetSearch(int id, CancellationToken ct)
{
var search = await _repository.GetSearchAsync(id, ct);
if (search is null)
{
return NotFound();
}
return Ok(new SearchViewModel(search));
}
/// <summary>
/// Copies an existing search for the current user (returns copy without persisting)
/// </summary>
[HttpGet("{id:int}/copy")]
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SearchViewModel>> CopySearch(int id, CancellationToken ct)
{
var original = await _repository.GetSearchAsync(id, ct);
if (original is null)
{
return NotFound();
}
// Return a copy with reset status/timestamps (not persisted until user submits)
var copy = new Search
{
Id = 0,
UserName = CurrentUserName!,
Name = original.Name,
Status = SearchStatus.New,
SubmitDt = null,
StartDt = null,
EndDt = null,
CriteriaJson = original.CriteriaJson
};
return Ok(new SearchViewModel(copy));
}
/// <summary>
/// Creates a new search
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(int), StatusCodes.Status201Created)]
public async Task<ActionResult<int>> CreateSearch(
[FromBody] SearchViewModel viewModel,
CancellationToken ct)
{
var search = viewModel.ToEntity();
search.UserName = CurrentUserName!;
var searchId = await _repository.SubmitSearchAsync(search, ct);
// Publish to SignalR (best-effort, swallow exceptions)
try
{
var searchUpdate = new SearchUpdate
{
Id = searchId,
UserName = CurrentUserName!,
Name = search.Name,
Status = search.Status,
Timestamp = DateTime.UtcNow
};
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish search update to SignalR");
}
return CreatedAtAction(nameof(GetSearch), new { id = searchId }, searchId);
}
/// <summary>
/// Downloads search results as an Excel file
/// </summary>
[HttpGet("{id:int}/results")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetResults(int id, CancellationToken ct)
{
var data = await _repository.GetSearchResultsAsync(id, ct);
if (data is null || data.Length == 0)
{
return NotFound();
}
return File(
data,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"search_results.xlsx");
}
}
@@ -0,0 +1,110 @@
using System.Text.Json.Serialization;
using JdeScoping.Api.Hubs;
using JdeScoping.Core.Options;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for registering Web API services.
/// </summary>
public static class ApiDependencyInjection
{
/// <summary>
/// Adds Web API services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddWebApi(
this IServiceCollection services,
IConfiguration configuration)
{
// Read auth options for cookie configuration (binding handled by Infrastructure)
var authOptions = configuration
.GetSection(AuthOptions.SectionName)
.Get<AuthOptions>() ?? new AuthOptions();
// Register memory cache for file downloads
services.AddMemoryCache();
// Configure SignalR
services.AddSignalR();
// Configure cookie authentication
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = authOptions.CookieName;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromMinutes(authOptions.CookieExpirationMinutes);
options.SlidingExpiration = true;
// Return 401 instead of redirect for API requests (Blazor WASM)
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
services.AddAuthorization();
// Configure controllers with JSON options
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Configure Swagger/OpenAPI
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "JDE Scoping Tool API",
Version = "v1",
Description = "API for the JDE Scoping Tool application"
});
});
return services;
}
/// <summary>
/// Configures Web API middleware.
/// </summary>
/// <param name="app">Web application.</param>
/// <returns>Web application for chaining.</returns>
public static WebApplication UseWebApi(this WebApplication app)
{
// Use Swagger in development
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<StatusHub>("/hubs/status");
return app;
}
}
@@ -0,0 +1,55 @@
using System.Security.Claims;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace JdeScoping.Api.Extensions;
/// <summary>
/// Extension methods for ClaimsPrincipal
/// </summary>
public static class ClaimsExtensions
{
/// <summary>
/// Converts a ClaimsPrincipal to a UserInfo instance
/// </summary>
/// <param name="principal">Claims principal to extract user info from</param>
/// <returns>UserInfo populated from claims</returns>
public static UserInfo? ToUserInfo(this ClaimsPrincipal principal)
{
if (principal.Identity?.IsAuthenticated != true)
{
return null;
}
return new UserInfo
{
Dn = principal.FindFirstValue("dn") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty,
Username = principal.FindFirstValue(ClaimTypes.Name) ?? string.Empty,
FirstName = principal.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty,
LastName = principal.FindFirstValue(ClaimTypes.Surname) ?? string.Empty,
EmailAddress = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty,
Title = principal.FindFirstValue("title") ?? string.Empty
};
}
/// <summary>
/// Creates a ClaimsIdentity from a UserInfo instance
/// </summary>
/// <param name="user">User information to create claims from</param>
/// <returns>ClaimsIdentity with user claims</returns>
public static ClaimsIdentity FromUserInfo(UserInfo user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Dn),
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.GivenName, user.FirstName),
new(ClaimTypes.Surname, user.LastName),
new(ClaimTypes.Email, user.EmailAddress),
new("title", user.Title),
new("dn", user.Dn)
};
return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
}
}
+71
View File
@@ -0,0 +1,71 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.Core.Models.Search;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Hubs;
/// <summary>
/// SignalR hub for real-time status updates
/// </summary>
public class StatusHub : Hub
{
private static StatusUpdate _cachedStatus = new()
{
Message = "Unknown",
Timestamp = DateTime.UtcNow
};
private readonly ILogger<StatusHub> _logger;
public StatusHub(ILogger<StatusHub> logger)
{
_logger = logger;
}
/// <summary>
/// Called by worker service to update status.
/// Caches the update and broadcasts to all clients.
/// </summary>
/// <param name="statusUpdate">Status update to broadcast</param>
public async Task SetStatus(StatusUpdate statusUpdate)
{
_cachedStatus = statusUpdate;
await Clients.All.SendAsync("statusUpdate", statusUpdate);
_logger.LogDebug("Status updated: {Message}", statusUpdate.Message);
}
/// <summary>
/// Called by clients to get initial cached status on connection.
/// </summary>
/// <returns>The most recent status update</returns>
public StatusUpdate GetCachedStatus()
{
return _cachedStatus;
}
/// <summary>
/// Called by controllers/services to broadcast search updates.
/// </summary>
/// <param name="searchUpdate">Search update to broadcast</param>
public async Task PublishSearchUpdate(SearchUpdate searchUpdate)
{
await Clients.All.SendAsync("searchUpdate", searchUpdate);
_logger.LogDebug("Search update published: ID={Id}, Status={Status}", searchUpdate.Id, searchUpdate.Status);
}
/// <inheritdoc />
public override Task OnConnectedAsync()
{
_logger.LogInformation("Client {ConnectionId} connected to StatusHub", Context.ConnectionId);
return base.OnConnectedAsync();
}
/// <inheritdoc />
public override Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected from StatusHub", Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JdeScoping.Core\JdeScoping.Core.csproj" />
<ProjectReference Include="..\JdeScoping.ExcelIO\JdeScoping.ExcelIO.csproj" />
<ProjectReference Include="..\JdeScoping.Infrastructure\JdeScoping.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
namespace JdeScoping.Api.Models;
/// <summary>
/// Result of a file upload operation
/// </summary>
/// <typeparam name="T">Type of data parsed from the uploaded file</typeparam>
public class FileUploadResult<T>
{
/// <summary>
/// Whether the upload was successful
/// </summary>
public bool WasSuccessful { get; set; }
/// <summary>
/// Error message if the upload failed
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Parsed data from the uploaded file
/// </summary>
public T[]? Data { get; set; }
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace JdeScoping.Api.Models;
/// <summary>
/// Login request payload
/// </summary>
public class LoginRequest
{
/// <summary>
/// Username for authentication
/// </summary>
[Required(ErrorMessage = "Username is required")]
public string Username { get; set; } = string.Empty;
/// <summary>
/// Password for authentication
/// </summary>
[Required(ErrorMessage = "Password is required")]
public string Password { get; set; } = string.Empty;
}