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:
Joseph Doherty
2026-01-19 11:05:36 -05:00
parent 08f5aa1447
commit 604bfe919c
148 changed files with 8696 additions and 1538 deletions
@@ -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();
+13 -8
View File
@@ -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
+20 -3
View File
@@ -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
+105 -264
View File
@@ -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]
+5
View File
@@ -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();
}
}
+1
View File
@@ -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" />
+28 -11
View File
@@ -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);
}
}
@@ -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();
+103
View File
@@ -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
};
}
}
+23 -3
View File
@@ -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 { }
+7
View File
@@ -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