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