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