refactor: address code review findings across all projects
Apply comprehensive fixes from code reviews including: - Extract shared utilities (SqlFormatHelper, CellValueConverter, DbDestinationBase) - Add interface abstractions (IAuthenticationService, IDatabaseMigrator, IMisQueryBuilder) - Implement SecureStore for encrypted secrets storage - Fix error handling with proper HTTP status codes and logging - Optimize double enumeration in DevEtlRegistry - Add DataSync.Dev README for developer onboarding - Extract filter panel base classes to reduce duplication - Update code review docs to mark all issues as fixed
This commit is contained in:
@@ -12,6 +12,9 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
// Alias to avoid conflict with Microsoft.AspNetCore.Authentication.IAuthenticationService
|
||||
using IAuthService = JdeScoping.Core.Interfaces.IAuthenticationService;
|
||||
|
||||
namespace JdeScoping.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,13 +16,15 @@ public partial class FileIOController
|
||||
/// </summary>
|
||||
[HttpPost("componentlots/upload")]
|
||||
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<FileUploadResult<LotViewModel>>> UploadComponentLots(
|
||||
IFormFile? file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<LotViewModel>
|
||||
return BadRequest(new FileUploadResult<LotViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
@@ -50,7 +52,7 @@ public partial class FileIOController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse uploaded component lots file");
|
||||
return Ok(new FileUploadResult<LotViewModel>
|
||||
return UnprocessableEntity(new FileUploadResult<LotViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "Failed to parse uploaded file"
|
||||
|
||||
@@ -16,13 +16,15 @@ public partial class FileIOController
|
||||
/// </summary>
|
||||
[HttpPost("items/upload")]
|
||||
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<FileUploadResult<ItemViewModel>>> UploadItems(
|
||||
IFormFile? file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<ItemViewModel>
|
||||
return BadRequest(new FileUploadResult<ItemViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
@@ -49,7 +51,7 @@ public partial class FileIOController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse uploaded items file");
|
||||
return Ok(new FileUploadResult<ItemViewModel>
|
||||
return UnprocessableEntity(new FileUploadResult<ItemViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "Failed to parse uploaded file"
|
||||
|
||||
@@ -16,11 +16,13 @@ public partial class FileIOController
|
||||
/// </summary>
|
||||
[HttpPost("partoperations/upload")]
|
||||
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public ActionResult<FileUploadResult<PartOperationViewModel>> UploadPartOperations(IFormFile? file)
|
||||
{
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<PartOperationViewModel>
|
||||
return BadRequest(new FileUploadResult<PartOperationViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
@@ -41,7 +43,7 @@ public partial class FileIOController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse uploaded part operations file");
|
||||
return Ok(new FileUploadResult<PartOperationViewModel>
|
||||
return UnprocessableEntity(new FileUploadResult<PartOperationViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "Failed to parse uploaded file"
|
||||
|
||||
@@ -16,13 +16,15 @@ public partial class FileIOController
|
||||
/// </summary>
|
||||
[HttpPost("workorders/upload")]
|
||||
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
|
||||
IFormFile? file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<WorkOrderViewModel>
|
||||
return BadRequest(new FileUploadResult<WorkOrderViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
@@ -50,7 +52,7 @@ public partial class FileIOController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse uploaded work order file");
|
||||
return Ok(new FileUploadResult<WorkOrderViewModel>
|
||||
return UnprocessableEntity(new FileUploadResult<WorkOrderViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "Failed to parse uploaded file"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using JdeScoping.Api.Mapping;
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.Models.Pipelines;
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
using JdeScoping.DataSync.Contracts;
|
||||
using JdeScoping.DataSync.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -12,20 +12,22 @@ namespace JdeScoping.Api.Controllers;
|
||||
/// <summary>
|
||||
/// API endpoints for pipeline configuration and status.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route(ApiRoutes.Pipelines.Base)]
|
||||
[Authorize]
|
||||
public class PipelineController : ControllerBase
|
||||
public class PipelineController : ApiControllerBase
|
||||
{
|
||||
private readonly IEtlPipelineFactory _pipelineFactory;
|
||||
private readonly IDataUpdateRepository _dataUpdateRepository;
|
||||
private readonly IPipelineMapper _mapper;
|
||||
|
||||
public PipelineController(
|
||||
IEtlPipelineFactory pipelineFactory,
|
||||
IDataUpdateRepository dataUpdateRepository)
|
||||
IDataUpdateRepository dataUpdateRepository,
|
||||
IPipelineMapper mapper)
|
||||
{
|
||||
_pipelineFactory = pipelineFactory;
|
||||
_dataUpdateRepository = dataUpdateRepository;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -51,7 +53,7 @@ public class PipelineController : ControllerBase
|
||||
return NotFound();
|
||||
|
||||
var defaults = _pipelineFactory.GetScheduleDefaults();
|
||||
var dto = MapToDto(name, config, defaults);
|
||||
var dto = _mapper.MapToDto(name, config, defaults);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
@@ -75,8 +77,8 @@ public class PipelineController : ControllerBase
|
||||
var statuses = new List<PipelineScheduleStatusDto>();
|
||||
foreach (var updateType in new[] { UpdateTypes.Mass, UpdateTypes.Daily, UpdateTypes.Hourly })
|
||||
{
|
||||
var scheduleConfig = GetScheduleConfig(config, updateType);
|
||||
var interval = GetEffectiveInterval(scheduleConfig, defaults, updateType);
|
||||
var scheduleConfig = _mapper.GetScheduleConfig(config, updateType);
|
||||
var interval = _mapper.GetEffectiveInterval(scheduleConfig, defaults, updateType);
|
||||
|
||||
lastRuns.TryGetValue(updateType, out var lastRun);
|
||||
var successKey = $"{tableName}_{(int)updateType}";
|
||||
@@ -127,98 +129,4 @@ public class PipelineController : ControllerBase
|
||||
|
||||
return Ok(new PipelineExecutionsResponse(executions));
|
||||
}
|
||||
|
||||
private static PipelineConfigDto MapToDto(
|
||||
string name,
|
||||
PipelineConfig config,
|
||||
ScheduleDefaults defaults)
|
||||
{
|
||||
var source = new PipelineSourceDto(
|
||||
config.Source.Connection,
|
||||
Truncate(config.Source.Query),
|
||||
Truncate(config.Source.MassQuery),
|
||||
config.Source.Query,
|
||||
config.Source.MassQuery,
|
||||
config.Source.Parameters?.Select(p => new PipelineParameterDto(
|
||||
p.Key, p.Value.Format, p.Value.Source)).ToList() ?? []);
|
||||
|
||||
var matchCols = config.Destination.MatchColumns?.ToList();
|
||||
var destination = new PipelineDestinationDto(
|
||||
config.Destination.Table,
|
||||
matchCols?.Count > 0 ? "BulkMerge" : "BulkImport",
|
||||
matchCols,
|
||||
config.Destination.ExcludeFromUpdate?.ToList());
|
||||
|
||||
// Mass uses massQuery with no parameters; Daily/Hourly use query with parameters
|
||||
var parameters = config.Source.Parameters?.Select(p => new PipelineParameterDto(
|
||||
p.Key, p.Value.Format, p.Value.Source)).ToList() ?? [];
|
||||
|
||||
var schedules = new PipelineSchedulesDto(
|
||||
MapSchedule(config.Schedules?.Mass, defaults.Mass, config.Source.MassQuery, [], config.PreScripts, config.PostScripts),
|
||||
MapSchedule(config.Schedules?.Daily, defaults.Daily, config.Source.Query, parameters, config.PreScripts, config.PostScripts),
|
||||
MapSchedule(config.Schedules?.Hourly, defaults.Hourly, config.Source.Query, parameters, config.PreScripts, config.PostScripts));
|
||||
|
||||
return new PipelineConfigDto(
|
||||
name,
|
||||
source,
|
||||
destination,
|
||||
schedules,
|
||||
config.PreScripts?.Count ?? 0,
|
||||
config.PostScripts?.Count ?? 0,
|
||||
config.PreScripts,
|
||||
config.PostScripts);
|
||||
}
|
||||
|
||||
private static PipelineScheduleDto MapSchedule(
|
||||
ScheduleConfig? config,
|
||||
ScheduleConfig defaults,
|
||||
string? query,
|
||||
List<PipelineParameterDto> parameters,
|
||||
List<string>? preScripts,
|
||||
List<string>? postScripts)
|
||||
{
|
||||
return new PipelineScheduleDto(
|
||||
config?.Enabled ?? defaults.Enabled,
|
||||
config?.IntervalMinutes > 0 ? config.IntervalMinutes : defaults.IntervalMinutes,
|
||||
config?.PrePurge ?? defaults.PrePurge,
|
||||
config?.ReIndex ?? defaults.ReIndex,
|
||||
config?.IntervalMinutes > 0 && config.IntervalMinutes != defaults.IntervalMinutes,
|
||||
config?.PrePurge != null && config.PrePurge != defaults.PrePurge,
|
||||
config?.ReIndex != null && config.ReIndex != defaults.ReIndex,
|
||||
query,
|
||||
parameters,
|
||||
preScripts,
|
||||
postScripts);
|
||||
}
|
||||
|
||||
private static ScheduleConfig? GetScheduleConfig(
|
||||
PipelineConfig config,
|
||||
UpdateTypes updateType) => updateType switch
|
||||
{
|
||||
UpdateTypes.Mass => config.Schedules?.Mass,
|
||||
UpdateTypes.Daily => config.Schedules?.Daily,
|
||||
UpdateTypes.Hourly => config.Schedules?.Hourly,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static int GetEffectiveInterval(
|
||||
ScheduleConfig? config,
|
||||
ScheduleDefaults defaults,
|
||||
UpdateTypes updateType)
|
||||
{
|
||||
if (config?.IntervalMinutes > 0)
|
||||
return config.IntervalMinutes;
|
||||
|
||||
return updateType switch
|
||||
{
|
||||
UpdateTypes.Mass => defaults.Mass.IntervalMinutes,
|
||||
UpdateTypes.Daily => defaults.Daily.IntervalMinutes,
|
||||
UpdateTypes.Hourly => defaults.Hourly.IntervalMinutes,
|
||||
_ => 60
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Truncate(string? value, int maxLength = 100) =>
|
||||
value is null ? null :
|
||||
value.Length <= maxLength ? value : value[..maxLength] + "...";
|
||||
}
|
||||
|
||||
@@ -24,15 +24,18 @@ public class SearchController : ApiControllerBase
|
||||
private readonly ILotFinderRepository _repository;
|
||||
private readonly IHubContext<StatusHub> _hubContext;
|
||||
private readonly ILogger<SearchController> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SearchController(
|
||||
ILotFinderRepository repository,
|
||||
IHubContext<StatusHub> hubContext,
|
||||
ILogger<SearchController> logger)
|
||||
ILogger<SearchController> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository;
|
||||
_hubContext = hubContext;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -40,9 +43,13 @@ public class SearchController : ApiControllerBase
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetSearches(CancellationToken ct)
|
||||
{
|
||||
var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
|
||||
if (CurrentUserName is null)
|
||||
return Unauthorized();
|
||||
|
||||
var searches = await _repository.GetUserSearchesAsync(CurrentUserName, ct);
|
||||
var viewModels = searches
|
||||
.OrderByDescending(s => s.StartDt)
|
||||
.Select(s => new SearchViewModel(s));
|
||||
@@ -82,9 +89,13 @@ public class SearchController : ApiControllerBase
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/copy")]
|
||||
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SearchViewModel>> CopySearch(int id, CancellationToken ct)
|
||||
{
|
||||
if (CurrentUserName is null)
|
||||
return Unauthorized();
|
||||
|
||||
var original = await _repository.GetSearchAsync(id, ct);
|
||||
if (original is null)
|
||||
{
|
||||
@@ -95,7 +106,7 @@ public class SearchController : ApiControllerBase
|
||||
var copy = new Search
|
||||
{
|
||||
Id = 0,
|
||||
UserName = CurrentUserName!,
|
||||
UserName = CurrentUserName,
|
||||
Name = original.Name,
|
||||
Status = SearchStatus.New,
|
||||
SubmitDt = null,
|
||||
@@ -112,12 +123,16 @@ public class SearchController : ApiControllerBase
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(int), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<int>> CreateSearch(
|
||||
[FromBody] SearchViewModel viewModel,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (CurrentUserName is null)
|
||||
return Unauthorized();
|
||||
|
||||
var search = viewModel.ToEntity();
|
||||
search.UserName = CurrentUserName!;
|
||||
search.UserName = CurrentUserName;
|
||||
|
||||
var searchId = await _repository.SubmitSearchAsync(search, ct);
|
||||
|
||||
@@ -127,10 +142,10 @@ public class SearchController : ApiControllerBase
|
||||
var searchUpdate = new SearchUpdate
|
||||
{
|
||||
Id = searchId,
|
||||
UserName = CurrentUserName!,
|
||||
UserName = CurrentUserName,
|
||||
Name = search.Name,
|
||||
Status = search.Status,
|
||||
Timestamp = DateTime.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow().DateTime
|
||||
};
|
||||
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using JdeScoping.Api.Hubs;
|
||||
using JdeScoping.Api.Mapping;
|
||||
using JdeScoping.Api.Options;
|
||||
using JdeScoping.Api.Services;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
@@ -35,6 +36,12 @@ public static class ApiDependencyInjection
|
||||
// Register memory cache for file downloads
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Register TimeProvider for testability (allows mocking DateTime.UtcNow)
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Register mappers
|
||||
services.AddSingleton<IPipelineMapper, PipelineMapper>();
|
||||
|
||||
// Configure SignalR
|
||||
services.AddSignalR();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using JdeScoping.Core.ViewModels;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.Api.Hubs;
|
||||
@@ -9,17 +10,17 @@ namespace JdeScoping.Api.Hubs;
|
||||
/// </summary>
|
||||
public class StatusHub : Hub
|
||||
{
|
||||
private static StatusUpdateViewModel _cachedStatus = new()
|
||||
{
|
||||
Message = "Unknown",
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
private const string StatusCacheKey = "StatusHub_CachedStatus";
|
||||
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<StatusHub> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public StatusHub(ILogger<StatusHub> logger)
|
||||
public StatusHub(IMemoryCache cache, ILogger<StatusHub> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -29,7 +30,7 @@ public class StatusHub : Hub
|
||||
/// <param name="statusUpdate">Status update to broadcast</param>
|
||||
public async Task SetStatus(StatusUpdateViewModel statusUpdate)
|
||||
{
|
||||
_cachedStatus = statusUpdate;
|
||||
_cache.Set(StatusCacheKey, statusUpdate);
|
||||
await Clients.All.SendAsync("statusUpdate", statusUpdate);
|
||||
_logger.LogDebug("Status updated: {Message}", statusUpdate.Message);
|
||||
}
|
||||
@@ -40,7 +41,11 @@ public class StatusHub : Hub
|
||||
/// <returns>The most recent status update</returns>
|
||||
public StatusUpdateViewModel GetCachedStatus()
|
||||
{
|
||||
return _cachedStatus;
|
||||
return _cache.GetOrCreate(StatusCacheKey, _ => new StatusUpdateViewModel
|
||||
{
|
||||
Message = "Unknown",
|
||||
Timestamp = _timeProvider.GetUtcNow().DateTime
|
||||
})!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using JdeScoping.Core.Models.Pipelines;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
|
||||
namespace JdeScoping.Api.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Mapper for pipeline configuration to DTOs.
|
||||
/// </summary>
|
||||
public interface IPipelineMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a pipeline configuration to its DTO representation.
|
||||
/// </summary>
|
||||
PipelineConfigDto MapToDto(string name, PipelineConfig config, ScheduleDefaults defaults);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective interval for a schedule, applying defaults if not specified.
|
||||
/// </summary>
|
||||
int GetEffectiveInterval(ScheduleConfig? config, ScheduleDefaults defaults, UpdateTypes updateType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schedule configuration for a specific update type.
|
||||
/// </summary>
|
||||
ScheduleConfig? GetScheduleConfig(PipelineConfig config, UpdateTypes updateType);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using JdeScoping.Core.Models.Pipelines;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
|
||||
namespace JdeScoping.Api.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Maps pipeline configuration to DTOs.
|
||||
/// </summary>
|
||||
public class PipelineMapper : IPipelineMapper
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public PipelineConfigDto MapToDto(
|
||||
string name,
|
||||
PipelineConfig config,
|
||||
ScheduleDefaults defaults)
|
||||
{
|
||||
var source = new PipelineSourceDto(
|
||||
config.Source.Connection,
|
||||
Truncate(config.Source.Query),
|
||||
Truncate(config.Source.MassQuery),
|
||||
config.Source.Query,
|
||||
config.Source.MassQuery,
|
||||
config.Source.Parameters?.Select(p => new PipelineParameterDto(
|
||||
p.Key, p.Value.Format, p.Value.Source)).ToList() ?? []);
|
||||
|
||||
var matchCols = config.Destination.MatchColumns?.ToList();
|
||||
var destination = new PipelineDestinationDto(
|
||||
config.Destination.Table,
|
||||
matchCols?.Count > 0 ? "BulkMerge" : "BulkImport",
|
||||
matchCols,
|
||||
config.Destination.ExcludeFromUpdate?.ToList());
|
||||
|
||||
// Mass uses massQuery with no parameters; Daily/Hourly use query with parameters
|
||||
var parameters = config.Source.Parameters?.Select(p => new PipelineParameterDto(
|
||||
p.Key, p.Value.Format, p.Value.Source)).ToList() ?? [];
|
||||
|
||||
var schedules = new PipelineSchedulesDto(
|
||||
MapSchedule(config.Schedules?.Mass, defaults.Mass, config.Source.MassQuery, [], config.PreScripts, config.PostScripts),
|
||||
MapSchedule(config.Schedules?.Daily, defaults.Daily, config.Source.Query, parameters, config.PreScripts, config.PostScripts),
|
||||
MapSchedule(config.Schedules?.Hourly, defaults.Hourly, config.Source.Query, parameters, config.PreScripts, config.PostScripts));
|
||||
|
||||
return new PipelineConfigDto(
|
||||
name,
|
||||
source,
|
||||
destination,
|
||||
schedules,
|
||||
config.PreScripts?.Count ?? 0,
|
||||
config.PostScripts?.Count ?? 0,
|
||||
config.PreScripts,
|
||||
config.PostScripts);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ScheduleConfig? GetScheduleConfig(
|
||||
PipelineConfig config,
|
||||
UpdateTypes updateType) => updateType switch
|
||||
{
|
||||
UpdateTypes.Mass => config.Schedules?.Mass,
|
||||
UpdateTypes.Daily => config.Schedules?.Daily,
|
||||
UpdateTypes.Hourly => config.Schedules?.Hourly,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetEffectiveInterval(
|
||||
ScheduleConfig? config,
|
||||
ScheduleDefaults defaults,
|
||||
UpdateTypes updateType)
|
||||
{
|
||||
if (config?.IntervalMinutes > 0)
|
||||
return config.IntervalMinutes;
|
||||
|
||||
return updateType switch
|
||||
{
|
||||
UpdateTypes.Mass => defaults.Mass.IntervalMinutes,
|
||||
UpdateTypes.Daily => defaults.Daily.IntervalMinutes,
|
||||
UpdateTypes.Hourly => defaults.Hourly.IntervalMinutes,
|
||||
_ => 60
|
||||
};
|
||||
}
|
||||
|
||||
private static PipelineScheduleDto MapSchedule(
|
||||
ScheduleConfig? config,
|
||||
ScheduleConfig defaults,
|
||||
string? query,
|
||||
List<PipelineParameterDto> parameters,
|
||||
List<string>? preScripts,
|
||||
List<string>? postScripts)
|
||||
{
|
||||
return new PipelineScheduleDto(
|
||||
config?.Enabled ?? defaults.Enabled,
|
||||
config?.IntervalMinutes > 0 ? config.IntervalMinutes : defaults.IntervalMinutes,
|
||||
config?.PrePurge ?? defaults.PrePurge,
|
||||
config?.ReIndex ?? defaults.ReIndex,
|
||||
config?.IntervalMinutes > 0 && config.IntervalMinutes != defaults.IntervalMinutes,
|
||||
config?.PrePurge != null && config.PrePurge != defaults.PrePurge,
|
||||
config?.ReIndex != null && config.ReIndex != defaults.ReIndex,
|
||||
query,
|
||||
parameters,
|
||||
preScripts,
|
||||
postScripts);
|
||||
}
|
||||
|
||||
private static string? Truncate(string? value, int maxLength = 100) =>
|
||||
value is null ? null :
|
||||
value.Length <= maxLength ? value : value[..maxLength] + "...";
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using JdeScoping.Core.Models.Auth;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.Client.Auth;
|
||||
|
||||
@@ -10,16 +11,21 @@ namespace JdeScoping.Client.Auth;
|
||||
/// Works with cookie-based authentication where the browser automatically
|
||||
/// sends cookies with each request.
|
||||
/// </summary>
|
||||
public class AuthStateProvider : AuthenticationStateProvider
|
||||
public class AuthStateProvider : AuthenticationStateProvider, IAuthStateProvider
|
||||
{
|
||||
private readonly IUserStorageService _userStorage;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<AuthStateProvider>? _logger;
|
||||
private readonly ClaimsPrincipal _anonymous = new(new ClaimsIdentity());
|
||||
|
||||
public AuthStateProvider(IUserStorageService userStorage, HttpClient httpClient)
|
||||
public AuthStateProvider(
|
||||
IUserStorageService userStorage,
|
||||
HttpClient httpClient,
|
||||
ILogger<AuthStateProvider>? logger = null)
|
||||
{
|
||||
_userStorage = userStorage;
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
@@ -56,9 +62,9 @@ public class AuthStateProvider : AuthenticationStateProvider
|
||||
return await response.Content.ReadFromJsonAsync<UserInfoDto>();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Network error or other issue - treat as not authenticated
|
||||
_logger?.LogWarning(ex, "Session validation failed, treating as unauthenticated");
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using JdeScoping.Core.Models.Auth;
|
||||
|
||||
namespace JdeScoping.Client.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing authentication state in the Blazor client.
|
||||
/// Extracted from AuthStateProvider for testability.
|
||||
/// </summary>
|
||||
public interface IAuthStateProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Called after successful login to update auth state and persist user info.
|
||||
/// </summary>
|
||||
/// <param name="user">Authenticated user info from the server.</param>
|
||||
Task MarkUserAsAuthenticated(UserInfoDto user);
|
||||
|
||||
/// <summary>
|
||||
/// Notifies that authentication state has changed, triggering a re-evaluation.
|
||||
/// </summary>
|
||||
void NotifyAuthenticationStateChanged();
|
||||
|
||||
/// <summary>
|
||||
/// Logs out the user by removing cached data and notifying of state change.
|
||||
/// </summary>
|
||||
Task LogoutAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current username from the cached user info.
|
||||
/// </summary>
|
||||
/// <returns>The username if authenticated, null otherwise.</returns>
|
||||
Task<string?> GetUsernameAsync();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@namespace JdeScoping.Client.Components.Admin
|
||||
@using JdeScoping.Core.Models.Pipelines
|
||||
@using JdeScoping.Core.Models.Enums
|
||||
@using JdeScoping.Client.Helpers
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenRow AlignItems="AlignItems.Center" class="rz-mb-3">
|
||||
@@ -65,7 +66,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(Config.Query))
|
||||
{
|
||||
<RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mb-2">Query</RadzenText>
|
||||
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.875rem; line-height: 1.5; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto;">@FormatSql(Config.Query)</pre>
|
||||
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.875rem; line-height: 1.5; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto;">@SqlFormatHelper.FormatSql(Config.Query)</pre>
|
||||
}
|
||||
|
||||
@if (Config.PreScripts?.Count > 0)
|
||||
@@ -75,7 +76,7 @@
|
||||
{
|
||||
var script = Config.PreScripts[i];
|
||||
<RadzenText TextStyle="TextStyle.Body2" class="rz-mb-1"><strong>Script @(i + 1):</strong></RadzenText>
|
||||
<pre style="background: #fff3cd; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@FormatSql(script)</pre>
|
||||
<pre style="background: #fff3cd; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@SqlFormatHelper.FormatSql(script)</pre>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@
|
||||
{
|
||||
var script = Config.PostScripts[i];
|
||||
<RadzenText TextStyle="TextStyle.Body2" class="rz-mb-1"><strong>Script @(i + 1):</strong></RadzenText>
|
||||
<pre style="background: #d1ecf1; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@FormatSql(script)</pre>
|
||||
<pre style="background: #d1ecf1; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@SqlFormatHelper.FormatSql(script)</pre>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,50 +113,4 @@
|
||||
return $"{minutes / 60} hour(s) ({minutes} min)";
|
||||
return $"{minutes} minutes";
|
||||
}
|
||||
|
||||
private static string FormatSql(string? sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
return "";
|
||||
|
||||
// Format SELECT columns - put each column on its own line
|
||||
var result = FormatSelectColumns(sql);
|
||||
|
||||
// Add line breaks before major clauses
|
||||
result = result
|
||||
.Replace(" FROM ", "\nFROM ")
|
||||
.Replace(" WHERE ", "\nWHERE ")
|
||||
.Replace(" AND ", "\n AND ")
|
||||
.Replace(" OR ", "\n OR ")
|
||||
.Replace(" LEFT ", "\nLEFT ")
|
||||
.Replace(" RIGHT ", "\nRIGHT ")
|
||||
.Replace(" INNER ", "\nINNER ")
|
||||
.Replace(" OUTER ", "\nOUTER ")
|
||||
.Replace(" JOIN ", " JOIN\n ")
|
||||
.Replace(" ORDER BY ", "\nORDER BY ")
|
||||
.Replace(" GROUP BY ", "\nGROUP BY ")
|
||||
.Replace(" HAVING ", "\nHAVING ");
|
||||
|
||||
return result.Trim();
|
||||
}
|
||||
|
||||
private static string FormatSelectColumns(string sql)
|
||||
{
|
||||
var selectIndex = sql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase);
|
||||
var fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (selectIndex < 0 || fromIndex < 0 || fromIndex <= selectIndex)
|
||||
return sql;
|
||||
|
||||
var beforeSelect = sql[..selectIndex];
|
||||
var selectKeyword = sql.Substring(selectIndex, 6);
|
||||
var columnsStart = selectIndex + 6;
|
||||
var columns = sql[columnsStart..fromIndex];
|
||||
var afterColumns = sql[fromIndex..];
|
||||
|
||||
var columnList = columns.Split(',');
|
||||
var formattedColumns = string.Join(",\n ", columnList.Select(c => c.Trim()));
|
||||
|
||||
return $"{beforeSelect}{selectKeyword} {formattedColumns}{afterColumns}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@namespace JdeScoping.Client.Components.Admin
|
||||
@using JdeScoping.Client.Helpers
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@if (Visible)
|
||||
@@ -83,55 +84,7 @@
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public string? Sql { get; set; }
|
||||
|
||||
private string FormattedSql => FormatSql(Sql);
|
||||
|
||||
private static string FormatSql(string? sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
return "";
|
||||
|
||||
// Format SELECT columns - put each column on its own line
|
||||
var result = FormatSelectColumns(sql);
|
||||
|
||||
// Add line breaks before major clauses
|
||||
result = result
|
||||
.Replace(" FROM ", "\nFROM ")
|
||||
.Replace(" WHERE ", "\nWHERE ")
|
||||
.Replace(" AND ", "\n AND ")
|
||||
.Replace(" OR ", "\n OR ")
|
||||
.Replace(" LEFT ", "\nLEFT ")
|
||||
.Replace(" RIGHT ", "\nRIGHT ")
|
||||
.Replace(" INNER ", "\nINNER ")
|
||||
.Replace(" OUTER ", "\nOUTER ")
|
||||
.Replace(" JOIN ", " JOIN\n ")
|
||||
.Replace(" ORDER BY ", "\nORDER BY ")
|
||||
.Replace(" GROUP BY ", "\nGROUP BY ")
|
||||
.Replace(" HAVING ", "\nHAVING ");
|
||||
|
||||
return result.Trim();
|
||||
}
|
||||
|
||||
private static string FormatSelectColumns(string sql)
|
||||
{
|
||||
// Find SELECT and FROM positions (case-insensitive)
|
||||
var selectIndex = sql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase);
|
||||
var fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (selectIndex < 0 || fromIndex < 0 || fromIndex <= selectIndex)
|
||||
return sql;
|
||||
|
||||
var beforeSelect = sql[..selectIndex];
|
||||
var selectKeyword = sql.Substring(selectIndex, 6); // "SELECT"
|
||||
var columnsStart = selectIndex + 6;
|
||||
var columns = sql[columnsStart..fromIndex];
|
||||
var afterColumns = sql[fromIndex..];
|
||||
|
||||
// Split columns by comma and rejoin with newlines
|
||||
var columnList = columns.Split(',');
|
||||
var formattedColumns = string.Join(",\n ", columnList.Select(c => c.Trim()));
|
||||
|
||||
return $"{beforeSelect}{selectKeyword} {formattedColumns}{afterColumns}";
|
||||
}
|
||||
private string FormattedSql => SqlFormatHelper.FormatSql(Sql);
|
||||
|
||||
private async Task CopyToClipboard()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Radzen;
|
||||
|
||||
namespace JdeScoping.Client.Components.FilterPanels;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for filter panels that use autocomplete search to select items.
|
||||
/// </summary>
|
||||
/// <typeparam name="TItem">The type of items displayed in the grid.</typeparam>
|
||||
public abstract class AutocompleteFilterPanelBase<TItem> : ComponentBase where TItem : class
|
||||
{
|
||||
[Inject]
|
||||
protected DialogService DialogService { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The list of items displayed in the grid.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<TItem> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Callback when the items list changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<List<TItem>> ItemsChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel is in read-only mode.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The current search text.
|
||||
/// </summary>
|
||||
protected string SearchText { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The search results from the API.
|
||||
/// </summary>
|
||||
protected List<TItem> SearchResults { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The currently selected item from search results.
|
||||
/// </summary>
|
||||
protected TItem? SelectedItem { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the title displayed in the panel header.
|
||||
/// </summary>
|
||||
protected abstract string PanelTitle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the placeholder text for the autocomplete input.
|
||||
/// </summary>
|
||||
protected abstract string SearchPlaceholder { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the label for the autocomplete form field.
|
||||
/// </summary>
|
||||
protected abstract string SearchFieldLabel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the property name used for the autocomplete text display.
|
||||
/// </summary>
|
||||
protected abstract string AutocompleteTextProperty { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confirmation message for clearing data.
|
||||
/// </summary>
|
||||
protected abstract string ClearConfirmMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Performs the search API call and returns matching items.
|
||||
/// </summary>
|
||||
protected abstract Task<List<TItem>> SearchApiAsync(string filter);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique key value for an item.
|
||||
/// </summary>
|
||||
protected abstract object GetItemKey(TItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display text value for an item (used for matching in autocomplete).
|
||||
/// </summary>
|
||||
protected abstract string GetDisplayText(TItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the autocomplete search.
|
||||
/// </summary>
|
||||
protected async Task OnSearchAsync(LoadDataArgs args)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
|
||||
{
|
||||
SearchResults = await SearchApiAsync(args.Filter);
|
||||
}
|
||||
else
|
||||
{
|
||||
SearchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles selection from the autocomplete.
|
||||
/// </summary>
|
||||
protected void OnItemSelected(object value)
|
||||
{
|
||||
if (value is string text && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
SelectedItem = SearchResults.FirstOrDefault(i => GetDisplayText(i) == text);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the selected item to the list.
|
||||
/// </summary>
|
||||
protected async Task AddItemAsync()
|
||||
{
|
||||
if (SelectedItem != null)
|
||||
{
|
||||
var selectedKey = GetItemKey(SelectedItem);
|
||||
var isDuplicate = Items.Any(i => GetItemKey(i).Equals(selectedKey));
|
||||
|
||||
if (!isDuplicate)
|
||||
{
|
||||
Items.Add(SelectedItem);
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
}
|
||||
SearchText = "";
|
||||
SelectedItem = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the list.
|
||||
/// </summary>
|
||||
protected async Task DeleteItem(TItem item)
|
||||
{
|
||||
Items.Remove(item);
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all items after confirmation.
|
||||
/// </summary>
|
||||
protected async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm(ClearConfirmMessage, "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
Items.Clear();
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
@* Component lot filter panel with upload/download/clear functionality *@
|
||||
@inherits FileUploadFilterPanelBase<ComponentLotViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Core.ViewModels
|
||||
@inject IFileApiClient FileApi
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Component Lot</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
|
||||
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="componentLotFileInput" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
|
||||
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
</RadzenStack>
|
||||
}
|
||||
</RadzenStack>
|
||||
|
||||
<RadzenDataGrid Data="@ComponentLots" TItem="ComponentLotViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="ComponentLotViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="LotNumber" Title="Lot Number" />
|
||||
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="ItemNumber" Title="Item Number" />
|
||||
@@ -28,86 +27,46 @@
|
||||
</RadzenDataGrid>
|
||||
|
||||
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
|
||||
<strong># of component lots: @ComponentLots.Count</strong>
|
||||
<strong>@CountLabel: @Items.Count</strong>
|
||||
</RadzenText>
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<ComponentLotViewModel> ComponentLots { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Component Lot";
|
||||
protected override string CountLabel => "# of component lots";
|
||||
protected override string FileInputId => "componentLotFileInput";
|
||||
protected override string TemplateFilename => "componentlots_template.xlsx";
|
||||
protected override string EntityName => "component lots";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all component lots?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<ComponentLotViewModel>> ComponentLotsChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
private bool _isUploading;
|
||||
|
||||
private async Task DownloadTemplateAsync()
|
||||
protected override async Task<byte[]?> DownloadTemplateApiAsync()
|
||||
{
|
||||
var lotData = ComponentLots.Select(cl => new LotViewModel { LotNumber = cl.LotNumber, ItemNumber = cl.ItemNumber }).ToList().AsReadOnly();
|
||||
var lotData = Items.Select(cl => new LotViewModel { LotNumber = cl.LotNumber, ItemNumber = cl.ItemNumber }).ToList().AsReadOnly();
|
||||
var result = await FileApi.DownloadComponentLotsTemplateAsync(lotData);
|
||||
byte[]? bytes = null;
|
||||
result.Switch(
|
||||
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "componentlots_template.xlsx", bytes); },
|
||||
data => { bytes = data; },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task TriggerFileInput()
|
||||
protected override async Task<List<ComponentLotViewModel>?> UploadFileApiAsync(Stream stream, string filename)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('componentLotFileInput').click()");
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (e.File == null) return;
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
var result = await FileApi.UploadComponentLotsAsync(stream, e.File.Name);
|
||||
|
||||
result.Switch(
|
||||
lots =>
|
||||
{
|
||||
ComponentLots.Clear();
|
||||
ComponentLots.AddRange(lots.Select(l => new ComponentLotViewModel { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber }));
|
||||
_ = ComponentLotsChanged.InvokeAsync(ComponentLots);
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {lots.Count} component lots.");
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all component lots?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
ComponentLots.Clear();
|
||||
await ComponentLotsChanged.InvokeAsync(ComponentLots);
|
||||
}
|
||||
var result = await FileApi.UploadComponentLotsAsync(stream, filename);
|
||||
List<ComponentLotViewModel>? items = null;
|
||||
result.Switch(
|
||||
lots => { items = lots.Select(l => new ComponentLotViewModel { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber }).ToList(); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
using Microsoft.JSInterop;
|
||||
using Radzen;
|
||||
|
||||
namespace JdeScoping.Client.Components.FilterPanels;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for filter panels that use file upload for data input.
|
||||
/// </summary>
|
||||
/// <typeparam name="TItem">The type of items displayed in the grid.</typeparam>
|
||||
public abstract class FileUploadFilterPanelBase<TItem> : ComponentBase where TItem : class
|
||||
{
|
||||
[Inject]
|
||||
protected DialogService DialogService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected NotificationService NotificationService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The list of items displayed in the grid.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<TItem> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Callback when the items list changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<List<TItem>> ItemsChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel is in read-only mode.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether a file upload is in progress.
|
||||
/// </summary>
|
||||
protected bool IsUploading { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the title displayed in the panel header.
|
||||
/// </summary>
|
||||
protected abstract string PanelTitle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count label text (e.g., "# of work orders").
|
||||
/// </summary>
|
||||
protected abstract string CountLabel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTML element ID for the file input.
|
||||
/// </summary>
|
||||
protected abstract string FileInputId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filename for the downloaded template.
|
||||
/// </summary>
|
||||
protected abstract string TemplateFilename { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity name for notifications (e.g., "work orders").
|
||||
/// </summary>
|
||||
protected abstract string EntityName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confirmation message for clearing data.
|
||||
/// </summary>
|
||||
protected abstract string ClearConfirmMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the template file from the API.
|
||||
/// </summary>
|
||||
protected abstract Task<byte[]?> DownloadTemplateApiAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the file and returns the parsed items.
|
||||
/// </summary>
|
||||
protected abstract Task<List<TItem>?> UploadFileApiAsync(Stream stream, string filename);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the template.
|
||||
/// </summary>
|
||||
protected async Task DownloadTemplateAsync()
|
||||
{
|
||||
var bytes = await DownloadTemplateApiAsync();
|
||||
if (bytes != null)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", TemplateFilename, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers the file input click.
|
||||
/// </summary>
|
||||
protected async Task TriggerFileInput()
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("jdeScopingInterop.clickElementById", FileInputId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles file selection and upload.
|
||||
/// </summary>
|
||||
protected async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (e.File == null) return;
|
||||
|
||||
IsUploading = true;
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
var uploadedItems = await UploadFileApiAsync(stream, e.File.Name);
|
||||
|
||||
if (uploadedItems != null)
|
||||
{
|
||||
Items.Clear();
|
||||
Items.AddRange(uploadedItems);
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {uploadedItems.Count} {EntityName}.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all items after confirmation.
|
||||
/// </summary>
|
||||
protected async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm(ClearConfirmMessage, "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
Items.Clear();
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@* Item number filter panel with autocomplete and grid *@
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using Microsoft.JSInterop
|
||||
@inject ILookupApiClient LookupApi
|
||||
@inject IFileApiClient FileApi
|
||||
@inject DialogService DialogService
|
||||
@@ -128,7 +129,21 @@
|
||||
{
|
||||
var result = await FileApi.DownloadItemsTemplateAsync(Items.AsReadOnly());
|
||||
result.Switch(
|
||||
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "items_template.xlsx", bytes); },
|
||||
bytes =>
|
||||
{
|
||||
// Fire-and-forget with error handling to prevent silent failures
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", "items_template.xlsx", bytes);
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Console.WriteLine($"JS interop failed during template download: {ex.Message}");
|
||||
}
|
||||
});
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
@@ -139,7 +154,7 @@
|
||||
|
||||
private async Task TriggerFileInput()
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('itemNumberFileInput').click()");
|
||||
await JSRuntime.InvokeVoidAsync("jdeScopingInterop.clickElementById", "itemNumberFileInput");
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
@@ -157,7 +172,18 @@
|
||||
{
|
||||
Items.Clear();
|
||||
Items.AddRange(items);
|
||||
_ = ItemsChanged.InvokeAsync(Items);
|
||||
// Fire-and-forget with error handling to prevent silent failures
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"EventCallback invocation failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {items.Count} items.");
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@* Operator filter panel with autocomplete and grid *@
|
||||
@inherits AutocompleteFilterPanelBase<OperatorViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Client.Extensions
|
||||
@inject ILookupApiClient LookupApi
|
||||
@inject DialogService DialogService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Operator</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
@@ -17,20 +17,20 @@
|
||||
{
|
||||
<RadzenRow Gap="0.5rem" class="rz-mb-3">
|
||||
<RadzenColumn Size="10">
|
||||
<RadzenFormField Text="Name" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="FullName"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search operators (3+ chars)..."
|
||||
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
|
||||
Style="width: 100%;" Change="@OnItemSelected" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="2">
|
||||
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
|
||||
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
|
||||
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
}
|
||||
|
||||
<RadzenDataGrid Data="@Operators" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="OperatorViewModel" Property="AddressNumber" Title="Address Number" Width="150px" />
|
||||
<RadzenDataGridColumn TItem="OperatorViewModel" Property="UserID" Title="User Name" Width="150px" />
|
||||
@@ -48,75 +48,27 @@
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<OperatorViewModel> Operators { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Operator";
|
||||
protected override string SearchPlaceholder => "Search operators (3+ chars)...";
|
||||
protected override string SearchFieldLabel => "Name";
|
||||
protected override string AutocompleteTextProperty => "FullName";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all operators?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<OperatorViewModel>> OperatorsChanged { get; set; }
|
||||
protected override object GetItemKey(OperatorViewModel item) => item.UserId;
|
||||
protected override string GetDisplayText(OperatorViewModel item) => item.FullName;
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
private string _searchText = "";
|
||||
private List<OperatorViewModel> _searchResults = [];
|
||||
private OperatorViewModel? _selectedItem;
|
||||
|
||||
private async Task OnSearchAsync(LoadDataArgs args)
|
||||
protected override async Task<List<OperatorViewModel>> SearchApiAsync(string filter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
|
||||
{
|
||||
var result = await LookupApi.FindOperatorsAsync(args.Filter);
|
||||
result.Switch(
|
||||
jdeUsers => { _searchResults = jdeUsers.ToClientOperatorList(); },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemSelected(object value)
|
||||
{
|
||||
if (value is string text && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
_selectedItem = _searchResults.FirstOrDefault(i => i.FullName == text);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddItemAsync()
|
||||
{
|
||||
if (_selectedItem != null && !Operators.Any(i => i.UserId == _selectedItem.UserId))
|
||||
{
|
||||
Operators.Add(_selectedItem);
|
||||
await OperatorsChanged.InvokeAsync(Operators);
|
||||
}
|
||||
_searchText = "";
|
||||
_selectedItem = null;
|
||||
}
|
||||
|
||||
private async Task DeleteItem(OperatorViewModel item)
|
||||
{
|
||||
Operators.Remove(item);
|
||||
await OperatorsChanged.InvokeAsync(Operators);
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all operators?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
Operators.Clear();
|
||||
await OperatorsChanged.InvokeAsync(Operators);
|
||||
}
|
||||
var result = await LookupApi.FindOperatorsAsync(filter);
|
||||
List<OperatorViewModel> items = [];
|
||||
result.Switch(
|
||||
jdeUsers => { items = jdeUsers.ToClientOperatorList(); },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
@* Part operation/MIS filter panel with upload/download/clear functionality *@
|
||||
@inherits FileUploadFilterPanelBase<PartOperationViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@inject IFileApiClient FileApi
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter By Item/Operation/MIS</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
|
||||
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="partOperationFileInput" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
|
||||
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
</RadzenStack>
|
||||
}
|
||||
</RadzenStack>
|
||||
|
||||
<RadzenDataGrid Data="@PartOperations" TItem="PartOperationViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="PartOperationViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="ItemNumber" Title="Item Number" />
|
||||
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="OperationNumber" Title="Operation Step Number" />
|
||||
@@ -29,85 +28,45 @@
|
||||
</RadzenDataGrid>
|
||||
|
||||
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
|
||||
<strong># of item / operations: @PartOperations.Count</strong>
|
||||
<strong>@CountLabel: @Items.Count</strong>
|
||||
</RadzenText>
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<PartOperationViewModel> PartOperations { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter By Item/Operation/MIS";
|
||||
protected override string CountLabel => "# of item / operations";
|
||||
protected override string FileInputId => "partOperationFileInput";
|
||||
protected override string TemplateFilename => "partoperations_template.xlsx";
|
||||
protected override string EntityName => "part operations";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all item/operation/MIS entries?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<PartOperationViewModel>> PartOperationsChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
private bool _isUploading;
|
||||
|
||||
private async Task DownloadTemplateAsync()
|
||||
protected override async Task<byte[]?> DownloadTemplateApiAsync()
|
||||
{
|
||||
var result = await FileApi.DownloadPartOperationsTemplateAsync(PartOperations.AsReadOnly());
|
||||
var result = await FileApi.DownloadPartOperationsTemplateAsync(Items.AsReadOnly());
|
||||
byte[]? bytes = null;
|
||||
result.Switch(
|
||||
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "partoperations_template.xlsx", bytes); },
|
||||
data => { bytes = data; },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task TriggerFileInput()
|
||||
protected override async Task<List<PartOperationViewModel>?> UploadFileApiAsync(Stream stream, string filename)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('partOperationFileInput').click()");
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (e.File == null) return;
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
var result = await FileApi.UploadPartOperationsAsync(stream, e.File.Name);
|
||||
|
||||
result.Switch(
|
||||
partOperations =>
|
||||
{
|
||||
PartOperations.Clear();
|
||||
PartOperations.AddRange(partOperations);
|
||||
_ = PartOperationsChanged.InvokeAsync(PartOperations);
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {partOperations.Count} part operations.");
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all item/operation/MIS entries?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
PartOperations.Clear();
|
||||
await PartOperationsChanged.InvokeAsync(PartOperations);
|
||||
}
|
||||
var result = await FileApi.UploadPartOperationsAsync(stream, filename);
|
||||
List<PartOperationViewModel>? items = null;
|
||||
result.Switch(
|
||||
partOperations => { items = partOperations.ToList(); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@* Profit center filter panel with autocomplete and grid *@
|
||||
@inherits AutocompleteFilterPanelBase<ProfitCenterViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@inject ILookupApiClient LookupApi
|
||||
@inject DialogService DialogService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Profit Center</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
@@ -16,20 +16,20 @@
|
||||
{
|
||||
<RadzenRow Gap="0.5rem" class="rz-mb-3">
|
||||
<RadzenColumn Size="10">
|
||||
<RadzenFormField Text="Profit Center" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search profit centers (3+ chars)..."
|
||||
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
|
||||
Style="width: 100%;" Change="@OnItemSelected" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="2">
|
||||
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
|
||||
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
|
||||
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
}
|
||||
|
||||
<RadzenDataGrid Data="@ProfitCenters" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Code" Title="Profit Center" Width="150px" />
|
||||
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Description" Title="Description" />
|
||||
@@ -46,75 +46,27 @@
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<ProfitCenterViewModel> ProfitCenters { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Profit Center";
|
||||
protected override string SearchPlaceholder => "Search profit centers (3+ chars)...";
|
||||
protected override string SearchFieldLabel => "Profit Center";
|
||||
protected override string AutocompleteTextProperty => "Code";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all profit centers?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<ProfitCenterViewModel>> ProfitCentersChanged { get; set; }
|
||||
protected override object GetItemKey(ProfitCenterViewModel item) => item.Code;
|
||||
protected override string GetDisplayText(ProfitCenterViewModel item) => item.Code;
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
private string _searchText = "";
|
||||
private List<ProfitCenterViewModel> _searchResults = [];
|
||||
private ProfitCenterViewModel? _selectedItem;
|
||||
|
||||
private async Task OnSearchAsync(LoadDataArgs args)
|
||||
protected override async Task<List<ProfitCenterViewModel>> SearchApiAsync(string filter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
|
||||
{
|
||||
var result = await LookupApi.FindProfitCentersAsync(args.Filter);
|
||||
result.Switch(
|
||||
profitCenters => { _searchResults = profitCenters.ToList(); },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemSelected(object value)
|
||||
{
|
||||
if (value is string text && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddItemAsync()
|
||||
{
|
||||
if (_selectedItem != null && !ProfitCenters.Any(i => i.Code == _selectedItem.Code))
|
||||
{
|
||||
ProfitCenters.Add(_selectedItem);
|
||||
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
|
||||
}
|
||||
_searchText = "";
|
||||
_selectedItem = null;
|
||||
}
|
||||
|
||||
private async Task DeleteItem(ProfitCenterViewModel item)
|
||||
{
|
||||
ProfitCenters.Remove(item);
|
||||
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all profit centers?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
ProfitCenters.Clear();
|
||||
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
|
||||
}
|
||||
var result = await LookupApi.FindProfitCentersAsync(filter);
|
||||
List<ProfitCenterViewModel> items = [];
|
||||
result.Switch(
|
||||
profitCenters => { items = profitCenters.ToList(); },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@* Work center filter panel with autocomplete and grid *@
|
||||
@inherits AutocompleteFilterPanelBase<WorkCenterViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@inject ILookupApiClient LookupApi
|
||||
@inject DialogService DialogService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Center</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
@@ -16,20 +16,20 @@
|
||||
{
|
||||
<RadzenRow Gap="0.5rem" class="rz-mb-3">
|
||||
<RadzenColumn Size="10">
|
||||
<RadzenFormField Text="Work Center" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search work centers (3+ chars)..."
|
||||
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
|
||||
Style="width: 100%;" Change="@OnItemSelected" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="2">
|
||||
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
|
||||
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
|
||||
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
}
|
||||
|
||||
<RadzenDataGrid Data="@WorkCenters" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Code" Title="Work Center" Width="150px" />
|
||||
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Description" Title="Description" />
|
||||
@@ -46,75 +46,27 @@
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<WorkCenterViewModel> WorkCenters { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Work Center";
|
||||
protected override string SearchPlaceholder => "Search work centers (3+ chars)...";
|
||||
protected override string SearchFieldLabel => "Work Center";
|
||||
protected override string AutocompleteTextProperty => "Code";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all work centers?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<WorkCenterViewModel>> WorkCentersChanged { get; set; }
|
||||
protected override object GetItemKey(WorkCenterViewModel item) => item.Code;
|
||||
protected override string GetDisplayText(WorkCenterViewModel item) => item.Code;
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
private string _searchText = "";
|
||||
private List<WorkCenterViewModel> _searchResults = [];
|
||||
private WorkCenterViewModel? _selectedItem;
|
||||
|
||||
private async Task OnSearchAsync(LoadDataArgs args)
|
||||
protected override async Task<List<WorkCenterViewModel>> SearchApiAsync(string filter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
|
||||
{
|
||||
var result = await LookupApi.FindWorkCentersAsync(args.Filter);
|
||||
result.Switch(
|
||||
workCenters => { _searchResults = workCenters.ToList(); },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemSelected(object value)
|
||||
{
|
||||
if (value is string text && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddItemAsync()
|
||||
{
|
||||
if (_selectedItem != null && !WorkCenters.Any(i => i.Code == _selectedItem.Code))
|
||||
{
|
||||
WorkCenters.Add(_selectedItem);
|
||||
await WorkCentersChanged.InvokeAsync(WorkCenters);
|
||||
}
|
||||
_searchText = "";
|
||||
_selectedItem = null;
|
||||
}
|
||||
|
||||
private async Task DeleteItem(WorkCenterViewModel item)
|
||||
{
|
||||
WorkCenters.Remove(item);
|
||||
await WorkCentersChanged.InvokeAsync(WorkCenters);
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work centers?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
WorkCenters.Clear();
|
||||
await WorkCentersChanged.InvokeAsync(WorkCenters);
|
||||
}
|
||||
var result = await LookupApi.FindWorkCentersAsync(filter);
|
||||
List<WorkCenterViewModel> items = [];
|
||||
result.Switch(
|
||||
workCenters => { items = workCenters.ToList(); },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
@* Work order filter panel with upload/download/clear functionality *@
|
||||
@inherits FileUploadFilterPanelBase<WorkOrderViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@inject IFileApiClient FileApi
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Order</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
|
||||
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="workOrderFileInput" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
|
||||
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
</RadzenStack>
|
||||
}
|
||||
</RadzenStack>
|
||||
|
||||
<RadzenDataGrid Data="@WorkOrders" TItem="WorkOrderViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="WorkOrderViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="WorkOrderNumber" Title="Work Order Number" />
|
||||
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="ItemNumber" Title="Item Number" />
|
||||
@@ -27,85 +26,45 @@
|
||||
</RadzenDataGrid>
|
||||
|
||||
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
|
||||
<strong># of work orders: @WorkOrders.Count</strong>
|
||||
<strong>@CountLabel: @Items.Count</strong>
|
||||
</RadzenText>
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<WorkOrderViewModel> WorkOrders { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Work Order";
|
||||
protected override string CountLabel => "# of work orders";
|
||||
protected override string FileInputId => "workOrderFileInput";
|
||||
protected override string TemplateFilename => "workorders_template.xlsx";
|
||||
protected override string EntityName => "work orders";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all work orders?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<WorkOrderViewModel>> WorkOrdersChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
private bool _isUploading;
|
||||
|
||||
private async Task DownloadTemplateAsync()
|
||||
protected override async Task<byte[]?> DownloadTemplateApiAsync()
|
||||
{
|
||||
var result = await FileApi.DownloadWorkOrdersTemplateAsync(WorkOrders.AsReadOnly());
|
||||
var result = await FileApi.DownloadWorkOrdersTemplateAsync(Items.AsReadOnly());
|
||||
byte[]? bytes = null;
|
||||
result.Switch(
|
||||
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "workorders_template.xlsx", bytes); },
|
||||
data => { bytes = data; },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task TriggerFileInput()
|
||||
protected override async Task<List<WorkOrderViewModel>?> UploadFileApiAsync(Stream stream, string filename)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('workOrderFileInput').click()");
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (e.File == null) return;
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
var result = await FileApi.UploadWorkOrdersAsync(stream, e.File.Name);
|
||||
|
||||
result.Switch(
|
||||
workOrders =>
|
||||
{
|
||||
WorkOrders.Clear();
|
||||
WorkOrders.AddRange(workOrders);
|
||||
_ = WorkOrdersChanged.InvokeAsync(WorkOrders);
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {workOrders.Count} work orders.");
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work orders?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
WorkOrders.Clear();
|
||||
await WorkOrdersChanged.InvokeAsync(WorkOrders);
|
||||
}
|
||||
var result = await FileApi.UploadWorkOrdersAsync(stream, filename);
|
||||
List<WorkOrderViewModel>? items = null;
|
||||
result.Switch(
|
||||
workOrders => { items = workOrders.ToList(); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
@*
|
||||
FilterVisibilityManager.razor - Filter panel visibility controller.
|
||||
|
||||
Manages which filter panels are visible based on the selected search type.
|
||||
Cascades itself to child components so they can check visibility.
|
||||
*@
|
||||
@namespace JdeScoping.Client.Components.Search
|
||||
|
||||
<CascadingValue Value="this">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public SearchCriteriaViewModel Criteria { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ValidCombination?> OnSearchTypeChanged { get; set; }
|
||||
|
||||
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
|
||||
private int? _selectedSearchType;
|
||||
|
||||
public int? SelectedSearchType
|
||||
{
|
||||
get => _selectedSearchType;
|
||||
set
|
||||
{
|
||||
if (_selectedSearchType != value)
|
||||
{
|
||||
_selectedSearchType = value;
|
||||
var combo = _validCombinations.FirstOrDefault(c => c.Id == value);
|
||||
if (combo != null)
|
||||
{
|
||||
UpdateFilterVisibility(combo);
|
||||
}
|
||||
OnSearchTypeChanged.InvokeAsync(combo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ValidCombination> ValidCombinations => _validCombinations;
|
||||
|
||||
// Filter visibility flags
|
||||
public bool ShowTimespan { get; private set; }
|
||||
public bool ShowWorkOrder { get; private set; }
|
||||
public bool ShowItemNumber { get; private set; }
|
||||
public bool ShowProfitCenter { get; private set; }
|
||||
public bool ShowWorkCenter { get; private set; }
|
||||
public bool ShowComponentLot { get; private set; }
|
||||
public bool ShowOperator { get; private set; }
|
||||
public bool ShowItemOperationMis { get; private set; }
|
||||
public bool ShowExtractMis { get; private set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
DetectSearchType();
|
||||
}
|
||||
|
||||
public void DetectSearchType()
|
||||
{
|
||||
bool hasTimespan = Criteria.MinimumDt.HasValue || Criteria.MaximumDt.HasValue;
|
||||
bool hasWorkOrder = Criteria.WorkOrders.Count > 0;
|
||||
bool hasItemNumber = Criteria.Items.Count > 0;
|
||||
bool hasProfitCenter = Criteria.ProfitCenters.Count > 0;
|
||||
bool hasWorkCenter = Criteria.WorkCenters.Count > 0;
|
||||
bool hasComponentLot = Criteria.ComponentLots.Count > 0;
|
||||
bool hasOperator = Criteria.Operators.Count > 0;
|
||||
bool hasPartOperation = Criteria.PartOperations.Count > 0;
|
||||
bool hasExtractMis = Criteria.ExtractMisData;
|
||||
|
||||
foreach (var combo in _validCombinations)
|
||||
{
|
||||
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
|
||||
{
|
||||
_selectedSearchType = combo.Id;
|
||||
UpdateFilterVisibility(combo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFilterVisibility(ValidCombination combo)
|
||||
{
|
||||
ShowTimespan = combo.Timespan;
|
||||
ShowWorkOrder = combo.WorkOrder;
|
||||
ShowItemNumber = combo.ItemNumber;
|
||||
ShowProfitCenter = combo.ProfitCenter;
|
||||
ShowWorkCenter = combo.WorkCenter;
|
||||
ShowComponentLot = combo.ComponentLot;
|
||||
ShowOperator = combo.Operator;
|
||||
ShowItemOperationMis = combo.ItemOperationMis;
|
||||
ShowExtractMis = combo.ExtractMis;
|
||||
|
||||
// Set ExtractMisData flag based on combo
|
||||
Criteria.ExtractMisData = combo.ExtractMis;
|
||||
}
|
||||
|
||||
public string? ValidateFilters()
|
||||
{
|
||||
if (ShowWorkOrder && Criteria.WorkOrders.Count == 0)
|
||||
return "At least one work order must be specified for the work order filter.";
|
||||
|
||||
if (ShowItemNumber && Criteria.Items.Count == 0)
|
||||
return "At least one item number must be specified for the item number filter.";
|
||||
|
||||
if (ShowProfitCenter && Criteria.ProfitCenters.Count == 0)
|
||||
return "At least one profit center must be specified for the profit center filter.";
|
||||
|
||||
if (ShowWorkCenter && Criteria.WorkCenters.Count == 0)
|
||||
return "At least one work center must be specified for the work center filter.";
|
||||
|
||||
if (ShowComponentLot && Criteria.ComponentLots.Count == 0)
|
||||
return "At least one component lot must be specified for the component lot filter.";
|
||||
|
||||
if (ShowOperator && Criteria.Operators.Count == 0)
|
||||
return "At least one operator must be specified for the operator filter.";
|
||||
|
||||
if (ShowItemOperationMis && Criteria.PartOperations.Count == 0)
|
||||
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@*
|
||||
SearchDetailsSection.razor - Search metadata input section.
|
||||
|
||||
Provides inputs for search name and type selection.
|
||||
Integrates with FilterVisibilityManager to show/hide filter panels based on search type.
|
||||
*@
|
||||
@namespace JdeScoping.Client.Components.Search
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
|
||||
|
||||
<RadzenRow Gap="1rem">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Search Type" Style="width: 100%;">
|
||||
<RadzenDropDown @bind-Value="SelectedSearchType" Data="@ValidCombinations" TextProperty="Name" ValueProperty="Id"
|
||||
Placeholder="Select type" Disabled="@Search.IsReadOnly" Change="@OnSearchTypeChangedHandler" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Name" Style="width: 100%;">
|
||||
<RadzenTextBox @bind-Value="Search.Name" Disabled="@Search.IsReadOnly" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
<ValidationMessage For="@(() => Search.Name)" class="validation-message text-danger" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Submitted At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(Search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Started At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(Search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Completed At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(Search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="User" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@Search.UserName" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Status" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@Search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {Search.StatusColor};")" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
@if (Search.HasResults)
|
||||
{
|
||||
<RadzenFormField Text=" " Style="width: 100%;">
|
||||
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@OnDownloadResults" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
}
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public ClientSearchViewModel Search { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public int? SelectedSearchType { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int?> SelectedSearchTypeChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<ValidCombination> ValidCombinations { get; set; } = ValidCombination.GetAll();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnDownloadResults { get; set; }
|
||||
|
||||
private async Task OnSearchTypeChangedHandler()
|
||||
{
|
||||
await SelectedSearchTypeChanged.InvokeAsync(SelectedSearchType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@*
|
||||
SignalRStatusHandler.razor - Real-time search status handler.
|
||||
|
||||
Subscribes to SignalR hub for search status updates.
|
||||
Filters updates by SearchId and raises OnStatusChanged callback.
|
||||
*@
|
||||
@namespace JdeScoping.Client.Components.Search
|
||||
@inject IHubConnectionService HubConnection
|
||||
@implements IDisposable
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int SearchId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<SearchUpdateViewModel> OnStatusChanged { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
HubConnection.OnSearchUpdate += HandleSearchUpdate;
|
||||
await HubConnection.StartAsync();
|
||||
}
|
||||
|
||||
private void HandleSearchUpdate(SearchUpdateViewModel update)
|
||||
{
|
||||
if (update.Id == SearchId)
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
await OnStatusChanged.InvokeAsync(update);
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
@* Loading indicator component with optional message *@
|
||||
@*
|
||||
LoadingIndicator.razor - Reusable loading spinner component.
|
||||
|
||||
Displays a circular progress indicator with an optional message.
|
||||
Used throughout the app during async data loading operations.
|
||||
|
||||
Parameters:
|
||||
- Message: Optional text to display below the spinner.
|
||||
*@
|
||||
|
||||
<div class="loading-container">
|
||||
<RadzenProgressBarCircular ShowValue="false" Mode="ProgressBarMode.Indeterminate" Size="ProgressBarCircularSize.Large" />
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
RedirectToLogin.razor - Authentication redirect component.
|
||||
|
||||
Automatically redirects unauthenticated users to the login page,
|
||||
preserving the original URL as a return parameter.
|
||||
*@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
|
||||
@@ -37,17 +37,22 @@ public static class ViewModelMappingExtensions
|
||||
/// <summary>
|
||||
/// Maps Client SearchViewModel to Core SearchViewModel.
|
||||
/// </summary>
|
||||
public static CoreSearch ToCore(this SearchViewModel vm) => new()
|
||||
public static CoreSearch ToCore(this SearchViewModel vm)
|
||||
{
|
||||
Id = vm.Id,
|
||||
Name = vm.Name,
|
||||
UserName = vm.UserName,
|
||||
Status = Enum.TryParse<SearchStatus>(vm.Status, out var status) ? status : SearchStatus.New,
|
||||
SubmitDt = vm.SubmitDt,
|
||||
StartDt = vm.StartDt,
|
||||
EndDt = vm.EndDt,
|
||||
Criteria = vm.Criteria.ToCoreCriteria()
|
||||
};
|
||||
ArgumentNullException.ThrowIfNull(vm);
|
||||
|
||||
return new CoreSearch
|
||||
{
|
||||
Id = vm.Id,
|
||||
Name = vm.Name,
|
||||
UserName = vm.UserName,
|
||||
Status = Enum.TryParse<SearchStatus>(vm.Status, out var status) ? status : SearchStatus.New,
|
||||
SubmitDt = vm.SubmitDt,
|
||||
StartDt = vm.StartDt,
|
||||
EndDt = vm.EndDt,
|
||||
Criteria = vm.Criteria.ToCoreCriteria()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Core SearchCriteria to Client SearchCriteriaViewModel.
|
||||
@@ -102,28 +107,33 @@ public static class ViewModelMappingExtensions
|
||||
/// Maps Client SearchCriteriaViewModel to Core SearchCriteria.
|
||||
/// Client uses full view model objects; Core uses primitive lists.
|
||||
/// </summary>
|
||||
public static SearchCriteria ToCoreCriteria(this SearchCriteriaViewModel criteria) => new()
|
||||
public static SearchCriteria ToCoreCriteria(this SearchCriteriaViewModel criteria)
|
||||
{
|
||||
MinimumDt = criteria.MinimumDt,
|
||||
MaximumDt = criteria.MaximumDt,
|
||||
ExtractMisData = criteria.ExtractMisData,
|
||||
WorkOrderNumbers = criteria.WorkOrders.Select(wo => wo.WorkOrderNumber).ToList(),
|
||||
ItemNumbers = criteria.Items.Select(i => i.ItemNumber).ToList(),
|
||||
ProfitCenters = criteria.ProfitCenters.Select(pc => pc.Code).ToList(),
|
||||
WorkCenters = criteria.WorkCenters.Select(wc => wc.Code).ToList(),
|
||||
OperatorIDs = criteria.Operators.Select(o => o.UserId).ToList(),
|
||||
ComponentLotNumbers = criteria.ComponentLots
|
||||
.Select(l => new CoreLot { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber })
|
||||
.ToList(),
|
||||
PartOperations = criteria.PartOperations.ToList()
|
||||
};
|
||||
ArgumentNullException.ThrowIfNull(criteria);
|
||||
|
||||
return new SearchCriteria
|
||||
{
|
||||
MinimumDt = criteria.MinimumDt,
|
||||
MaximumDt = criteria.MaximumDt,
|
||||
ExtractMisData = criteria.ExtractMisData,
|
||||
WorkOrderNumbers = criteria.WorkOrders.Select(wo => wo.WorkOrderNumber).ToList(),
|
||||
ItemNumbers = criteria.Items.Select(i => i.ItemNumber).ToList(),
|
||||
ProfitCenters = criteria.ProfitCenters.Select(pc => pc.Code).ToList(),
|
||||
WorkCenters = criteria.WorkCenters.Select(wc => wc.Code).ToList(),
|
||||
OperatorIDs = criteria.Operators.Select(o => o.UserId).ToList(),
|
||||
ComponentLotNumbers = criteria.ComponentLots
|
||||
.Select(l => new CoreLot { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber })
|
||||
.ToList(),
|
||||
PartOperations = criteria.PartOperations.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Core JdeUserViewModel to Client OperatorViewModel.
|
||||
/// </summary>
|
||||
public static OperatorViewModel ToClientOperator(this CoreJdeUser vm) => new()
|
||||
{
|
||||
AddressNumber = (int)vm.AddressNumber,
|
||||
AddressNumber = vm.AddressNumber,
|
||||
UserId = vm.UserId,
|
||||
FullName = vm.FullName
|
||||
};
|
||||
@@ -131,12 +141,18 @@ public static class ViewModelMappingExtensions
|
||||
/// <summary>
|
||||
/// Maps a collection of Core SearchViewModels to Client SearchViewModels.
|
||||
/// </summary>
|
||||
public static List<SearchViewModel> ToClientList(this IEnumerable<CoreSearch> list) =>
|
||||
list.Select(s => s.ToClient()).ToList();
|
||||
public static List<SearchViewModel> ToClientList(this IEnumerable<CoreSearch> list)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(list);
|
||||
return list.Select(s => s.ToClient()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a collection of Core JdeUserViewModels to Client OperatorViewModels.
|
||||
/// </summary>
|
||||
public static List<OperatorViewModel> ToClientOperatorList(this IEnumerable<CoreJdeUser> list) =>
|
||||
list.Select(u => u.ToClientOperator()).ToList();
|
||||
public static List<OperatorViewModel> ToClientOperatorList(this IEnumerable<CoreJdeUser> list)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(list);
|
||||
return list.Select(u => u.ToClientOperator()).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace JdeScoping.Client.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for formatting SQL queries for display.
|
||||
/// </summary>
|
||||
public static class SqlFormatHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats a SQL query string for readable display by adding line breaks
|
||||
/// before major SQL clauses and formatting SELECT columns.
|
||||
/// </summary>
|
||||
/// <param name="sql">The raw SQL query string.</param>
|
||||
/// <returns>A formatted SQL string with line breaks for readability.</returns>
|
||||
public static string FormatSql(string? sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
return "";
|
||||
|
||||
// Format SELECT columns - put each column on its own line
|
||||
var result = FormatSelectColumns(sql);
|
||||
|
||||
// Add line breaks before major clauses
|
||||
result = result
|
||||
.Replace(" FROM ", "\nFROM ")
|
||||
.Replace(" WHERE ", "\nWHERE ")
|
||||
.Replace(" AND ", "\n AND ")
|
||||
.Replace(" OR ", "\n OR ")
|
||||
.Replace(" LEFT ", "\nLEFT ")
|
||||
.Replace(" RIGHT ", "\nRIGHT ")
|
||||
.Replace(" INNER ", "\nINNER ")
|
||||
.Replace(" OUTER ", "\nOUTER ")
|
||||
.Replace(" JOIN ", " JOIN\n ")
|
||||
.Replace(" ORDER BY ", "\nORDER BY ")
|
||||
.Replace(" GROUP BY ", "\nGROUP BY ")
|
||||
.Replace(" HAVING ", "\nHAVING ");
|
||||
|
||||
return result.Trim();
|
||||
}
|
||||
|
||||
private static string FormatSelectColumns(string sql)
|
||||
{
|
||||
// Find SELECT and FROM positions (case-insensitive)
|
||||
var selectIndex = sql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase);
|
||||
var fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (selectIndex < 0 || fromIndex < 0 || fromIndex <= selectIndex)
|
||||
return sql;
|
||||
|
||||
var beforeSelect = sql[..selectIndex];
|
||||
var selectKeyword = sql.Substring(selectIndex, 6); // "SELECT"
|
||||
var columnsStart = selectIndex + 6;
|
||||
var columns = sql[columnsStart..fromIndex];
|
||||
var afterColumns = sql[fromIndex..];
|
||||
|
||||
// Split columns by comma and rejoin with newlines
|
||||
var columnList = columns.Split(',');
|
||||
var formattedColumns = string.Join(",\n ", columnList.Select(c => c.Trim()));
|
||||
|
||||
return $"{beforeSelect}{selectKeyword} {formattedColumns}{afterColumns}";
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
PipelineViewer.razor - ETL pipeline configuration viewer (admin).
|
||||
|
||||
Displays all configured data sync pipelines with their schedules, queries, and mappings.
|
||||
Read-only view for inspecting pipeline configuration without modifying.
|
||||
*@
|
||||
@page "/admin/pipeline-viewer"
|
||||
@attribute [Authorize]
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
@*
|
||||
Login.razor - User authentication page.
|
||||
|
||||
Handles LDAP authentication with RSA-encrypted credential transmission.
|
||||
Redirects to the home page on successful login.
|
||||
*@
|
||||
@page "/login"
|
||||
@using JdeScoping.Core.Models.Auth
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Client.Auth
|
||||
@inject IAuthApiClient AuthApi
|
||||
@inject ICryptoService CryptoService
|
||||
@inject AuthStateProvider AuthStateProvider
|
||||
@inject IAuthStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Login - JDE Scoping Tool</PageTitle>
|
||||
@@ -73,8 +80,18 @@
|
||||
{
|
||||
if (loginResult.Success && loginResult.User is not null)
|
||||
{
|
||||
// Notify auth state provider of successful login
|
||||
_ = AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
|
||||
// Fire-and-forget with error handling to prevent silent failures
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to mark user as authenticated: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
|
||||
NavigationManager.NavigateTo(returnUrl);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
RefreshStatus.razor - Data cache refresh status dashboard.
|
||||
|
||||
Shows the status of JDE/CMS data synchronization jobs (hourly, daily, mass).
|
||||
Allows filtering by date range and entity name.
|
||||
*@
|
||||
@page "/refresh-status"
|
||||
@attribute [Authorize]
|
||||
@inject IRefreshStatusService RefreshStatusService
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
@*
|
||||
SearchEdit.razor - Main search creation and editing page.
|
||||
|
||||
Handles creating new searches, editing existing drafts, and viewing completed searches.
|
||||
Integrates with SignalR for real-time status updates during search execution.
|
||||
*@
|
||||
@page "/search"
|
||||
@page "/search/{Id:int}"
|
||||
@attribute [Authorize]
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Client.Extensions
|
||||
@using JdeScoping.Client.Auth
|
||||
@using JdeScoping.Client.Components.Search
|
||||
@using Microsoft.JSInterop
|
||||
@inject ISearchApiClient SearchApi
|
||||
@inject IHubConnectionService HubConnection
|
||||
@inject AuthStateProvider AuthStateProvider
|
||||
@inject IAuthStateProvider AuthStateProvider
|
||||
@inject ISearchValidationService ValidationService
|
||||
@inject ISearchSubmissionService SubmissionService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
@@ -34,133 +45,79 @@ else if (!string.IsNullOrEmpty(_errorMessage))
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
<SignalRStatusHandler SearchId="@_search.Id" OnStatusChanged="HandleSearchUpdate" />
|
||||
|
||||
@if (_search.IsReadOnly)
|
||||
{
|
||||
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
|
||||
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
|
||||
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
|
||||
</RadzenAlert>
|
||||
}
|
||||
<FilterVisibilityManager @ref="_visibilityManager" Criteria="@_search.Criteria" OnSearchTypeChanged="OnSearchTypeChanged">
|
||||
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<!-- Validation Summary -->
|
||||
<ValidationSummary class="rz-mb-4" />
|
||||
@if (_search.IsReadOnly)
|
||||
{
|
||||
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
|
||||
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
|
||||
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
|
||||
</RadzenAlert>
|
||||
}
|
||||
|
||||
<!-- Search Details Panel -->
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
|
||||
<ValidationSummary class="rz-mb-4" />
|
||||
|
||||
<RadzenRow Gap="1rem">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Search Type" Style="width: 100%;">
|
||||
<RadzenDropDown @bind-Value="_selectedSearchType" Data="@_validCombinations" TextProperty="Name" ValueProperty="Id"
|
||||
Placeholder="Select type" Disabled="@_search.IsReadOnly" Change="@OnSearchTypeChanged" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
<SearchDetailsSection
|
||||
Search="@_search"
|
||||
@bind-SelectedSearchType="@_visibilityManager.SelectedSearchType"
|
||||
ValidCombinations="@_visibilityManager.ValidCombinations"
|
||||
OnDownloadResults="@DownloadResultsAsync" />
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Name" Style="width: 100%;">
|
||||
<RadzenTextBox @bind-Value="_search.Name" Disabled="@_search.IsReadOnly" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
<ValidationMessage For="@(() => _search.Name)" class="validation-message text-danger" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
@if (_visibilityManager.ShowTimespan)
|
||||
{
|
||||
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Submitted At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Started At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Completed At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
@if (_visibilityManager.ShowWorkOrder)
|
||||
{
|
||||
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="User" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@_search.UserName" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Status" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@_search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {_search.StatusColor};")" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
@if (_search.HasResults)
|
||||
{
|
||||
<RadzenFormField Text=" " Style="width: 100%;">
|
||||
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@DownloadResultsAsync" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
}
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
</RadzenCard>
|
||||
@if (_visibilityManager.ShowItemNumber)
|
||||
{
|
||||
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<!-- Filter Panels -->
|
||||
@if (_showTimespan)
|
||||
{
|
||||
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowProfitCenter)
|
||||
{
|
||||
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showWorkOrder)
|
||||
{
|
||||
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowWorkCenter)
|
||||
{
|
||||
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showItemNumber)
|
||||
{
|
||||
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowComponentLot)
|
||||
{
|
||||
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showProfitCenter)
|
||||
{
|
||||
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowOperator)
|
||||
{
|
||||
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showWorkCenter)
|
||||
{
|
||||
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowItemOperationMis)
|
||||
{
|
||||
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showComponentLot)
|
||||
{
|
||||
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showOperator)
|
||||
{
|
||||
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showItemOperationMis)
|
||||
{
|
||||
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showExtractMis)
|
||||
{
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
|
||||
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
}
|
||||
</EditForm>
|
||||
@if (_visibilityManager.ShowExtractMis)
|
||||
{
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
|
||||
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
}
|
||||
</EditForm>
|
||||
</FilterVisibilityManager>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -171,28 +128,15 @@ else
|
||||
public int? CopySearchId { get; set; }
|
||||
|
||||
private ClientSearchViewModel _search = new() { Criteria = new() };
|
||||
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
|
||||
private int? _selectedSearchType;
|
||||
private FilterVisibilityManager _visibilityManager = null!;
|
||||
|
||||
private bool _isLoading = true;
|
||||
private bool _isSubmitting;
|
||||
private string? _errorMessage;
|
||||
|
||||
// Filter visibility flags
|
||||
private bool _showTimespan;
|
||||
private bool _showWorkOrder;
|
||||
private bool _showItemNumber;
|
||||
private bool _showProfitCenter;
|
||||
private bool _showWorkCenter;
|
||||
private bool _showComponentLot;
|
||||
private bool _showOperator;
|
||||
private bool _showItemOperationMis;
|
||||
private bool _showExtractMis;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadSearchAsync();
|
||||
await SetupSignalRAsync();
|
||||
}
|
||||
|
||||
private async Task LoadSearchAsync()
|
||||
@@ -232,7 +176,6 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
// New search
|
||||
_search = new ClientSearchViewModel
|
||||
{
|
||||
Status = "New",
|
||||
@@ -240,12 +183,6 @@ else
|
||||
Criteria = new SearchCriteriaViewModel()
|
||||
};
|
||||
}
|
||||
|
||||
// Detect search type from criteria (only if no error)
|
||||
if (string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
DetectSearchType();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -259,92 +196,26 @@ else
|
||||
return string.Join(" ", messages);
|
||||
}
|
||||
|
||||
private void DetectSearchType()
|
||||
private void OnSearchTypeChanged(ValidCombination? combo)
|
||||
{
|
||||
var criteria = _search.Criteria;
|
||||
|
||||
bool hasTimespan = criteria.MinimumDt.HasValue || criteria.MaximumDt.HasValue;
|
||||
bool hasWorkOrder = criteria.WorkOrders.Count > 0;
|
||||
bool hasItemNumber = criteria.Items.Count > 0;
|
||||
bool hasProfitCenter = criteria.ProfitCenters.Count > 0;
|
||||
bool hasWorkCenter = criteria.WorkCenters.Count > 0;
|
||||
bool hasComponentLot = criteria.ComponentLots.Count > 0;
|
||||
bool hasOperator = criteria.Operators.Count > 0;
|
||||
bool hasPartOperation = criteria.PartOperations.Count > 0;
|
||||
bool hasExtractMis = criteria.ExtractMisData;
|
||||
|
||||
foreach (var combo in _validCombinations)
|
||||
{
|
||||
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
|
||||
{
|
||||
_selectedSearchType = combo.Id;
|
||||
UpdateFilterVisibility(combo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearchTypeChanged()
|
||||
{
|
||||
var combo = _validCombinations.FirstOrDefault(c => c.Id == _selectedSearchType);
|
||||
if (combo != null)
|
||||
{
|
||||
UpdateFilterVisibility(combo);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFilterVisibility(ValidCombination combo)
|
||||
{
|
||||
_showTimespan = combo.Timespan;
|
||||
_showWorkOrder = combo.WorkOrder;
|
||||
_showItemNumber = combo.ItemNumber;
|
||||
_showProfitCenter = combo.ProfitCenter;
|
||||
_showWorkCenter = combo.WorkCenter;
|
||||
_showComponentLot = combo.ComponentLot;
|
||||
_showOperator = combo.Operator;
|
||||
_showItemOperationMis = combo.ItemOperationMis;
|
||||
_showExtractMis = combo.ExtractMis;
|
||||
|
||||
// Set ExtractMisData flag based on combo
|
||||
_search.Criteria.ExtractMisData = combo.ExtractMis;
|
||||
}
|
||||
|
||||
private async Task SetupSignalRAsync()
|
||||
{
|
||||
HubConnection.OnSearchUpdate += HandleSearchUpdate;
|
||||
await HubConnection.StartAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void HandleSearchUpdate(SearchUpdateViewModel update)
|
||||
{
|
||||
if (update.Id == _search.Id)
|
||||
{
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
_search.Status = update.Status;
|
||||
_search.SubmitDt = update.SubmitDt;
|
||||
_search.StartDt = update.StartDt;
|
||||
_search.EndDt = update.EndDt;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
_search.Status = update.Status;
|
||||
_search.SubmitDt = update.SubmitDt;
|
||||
_search.StartDt = update.StartDt;
|
||||
_search.EndDt = update.EndDt;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleValidSubmit()
|
||||
{
|
||||
// DataAnnotationsValidator has already validated the model
|
||||
// Now perform additional custom validation
|
||||
if (_selectedSearchType == null)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filter data based on search type
|
||||
var validationError = ValidateFilters();
|
||||
var validationError = ValidationService.Validate(_search, _visibilityManager.SelectedSearchType, _visibilityManager);
|
||||
if (!string.IsNullOrEmpty(validationError))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -353,24 +224,10 @@ else
|
||||
|
||||
private async Task SubmitSearchAsync()
|
||||
{
|
||||
// Manual submit button handler - validate and submit
|
||||
if (string.IsNullOrWhiteSpace(_search.Name))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedSearchType == null)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filter data based on search type
|
||||
var validationError = ValidateFilters();
|
||||
var validationError = ValidationService.Validate(_search, _visibilityManager.SelectedSearchType, _visibilityManager);
|
||||
if (!string.IsNullOrEmpty(validationError))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,15 +250,15 @@ else
|
||||
_isSubmitting = true;
|
||||
try
|
||||
{
|
||||
var result = await SearchApi.CreateSearchAsync(_search.ToCore());
|
||||
result.Switch(
|
||||
id => { NavigationManager.NavigateTo($"/search/{id}"); },
|
||||
notFound => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Search not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Validation Error", FormatValidationErrors(validation.FieldErrors)); },
|
||||
unauthorized => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
forbidden => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
var result = await SubmissionService.SubmitAsync(_search);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/search/{result.SearchId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Error", result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -409,32 +266,6 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private string? ValidateFilters()
|
||||
{
|
||||
if (_showWorkOrder && _search.Criteria.WorkOrders.Count == 0)
|
||||
return "At least one work order must be specified for the work order filter.";
|
||||
|
||||
if (_showItemNumber && _search.Criteria.Items.Count == 0)
|
||||
return "At least one item number must be specified for the item number filter.";
|
||||
|
||||
if (_showProfitCenter && _search.Criteria.ProfitCenters.Count == 0)
|
||||
return "At least one profit center must be specified for the profit center filter.";
|
||||
|
||||
if (_showWorkCenter && _search.Criteria.WorkCenters.Count == 0)
|
||||
return "At least one work center must be specified for the work center filter.";
|
||||
|
||||
if (_showComponentLot && _search.Criteria.ComponentLots.Count == 0)
|
||||
return "At least one component lot must be specified for the component lot filter.";
|
||||
|
||||
if (_showOperator && _search.Criteria.Operators.Count == 0)
|
||||
return "At least one operator must be specified for the operator filter.";
|
||||
|
||||
if (_showItemOperationMis && _search.Criteria.PartOperations.Count == 0)
|
||||
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CopySearchAsync()
|
||||
{
|
||||
NavigationManager.NavigateTo($"/search?copySearchId={_search.Id}");
|
||||
@@ -448,7 +279,17 @@ else
|
||||
{
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
_ = JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", bytes);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", bytes);
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Console.WriteLine($"JS interop failed during file download: {ex.Message}");
|
||||
}
|
||||
});
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Download", "Results downloaded successfully.");
|
||||
}
|
||||
else
|
||||
@@ -466,6 +307,6 @@ else
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
|
||||
// SignalRStatusHandler handles its own disposal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
SearchQueue.razor - Real-time search processing queue.
|
||||
|
||||
Displays all queued and running searches with live status updates via SignalR.
|
||||
Shows progress indicators and allows users to monitor their search execution.
|
||||
*@
|
||||
@page "/search/queue"
|
||||
@attribute [Authorize]
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
Searches.razor - Search list dashboard (home page).
|
||||
|
||||
Displays all searches for the current user with filtering, sorting, and pagination.
|
||||
Allows creating new searches, viewing/editing drafts, and downloading completed results.
|
||||
*@
|
||||
@page "/"
|
||||
@page "/searches"
|
||||
@attribute [Authorize]
|
||||
|
||||
@@ -37,6 +37,7 @@ builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddScoped<IUserStorageService, UserStorageService>();
|
||||
builder.Services.AddScoped<AuthStateProvider>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
|
||||
builder.Services.AddScoped<IAuthStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
|
||||
|
||||
// Crypto service for login encryption
|
||||
builder.Services.AddScoped<ICryptoService, CryptoService>();
|
||||
@@ -56,4 +57,8 @@ builder.Services.AddScoped<IAuthApiClient, AuthApiClient>();
|
||||
builder.Services.AddScoped<IFileApiClient, FileApiClient>();
|
||||
builder.Services.AddScoped<IPipelineApiClient, PipelineApiClient>();
|
||||
|
||||
// Search services
|
||||
builder.Services.AddScoped<ISearchValidationService, SearchValidationService>();
|
||||
builder.Services.AddScoped<ISearchSubmissionService, SearchSubmissionService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -11,12 +11,12 @@ public class AuthService : IAuthService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ICryptoService _cryptoService;
|
||||
private readonly AuthStateProvider _authStateProvider;
|
||||
private readonly IAuthStateProvider _authStateProvider;
|
||||
|
||||
public AuthService(
|
||||
HttpClient httpClient,
|
||||
ICryptoService cryptoService,
|
||||
AuthStateProvider authStateProvider)
|
||||
IAuthStateProvider authStateProvider)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_cryptoService = cryptoService;
|
||||
|
||||
@@ -9,12 +9,13 @@ namespace JdeScoping.Client.Services;
|
||||
/// Encrypts login credentials using Web Crypto API via JavaScript interop.
|
||||
/// Uses RSA-OAEP with SHA-256 to encrypt credentials before transmission.
|
||||
/// </summary>
|
||||
public class CryptoService : ICryptoService
|
||||
public class CryptoService : ICryptoService, IAsyncDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private string? _cachedPublicKeyPem;
|
||||
private readonly SemaphoreSlim _keyLock = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
public CryptoService(HttpClient httpClient, IJSRuntime jsRuntime)
|
||||
{
|
||||
@@ -40,6 +41,8 @@ public class CryptoService : ICryptoService
|
||||
|
||||
private async Task<string> GetOrFetchPublicKeyAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_cachedPublicKeyPem is not null)
|
||||
return _cachedPublicKeyPem;
|
||||
|
||||
@@ -60,4 +63,18 @@ public class CryptoService : ICryptoService
|
||||
_keyLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the semaphore used for thread-safe key caching.
|
||||
/// </summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_keyLock.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using JdeScoping.Client.Models;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for handling search submission operations.
|
||||
/// </summary>
|
||||
public interface ISearchSubmissionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits a search and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="search">The search view model to submit.</param>
|
||||
/// <returns>The submission result containing the search ID on success, or error information on failure.</returns>
|
||||
Task<SearchSubmissionResult> SubmitAsync(SearchViewModel search);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a search submission operation.
|
||||
/// </summary>
|
||||
public class SearchSubmissionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the search ID if submission was successful.
|
||||
/// </summary>
|
||||
public int? SearchId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if submission failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the submission was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess => SearchId.HasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static SearchSubmissionResult Success(int searchId) => new() { SearchId = searchId };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure result.
|
||||
/// </summary>
|
||||
public static SearchSubmissionResult Failure(string error) => new() { ErrorMessage = error };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using JdeScoping.Client.Components.Search;
|
||||
using JdeScoping.Client.Models;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for validating search criteria before submission.
|
||||
/// </summary>
|
||||
public interface ISearchValidationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that a search is ready for submission.
|
||||
/// </summary>
|
||||
/// <param name="search">The search view model to validate.</param>
|
||||
/// <param name="selectedSearchType">The selected search type ID.</param>
|
||||
/// <param name="visibilityManager">The filter visibility manager with current filter state.</param>
|
||||
/// <returns>A validation error message, or null if valid.</returns>
|
||||
string? Validate(SearchViewModel search, int? selectedSearchType, FilterVisibilityManager visibilityManager);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using JdeScoping.Client.Extensions;
|
||||
using JdeScoping.Client.Models;
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for handling search submission operations.
|
||||
/// </summary>
|
||||
public class SearchSubmissionService : ISearchSubmissionService
|
||||
{
|
||||
private readonly ISearchApiClient _searchApi;
|
||||
|
||||
public SearchSubmissionService(ISearchApiClient searchApi)
|
||||
{
|
||||
_searchApi = searchApi;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SearchSubmissionResult> SubmitAsync(SearchViewModel search)
|
||||
{
|
||||
var result = await _searchApi.CreateSearchAsync(search.ToCore());
|
||||
|
||||
SearchSubmissionResult? submissionResult = null;
|
||||
|
||||
result.Switch(
|
||||
id => { submissionResult = SearchSubmissionResult.Success(id); },
|
||||
notFound => { submissionResult = SearchSubmissionResult.Failure("Search not found."); },
|
||||
validation => { submissionResult = SearchSubmissionResult.Failure(FormatValidationErrors(validation.FieldErrors)); },
|
||||
unauthorized => { submissionResult = SearchSubmissionResult.Failure("Session expired."); },
|
||||
forbidden => { submissionResult = SearchSubmissionResult.Failure("Access denied."); },
|
||||
error => { submissionResult = SearchSubmissionResult.Failure(error.Message); }
|
||||
);
|
||||
|
||||
return submissionResult ?? SearchSubmissionResult.Failure("Unknown error occurred.");
|
||||
}
|
||||
|
||||
private static string FormatValidationErrors(IReadOnlyDictionary<string, string[]> fieldErrors)
|
||||
{
|
||||
var messages = fieldErrors.SelectMany(kv => kv.Value);
|
||||
return string.Join(" ", messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using JdeScoping.Client.Components.Search;
|
||||
using JdeScoping.Client.Models;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for validating search criteria before submission.
|
||||
/// </summary>
|
||||
public class SearchValidationService : ISearchValidationService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string? Validate(SearchViewModel search, int? selectedSearchType, FilterVisibilityManager visibilityManager)
|
||||
{
|
||||
// Validate name
|
||||
if (string.IsNullOrWhiteSpace(search.Name))
|
||||
{
|
||||
return "Name is required.";
|
||||
}
|
||||
|
||||
// Validate search type
|
||||
if (selectedSearchType == null)
|
||||
{
|
||||
return "Search type is required.";
|
||||
}
|
||||
|
||||
// Validate filters based on search type
|
||||
return visibilityManager.ValidateFilters();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
@using JdeScoping.Client.Components.Admin
|
||||
@using JdeScoping.Client.Components.FilterPanels
|
||||
@using JdeScoping.Client.Extensions
|
||||
@using JdeScoping.Client.Components.Search
|
||||
@using JdeScoping.Client.Components.Shared
|
||||
@using JdeScoping.Client.Layout
|
||||
@using JdeScoping.Client.Models
|
||||
|
||||
@@ -13,6 +13,14 @@ window.downloadFile = function (fileName, byteArray) {
|
||||
};
|
||||
|
||||
window.jdeScopingInterop = {
|
||||
// Programmatically click an element by its ID (used for triggering file inputs)
|
||||
clickElementById: function (elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.click();
|
||||
}
|
||||
},
|
||||
|
||||
// Download file from a byte array stream
|
||||
downloadFileFromStream: async function (fileName, contentStreamReference) {
|
||||
const arrayBuffer = await contentStreamReference.arrayBuffer();
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
using JdeScoping.Core.Models;
|
||||
|
||||
namespace JdeScoping.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication service interface
|
||||
/// </summary>
|
||||
public interface IAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates a user with the given credentials
|
||||
/// </summary>
|
||||
/// <param name="username">Username</param>
|
||||
/// <param name="password">Password</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Authentication result</returns>
|
||||
Task<AuthResult> AuthenticateAsync(
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets user information for the given username
|
||||
/// </summary>
|
||||
/// <param name="username">Username to lookup</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>User info if found, null otherwise</returns>
|
||||
Task<UserInfo?> GetUserInfoAsync(
|
||||
string username,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user is a member of a specific group
|
||||
/// </summary>
|
||||
/// <param name="username">Username to check</param>
|
||||
/// <param name="groupName">Group name or DN to check membership</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>True if user is in the group, false otherwise</returns>
|
||||
Task<bool> IsInGroupAsync(
|
||||
string username,
|
||||
string groupName,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using JdeScoping.Core.Models;
|
||||
|
||||
namespace JdeScoping.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Core authentication service interface.
|
||||
/// Provides credential-based user authentication.
|
||||
/// </summary>
|
||||
public interface IAuthenticationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates a user with the given credentials.
|
||||
/// </summary>
|
||||
/// <param name="username">Username</param>
|
||||
/// <param name="password">Password</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Authentication result containing success status and user info if successful</returns>
|
||||
Task<AuthResult> AuthenticateAsync(
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using JdeScoping.Core.Models.SearchResults;
|
||||
|
||||
namespace JdeScoping.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,5 +13,5 @@ public interface IExcelExportService
|
||||
/// <param name="search">Search model with criteria and results.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Excel file as byte array.</returns>
|
||||
Task<byte[]> GenerateAsync(object search, CancellationToken cancellationToken = default);
|
||||
Task<byte[]> GenerateAsync(SearchModel search, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace JdeScoping.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service for securely storing and retrieving encrypted secrets.
|
||||
/// </summary>
|
||||
public interface ISecureStoreService : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a secret value by key, returning null if not found.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>The secret value, or null if not found.</returns>
|
||||
string? Get(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a required secret value by key, throwing if not found.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>The secret value.</returns>
|
||||
/// <exception cref="KeyNotFoundException">Thrown when the key is not found.</exception>
|
||||
string GetRequired(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a secret value.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The secret value.</param>
|
||||
void Set(string key, string value);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a secret exists.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>True if the secret exists, false otherwise.</returns>
|
||||
bool Contains(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a secret by key.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>True if the secret was removed, false if it didn't exist.</returns>
|
||||
bool Remove(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Persists any pending changes to the store.
|
||||
/// </summary>
|
||||
void Save();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all secret keys in the store.
|
||||
/// </summary>
|
||||
IEnumerable<string> Keys { get; }
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="OneOf" Version="3.0.271" />
|
||||
<PackageReference Include="OneOf.SourceGenerator" Version="3.0.271" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.Core.Models.Search;
|
||||
|
||||
@@ -58,17 +59,8 @@ public class Search
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(CriteriaJson))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SearchCriteria>(CriteriaJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
TryGetCriteria(out var criteria);
|
||||
return criteria;
|
||||
}
|
||||
set
|
||||
{
|
||||
@@ -78,6 +70,31 @@ public class Search
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to deserialize the search criteria from JSON.
|
||||
/// </summary>
|
||||
/// <param name="criteria">The deserialized criteria, or null if deserialization fails or JSON is empty.</param>
|
||||
/// <param name="logger">Optional logger for warning on deserialization failures.</param>
|
||||
/// <returns>True if deserialization succeeded or JSON was empty; false if deserialization failed.</returns>
|
||||
public bool TryGetCriteria(out SearchCriteria? criteria, ILogger? logger = null)
|
||||
{
|
||||
criteria = null;
|
||||
|
||||
if (string.IsNullOrEmpty(CriteriaJson))
|
||||
return true; // Empty is valid, not an error
|
||||
|
||||
try
|
||||
{
|
||||
criteria = JsonSerializer.Deserialize<SearchCriteria>(CriteriaJson);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Failed to deserialize search criteria for Search ID {SearchId}", Id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Excel search results file (binary)
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace JdeScoping.Core.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the secure secrets store.
|
||||
/// </summary>
|
||||
public class SecureStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name in appsettings.json.
|
||||
/// </summary>
|
||||
public const string SectionName = "SecureStore";
|
||||
|
||||
/// <summary>
|
||||
/// Path to the encrypted secrets store file.
|
||||
/// Defaults to "data/secrets.json" relative to app directory.
|
||||
/// </summary>
|
||||
public string StorePath { get; set; } = "data/secrets.json";
|
||||
|
||||
/// <summary>
|
||||
/// Path to the key file (used in development).
|
||||
/// Defaults to "data/secrets.key" relative to app directory.
|
||||
/// </summary>
|
||||
public string KeyFilePath { get; set; } = "data/secrets.key";
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable name containing the master key (used in production).
|
||||
/// If set and the env var exists, it takes precedence over the key file.
|
||||
/// </summary>
|
||||
public string MasterKeyEnvVar { get; set; } = "SCOPINGTOOL_MASTER_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to auto-create the store and generate a key file on first run.
|
||||
/// </summary>
|
||||
public bool AutoCreateStore { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to migrate existing secrets (RSA key, Excel passwords) on startup.
|
||||
/// </summary>
|
||||
public bool MigrateExistingSecrets { get; set; } = true;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
namespace JdeScoping.Core.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// View model for component lot filter.
|
||||
/// View model for component lot filter with value-based equality semantics.
|
||||
/// Implements <see cref="Equals"/> and <see cref="GetHashCode"/> to support
|
||||
/// deduplication in collections, LINQ Distinct(), and Contains() checks.
|
||||
/// For simple display scenarios without collection operations, use <see cref="LotViewModel"/>.
|
||||
/// </summary>
|
||||
public class ComponentLotViewModel
|
||||
{
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
namespace JdeScoping.Core.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// View model for lot projection
|
||||
/// View model for simple lot projection without equality semantics.
|
||||
/// Use this for read-only display and data transfer where collection membership
|
||||
/// checks are not needed. For collection operations requiring deduplication or
|
||||
/// Contains() checks, use <see cref="ComponentLotViewModel"/> instead.
|
||||
/// </summary>
|
||||
public class LotViewModel
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace JdeScoping.Core.ViewModels;
|
||||
/// </summary>
|
||||
public class OperatorViewModel
|
||||
{
|
||||
public int AddressNumber { get; set; }
|
||||
public long AddressNumber { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@@ -62,9 +62,10 @@ public class SearchViewModel
|
||||
/// Constructor that copies values from a Search entity
|
||||
/// </summary>
|
||||
/// <param name="search">Search to copy values from</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when search is null</exception>
|
||||
public SearchViewModel(Search search)
|
||||
{
|
||||
if (search == null) return;
|
||||
ArgumentNullException.ThrowIfNull(search);
|
||||
|
||||
Id = search.Id;
|
||||
UserName = search.UserName;
|
||||
|
||||
@@ -42,10 +42,11 @@ public static class DataAccessDependencyInjection
|
||||
// Register SqlKata compiler (singleton, thread-safe)
|
||||
services.AddSingleton<SqlServerCompiler>();
|
||||
|
||||
// Register query builder (scoped)
|
||||
// Register query builders (scoped)
|
||||
// Note: Filter criteria are extracted from database JSON using SQL functions,
|
||||
// eliminating the need for filter handler classes.
|
||||
services.AddScoped<ISearchQueryBuilder, SqlKataSearchQueryBuilder>();
|
||||
services.AddScoped<IMisQueryBuilder, MisQueryBuilder>();
|
||||
|
||||
// Register search processing services (scoped)
|
||||
services.AddScoped<IWorkOrderTraversalService, WorkOrderTraversalService>();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace JdeScoping.DataAccess.QueryBuilders;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for building MIS (Manufacturing Information System) extraction queries.
|
||||
/// </summary>
|
||||
public interface IMisQueryBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the complete MIS extraction SQL including temp table setup and data population.
|
||||
/// Uses extraction functions to get filter criteria from the database.
|
||||
/// </summary>
|
||||
/// <param name="searchId">The search ID to extract criteria from.</param>
|
||||
/// <returns>The SQL statements for MIS extraction.</returns>
|
||||
IReadOnlyList<string> BuildMisExtractionSql(int searchId);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace JdeScoping.DataAccess.QueryBuilders;
|
||||
/// Builds MIS extraction queries for work order step matching.
|
||||
/// Uses SQL extraction functions to retrieve criteria from Search.Criteria JSON.
|
||||
/// </summary>
|
||||
public sealed class MisQueryBuilder
|
||||
public sealed class MisQueryBuilder : IMisQueryBuilder
|
||||
{
|
||||
private readonly SqlServerCompiler _compiler;
|
||||
|
||||
|
||||
@@ -12,23 +12,12 @@ public partial class LotFinderRepository
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<DataUpdate>> GetLastDataUpdatesAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetLastDataUpdatesAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<DataUpdate>(
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetLastDataUpdatesAsync),
|
||||
"SQL_GET_LAST_DATA_UPDATES",
|
||||
async connection => (await connection.QueryAsync<DataUpdate>(
|
||||
LotFinderQueries.SqlGetLastDataUpdates,
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_LAST_DATA_UPDATES");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,175 +16,112 @@ public partial class LotFinderRepository
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Item>> SearchItemsAsync(string filter, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SearchItemsAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Item>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SearchItemsAsync),
|
||||
"SQL_SEARCH_ITEMS",
|
||||
async connection => (await connection.QueryAsync<Item>(
|
||||
LotFinderQueries.SqlSearchItems,
|
||||
new { filter },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_SEARCH_ITEMS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Item>> LookupItemsAsync(List<string> itemNumbers, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(LookupItemsAsync);
|
||||
try
|
||||
{
|
||||
var itemNumbersCsv = string.Join(",", itemNumbers);
|
||||
ArgumentNullException.ThrowIfNull(itemNumbers);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Item>(
|
||||
var itemNumbersCsv = string.Join(",", itemNumbers);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(LookupItemsAsync),
|
||||
"SQL_LOOKUP_ITEMS",
|
||||
async connection => (await connection.QueryAsync<Item>(
|
||||
LotFinderQueries.SqlLookupItems,
|
||||
new { itemNumbers = itemNumbersCsv },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_LOOKUP_ITEMS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<WorkOrder>> LookupWorkordersAsync(List<long> workorderNumbers, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(LookupWorkordersAsync);
|
||||
try
|
||||
{
|
||||
var workOrderNumbersCsv = string.Join(",", workorderNumbers);
|
||||
ArgumentNullException.ThrowIfNull(workorderNumbers);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<WorkOrder>(
|
||||
var workOrderNumbersCsv = string.Join(",", workorderNumbers);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(LookupWorkordersAsync),
|
||||
"SQL_LOOKUP_WORKORDERS",
|
||||
async connection => (await connection.QueryAsync<WorkOrder>(
|
||||
LotFinderQueries.SqlLookupWorkorders,
|
||||
new { workOrderNumbers = workOrderNumbersCsv },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_LOOKUP_WORKORDERS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<WorkCenter>> SearchWorkCentersAsync(string filter, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SearchWorkCentersAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<WorkCenter>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SearchWorkCentersAsync),
|
||||
"SQL_SEARCH_WORK_CENTERS",
|
||||
async connection => (await connection.QueryAsync<WorkCenter>(
|
||||
LotFinderQueries.SqlSearchWorkCenters,
|
||||
new { filter },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_SEARCH_WORK_CENTERS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<ProfitCenter>> SearchProfitCentersAsync(string filter, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SearchProfitCentersAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<ProfitCenter>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SearchProfitCentersAsync),
|
||||
"SQL_SEARCH_PROFIT_CENTERS",
|
||||
async connection => (await connection.QueryAsync<ProfitCenter>(
|
||||
LotFinderQueries.SqlSearchProfitCenters,
|
||||
new { filter },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_SEARCH_PROFIT_CENTERS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JdeUser>> SearchUsersAsync(string filter, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SearchUsersAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<JdeUser>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SearchUsersAsync),
|
||||
"SQL_SEARCH_USERS",
|
||||
async connection => (await connection.QueryAsync<JdeUser>(
|
||||
LotFinderQueries.SqlSearchUsers,
|
||||
new { filter },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_SEARCH_USERS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Lot>> LookupLotsAsync(List<LotViewModel> lots, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(LookupLotsAsync);
|
||||
try
|
||||
{
|
||||
var lotsJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
lots.Select(l => new { l.LotNumber, l.ItemNumber }));
|
||||
ArgumentNullException.ThrowIfNull(lots);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Lot>(
|
||||
var lotsJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
lots.Select(l => new { l.LotNumber, l.ItemNumber }));
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(LookupLotsAsync),
|
||||
"SQL_LOOKUP_LOTS",
|
||||
async connection => (await connection.QueryAsync<Lot>(
|
||||
LotFinderQueries.SqlLookupLots,
|
||||
new { lotsJson },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_LOOKUP_LOTS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
+61
-103
@@ -15,141 +15,99 @@ public partial class LotFinderRepository
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Search>> GetUserSearchesAsync(string userName, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetUserSearchesAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Search>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetUserSearchesAsync),
|
||||
"SQL_GET_USER_SEARCHES",
|
||||
async connection => (await connection.QueryAsync<Search>(
|
||||
LotFinderQueries.SqlGetUserSearches,
|
||||
new { userName },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_USER_SEARCHES");
|
||||
throw; // Unreachable but satisfies compiler
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Search>> GetQueuedSearchesAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetQueuedSearchesAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Search>(
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetQueuedSearchesAsync),
|
||||
"SQL_GET_QUEUED_SEARCHES",
|
||||
async connection => (await connection.QueryAsync<Search>(
|
||||
LotFinderQueries.SqlGetQueuedSearches,
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_QUEUED_SEARCHES");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Search?> GetSearchAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetSearchAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryFirstOrDefaultAsync<Search>(
|
||||
LotFinderQueries.SqlGetSearch,
|
||||
new { id },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
|
||||
if (result != null)
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetSearchAsync),
|
||||
"SQL_GET_SEARCH",
|
||||
async connection =>
|
||||
{
|
||||
result.Id = id;
|
||||
}
|
||||
var result = await connection.QueryFirstOrDefaultAsync<Search>(
|
||||
LotFinderQueries.SqlGetSearch,
|
||||
new { id },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_SEARCH");
|
||||
throw;
|
||||
}
|
||||
if (result != null)
|
||||
{
|
||||
result.Id = id;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<byte[]?> GetSearchResultsAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetSearchResultsAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
return await connection.QueryFirstOrDefaultAsync<byte[]>(
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetSearchResultsAsync),
|
||||
"SQL_GET_SEARCH_RESULTS",
|
||||
connection => connection.QueryFirstOrDefaultAsync<byte[]>(
|
||||
LotFinderQueries.SqlGetSearchResults,
|
||||
new { id },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_SEARCH_RESULTS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> SubmitSearchAsync(Search search, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SubmitSearchAsync);
|
||||
try
|
||||
{
|
||||
search.Status = SearchStatus.Queued;
|
||||
search.SubmitDt = DateTime.UtcNow;
|
||||
ArgumentNullException.ThrowIfNull(search);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
await using var command = new SqlCommand(SqlObjects.SubmitSearch, connection)
|
||||
search.Status = SearchStatus.Queued;
|
||||
search.SubmitDt = DateTime.UtcNow;
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SubmitSearchAsync),
|
||||
SqlObjects.SubmitSearch,
|
||||
async connection =>
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = _options.Value.DefaultTimeoutSeconds
|
||||
};
|
||||
await using var command = new SqlCommand(SqlObjects.SubmitSearch, connection)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = _options.Value.DefaultTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("p_UserName", search.UserName);
|
||||
command.Parameters.AddWithValue("p_Name", search.Name);
|
||||
command.Parameters.AddWithValue("p_Criteria", search.CriteriaJson);
|
||||
command.Parameters.AddWithValue("p_UserName", search.UserName);
|
||||
command.Parameters.AddWithValue("p_Name", search.Name);
|
||||
command.Parameters.AddWithValue("p_Criteria", search.CriteriaJson);
|
||||
|
||||
var searchIdParam = new SqlParameter("o_SearchID", SqlDbType.Int)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
command.Parameters.Add(searchIdParam);
|
||||
var searchIdParam = new SqlParameter("o_SearchID", SqlDbType.Int)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
command.Parameters.Add(searchIdParam);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct);
|
||||
return Convert.ToInt32(searchIdParam.Value);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, SqlObjects.SubmitSearch);
|
||||
throw;
|
||||
}
|
||||
await command.ExecuteNonQueryAsync(ct);
|
||||
return Convert.ToInt32(searchIdParam.Value);
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -66,4 +66,35 @@ public partial class LotFinderRepository : ILotFinderRepository
|
||||
// SQL Server timeout error number: -2
|
||||
return ex.Number == -2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a database query with standard error handling.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The return type of the query.</typeparam>
|
||||
/// <param name="operation">The name of the calling operation for logging.</param>
|
||||
/// <param name="queryName">The name of the query for logging.</param>
|
||||
/// <param name="queryAction">The async function that executes the query.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The result of the query action.</returns>
|
||||
private async Task<T> ExecuteQueryAsync<T>(
|
||||
string operation,
|
||||
string queryName,
|
||||
Func<SqlConnection, Task<T>> queryAction,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
return await queryAction(connection);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, queryName);
|
||||
throw; // Unreachable but satisfies compiler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ using JdeScoping.DataAccess.QueryBuilders;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SqlKata.Compilers;
|
||||
|
||||
namespace JdeScoping.DataAccess.Services;
|
||||
|
||||
@@ -21,7 +20,7 @@ public sealed class SearchProcessor : ISearchProcessor
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly ISearchQueryBuilder _queryBuilder;
|
||||
private readonly IWorkOrderTraversalService _traversalService;
|
||||
private readonly MisQueryBuilder _misQueryBuilder;
|
||||
private readonly IMisQueryBuilder _misQueryBuilder;
|
||||
private readonly SearchProcessingConfiguration _options;
|
||||
private readonly ILogger<SearchProcessor> _logger;
|
||||
|
||||
@@ -32,14 +31,14 @@ public sealed class SearchProcessor : ISearchProcessor
|
||||
IDbConnectionFactory connectionFactory,
|
||||
ISearchQueryBuilder queryBuilder,
|
||||
IWorkOrderTraversalService traversalService,
|
||||
SqlServerCompiler compiler,
|
||||
IMisQueryBuilder misQueryBuilder,
|
||||
IOptions<SearchProcessingConfiguration> options,
|
||||
ILogger<SearchProcessor> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_queryBuilder = queryBuilder;
|
||||
_traversalService = traversalService;
|
||||
_misQueryBuilder = new MisQueryBuilder(compiler);
|
||||
_misQueryBuilder = misQueryBuilder;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -79,10 +79,11 @@ public class DevEtlRegistry
|
||||
using var semaphore = new SemaphoreSlim(maxDegreeOfParallelism);
|
||||
|
||||
// Separate tables by size - run very large ones sequentially at the end
|
||||
var smallMediumTables = GetAvailableTables()
|
||||
var allTables = GetAvailableTables().ToList();
|
||||
var smallMediumTables = allTables
|
||||
.Where(t => !_pipelineFactory.IsVeryLargeTable(t))
|
||||
.ToList();
|
||||
var veryLargeTables = GetAvailableTables()
|
||||
var veryLargeTables = allTables
|
||||
.Where(t => _pipelineFactory.IsVeryLargeTable(t))
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# JdeScoping.DataSync.Dev
|
||||
|
||||
Development-only ETL tooling for loading cached protobuf data into SQL Server. This project enables developers to work with production-like data locally without connecting to live JDE/CMS systems.
|
||||
|
||||
## Purpose
|
||||
|
||||
This project provides a way to load pre-cached data snapshots (in protobuf format with zstd compression) into the local SQL Server database. It is intended **only for development and testing** - production data sync uses the `JdeScoping.DataSync` project with live connections to enterprise systems.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Cache Directory**: A folder containing protobuf data files (`.pb.zstd` format)
|
||||
2. **SQL Server Database**: Local SQL Server instance with the JDE Scoping schema
|
||||
3. **Connection String**: Valid SQL Server connection configured in `appsettings.json`
|
||||
|
||||
## Configuration
|
||||
|
||||
Pipeline configurations are stored in `Pipelines/dev-pipelines.json`. This file defines:
|
||||
|
||||
- **Size categories**: Tables are categorized as small, medium, large, or veryLarge
|
||||
- **Pipeline definitions**: Source file mappings to destination tables
|
||||
|
||||
### Size Categories
|
||||
|
||||
| Category | Tables | Parallelization |
|
||||
|----------|--------|-----------------|
|
||||
| Small | Branch, OrgHierarchy, WorkCenter, ProfitCenter | Parallel |
|
||||
| Medium | JdeUser, FunctionCode, Item, RouteMaster, MisData_Curr | Parallel |
|
||||
| Large | Lot, MisData_Hist, WorkOrder_Curr/Hist, LotUsage_Hist, WorkOrderComponent_Hist | Parallel |
|
||||
| VeryLarge | WorkOrderStep_*, WorkOrderComponent_Curr, WorkOrderRouting, LotUsage_Curr, WorkOrderTime_* | Sequential |
|
||||
|
||||
Very large tables run sequentially at the end to avoid I/O contention.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
JdeScoping.DataSync.Dev/
|
||||
├── Configuration/ # DTOs for JSON config deserialization
|
||||
├── Contracts/ # Interface definitions (IDevEtlPipelineFactory)
|
||||
├── Options/ # Options pattern classes
|
||||
├── Services/ # Implementation (DevEtlPipelineFactory)
|
||||
├── Sources/ # IImportSource implementations (ProtobufZstdFileSource)
|
||||
├── Pipelines/ # JSON configuration files
|
||||
└── DevEtlRegistry.cs # Main orchestrator class
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```csharp
|
||||
// Create the registry
|
||||
var factory = new DevEtlPipelineFactory(options, connectionString, logger);
|
||||
var registry = new DevEtlRegistry(factory, cacheDirectory, logger);
|
||||
|
||||
// List available tables
|
||||
foreach (var table in registry.GetAvailableTables())
|
||||
{
|
||||
Console.WriteLine(table);
|
||||
}
|
||||
```
|
||||
|
||||
### Run Single Table
|
||||
|
||||
```csharp
|
||||
var result = await registry.RunAsync("Branch");
|
||||
if (result.Success)
|
||||
{
|
||||
Console.WriteLine($"Loaded {result.TotalRows} rows in {result.Elapsed}");
|
||||
}
|
||||
```
|
||||
|
||||
### Run All Tables Sequentially
|
||||
|
||||
```csharp
|
||||
var results = await registry.RunAllAsync(cancellationToken);
|
||||
```
|
||||
|
||||
### Run All Tables with Parallelization
|
||||
|
||||
```csharp
|
||||
// Run small/medium/large tables in parallel (max 4 concurrent)
|
||||
// Very large tables run sequentially at the end
|
||||
var results = await registry.RunAllParallelAsync(
|
||||
maxDegreeOfParallelism: 4,
|
||||
cancellationToken);
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Source**: `ProtobufZstdFileSource` reads `.pb.zstd` files using protobuf-net-data
|
||||
2. **Transform**: Data passes through as `IDataReader` (no transformation)
|
||||
3. **Destination**: Uses `JdeScoping.DataSync` bulk import/merge destinations
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **JdeScoping.DataSync**: Core ETL pipeline infrastructure
|
||||
- **protobuf-net-data**: Protobuf serialization with IDataReader support
|
||||
|
||||
## Testing
|
||||
|
||||
The project supports unit testing via `InternalsVisibleTo`:
|
||||
- `JdeScoping.DataSync.Dev.Tests`
|
||||
- `DynamicProxyGenAssembly2` (for Moq)
|
||||
@@ -9,6 +9,12 @@ namespace JdeScoping.DataSync.Dev.Sources;
|
||||
/// Import source that reads from a zstd-compressed protobuf file.
|
||||
/// Uses protobuf-net-data for IDataReader deserialization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This source wraps the synchronous <c>DataSerializer.Deserialize</c> in <c>Task.Run</c>
|
||||
/// because protobuf-net-data does not provide a native async API. The file is opened with
|
||||
/// <c>FileOptions.Asynchronous</c> to optimize for async I/O patterns, and the synchronous
|
||||
/// deserialization is offloaded to the thread pool to prevent blocking the calling context.
|
||||
/// </remarks>
|
||||
public sealed class ProtobufZstdFileSource : IImportSource
|
||||
{
|
||||
private const int FileBufferSize = 256 * 1024; // 256 KB
|
||||
@@ -33,28 +39,32 @@ public sealed class ProtobufZstdFileSource : IImportSource
|
||||
_filePath = filePath;
|
||||
}
|
||||
|
||||
public Task<IDataReader> ReadDataAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<IDataReader> ReadDataAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_fileStream != null)
|
||||
throw new InvalidOperationException("ReadDataAsync has already been called. Dispose and create a new source to read again.");
|
||||
|
||||
try
|
||||
{
|
||||
// Use FileOptions.Asynchronous for optimized async I/O patterns
|
||||
_fileStream = new FileStream(
|
||||
_filePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: FileBufferSize,
|
||||
FileOptions.SequentialScan);
|
||||
FileOptions.SequentialScan | FileOptions.Asynchronous);
|
||||
|
||||
_decompressionStream = new DecompressionStream(_fileStream);
|
||||
_bufferedStream = new BufferedStream(_decompressionStream, DecompressBufferSize);
|
||||
|
||||
// protobuf-net-data returns IDataReader directly
|
||||
_reader = DataSerializer.Deserialize(_bufferedStream);
|
||||
// Offload synchronous deserialization to thread pool since protobuf-net-data
|
||||
// doesn't have a native async API. This prevents blocking the calling context.
|
||||
_reader = await Task.Run(
|
||||
() => DataSerializer.Deserialize(_bufferedStream),
|
||||
cancellationToken);
|
||||
|
||||
return Task.FromResult(_reader);
|
||||
return _reader;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Dapper;
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using JdeScoping.DataSync.Etl.Contracts;
|
||||
using JdeScoping.DataSync.Etl.Results;
|
||||
@@ -13,7 +12,7 @@ namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
/// Imports data into a SQL Server table using bulk copy operations.
|
||||
/// Performs a full table refresh by truncating the table before loading.
|
||||
/// </summary>
|
||||
public class DbBulkImportDestination : IImportDestination
|
||||
public class DbBulkImportDestination : DbDestinationBase, IImportDestination
|
||||
{
|
||||
private const int DefaultBatchSize = 100000;
|
||||
private const int DefaultCommandTimeoutSeconds = 600;
|
||||
@@ -22,9 +21,7 @@ public class DbBulkImportDestination : IImportDestination
|
||||
public const int InfiniteTimeout = 0;
|
||||
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly string _tableName;
|
||||
private readonly int _batchSize;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DestinationName => $"BulkImport:{_tableName}";
|
||||
@@ -41,14 +38,13 @@ public class DbBulkImportDestination : IImportDestination
|
||||
string tableName,
|
||||
int batchSize = 0,
|
||||
int commandTimeoutSeconds = 0)
|
||||
: base(tableName, commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connectionFactory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
|
||||
_connectionFactory = connectionFactory;
|
||||
_tableName = tableName;
|
||||
_batchSize = batchSize > 0 ? batchSize : DefaultBatchSize;
|
||||
_commandTimeoutSeconds = commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -121,17 +117,4 @@ public class DbBulkImportDestination : IImportDestination
|
||||
stopwatch.Stop();
|
||||
return new DestinationResult(totalRows, batchCount, stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetDestinationColumnsAsync(
|
||||
SqlConnection connection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (schema, table) = CommonScripts.ParseTableName(_tableName);
|
||||
var sql = @"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = @tableName AND TABLE_SCHEMA = @schemaName";
|
||||
var columns = await connection.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { tableName = table, schemaName = schema },
|
||||
commandTimeout: _commandTimeoutSeconds, cancellationToken: ct));
|
||||
return columns.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using JdeScoping.DataSync.Etl.Contracts;
|
||||
using JdeScoping.DataSync.Etl.Results;
|
||||
using JdeScoping.DataSync.Etl.Scripts;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
|
||||
@@ -15,19 +15,18 @@ namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
/// This approach supports incremental updates by matching on key columns and updating
|
||||
/// existing rows or inserting new ones.
|
||||
/// </summary>
|
||||
public class DbBulkMergeDestination : IImportDestination
|
||||
public class DbBulkMergeDestination : DbDestinationBase, IImportDestination
|
||||
{
|
||||
private const int DefaultBatchSize = 10000;
|
||||
private const int DefaultCommandTimeoutSeconds = 600;
|
||||
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly string _tableName;
|
||||
private readonly ILogger<DbBulkMergeDestination>? _logger;
|
||||
private readonly string[] _matchColumns;
|
||||
private readonly string[]? _updateColumns;
|
||||
private readonly string[]? _excludeFromUpdate;
|
||||
private readonly string? _updateCondition;
|
||||
private readonly int _batchSize;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DestinationName => $"BulkMerge:{_tableName}";
|
||||
@@ -43,6 +42,7 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
/// <param name="updateCondition">Optional SQL condition to add to the WHEN MATCHED clause (e.g., "source.LastUpdate > target.LastUpdate").</param>
|
||||
/// <param name="batchSize">Number of rows per batch. 0 uses the default (10000).</param>
|
||||
/// <param name="commandTimeoutSeconds">Command timeout in seconds. 0 uses the default (600).</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
public DbBulkMergeDestination(
|
||||
IDbConnectionFactory connectionFactory,
|
||||
string tableName,
|
||||
@@ -51,7 +51,9 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
string[]? excludeFromUpdate = null,
|
||||
string? updateCondition = null,
|
||||
int batchSize = 0,
|
||||
int commandTimeoutSeconds = 0)
|
||||
int commandTimeoutSeconds = 0,
|
||||
ILogger<DbBulkMergeDestination>? logger = null)
|
||||
: base(tableName, commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connectionFactory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
@@ -60,13 +62,12 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
throw new ArgumentException("At least one match column is required.", nameof(matchColumns));
|
||||
|
||||
_connectionFactory = connectionFactory;
|
||||
_tableName = tableName;
|
||||
_logger = logger;
|
||||
_matchColumns = matchColumns;
|
||||
_updateColumns = updateColumns;
|
||||
_excludeFromUpdate = excludeFromUpdate;
|
||||
_updateCondition = updateCondition;
|
||||
_batchSize = batchSize > 0 ? batchSize : DefaultBatchSize;
|
||||
_commandTimeoutSeconds = commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -177,9 +178,9 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
cmd.CommandTimeout = _commandTimeoutSeconds;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
_logger?.LogDebug(ex, "Failed to drop temporary table {TempTableName} during cleanup", tempTableName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,17 +261,4 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
table.Columns.Add(source.GetName(i), baseType);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetDestinationColumnsAsync(
|
||||
SqlConnection connection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (schema, table) = CommonScripts.ParseTableName(_tableName);
|
||||
var sql = @"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = @tableName AND TABLE_SCHEMA = @schemaName";
|
||||
var columns = await connection.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { tableName = table, schemaName = schema },
|
||||
commandTimeout: _commandTimeoutSeconds, cancellationToken: ct));
|
||||
return columns.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Dapper;
|
||||
using JdeScoping.DataSync.Etl.Scripts;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for SQL Server database destinations providing common functionality
|
||||
/// for table metadata operations.
|
||||
/// </summary>
|
||||
public abstract class DbDestinationBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the destination table.
|
||||
/// </summary>
|
||||
protected readonly string _tableName;
|
||||
|
||||
/// <summary>
|
||||
/// The command timeout in seconds for database operations.
|
||||
/// </summary>
|
||||
protected readonly int _commandTimeoutSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the DbDestinationBase class.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The name of the destination table.</param>
|
||||
/// <param name="commandTimeoutSeconds">The command timeout in seconds.</param>
|
||||
protected DbDestinationBase(string tableName, int commandTimeoutSeconds)
|
||||
{
|
||||
_tableName = tableName;
|
||||
_commandTimeoutSeconds = commandTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the column names from the destination table.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQL Server connection to use.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A HashSet containing the column names (case-insensitive).</returns>
|
||||
protected async Task<HashSet<string>> GetDestinationColumnsAsync(
|
||||
SqlConnection connection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (schema, table) = CommonScripts.ParseTableName(_tableName);
|
||||
var sql = @"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = @tableName AND TABLE_SCHEMA = @schemaName";
|
||||
var columns = await connection.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { tableName = table, schemaName = schema },
|
||||
commandTimeout: _commandTimeoutSeconds, cancellationToken: ct));
|
||||
return columns.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,25 @@ using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace JdeScoping.Database;
|
||||
|
||||
public class DatabaseMigrator
|
||||
/// <summary>
|
||||
/// Handles database migrations using DbUp.
|
||||
/// </summary>
|
||||
public class DatabaseMigrator : IDatabaseMigrator
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the DatabaseMigrator class.
|
||||
/// </summary>
|
||||
/// <param name="configuration">Application configuration containing connection strings.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when SqlServer connection string is not configured.</exception>
|
||||
public DatabaseMigrator(IConfiguration configuration)
|
||||
{
|
||||
_connectionString = configuration.GetConnectionString("SqlServer")
|
||||
?? throw new InvalidOperationException("SqlServer connection string not configured");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DatabaseUpgradeResult Migrate()
|
||||
{
|
||||
EnsureDatabase.For.SqlDatabase(_connectionString);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using DbUp.Engine;
|
||||
|
||||
namespace JdeScoping.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for database migration operations.
|
||||
/// </summary>
|
||||
public interface IDatabaseMigrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs all pending database migrations.
|
||||
/// </summary>
|
||||
/// <returns>The result of the migration operation.</returns>
|
||||
DatabaseUpgradeResult Migrate();
|
||||
}
|
||||
@@ -8,6 +8,8 @@ using JdeScoping.ExcelIO.Mapping;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
// Use Core's SearchModel for the public interface
|
||||
using CoreSearchModel = JdeScoping.Core.Models.SearchResults.SearchModel;
|
||||
// Use ExcelIO's SearchModel which contains criteria filter properties for CriteriaSheetGenerator
|
||||
using ExcelSearchModel = JdeScoping.ExcelIO.Models.Reporting.SearchModel;
|
||||
|
||||
@@ -47,23 +49,42 @@ public class ExcelExportService : IExcelExportService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> GenerateAsync(object search, CancellationToken cancellationToken = default)
|
||||
public async Task<byte[]> GenerateAsync(CoreSearchModel search, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (search is not ExcelSearchModel searchModel)
|
||||
{
|
||||
throw new ArgumentException($"Expected {nameof(ExcelSearchModel)} but received {search.GetType().Name}", nameof(search));
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(search);
|
||||
|
||||
return await GenerateAsync(searchModel, cancellationToken);
|
||||
// Map Core SearchModel to ExcelIO SearchModel for internal processing
|
||||
var excelModel = MapToExcelModel(search);
|
||||
return await GenerateInternalAsync(excelModel, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an Excel file from the provided search model.
|
||||
/// Maps Core SearchModel to ExcelIO SearchModel for Excel generation.
|
||||
/// </summary>
|
||||
/// <param name="search">Search model with criteria and results.</param>
|
||||
private static ExcelSearchModel MapToExcelModel(CoreSearchModel source)
|
||||
{
|
||||
return new ExcelSearchModel
|
||||
{
|
||||
Id = source.Id,
|
||||
UserName = source.UserName,
|
||||
Name = source.Name,
|
||||
SubmitDt = source.SubmitDt,
|
||||
StartDt = source.StartDt,
|
||||
EndDt = source.EndDt,
|
||||
ExtractMisData = source.ExtractMisData,
|
||||
Results = source.Results,
|
||||
MisResults = source.MisResults,
|
||||
MisNonMatchResults = source.MisNonMatchResults
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal method that generates an Excel file from the ExcelIO search model.
|
||||
/// </summary>
|
||||
/// <param name="search">ExcelIO search model with criteria and results.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Excel file as byte array.</returns>
|
||||
public async Task<byte[]> GenerateAsync(ExcelSearchModel search, CancellationToken cancellationToken = default)
|
||||
private async Task<byte[]> GenerateInternalAsync(ExcelSearchModel search, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
|
||||
@@ -168,13 +168,17 @@ public class CriteriaSheetGenerator
|
||||
WorksheetProtector.ApplyCriteriaProtection(worksheet, _options.Value.CriteriaSheetPassword);
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTime? dateTime)
|
||||
private string FormatTimestamp(DateTime? dateTime)
|
||||
{
|
||||
if (!dateTime.HasValue)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{dateTime.Value:MMM dd, yyyy hh:mm:ss tt} EST";
|
||||
var options = _options.Value;
|
||||
var timezone = TimeZoneInfo.FindSystemTimeZoneById(options.TimezoneId);
|
||||
var localTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime.Value, timezone);
|
||||
|
||||
return $"{localTime:MMM dd, yyyy hh:mm:ss tt} {options.TimezoneAbbreviation}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ClosedXML.Excel;
|
||||
using JdeScoping.ExcelIO.Formatting;
|
||||
using JdeScoping.ExcelIO.Utilities;
|
||||
|
||||
namespace JdeScoping.ExcelIO.Generators;
|
||||
|
||||
@@ -31,7 +32,7 @@ public class DataEntryTemplateGenerator
|
||||
var row = 2;
|
||||
foreach (var item in sourceData)
|
||||
{
|
||||
worksheet.Cell(row++, 1).Value = ConvertToXlValue(item);
|
||||
worksheet.Cell(row++, 1).Value = CellValueConverter.ConvertToXlValue(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ public class DataEntryTemplateGenerator
|
||||
{
|
||||
for (var col = 0; col < sourceData[row].Length; col++)
|
||||
{
|
||||
worksheet.Cell(row + 2, col + 1).Value = ConvertToXlValue(sourceData[row][col]);
|
||||
worksheet.Cell(row + 2, col + 1).Value = CellValueConverter.ConvertToXlValue(sourceData[row][col]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,21 +79,4 @@ public class DataEntryTemplateGenerator
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static XLCellValue ConvertToXlValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => Blank.Value,
|
||||
string s => s,
|
||||
int i => i,
|
||||
long l => l,
|
||||
decimal d => d,
|
||||
double dbl => dbl,
|
||||
float f => f,
|
||||
DateTime dt => dt,
|
||||
bool b => b,
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClosedXML.Excel;
|
||||
using JdeScoping.ExcelIO.Formatting;
|
||||
using JdeScoping.ExcelIO.Mapping;
|
||||
using JdeScoping.ExcelIO.Utilities;
|
||||
|
||||
namespace JdeScoping.ExcelIO.Generators;
|
||||
|
||||
@@ -72,7 +73,7 @@ public sealed class FluentTableWriter
|
||||
foreach (var column in columns)
|
||||
{
|
||||
var value = column.ValueGetter(item!);
|
||||
worksheet.Cell(row, col).Value = ConvertToXlValue(value);
|
||||
worksheet.Cell(row, col).Value = CellValueConverter.ConvertToXlValue(value);
|
||||
col++;
|
||||
}
|
||||
row++;
|
||||
@@ -125,21 +126,4 @@ public sealed class FluentTableWriter
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static XLCellValue ConvertToXlValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => Blank.Value,
|
||||
string s => s,
|
||||
int i => i,
|
||||
long l => l,
|
||||
decimal d => d,
|
||||
double dbl => dbl,
|
||||
float f => f,
|
||||
DateTime dt => dt,
|
||||
bool b => b,
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,15 @@ public class ExcelExportOptions
|
||||
|
||||
/// <summary>
|
||||
/// Password for protecting the Search Criteria sheet.
|
||||
/// When empty, the password is retrieved from SecureStore using key "ExcelExport:CriteriaSheetPassword".
|
||||
/// </summary>
|
||||
public string CriteriaSheetPassword { get; set; } = "JDE_SCOPING_TOOL_PASS";
|
||||
public string CriteriaSheetPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Password for protecting data sheets (Results, MIS Info, Investigation).
|
||||
/// When empty, the password is retrieved from SecureStore using key "ExcelExport:DataSheetPassword".
|
||||
/// </summary>
|
||||
public string DataSheetPassword { get; set; } = "JDESCOPINGTOOL";
|
||||
public string DataSheetPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of rows per Excel sheet.
|
||||
@@ -39,4 +41,15 @@ public class ExcelExportOptions
|
||||
/// Directory for debug output files.
|
||||
/// </summary>
|
||||
public string DebugOutputDirectory { get; set; } = "/tmp/lotfinder";
|
||||
|
||||
/// <summary>
|
||||
/// Windows timezone ID for timestamp display in exported files.
|
||||
/// Examples: "Eastern Standard Time", "Pacific Standard Time", "UTC"
|
||||
/// </summary>
|
||||
public string TimezoneId { get; set; } = "Eastern Standard Time";
|
||||
|
||||
/// <summary>
|
||||
/// Abbreviation to display after timestamps (e.g., "EST", "PST", "UTC").
|
||||
/// </summary>
|
||||
public string TimezoneAbbreviation { get; set; } = "EST";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClosedXML.Excel;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.ExcelIO.Parsing;
|
||||
|
||||
@@ -9,6 +10,13 @@ namespace JdeScoping.ExcelIO.Parsing;
|
||||
/// </summary>
|
||||
public class ExcelParserService : IExcelParserService
|
||||
{
|
||||
private readonly ILogger<ExcelParserService> _logger;
|
||||
|
||||
public ExcelParserService(ILogger<ExcelParserService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<long> ParseWorkOrders(Stream fileStream)
|
||||
{
|
||||
@@ -113,9 +121,9 @@ public class ExcelParserService : IExcelParserService
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Skip invalid rows
|
||||
_logger.LogWarning(ex, "Failed to parse row {RowNumber} in part operations sheet", row);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace JdeScoping.ExcelIO.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for converting .NET objects to ClosedXML cell values.
|
||||
/// </summary>
|
||||
public static class CellValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a .NET object to an XLCellValue for use in ClosedXML worksheets.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to convert.</param>
|
||||
/// <returns>An XLCellValue suitable for setting as a cell value.</returns>
|
||||
public static XLCellValue ConvertToXlValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => Blank.Value,
|
||||
string s => s,
|
||||
int i => i,
|
||||
long l => l,
|
||||
decimal d => d,
|
||||
double dbl => dbl,
|
||||
float f => f,
|
||||
DateTime dt => dt,
|
||||
bool b => b,
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using JdeScoping.DataAccess.Options;
|
||||
using JdeScoping.DataSync.Options;
|
||||
using JdeScoping.ExcelIO.Options;
|
||||
using JdeScoping.Database;
|
||||
using JdeScoping.Infrastructure.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -11,14 +13,21 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseWindowsService();
|
||||
|
||||
// Run database migrations (skip in Testing environment)
|
||||
// Note: IDatabaseMigrator interface enables mocking for integration tests
|
||||
if (!builder.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
var migrator = new DatabaseMigrator(builder.Configuration);
|
||||
// Create early logger for startup diagnostics
|
||||
using var loggerFactory = LoggerFactory.Create(b => b
|
||||
.AddConfiguration(builder.Configuration.GetSection("Logging"))
|
||||
.AddConsole());
|
||||
var startupLogger = loggerFactory.CreateLogger("JdeScoping.Host.Startup");
|
||||
|
||||
IDatabaseMigrator migrator = new DatabaseMigrator(builder.Configuration);
|
||||
var migrationResult = migrator.Migrate();
|
||||
|
||||
if (!migrationResult.Successful)
|
||||
{
|
||||
Console.WriteLine($"Database migration failed: {migrationResult.Error?.Message}");
|
||||
startupLogger.LogError(migrationResult.Error, "Database migration failed: {ErrorMessage}", migrationResult.Error?.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +45,13 @@ builder.Services
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Migrate existing secrets to SecureStore (skip in Testing environment)
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
var migrator = app.Services.GetRequiredService<SecretsMigrator>();
|
||||
migrator.MigrateIfNeeded();
|
||||
}
|
||||
|
||||
// Startup validation - verify critical services are registered
|
||||
ValidateServices(app.Services);
|
||||
|
||||
@@ -64,11 +80,15 @@ static void ValidateServices(IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var provider = scope.ServiceProvider;
|
||||
var logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("JdeScoping.Host.Startup");
|
||||
|
||||
// Validate Options classes are bound
|
||||
_ = provider.GetRequiredService<IOptions<DataAccessOptions>>();
|
||||
_ = provider.GetRequiredService<IOptions<DataSyncOptions>>();
|
||||
_ = provider.GetRequiredService<IOptions<ExcelExportOptions>>();
|
||||
_ = provider.GetRequiredService<IOptions<SearchProcessingOptions>>();
|
||||
Console.WriteLine("Service validation completed successfully.");
|
||||
logger.LogInformation("Service validation completed successfully");
|
||||
}
|
||||
|
||||
// Enable WebApplicationFactory<Program> for integration testing
|
||||
public partial class Program { }
|
||||
|
||||
@@ -128,6 +128,13 @@
|
||||
"UseFileDataSource": false,
|
||||
"FileDirectory": "DevData"
|
||||
},
|
||||
"SecureStore": {
|
||||
"StorePath": "data/secrets.json",
|
||||
"KeyFilePath": "data/secrets.key",
|
||||
"MasterKeyEnvVar": "SCOPINGTOOL_MASTER_KEY",
|
||||
"AutoCreateStore": true,
|
||||
"MigrateExistingSecrets": true
|
||||
},
|
||||
"WorkProcessor": {
|
||||
"Enabled": true,
|
||||
"WorkInterval": "00:00:05",
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace JdeScoping.Infrastructure.Auth;
|
||||
/// Fake authentication service for development mode.
|
||||
/// Accepts any credentials and returns a predefined user.
|
||||
/// </summary>
|
||||
public sealed class FakeAuthService : IAuthService
|
||||
public sealed class FakeAuthService : IAuthenticationService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<AuthResult> AuthenticateAsync(
|
||||
@@ -20,25 +20,6 @@ public sealed class FakeAuthService : IAuthService
|
||||
return Task.FromResult(new AuthResult(true, user, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<UserInfo?> GetUserInfoAsync(
|
||||
string username,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var user = CreateFakeUser(username);
|
||||
return Task.FromResult<UserInfo?>(user);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsInGroupAsync(
|
||||
string username,
|
||||
string groupName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Always return true in development mode
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private static UserInfo CreateFakeUser(string username)
|
||||
{
|
||||
return new UserInfo
|
||||
|
||||
@@ -9,9 +9,29 @@ using Microsoft.Extensions.Options;
|
||||
namespace JdeScoping.Infrastructure.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP-based authentication service implementation using System.DirectoryServices.Protocols
|
||||
/// LDAP-based authentication service implementation using System.DirectoryServices.Protocols.
|
||||
/// Note: This service only implements IAuthenticationService because LDAP requires credentials
|
||||
/// for each operation - standalone user lookups are not possible without stored credentials.
|
||||
/// </summary>
|
||||
public sealed class LdapAuthService : IAuthService
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Async Pattern Note:</strong> This service uses <c>Task.Run</c> to wrap synchronous
|
||||
/// LDAP operations. This is intentional because <see cref="System.DirectoryServices.Protocols"/>
|
||||
/// does not provide a native async API.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The synchronous methods <see cref="LdapConnection.Bind"/> and <see cref="LdapConnection.SendRequest"/>
|
||||
/// perform blocking I/O operations that can take several seconds to complete (especially with network
|
||||
/// timeouts). Wrapping these in <c>Task.Run</c> offloads them to the thread pool, preventing the calling
|
||||
/// async context (e.g., ASP.NET Core request thread) from being blocked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This pattern is an accepted workaround when no native async API exists. The alternative would be
|
||||
/// to use a different LDAP library with async support, but System.DirectoryServices.Protocols is the
|
||||
/// standard .NET library for LDAP operations and is well-tested for enterprise Active Directory scenarios.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LdapAuthService : IAuthenticationService
|
||||
{
|
||||
private const string LdapLookupFormat = "(sAMAccountName={0})";
|
||||
|
||||
@@ -93,38 +113,15 @@ public sealed class LdapAuthService : IAuthService
|
||||
return new AuthResult(false, null, lastError ?? "Unable to connect to directory server");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
|
||||
{
|
||||
// Not implemented for LDAP - user info is only available during authentication
|
||||
throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsInGroupAsync(
|
||||
string username,
|
||||
string groupName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// This method requires stored credentials or service account - not supported
|
||||
// Group membership is checked during authentication when credentials are available
|
||||
throw new NotSupportedException("IsInGroupAsync requires password for LDAP lookup. Use AuthenticateAsync instead.");
|
||||
}
|
||||
|
||||
private async Task<bool> TryBindAsync(
|
||||
string serverUrl,
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var connection = CreateConnection(serverUrl);
|
||||
var credential = new NetworkCredential(username, password);
|
||||
connection.Credential = credential;
|
||||
connection.AuthType = AuthType.Negotiate;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() => connection.Bind(), ct);
|
||||
using var connection = await BindConnectionAsync(serverUrl, username, password, ct);
|
||||
return true;
|
||||
}
|
||||
catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials
|
||||
@@ -140,10 +137,7 @@ public sealed class LdapAuthService : IAuthService
|
||||
string groupDn,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var connection = CreateConnection(serverUrl);
|
||||
connection.Credential = new NetworkCredential(username, password);
|
||||
connection.AuthType = AuthType.Negotiate;
|
||||
await Task.Run(() => connection.Bind(), ct);
|
||||
using var connection = await BindConnectionAsync(serverUrl, username, password, ct);
|
||||
|
||||
var searchRequest = new SearchRequest(
|
||||
_options.SearchBase,
|
||||
@@ -151,6 +145,7 @@ public sealed class LdapAuthService : IAuthService
|
||||
SearchScope.Subtree,
|
||||
"memberOf");
|
||||
|
||||
// Task.Run wraps synchronous SendRequest() - see class remarks for rationale
|
||||
var response = (SearchResponse)await Task.Run(
|
||||
() => connection.SendRequest(searchRequest), ct);
|
||||
|
||||
@@ -178,10 +173,7 @@ public sealed class LdapAuthService : IAuthService
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var connection = CreateConnection(serverUrl);
|
||||
connection.Credential = new NetworkCredential(username, password);
|
||||
connection.AuthType = AuthType.Negotiate;
|
||||
await Task.Run(() => connection.Bind(), ct);
|
||||
using var connection = await BindConnectionAsync(serverUrl, username, password, ct);
|
||||
|
||||
var searchRequest = new SearchRequest(
|
||||
_options.SearchBase,
|
||||
@@ -189,6 +181,7 @@ public sealed class LdapAuthService : IAuthService
|
||||
SearchScope.Subtree,
|
||||
"distinguishedName", "givenName", "sn", "mail", "title");
|
||||
|
||||
// Task.Run wraps synchronous SendRequest() - see class remarks for rationale
|
||||
var response = (SearchResponse)await Task.Run(
|
||||
() => connection.SendRequest(searchRequest), ct);
|
||||
|
||||
@@ -206,6 +199,24 @@ public sealed class LdapAuthService : IAuthService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and binds an LDAP connection with the specified credentials.
|
||||
/// The caller is responsible for disposing the returned connection.
|
||||
/// </summary>
|
||||
private async Task<LdapConnection> BindConnectionAsync(
|
||||
string serverUrl,
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var connection = CreateConnection(serverUrl);
|
||||
connection.Credential = new NetworkCredential(username, password);
|
||||
connection.AuthType = AuthType.Negotiate;
|
||||
// Task.Run wraps synchronous Bind() - see class remarks for rationale
|
||||
await Task.Run(() => connection.Bind(), ct);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private LdapConnection CreateConnection(string serverUrl)
|
||||
{
|
||||
var connection = new LdapConnection(serverUrl);
|
||||
|
||||
@@ -33,26 +33,27 @@ public static class InfrastructureDependencyInjection
|
||||
|
||||
if (ldapOptions?.UseFakeAuth == true)
|
||||
{
|
||||
services.AddScoped<IAuthService, FakeAuthService>();
|
||||
services.AddScoped<IAuthenticationService, FakeAuthService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddScoped<IAuthService, LdapAuthService>();
|
||||
services.AddScoped<IAuthenticationService, LdapAuthService>();
|
||||
}
|
||||
|
||||
// Register RSA key service for login encryption
|
||||
// Register SecureStore for encrypted secrets storage
|
||||
services.Configure<SecureStoreOptions>(
|
||||
configuration.GetSection(SecureStoreOptions.SectionName));
|
||||
|
||||
services.AddSingleton<ISecureStoreService, SecureStoreService>();
|
||||
|
||||
// Register RSA key service backed by SecureStore
|
||||
services.Configure<RsaKeyOptions>(
|
||||
configuration.GetSection(RsaKeyOptions.SectionName));
|
||||
|
||||
var rsaKeyOptions = configuration
|
||||
.GetSection(RsaKeyOptions.SectionName)
|
||||
.Get<RsaKeyOptions>() ?? new RsaKeyOptions();
|
||||
services.AddSingleton<IRsaKeyService, SecureStoreRsaKeyService>();
|
||||
|
||||
var keyPath = Path.IsPathRooted(rsaKeyOptions.KeyFilePath)
|
||||
? rsaKeyOptions.KeyFilePath
|
||||
: Path.Combine(AppContext.BaseDirectory, rsaKeyOptions.KeyFilePath);
|
||||
|
||||
services.AddSingleton<IRsaKeyService>(new RsaKeyService(keyPath));
|
||||
// Register secrets migrator for one-time migration of existing secrets
|
||||
services.AddSingleton<SecretsMigrator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
|
||||
<PackageReference Include="SecureStore" Version="1.2.0" />
|
||||
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace JdeScoping.Infrastructure.Options;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP configuration options for authentication
|
||||
/// LDAP configuration options for authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <see cref="UseFakeAuth"/> is false (production mode), the following properties are required:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ServerUrls"/> - At least one LDAP server URL</item>
|
||||
/// <item><see cref="GroupDn"/> - Distinguished name of required group</item>
|
||||
/// <item><see cref="SearchBase"/> - LDAP search base for user lookups</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class LdapOptions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -12,30 +24,34 @@ public class LdapOptions
|
||||
|
||||
/// <summary>
|
||||
/// LDAP server URLs (supports multiple for failover).
|
||||
/// Required when <see cref="UseFakeAuth"/> is false.
|
||||
/// Example: ["ldap.corp.example.com", "ldap2.corp.example.com"]
|
||||
/// </summary>
|
||||
public string[] ServerUrls { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Distinguished name of required group for access.
|
||||
/// Required when <see cref="UseFakeAuth"/> is false.
|
||||
/// Example: "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com"
|
||||
/// </summary>
|
||||
public string GroupDn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP search base for user lookups.
|
||||
/// Required when <see cref="UseFakeAuth"/> is false.
|
||||
/// Example: "DC=corp,DC=example,DC=com"
|
||||
/// </summary>
|
||||
public string SearchBase { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Connection timeout in seconds.
|
||||
/// Connection timeout in seconds. Must be between 1 and 300.
|
||||
/// </summary>
|
||||
[Range(1, 300, ErrorMessage = "ConnectionTimeoutSeconds must be between 1 and 300 seconds.")]
|
||||
public int ConnectionTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Enable fake authentication for development.
|
||||
/// When true, any credentials are accepted.
|
||||
/// When true, any credentials are accepted and LDAP server configuration is not required.
|
||||
/// </summary>
|
||||
public bool UseFakeAuth { get; set; } = false;
|
||||
|
||||
|
||||
@@ -15,22 +15,36 @@ public class RsaKeyService : IRsaKeyService, IDisposable
|
||||
/// Creates a new RSA key service.
|
||||
/// </summary>
|
||||
/// <param name="keyFilePath">Path to persist the private key</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the RSA key file cannot be accessed or created.</exception>
|
||||
public RsaKeyService(string keyFilePath)
|
||||
{
|
||||
_rsa = RSA.Create(2048);
|
||||
|
||||
if (File.Exists(keyFilePath))
|
||||
try
|
||||
{
|
||||
var keyBytes = File.ReadAllBytes(keyFilePath);
|
||||
_rsa.ImportRSAPrivateKey(keyBytes, out _);
|
||||
if (File.Exists(keyFilePath))
|
||||
{
|
||||
var keyBytes = File.ReadAllBytes(keyFilePath);
|
||||
_rsa.ImportRSAPrivateKey(keyBytes, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
var privateKey = _rsa.ExportRSAPrivateKey();
|
||||
var directory = Path.GetDirectoryName(keyFilePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllBytes(keyFilePath, privateKey);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (IOException ex)
|
||||
{
|
||||
var privateKey = _rsa.ExportRSAPrivateKey();
|
||||
var directory = Path.GetDirectoryName(keyFilePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllBytes(keyFilePath, privateKey);
|
||||
_rsa.Dispose();
|
||||
throw new InvalidOperationException($"Failed to access RSA key file at '{keyFilePath}'", ex);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_rsa.Dispose();
|
||||
throw new InvalidOperationException($"Access denied to RSA key file at '{keyFilePath}'", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Security.Cryptography;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.Options;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JdeScoping.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Migrates existing secrets to SecureStore on first run.
|
||||
/// </summary>
|
||||
public class SecretsMigrator
|
||||
{
|
||||
private readonly ISecureStoreService _secureStore;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SecureStoreOptions _options;
|
||||
private readonly ILogger<SecretsMigrator> _logger;
|
||||
|
||||
// Well-known secret keys
|
||||
public const string RsaPrivateKeyName = "RsaPrivateKey";
|
||||
public const string ExcelCriteriaPasswordKey = "ExcelExport:CriteriaSheetPassword";
|
||||
public const string ExcelDataPasswordKey = "ExcelExport:DataSheetPassword";
|
||||
|
||||
public SecretsMigrator(
|
||||
ISecureStoreService secureStore,
|
||||
IConfiguration configuration,
|
||||
IOptions<SecureStoreOptions> options,
|
||||
ILogger<SecretsMigrator> logger)
|
||||
{
|
||||
_secureStore = secureStore;
|
||||
_configuration = configuration;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Migrates existing secrets if migration is enabled and secrets haven't been migrated yet.
|
||||
/// </summary>
|
||||
public void MigrateIfNeeded()
|
||||
{
|
||||
if (!_options.MigrateExistingSecrets)
|
||||
{
|
||||
_logger.LogDebug("Secret migration is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
var migrated = false;
|
||||
|
||||
migrated |= MigrateRsaKey();
|
||||
migrated |= MigrateExcelPasswords();
|
||||
|
||||
if (migrated)
|
||||
{
|
||||
// Save with metadata to persist keys list
|
||||
if (_secureStore is SecureStoreService sss)
|
||||
{
|
||||
sss.SaveWithMetadata();
|
||||
}
|
||||
else
|
||||
{
|
||||
_secureStore.Save();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Secret migration completed");
|
||||
}
|
||||
}
|
||||
|
||||
private bool MigrateRsaKey()
|
||||
{
|
||||
// Skip if already in SecureStore
|
||||
if (_secureStore.Contains(RsaPrivateKeyName))
|
||||
{
|
||||
_logger.LogDebug("RSA key already in SecureStore, skipping migration");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Look for existing key file
|
||||
var rsaKeyOptions = _configuration.GetSection("RsaKey").Get<RsaKeyOptions>() ?? new RsaKeyOptions();
|
||||
var keyFilePath = Path.IsPathRooted(rsaKeyOptions.KeyFilePath)
|
||||
? rsaKeyOptions.KeyFilePath
|
||||
: Path.Combine(AppContext.BaseDirectory, rsaKeyOptions.KeyFilePath);
|
||||
|
||||
if (!File.Exists(keyFilePath))
|
||||
{
|
||||
_logger.LogDebug("No existing RSA key file found at {KeyFilePath}", keyFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read the binary RSA key file
|
||||
var keyBytes = File.ReadAllBytes(keyFilePath);
|
||||
|
||||
// Import into RSA and export as PEM
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportRSAPrivateKey(keyBytes, out _);
|
||||
var pemKey = rsa.ExportRSAPrivateKeyPem();
|
||||
|
||||
// Store in SecureStore
|
||||
_secureStore.Set(RsaPrivateKeyName, pemKey);
|
||||
_logger.LogInformation("Migrated RSA key from {KeyFilePath} to SecureStore", keyFilePath);
|
||||
|
||||
// Optionally delete the old file
|
||||
try
|
||||
{
|
||||
File.Delete(keyFilePath);
|
||||
_logger.LogInformation("Deleted old RSA key file at {KeyFilePath}", keyFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not delete old RSA key file at {KeyFilePath}", keyFilePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to migrate RSA key from {KeyFilePath}", keyFilePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool MigrateExcelPasswords()
|
||||
{
|
||||
var migrated = false;
|
||||
|
||||
// Migrate criteria sheet password if configured and not already in SecureStore
|
||||
if (!_secureStore.Contains(ExcelCriteriaPasswordKey))
|
||||
{
|
||||
var password = _configuration["ExcelExport:CriteriaSheetPassword"];
|
||||
if (!string.IsNullOrEmpty(password) && password != string.Empty)
|
||||
{
|
||||
_secureStore.Set(ExcelCriteriaPasswordKey, password);
|
||||
_logger.LogInformation("Migrated Excel criteria sheet password to SecureStore");
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate data sheet password if configured and not already in SecureStore
|
||||
if (!_secureStore.Contains(ExcelDataPasswordKey))
|
||||
{
|
||||
var password = _configuration["ExcelExport:DataSheetPassword"];
|
||||
if (!string.IsNullOrEmpty(password) && password != string.Empty)
|
||||
{
|
||||
_secureStore.Set(ExcelDataPasswordKey, password);
|
||||
_logger.LogInformation("Migrated Excel data sheet password to SecureStore");
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Security.Cryptography;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// RSA key service that stores keys in SecureStore instead of plain files.
|
||||
/// </summary>
|
||||
public class SecureStoreRsaKeyService : IRsaKeyService, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Key name used in SecureStore for the RSA private key.
|
||||
/// </summary>
|
||||
public const string RsaPrivateKeyName = "RsaPrivateKey";
|
||||
|
||||
private readonly RSA _rsa;
|
||||
private readonly ISecureStoreService _secureStore;
|
||||
private readonly ILogger<SecureStoreRsaKeyService> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SecureStore-backed RSA key service.
|
||||
/// </summary>
|
||||
public SecureStoreRsaKeyService(
|
||||
ISecureStoreService secureStore,
|
||||
ILogger<SecureStoreRsaKeyService> logger)
|
||||
{
|
||||
_secureStore = secureStore;
|
||||
_logger = logger;
|
||||
_rsa = RSA.Create(2048);
|
||||
|
||||
if (_secureStore.Contains(RsaPrivateKeyName))
|
||||
{
|
||||
// Load existing key from SecureStore
|
||||
var pemKey = _secureStore.GetRequired(RsaPrivateKeyName);
|
||||
_rsa.ImportFromPem(pemKey);
|
||||
_logger.LogInformation("RSA key loaded from secure store");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate new key and store in SecureStore
|
||||
var pemKey = _rsa.ExportRSAPrivateKeyPem();
|
||||
_secureStore.Set(RsaPrivateKeyName, pemKey);
|
||||
_secureStore.Save();
|
||||
_logger.LogInformation("New RSA key generated and stored in secure store");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetPublicKeyPem()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _rsa.ExportSubjectPublicKeyInfoPem();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] Decrypt(byte[] ciphertext)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_rsa.Dispose();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeoSmart.SecureStore;
|
||||
|
||||
namespace JdeScoping.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// SecureStore-based implementation of encrypted secrets storage.
|
||||
/// Uses NeoSmart.SecureStore for encryption at rest.
|
||||
/// </summary>
|
||||
public class SecureStoreService : ISecureStoreService
|
||||
{
|
||||
private readonly SecretsManager _secretsManager;
|
||||
private readonly string _storePath;
|
||||
private readonly ILogger<SecureStoreService> _logger;
|
||||
private readonly HashSet<string> _keys = new();
|
||||
private bool _disposed;
|
||||
private bool _isDirty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SecureStoreService instance.
|
||||
/// </summary>
|
||||
public SecureStoreService(IOptions<SecureStoreOptions> options, ILogger<SecureStoreService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var opts = options.Value;
|
||||
|
||||
_storePath = Path.IsPathRooted(opts.StorePath)
|
||||
? opts.StorePath
|
||||
: Path.Combine(AppContext.BaseDirectory, opts.StorePath);
|
||||
|
||||
var keyFilePath = Path.IsPathRooted(opts.KeyFilePath)
|
||||
? opts.KeyFilePath
|
||||
: Path.Combine(AppContext.BaseDirectory, opts.KeyFilePath);
|
||||
|
||||
// Check for master key in environment variable (production)
|
||||
var masterKey = Environment.GetEnvironmentVariable(opts.MasterKeyEnvVar);
|
||||
var useMasterKey = !string.IsNullOrEmpty(masterKey);
|
||||
|
||||
if (File.Exists(_storePath))
|
||||
{
|
||||
// Load existing store
|
||||
_logger.LogInformation("Loading secure store from {StorePath}", _storePath);
|
||||
_secretsManager = SecretsManager.LoadStore(_storePath);
|
||||
|
||||
// Load the key
|
||||
if (useMasterKey)
|
||||
{
|
||||
_secretsManager.LoadKeyFromPassword(masterKey!);
|
||||
}
|
||||
else
|
||||
{
|
||||
_secretsManager.LoadKeyFromFile(keyFilePath);
|
||||
}
|
||||
|
||||
LoadKeys();
|
||||
}
|
||||
else if (opts.AutoCreateStore)
|
||||
{
|
||||
// Create new store
|
||||
_logger.LogInformation("Creating new secure store at {StorePath}", _storePath);
|
||||
EnsureDirectory(_storePath);
|
||||
|
||||
_secretsManager = SecretsManager.CreateStore();
|
||||
|
||||
if (useMasterKey)
|
||||
{
|
||||
_secretsManager.LoadKeyFromPassword(masterKey!);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate key file for development
|
||||
EnsureDirectory(keyFilePath);
|
||||
_secretsManager.GenerateKey();
|
||||
_secretsManager.ExportKey(keyFilePath);
|
||||
_logger.LogInformation("Generated key file at {KeyFilePath}", keyFilePath);
|
||||
}
|
||||
|
||||
// Save empty store
|
||||
_secretsManager.SaveStore(_storePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Secure store not found at '{_storePath}' and AutoCreateStore is disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? Get(string key)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!_keys.Contains(key))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return _secretsManager.Get(key);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetRequired(string key)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
return Get(key) ?? throw new KeyNotFoundException($"Secret '{key}' not found in secure store.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Set(string key, string value)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_secretsManager.Set(key, value);
|
||||
_keys.Add(key);
|
||||
_isDirty = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Contains(string key)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _keys.Contains(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Remove(string key)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!_keys.Remove(key))
|
||||
return false;
|
||||
|
||||
_secretsManager.Delete(key);
|
||||
_isDirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_isDirty)
|
||||
{
|
||||
_secretsManager.SaveStore(_storePath);
|
||||
_isDirty = false;
|
||||
_logger.LogDebug("Secure store saved to {StorePath}", _storePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<string> Keys => _keys.ToList().AsReadOnly();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// Auto-save on dispose
|
||||
if (_isDirty)
|
||||
{
|
||||
try
|
||||
{
|
||||
_secretsManager.SaveStore(_storePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save secure store on dispose");
|
||||
}
|
||||
}
|
||||
|
||||
_secretsManager.Dispose();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void LoadKeys()
|
||||
{
|
||||
// SecureStore doesn't expose key enumeration directly,
|
||||
// but we can retrieve them from the underlying store
|
||||
// For now, we'll track keys as they're accessed
|
||||
_keys.Clear();
|
||||
|
||||
// Try to load keys from a metadata entry if it exists
|
||||
try
|
||||
{
|
||||
var keysJson = _secretsManager.Get("__keys__");
|
||||
if (!string.IsNullOrEmpty(keysJson))
|
||||
{
|
||||
var keys = System.Text.Json.JsonSerializer.Deserialize<string[]>(keysJson);
|
||||
if (keys != null)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
_keys.Add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
// No keys metadata yet, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveKeysMetadata()
|
||||
{
|
||||
// Exclude the metadata key itself
|
||||
var keys = _keys.Where(k => k != "__keys__").ToArray();
|
||||
var keysJson = System.Text.Json.JsonSerializer.Serialize(keys);
|
||||
_secretsManager.Set("__keys__", keysJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the store with updated keys metadata.
|
||||
/// </summary>
|
||||
public void SaveWithMetadata()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
SaveKeysMetadata();
|
||||
Save();
|
||||
}
|
||||
|
||||
private static void EnsureDirectory(string filePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:JdeScoping.SecureStoreManager.Converters"
|
||||
x:Class="JdeScoping.SecureStoreManager.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
</Application.Styles>
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<!-- Converters -->
|
||||
<converters:InverseBooleanConverter x:Key="InverseBool" />
|
||||
<converters:BooleanToVisibilityIconConverter x:Key="BoolToVisibilityIcon" />
|
||||
<converters:NullToBoolConverter x:Key="NullToBool" />
|
||||
<converters:StringToBoolConverter x:Key="StringToBool" />
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using JdeScoping.SecureStoreManager.Views;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Inverts a boolean value.
|
||||
/// </summary>
|
||||
public class InverseBooleanConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool boolValue)
|
||||
{
|
||||
return !boolValue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool boolValue)
|
||||
{
|
||||
return !boolValue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a boolean to a visibility icon (eye open/closed).
|
||||
/// </summary>
|
||||
public class BooleanToVisibilityIconConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isVisible)
|
||||
{
|
||||
// Use simple text icons for cross-platform compatibility
|
||||
return isVisible ? "Hide" : "Show";
|
||||
}
|
||||
return "Show";
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts null to bool (null = false, not null = true).
|
||||
/// </summary>
|
||||
public class NullToBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value != null;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a string to bool (empty = false, not empty = true).
|
||||
/// </summary>
|
||||
public class StringToBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is string str)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(str);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.*" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.*" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.*" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.*" />
|
||||
<PackageReference Include="MessageBox.Avalonia" Version="3.1.*" />
|
||||
<PackageReference Include="SecureStore" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace JdeScoping.SecureStoreManager.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a secret entry with a key and value.
|
||||
/// </summary>
|
||||
public class SecretEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the secret key.
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the secret value.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager;
|
||||
|
||||
internal class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
# JdeScoping SecureStore Manager
|
||||
|
||||
A cross-platform desktop utility for managing encrypted SecureStore secrets. This tool provides a graphical interface for creating, editing, and managing secrets stored in encrypted JSON files using the NeoSmart.SecureStore library.
|
||||
|
||||
## Features
|
||||
|
||||
- **Cross-platform** - Runs on Windows, macOS, and Linux
|
||||
- **Create new stores** - Create encrypted secret stores with either key file or password-based encryption
|
||||
- **Open existing stores** - Open and manage existing SecureStore JSON files
|
||||
- **Manage secrets** - Add, edit, and delete key-value pairs
|
||||
- **Masked values** - Secret values are masked by default with a reveal toggle
|
||||
- **Copy to clipboard** - Quickly copy secret values
|
||||
- **Unsaved changes tracking** - Prompts before closing with unsaved changes
|
||||
- **Key file generation** - Generate standalone key files for deployment
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 10.0 SDK or later
|
||||
|
||||
### Build and Run
|
||||
|
||||
```bash
|
||||
# Build the application
|
||||
dotnet build src/Utils/JdeScoping.SecureStoreManager
|
||||
|
||||
# Run the application
|
||||
dotnet run --project src/Utils/JdeScoping.SecureStoreManager
|
||||
```
|
||||
|
||||
### Platform-Specific Notes
|
||||
|
||||
#### Windows
|
||||
No additional setup required.
|
||||
|
||||
#### macOS
|
||||
No additional setup required. The application uses native macOS windowing.
|
||||
|
||||
#### Linux
|
||||
Ensure you have the required GTK libraries installed:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install libgtk-3-0
|
||||
|
||||
# Fedora
|
||||
sudo dnf install gtk3
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
dotnet test tests/JdeScoping.SecureStoreManager.Tests
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a New Store
|
||||
|
||||
1. Launch the application
|
||||
2. Select **File > New Store** (or press `Ctrl+N`)
|
||||
3. Choose the store location (`.json` file)
|
||||
4. Select encryption method:
|
||||
- **Key File** (recommended for production): Generates a `.key` file that must be kept secure
|
||||
- **Password**: Uses a password for encryption
|
||||
5. Click **Create**
|
||||
|
||||
### Opening an Existing Store
|
||||
|
||||
1. Select **File > Open Store** (or press `Ctrl+O`)
|
||||
2. Browse to the store file (`.json`)
|
||||
3. Provide the decryption method:
|
||||
- Browse to the key file, or
|
||||
- Enter the password
|
||||
4. Click **Open**
|
||||
|
||||
### Managing Secrets
|
||||
|
||||
| Action | How To |
|
||||
|--------|--------|
|
||||
| Add secret | **Secrets > Add Secret** or toolbar **Add** button |
|
||||
| Edit secret | Double-click the row, or select and press **Enter** |
|
||||
| Delete secret | Select the row and press **Delete** |
|
||||
| Reveal value | Click the **Show/Hide** button in the Actions column |
|
||||
| Copy value | Click **Copy** in the Actions column |
|
||||
| Save changes | **File > Save** or press `Ctrl+S` |
|
||||
|
||||
### Generating a Standalone Key File
|
||||
|
||||
For deployment scenarios where you need to pre-generate a key file:
|
||||
|
||||
1. Select **Tools > Generate Key File**
|
||||
2. Choose the save location
|
||||
3. The generated key can be used with the main JdeScoping application
|
||||
|
||||
### Exporting the Current Key
|
||||
|
||||
To backup or copy the key from the currently open store:
|
||||
|
||||
1. Open a store that uses key file encryption
|
||||
2. Select **Tools > Export Current Key**
|
||||
3. Choose the export location
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+N` | New Store |
|
||||
| `Ctrl+O` | Open Store |
|
||||
| `Ctrl+S` | Save |
|
||||
| `Ctrl+W` | Close Store |
|
||||
| `Delete` | Delete selected secret |
|
||||
|
||||
## Integration with JdeScoping
|
||||
|
||||
This utility is compatible with the SecureStore format used by the main JdeScoping application. You can use it to:
|
||||
|
||||
- View and edit secrets in the application's `data/secrets.json` file
|
||||
- Pre-configure secrets before deployment
|
||||
- Migrate secrets between environments
|
||||
- Troubleshoot configuration issues
|
||||
|
||||
### Opening the Main Application's Store
|
||||
|
||||
1. Locate the store file: `data/secrets.json` (relative to the JdeScoping.Host executable)
|
||||
2. Locate the key file: `data/secrets.key` (or use the master key password if configured)
|
||||
3. Open the store using this utility
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Key files** should be treated as sensitive credentials and not committed to source control
|
||||
- **Values are masked** by default to prevent shoulder surfing
|
||||
- **No auto-save** - changes must be explicitly saved to prevent accidental overwrites
|
||||
- **Delete confirmation** - deleting secrets requires confirmation
|
||||
- **Unsaved changes prompt** - closing with unsaved changes prompts the user
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
JdeScoping.SecureStoreManager/
|
||||
├── Models/
|
||||
│ └── SecretEntry.cs # Secret key-value model
|
||||
├── Services/
|
||||
│ ├── ISecureStoreManager.cs # Service interface
|
||||
│ └── SecureStoreManager.cs # SecureStore wrapper implementation
|
||||
├── ViewModels/
|
||||
│ ├── ViewModelBase.cs # INotifyPropertyChanged base
|
||||
│ ├── RelayCommand.cs # ICommand implementation
|
||||
│ ├── MainWindowViewModel.cs # Main window logic
|
||||
│ ├── SecretItemViewModel.cs # Individual secret item
|
||||
│ └── DialogViewModels.cs # Dialog view models
|
||||
├── Views/
|
||||
│ ├── MainWindow.axaml # Main application window
|
||||
│ ├── NewStoreDialog.axaml # Create store dialog
|
||||
│ ├── OpenStoreDialog.axaml # Open store dialog
|
||||
│ └── SecretEditDialog.axaml # Add/edit secret dialog
|
||||
├── Converters/
|
||||
│ └── BooleanConverters.cs # Value converters
|
||||
├── App.axaml # Application resources
|
||||
├── Program.cs # Application entry point
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- .NET 10.0
|
||||
- Avalonia UI 11.2
|
||||
- Avalonia.Controls.DataGrid 11.2
|
||||
- MessageBox.Avalonia 3.1
|
||||
- NeoSmart.SecureStore 1.2.0
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace JdeScoping.SecureStoreManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing SecureStore encrypted secret stores.
|
||||
/// </summary>
|
||||
public interface ISecureStoreManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether a store is currently open.
|
||||
/// </summary>
|
||||
bool IsStoreOpen { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the currently open store, or null if no store is open.
|
||||
/// </summary>
|
||||
string? CurrentStorePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are unsaved changes to the current store.
|
||||
/// </summary>
|
||||
bool HasUnsavedChanges { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new store secured with a key file.
|
||||
/// </summary>
|
||||
/// <param name="storePath">Path for the new store file (.json).</param>
|
||||
/// <param name="keyFilePath">Path for the key file (.key).</param>
|
||||
void CreateStore(string storePath, string keyFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new store secured with a password.
|
||||
/// </summary>
|
||||
/// <param name="storePath">Path for the new store file (.json).</param>
|
||||
/// <param name="password">Password to encrypt the store.</param>
|
||||
void CreateStoreWithPassword(string storePath, string password);
|
||||
|
||||
/// <summary>
|
||||
/// Opens an existing store using a key file.
|
||||
/// </summary>
|
||||
/// <param name="storePath">Path to the store file (.json).</param>
|
||||
/// <param name="keyFilePath">Path to the key file (.key).</param>
|
||||
void OpenStore(string storePath, string keyFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Opens an existing store using a password.
|
||||
/// </summary>
|
||||
/// <param name="storePath">Path to the store file (.json).</param>
|
||||
/// <param name="password">Password to decrypt the store.</param>
|
||||
void OpenStoreWithPassword(string storePath, string password);
|
||||
|
||||
/// <summary>
|
||||
/// Closes the currently open store without saving.
|
||||
/// </summary>
|
||||
void CloseStore();
|
||||
|
||||
/// <summary>
|
||||
/// Saves changes to the currently open store.
|
||||
/// </summary>
|
||||
void Save();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all secret keys in the current store.
|
||||
/// </summary>
|
||||
/// <returns>Collection of secret key names.</returns>
|
||||
IReadOnlyList<string> GetKeys();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of a secret.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>The decrypted secret value.</returns>
|
||||
string GetSecret(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Sets or updates a secret value.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The value to encrypt and store.</param>
|
||||
void SetSecret(string key, string value);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a secret from the store.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key to remove.</param>
|
||||
void RemoveSecret(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new key file for use with store encryption.
|
||||
/// </summary>
|
||||
/// <param name="path">Path where the key file will be created.</param>
|
||||
void GenerateKeyFile(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Exports the current store's key to a file (for key file-based stores).
|
||||
/// </summary>
|
||||
/// <param name="path">Path where the key will be exported.</param>
|
||||
void ExportKey(string path);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using NeoSmart.SecureStore;
|
||||
|
||||
namespace JdeScoping.SecureStoreManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages SecureStore encrypted secret stores for the WPF application.
|
||||
/// </summary>
|
||||
public class SecureStoreManager : ISecureStoreManager, IDisposable
|
||||
{
|
||||
private SecretsManager? _secretsManager;
|
||||
private string? _currentStorePath;
|
||||
private readonly HashSet<string> _keys = new();
|
||||
private bool _hasUnsavedChanges;
|
||||
private bool _disposed;
|
||||
|
||||
private const string KeysMetadataKey = "__keys__";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsStoreOpen => _secretsManager != null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CurrentStorePath => _currentStorePath;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasUnsavedChanges => _hasUnsavedChanges;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CreateStore(string storePath, string keyFilePath)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
CloseStoreInternal();
|
||||
|
||||
EnsureDirectory(storePath);
|
||||
EnsureDirectory(keyFilePath);
|
||||
|
||||
_secretsManager = SecretsManager.CreateStore();
|
||||
_secretsManager.GenerateKey();
|
||||
_secretsManager.ExportKey(keyFilePath);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
_keys.Clear();
|
||||
_hasUnsavedChanges = true;
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CreateStoreWithPassword(string storePath, string password)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
CloseStoreInternal();
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
throw new ArgumentException("Password cannot be empty.", nameof(password));
|
||||
|
||||
EnsureDirectory(storePath);
|
||||
|
||||
_secretsManager = SecretsManager.CreateStore();
|
||||
_secretsManager.LoadKeyFromPassword(password);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
_keys.Clear();
|
||||
_hasUnsavedChanges = true;
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OpenStore(string storePath, string keyFilePath)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
CloseStoreInternal();
|
||||
|
||||
if (!File.Exists(storePath))
|
||||
throw new FileNotFoundException("Store file not found.", storePath);
|
||||
|
||||
if (!File.Exists(keyFilePath))
|
||||
throw new FileNotFoundException("Key file not found.", keyFilePath);
|
||||
|
||||
_secretsManager = SecretsManager.LoadStore(storePath);
|
||||
_secretsManager.LoadKeyFromFile(keyFilePath);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
LoadKeysMetadata();
|
||||
_hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OpenStoreWithPassword(string storePath, string password)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
CloseStoreInternal();
|
||||
|
||||
if (!File.Exists(storePath))
|
||||
throw new FileNotFoundException("Store file not found.", storePath);
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
throw new ArgumentException("Password cannot be empty.", nameof(password));
|
||||
|
||||
_secretsManager = SecretsManager.LoadStore(storePath);
|
||||
_secretsManager.LoadKeyFromPassword(password);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
LoadKeysMetadata();
|
||||
_hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CloseStore()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
CloseStoreInternal();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null || _currentStorePath == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
SaveKeysMetadata();
|
||||
_secretsManager.SaveStore(_currentStorePath);
|
||||
_hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetKeys()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
return _keys.Where(k => k != KeysMetadataKey).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetSecret(string key)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be empty.", nameof(key));
|
||||
|
||||
if (!_keys.Contains(key))
|
||||
throw new KeyNotFoundException($"Secret '{key}' not found.");
|
||||
|
||||
return _secretsManager.Get(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetSecret(string key, string value)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be empty.", nameof(key));
|
||||
|
||||
_secretsManager.Set(key, value ?? string.Empty);
|
||||
_keys.Add(key);
|
||||
_hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveSecret(string key)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be empty.", nameof(key));
|
||||
|
||||
if (!_keys.Remove(key))
|
||||
throw new KeyNotFoundException($"Secret '{key}' not found.");
|
||||
|
||||
_secretsManager.Delete(key);
|
||||
_hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void GenerateKeyFile(string path)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
throw new ArgumentException("Path cannot be empty.", nameof(path));
|
||||
|
||||
EnsureDirectory(path);
|
||||
|
||||
using var tempManager = SecretsManager.CreateStore();
|
||||
tempManager.GenerateKey();
|
||||
tempManager.ExportKey(path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ExportKey(string path)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
throw new ArgumentException("Path cannot be empty.", nameof(path));
|
||||
|
||||
EnsureDirectory(path);
|
||||
_secretsManager.ExportKey(path);
|
||||
}
|
||||
|
||||
private void LoadKeysMetadata()
|
||||
{
|
||||
_keys.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var keysJson = _secretsManager!.Get(KeysMetadataKey);
|
||||
if (!string.IsNullOrEmpty(keysJson))
|
||||
{
|
||||
var keys = JsonSerializer.Deserialize<string[]>(keysJson);
|
||||
if (keys != null)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
_keys.Add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
// No keys metadata yet
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveKeysMetadata()
|
||||
{
|
||||
var keys = _keys.Where(k => k != KeysMetadataKey).ToArray();
|
||||
var keysJson = JsonSerializer.Serialize(keys);
|
||||
_secretsManager!.Set(KeysMetadataKey, keysJson);
|
||||
_keys.Add(KeysMetadataKey);
|
||||
}
|
||||
|
||||
private void CloseStoreInternal()
|
||||
{
|
||||
_secretsManager?.Dispose();
|
||||
_secretsManager = null;
|
||||
_currentStorePath = null;
|
||||
_keys.Clear();
|
||||
_hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
private static void EnsureDirectory(string filePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_secretsManager?.Dispose();
|
||||
_secretsManager = null;
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user