diff --git a/NEW/src/JdeScoping.Api/Contracts/ManualSync/CancelManualSyncRequestDto.cs b/NEW/src/JdeScoping.Api/Contracts/ManualSync/CancelManualSyncRequestDto.cs new file mode 100644 index 0000000..d25fdd8 --- /dev/null +++ b/NEW/src/JdeScoping.Api/Contracts/ManualSync/CancelManualSyncRequestDto.cs @@ -0,0 +1,12 @@ +namespace JdeScoping.Api.Contracts.ManualSync; + +/// +/// Request to cancel a manual sync request. +/// +public class CancelManualSyncRequestDto +{ + /// + /// The row version for optimistic concurrency (Base64 encoded). + /// + public string RowVersionBase64 { get; set; } = string.Empty; +} diff --git a/NEW/src/JdeScoping.Api/Contracts/ManualSync/CreateManualSyncRequestDto.cs b/NEW/src/JdeScoping.Api/Contracts/ManualSync/CreateManualSyncRequestDto.cs new file mode 100644 index 0000000..c026ffd --- /dev/null +++ b/NEW/src/JdeScoping.Api/Contracts/ManualSync/CreateManualSyncRequestDto.cs @@ -0,0 +1,17 @@ +namespace JdeScoping.Api.Contracts.ManualSync; + +/// +/// Request to create a new manual sync request. +/// +public class CreateManualSyncRequestDto +{ + /// + /// The name of the pipeline to sync. + /// + public string PipelineName { get; set; } = string.Empty; + + /// + /// The type of sync to perform (mass, daily, hourly). + /// + public string SyncType { get; set; } = string.Empty; +} diff --git a/NEW/src/JdeScoping.Api/Contracts/ManualSync/ManualSyncRequestViewModel.cs b/NEW/src/JdeScoping.Api/Contracts/ManualSync/ManualSyncRequestViewModel.cs new file mode 100644 index 0000000..dad7066 --- /dev/null +++ b/NEW/src/JdeScoping.Api/Contracts/ManualSync/ManualSyncRequestViewModel.cs @@ -0,0 +1,57 @@ +namespace JdeScoping.Api.Contracts.ManualSync; + +/// +/// View model for a manual sync request. +/// +public class ManualSyncRequestViewModel +{ + /// + /// The unique identifier for the sync request. + /// + public int Id { get; set; } + + /// + /// The name of the pipeline being synced. + /// + public string PipelineName { get; set; } = string.Empty; + + /// + /// The type of sync (mass, daily, hourly). + /// + public string SyncType { get; set; } = string.Empty; + + /// + /// The date and time the request was made. + /// + public DateTime RequestDT { get; set; } + + /// + /// The username of the person who requested the sync. + /// + public string RequestedBy { get; set; } = string.Empty; + + /// + /// The date and time the sync completed, if applicable. + /// + public DateTime? CompletedDT { get; set; } + + /// + /// The date and time the sync was cancelled, if applicable. + /// + public DateTime? CancelDT { get; set; } + + /// + /// The username of the person who cancelled the sync, if applicable. + /// + public string? CancelledBy { get; set; } + + /// + /// The current status of the sync request. + /// + public string Status { get; set; } = string.Empty; + + /// + /// The row version for optimistic concurrency (Base64 encoded). + /// + public string RowVersionBase64 { get; set; } = string.Empty; +} diff --git a/NEW/src/JdeScoping.Api/Contracts/ManualSync/PipelineInfoViewModel.cs b/NEW/src/JdeScoping.Api/Contracts/ManualSync/PipelineInfoViewModel.cs new file mode 100644 index 0000000..3ec1047 --- /dev/null +++ b/NEW/src/JdeScoping.Api/Contracts/ManualSync/PipelineInfoViewModel.cs @@ -0,0 +1,17 @@ +namespace JdeScoping.Api.Contracts.ManualSync; + +/// +/// View model for pipeline information. +/// +public class PipelineInfoViewModel +{ + /// + /// The name of the pipeline. + /// + public string Name { get; set; } = string.Empty; + + /// + /// The sync types supported by this pipeline. + /// + public List SupportedSyncTypes { get; set; } = new(); +} diff --git a/NEW/src/JdeScoping.Api/Controllers/ManualSyncController.cs b/NEW/src/JdeScoping.Api/Controllers/ManualSyncController.cs new file mode 100644 index 0000000..82a59c7 --- /dev/null +++ b/NEW/src/JdeScoping.Api/Controllers/ManualSyncController.cs @@ -0,0 +1,189 @@ +using JdeScoping.Api.Contracts.ManualSync; +using JdeScoping.DataAccess.Services; +using JdeScoping.DataSync.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace JdeScoping.Api.Controllers; + +/// +/// API endpoints for manual data sync request management. +/// +[Route("api/manual-sync")] +[ApiController] +[Authorize] +public class ManualSyncController : ApiControllerBase +{ + private readonly IManualSyncRequestService _manualSyncRequestService; + private readonly IPipelineRegistry _pipelineRegistry; + + /// + /// Initializes a new instance of the class. + /// + /// The service for managing manual sync requests. + /// The pipeline registry for querying pipeline information. + public ManualSyncController( + IManualSyncRequestService manualSyncRequestService, + IPipelineRegistry pipelineRegistry) + { + _manualSyncRequestService = manualSyncRequestService; + _pipelineRegistry = pipelineRegistry; + } + + /// + /// Gets all manual sync requests, optionally filtered to pending only. + /// + /// If true, returns only pending requests. Default is false. + /// Cancellation token. + /// A list of manual sync request view models. + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetRequests( + [FromQuery] bool pendingOnly = false, + CancellationToken ct = default) + { + var requests = await _manualSyncRequestService.GetRequestsAsync(pendingOnly, ct); + + var viewModels = requests.Select(r => new ManualSyncRequestViewModel + { + Id = r.Id, + PipelineName = r.PipelineName, + SyncType = r.SyncType, + RequestDT = r.RequestDT, + RequestedBy = r.RequestedBy, + CompletedDT = r.CompletedDT, + CancelDT = r.CancelDT, + CancelledBy = r.CancelledBy, + Status = r.Status, + RowVersionBase64 = Convert.ToBase64String(r.RowVersion) + }).ToList(); + + return Ok(viewModels); + } + + /// + /// Gets all available pipelines with their supported sync types. + /// + /// A list of pipeline information view models. + [HttpGet("pipelines")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult> GetPipelines() + { + var pipelines = _pipelineRegistry.GetEnabledPipelines() + .Select(p => new PipelineInfoViewModel + { + Name = p.Name, + SupportedSyncTypes = GetSupportedSyncTypes(p) + }) + .ToList(); + + return Ok(pipelines); + } + + /// + /// Creates a new manual sync request. + /// + /// The create request containing pipeline name and sync type. + /// Cancellation token. + /// The created manual sync request view model. + [HttpPost] + [ProducesResponseType(typeof(ManualSyncRequestViewModel), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> CreateRequest( + [FromBody] CreateManualSyncRequestDto dto, + CancellationToken ct = default) + { + var username = User.Identity?.Name; + if (string.IsNullOrEmpty(username)) + { + return Unauthorized(); + } + + // Validate pipeline and sync type combination using the registry + if (!_pipelineRegistry.IsValidPipelineAndSyncType(dto.PipelineName, dto.SyncType)) + { + return BadRequest($"Invalid pipeline/sync type combination. Pipeline '{dto.PipelineName}' does not support sync type '{dto.SyncType}'."); + } + + var request = await _manualSyncRequestService.CreateRequestAsync( + dto.PipelineName, + dto.SyncType, + username, + ct); + + var viewModel = new ManualSyncRequestViewModel + { + Id = request.Id, + PipelineName = request.PipelineName, + SyncType = request.SyncType, + RequestDT = request.RequestDT, + RequestedBy = request.RequestedBy, + CompletedDT = request.CompletedDT, + CancelDT = request.CancelDT, + CancelledBy = request.CancelledBy, + Status = request.Status, + RowVersionBase64 = Convert.ToBase64String(request.RowVersion) + }; + + return CreatedAtAction(nameof(GetRequests), viewModel); + } + + /// + /// Cancels a pending manual sync request. + /// + /// The ID of the request to cancel. + /// The cancel request containing the row version for concurrency. + /// Cancellation token. + /// A success message if cancelled, or a conflict error if already processed. + [HttpPost("{id:int}/cancel")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CancelRequest( + int id, + [FromBody] CancelManualSyncRequestDto dto, + CancellationToken ct = default) + { + var username = User.Identity?.Name; + if (string.IsNullOrEmpty(username)) + { + return Unauthorized(); + } + + byte[] rowVersion; + try + { + rowVersion = Convert.FromBase64String(dto.RowVersionBase64); + } + catch (FormatException) + { + return BadRequest("Invalid RowVersionBase64 format."); + } + + var cancelled = await _manualSyncRequestService.CancelRequestAsync( + id, + username, + rowVersion, + ct); + + if (!cancelled) + { + return Conflict(new { message = "The request has already been completed or cancelled, or the data has been modified by another user." }); + } + + return Ok(new { message = "Request cancelled successfully." }); + } + + private static List GetSupportedSyncTypes(DataSync.Configuration.EtlPipelineConfig pipeline) + { + var types = new List(); + if (pipeline.SupportsMassSync) types.Add("mass"); + if (pipeline.SupportsDailySync) types.Add("daily"); + if (pipeline.SupportsHourlySync) types.Add("hourly"); + return types; + } +} diff --git a/NEW/src/JdeScoping.Api/Controllers/PipelineController.cs b/NEW/src/JdeScoping.Api/Controllers/PipelineController.cs new file mode 100644 index 0000000..8da9d7f --- /dev/null +++ b/NEW/src/JdeScoping.Api/Controllers/PipelineController.cs @@ -0,0 +1,224 @@ +using JdeScoping.Api.Contracts.ManualSync; +using JdeScoping.DataSync.Configuration; +using JdeScoping.DataSync.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JdeScoping.Api.Controllers; + +/// +/// API endpoints for pipeline management and hot reload. +/// +[Route("api/pipelines")] +[ApiController] +[Authorize] +public class PipelineController : ApiControllerBase +{ + private readonly IPipelineRegistry _pipelineRegistry; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The pipeline registry. + /// The logger. + public PipelineController( + IPipelineRegistry pipelineRegistry, + ILogger logger) + { + _pipelineRegistry = pipelineRegistry; + _logger = logger; + } + + /// + /// Gets all enabled pipelines with their supported sync types. + /// + /// A list of pipeline information view models. + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult> GetPipelines() + { + var pipelines = _pipelineRegistry.GetEnabledPipelines() + .Select(p => new PipelineInfoViewModel + { + Name = p.Name, + SupportedSyncTypes = GetSupportedSyncTypes(p) + }) + .ToList(); + + return Ok(pipelines); + } + + /// + /// Gets registry metadata including version and last load time. + /// + /// Registry metadata. + [HttpGet("status")] + [ProducesResponseType(typeof(PipelineRegistryStatusViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult GetStatus() + { + var allPipelines = _pipelineRegistry.GetAllPipelines(); + var enabledPipelines = _pipelineRegistry.GetEnabledPipelines(); + + return Ok(new PipelineRegistryStatusViewModel + { + Version = _pipelineRegistry.Version, + LastLoadedAt = _pipelineRegistry.LastLoadedAt, + TotalPipelines = allPipelines.Count, + EnabledPipelines = enabledPipelines.Count + }); + } + + /// + /// Reloads all pipeline definitions from disk. + /// Requires Admin role. + /// + /// Cancellation token. + /// The reload result. + [HttpPost("reload")] + [Authorize(Roles = "Admin")] + [ProducesResponseType(typeof(PipelineReloadResultViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> ReloadPipelines(CancellationToken ct = default) + { + var username = User.Identity?.Name ?? "unknown"; + + _logger.LogInformation("Pipeline reload requested by {User}", username); + + try + { + var result = await _pipelineRegistry.ReloadAsync(ct); + + _logger.LogInformation( + "Pipeline reload completed: Success={Success}, Loaded={Loaded}, Skipped={Skipped}, Version={Version}", + result.Success, + result.PipelinesLoaded, + result.PipelinesSkipped, + result.NewVersion); + + return Ok(new PipelineReloadResultViewModel + { + Success = result.Success, + PipelinesLoaded = result.PipelinesLoaded, + PipelinesSkipped = result.PipelinesSkipped, + PreviousVersion = result.PreviousVersion, + NewVersion = result.NewVersion, + Errors = result.Errors.Select(e => new PipelineLoadErrorViewModel + { + FileName = e.FileName, + PipelineName = e.PipelineName, + ErrorType = e.ErrorType, + Messages = e.Messages + }).ToList() + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Pipeline reload failed"); + return StatusCode(StatusCodes.Status500InternalServerError, new { message = "Pipeline reload failed. See server logs for details." }); + } + } + + private static List GetSupportedSyncTypes(EtlPipelineConfig pipeline) + { + var types = new List(); + if (pipeline.SupportsMassSync) types.Add("mass"); + if (pipeline.SupportsDailySync) types.Add("daily"); + if (pipeline.SupportsHourlySync) types.Add("hourly"); + return types; + } +} + +/// +/// View model for pipeline registry status. +/// +public class PipelineRegistryStatusViewModel +{ + /// + /// Gets or sets the current registry version. + /// + public int Version { get; set; } + + /// + /// Gets or sets the timestamp of the last successful load. + /// + public DateTime? LastLoadedAt { get; set; } + + /// + /// Gets or sets the total number of pipelines. + /// + public int TotalPipelines { get; set; } + + /// + /// Gets or sets the number of enabled pipelines. + /// + public int EnabledPipelines { get; set; } +} + +/// +/// View model for pipeline reload result. +/// +public class PipelineReloadResultViewModel +{ + /// + /// Gets or sets a value indicating whether the reload was successful. + /// + public bool Success { get; set; } + + /// + /// Gets or sets the number of pipelines loaded. + /// + public int PipelinesLoaded { get; set; } + + /// + /// Gets or sets the number of pipelines skipped. + /// + public int PipelinesSkipped { get; set; } + + /// + /// Gets or sets the previous version. + /// + public int PreviousVersion { get; set; } + + /// + /// Gets or sets the new version. + /// + public int NewVersion { get; set; } + + /// + /// Gets or sets the list of errors. + /// + public List Errors { get; set; } = []; +} + +/// +/// View model for pipeline load error. +/// +public class PipelineLoadErrorViewModel +{ + /// + /// Gets or sets the file name. + /// + public string FileName { get; set; } = string.Empty; + + /// + /// Gets or sets the pipeline name. + /// + public string PipelineName { get; set; } = string.Empty; + + /// + /// Gets or sets the error type. + /// + public string ErrorType { get; set; } = string.Empty; + + /// + /// Gets or sets the error messages. + /// + public List Messages { get; set; } = []; +} diff --git a/NEW/src/JdeScoping.Client/Components/DataSync/NewSyncRequestDialog.razor b/NEW/src/JdeScoping.Client/Components/DataSync/NewSyncRequestDialog.razor new file mode 100644 index 0000000..2f58377 --- /dev/null +++ b/NEW/src/JdeScoping.Client/Components/DataSync/NewSyncRequestDialog.razor @@ -0,0 +1,47 @@ +@* + NewSyncRequestDialog.razor - Dialog for creating new manual sync requests. + + Allows users to select a pipeline and sync type to queue a new data sync request. + The sync type dropdown is filtered based on the selected pipeline's supported types. +*@ +@namespace JdeScoping.Client.Components.DataSync +@using JdeScoping.Core.ViewModels + + + + + + + + + + + @if (!string.IsNullOrEmpty(_selectedPipeline) && !string.IsNullOrEmpty(_selectedSyncType)) + { + + @_selectedSyncType sync will be queued for @_selectedPipeline. + + } + + + + + + diff --git a/NEW/src/JdeScoping.Client/Components/DataSync/NewSyncRequestDialog.razor.cs b/NEW/src/JdeScoping.Client/Components/DataSync/NewSyncRequestDialog.razor.cs new file mode 100644 index 0000000..de4cfd7 --- /dev/null +++ b/NEW/src/JdeScoping.Client/Components/DataSync/NewSyncRequestDialog.razor.cs @@ -0,0 +1,114 @@ +using System.Net.Http.Json; +using JdeScoping.Core.ViewModels; +using Microsoft.AspNetCore.Components; +using Radzen; + +namespace JdeScoping.Client.Components.DataSync; + +/// +/// Dialog component for creating new manual sync requests. +/// +public partial class NewSyncRequestDialog : ComponentBase +{ + /// + /// Gets or sets the Radzen dialog service for closing the dialog. + /// + [Inject] + private DialogService DialogService { get; set; } = default!; + + /// + /// Gets or sets the HTTP client for API calls. + /// + [Inject] + private HttpClient HttpClient { get; set; } = default!; + + /// + /// Gets or sets the available pipelines to select from. + /// + [Parameter] + public List Pipelines { get; set; } = new(); + + private string? _selectedPipeline; + private string? _selectedSyncType; + private List _availableSyncTypes = new(); + private bool _isCreating; + + /// + /// Determines if the Create button should be enabled. + /// + private bool CanCreate => !string.IsNullOrEmpty(_selectedPipeline) && + !string.IsNullOrEmpty(_selectedSyncType) && + !_isCreating; + + /// + /// Handles pipeline selection change by filtering available sync types. + /// + private void OnPipelineChanged() + { + // Reset sync type when pipeline changes + _selectedSyncType = null; + + if (string.IsNullOrEmpty(_selectedPipeline)) + { + _availableSyncTypes = new(); + return; + } + + // Find the selected pipeline and get its supported sync types + var pipeline = Pipelines.FirstOrDefault(p => p.Name == _selectedPipeline); + _availableSyncTypes = pipeline?.SupportedSyncTypes ?? new(); + } + + /// + /// Handles the Cancel button click by closing the dialog with null result. + /// + private void OnCancelAsync() + { + DialogService.Close(null); + } + + /// + /// Handles the Create button click by calling the API and closing with the result. + /// + private async Task OnCreateAsync() + { + if (!CanCreate) + { + return; + } + + _isCreating = true; + StateHasChanged(); + + try + { + var createDto = new CreateManualSyncRequestDto + { + PipelineName = _selectedPipeline!, + SyncType = _selectedSyncType! + }; + + var response = await HttpClient.PostAsJsonAsync("api/manual-sync", createDto); + + if (response.IsSuccessStatusCode) + { + var createdRequest = await response.Content.ReadFromJsonAsync(); + DialogService.Close(createdRequest); + } + else + { + // On error, close with null (parent can show notification if needed) + DialogService.Close(null); + } + } + catch + { + // On exception, close with null + DialogService.Close(null); + } + finally + { + _isCreating = false; + } + } +} diff --git a/NEW/src/JdeScoping.Client/Layout/MainLayout.razor b/NEW/src/JdeScoping.Client/Layout/MainLayout.razor index c78b451..55995fa 100644 --- a/NEW/src/JdeScoping.Client/Layout/MainLayout.razor +++ b/NEW/src/JdeScoping.Client/Layout/MainLayout.razor @@ -13,6 +13,7 @@ New Search Search Queue Refresh Status + Data Sync