From a25f619c1ee19c719128833af8d10f1521406527 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sun, 2 Aug 2026 12:11:02 +0100 Subject: [PATCH] Migrate config to SQLite, add Blazor ops UI, remove JSON Migrated configuration management from JSON files to normalized SQLite tables, introducing new persistence entities and records for runtime, merchants, file profiles, and contracts. Added centralized configuration state and store abstractions, refactored all services to use the new model, and removed legacy JSON config. Implemented a new Blazor-based operations and configuration UI with CRUD endpoints, modern CSS, and live metrics. Updated database schema, startup, and reporting to support the new relational model. --- .../App.razor | 7 + .../Clients/FileProcessingClient.cs | 33 +- .../Clients/SharedAccessTokenProvider.cs | 4 +- .../Components/Layout/MainLayout.razor | 96 +++ .../Components/Pages/OpsConfiguration.razor | 121 +++ .../Components/Pages/OpsContracts.razor | 210 +++++ .../Components/Pages/OpsFileProfile.razor | 307 +++++++ .../Components/Pages/OpsFileProfiles.razor | 299 +++++++ .../Components/Pages/OpsMerchants.razor | 261 ++++++ .../Components/Pages/OpsOverview.razor | 215 +++++ .../Components/Pages/OpsRuns.razor | 111 +++ .../Components/Pages/OpsRuntime.razor | 129 +++ .../Components/Pages/StatusFile.razor | 170 ++++ .../Components/Pages/StatusMerchant.razor | 157 ++++ .../Components/Pages/StatusOverview.razor | 165 ++++ .../MerchantProcessingConfigurationState.cs | 38 + .../MerchantProcessingConfigurationStore.cs | 528 ++++++++++++ .../MerchantProcessingOptionsValidator.cs | 4 - .../TransactionFileGenerationService.cs | 3 +- .../FileBuilding/TransactionFileModels.cs | 3 +- .../FileStatusPollingWorker.cs | 4 +- .../MerchantFileProcessorDbContext.cs | 155 +++- ...MerchantProcessingConfigurationEntities.cs | 145 ++++ .../MerchantProcessingConfigurationRecord.cs | 10 + .../Program.cs | 234 +++++- ...anagementEndpointRouteBuilderExtensions.cs | 409 ++++++++++ .../Reporting/FileStatusReportService.cs | 11 +- .../Reporting/OperationsDashboardService.cs | 755 ++++++++++++++++++ ...ReportingEndpointRouteBuilderExtensions.cs | 33 +- .../Routes.razor | 11 + ...ionProcessing.MerchantFileProcessor.csproj | 3 + .../Worker.cs | 5 +- .../_Imports.razor | 9 + .../appsettings.json | 125 +-- .../appsettings.staging.json | 138 ---- .../merchant-processing.bootstrap.json | 140 ++++ .../wwwroot/ops.css | 648 +++++++++++++++ 37 files changed, 5375 insertions(+), 321 deletions(-) create mode 100644 TransactionProcessing.MerchantFileProcessor/App.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Layout/MainLayout.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsContracts.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfile.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfiles.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsOverview.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuns.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusFile.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusMerchant.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusOverview.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationState.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationRecord.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Reporting/OperationsDashboardService.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Routes.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/_Imports.razor create mode 100644 TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json create mode 100644 TransactionProcessing.MerchantFileProcessor/wwwroot/ops.css diff --git a/TransactionProcessing.MerchantFileProcessor/App.razor b/TransactionProcessing.MerchantFileProcessor/App.razor new file mode 100644 index 0000000..1dd0909 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/App.razor @@ -0,0 +1,7 @@ + + + + + + + diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs b/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs index 1ac585f..5f20907 100644 --- a/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs +++ b/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs @@ -22,19 +22,26 @@ Task> GetFileStatus(string accessToken, CancellationToken cancellationToken); } -public sealed class FileProcessingClient(IFileProcessorClient fileProcessorClient, MerchantProcessingOptions options) : IFileProcessingClient { +public sealed class FileProcessingClient( + IFileProcessorClient fileProcessorClient, + IMerchantProcessingConfigurationState configurationState) : IFileProcessingClient +{ public async Task> Upload(MerchantOptions merchant, ContractOptions contract, string accessToken, GeneratedFile file, - CancellationToken cancellationToken) { + CancellationToken cancellationToken) + { + var options = configurationState.Current; FileProfileOptions? fileProfile = options.FileProfiles.FirstOrDefault(profile => profile.FileProfileId.Equals(file.FileProfileId, StringComparison.OrdinalIgnoreCase)); - if (fileProfile is null) { + if (fileProfile is null) + { return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"Generated file references unknown file profile '{file.FileProfileId}'." }; } - UploadFileRequest request = new UploadFileRequest { + UploadFileRequest request = new UploadFileRequest + { EstateId = merchant.GetEstateGuid(), MerchantId = merchant.GetMerchantGuid(), UserId = options.FileProcessing.GetUserGuid(), @@ -44,7 +51,8 @@ public async Task> Upload(MerchantOptions merchant, Result? result = await fileProcessorClient.UploadFile(accessToken, file.FileName, file.Content, request, cancellationToken); - if (result.IsFailed) { + if (result.IsFailed) + { return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to upload file '{file.FileName}'." }; } @@ -54,10 +62,12 @@ public async Task> Upload(MerchantOptions merchant, public async Task> GetFileStatus(string accessToken, Guid estateId, Guid fileId, - CancellationToken cancellationToken) { + CancellationToken cancellationToken) + { Result? result = await fileProcessorClient.GetFile(accessToken, estateId, fileId, cancellationToken); - if (result.IsFailed || result.Data is null) { + if (result.IsFailed || result.Data is null) + { return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to retrieve status for file '{fileId}'." }; } @@ -69,13 +79,16 @@ public async Task> GetFileStatus(string acc private static FileProcessingLineStatusSnapshot MapLineStatus(FileLine line) => new(line.LineNumber, line.LineData, line.ProcessingResult.ToString(), string.IsNullOrWhiteSpace(line.RejectionReason) ? null : line.RejectionReason, line.TransactionId == Guid.Empty ? null : line.TransactionId); - private static bool AreAllLinesResolved(IEnumerable lines) { + private static bool AreAllLinesResolved(IEnumerable lines) + { bool hasLines = false; - foreach (FileProcessingLineStatusSnapshot line in lines) { + foreach (FileProcessingLineStatusSnapshot line in lines) + { hasLines = true; - if (line.ProcessingStatus.Equals(FileLineStatuses.Unknown, StringComparison.OrdinalIgnoreCase) || line.ProcessingStatus.Equals(FileLineStatuses.NotProcessed, StringComparison.OrdinalIgnoreCase)) { + if (line.ProcessingStatus.Equals(FileLineStatuses.Unknown, StringComparison.OrdinalIgnoreCase) || line.ProcessingStatus.Equals(FileLineStatuses.NotProcessed, StringComparison.OrdinalIgnoreCase)) + { return false; } } diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs b/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs index 58f70ce..8112e68 100644 --- a/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs +++ b/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs @@ -12,7 +12,7 @@ public interface IAccessTokenProvider public sealed class SharedAccessTokenProvider( ISecurityServiceClient securityServiceClient, - MerchantProcessingOptions options) : IAccessTokenProvider + IMerchantProcessingConfigurationState configurationState) : IAccessTokenProvider { private readonly SemaphoreSlim refreshLock = new(1, 1); private CachedToken? currentToken; @@ -53,7 +53,7 @@ public async Task> GetAccessToken(CancellationToken cancella private async Task> RequestToken(CancellationToken cancellationToken) { - var authentication = options.Authentication; + var authentication = configurationState.Current.Authentication; var tokenResult = await securityServiceClient.GetToken( authentication.ClientId, authentication.ClientSecret, diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Layout/MainLayout.razor b/TransactionProcessing.MerchantFileProcessor/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..532e1f5 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Layout/MainLayout.razor @@ -0,0 +1,96 @@ +@inherits LayoutComponentBase +@inject NavigationManager Navigation + +
+ + +
+
+
+
@CurrentAreaTitle
+

@CurrentPageTitle

+

@CurrentPageSummary

+
+ + +
+ + @Body +
+
+ +@code { + private string CurrentAreaTitle => IsStatusRoute ? "Status" : "Operations"; + + private string CurrentAreaSummary => IsStatusRoute + ? "Merchant rollups, file detail, and audit trail inspection." + : "Configuration, scheduling, and run monitoring."; + + private string CurrentPageTitle => Navigation.ToBaseRelativePath(Navigation.Uri).Trim('/') switch + { + "" or "ops" => "Merchant file processor overview", + "ops/config" => "Configuration hub", + "ops/config/runtime" => "Runtime settings", + "ops/config/merchants" => "Merchants", + "ops/config/file-profiles" => "File profiles", + "ops/runs" => "Run history", + "status" => "Merchant file activity", + _ when Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("status/", StringComparison.OrdinalIgnoreCase) => "Merchant file detail", + _ when Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("ops/config/file-profiles/", StringComparison.OrdinalIgnoreCase) => "File profile", + _ => "Merchant File Processor" + }; + + private string CurrentPageSummary => Navigation.ToBaseRelativePath(Navigation.Uri).Trim('/') switch + { + "" or "ops" => "Live configuration, scheduling, and processing visibility.", + "ops/config" => "Section-based configuration management.", + "ops/config/runtime" => "Authentication, file-processing, generation, and polling settings.", + "ops/config/merchants" => "Merchant schedules, estates, and enabled flags.", + "ops/config/file-profiles" => "Profile metadata and field tables.", + "ops/config/file-profiles/" => "Profile detail and field editing.", + "ops/runs" => "Recent processing execution history.", + "status" => "Merchant rollups and latest file activity.", + _ when Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("status/", StringComparison.OrdinalIgnoreCase) => "Line-level file drilldown and error details.", + _ when Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("ops/config/file-profiles/", StringComparison.OrdinalIgnoreCase) => "Profile metadata and field editing.", + _ => "Live operational control surface." + }; + + private bool IsStatusRoute => Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("status", StringComparison.OrdinalIgnoreCase); +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor new file mode 100644 index 0000000..df00311 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor @@ -0,0 +1,121 @@ +@page "/ops/config" +@inject IMerchantProcessingConfigurationStore ConfigurationStore + +Merchant File Processor - Configuration + +@if (!loaded) +{ +
+

Loading configuration data...

+
+} +else +{ +
+
+
+
Configuration hub
+

Relational configuration

+

The app now stores configuration in SQLite tables instead of a single JSON document. Use the section screens to manage runtime settings, merchants, file profiles, and contract mappings.

+
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
Merchants
+
@snapshot.Options.Merchants.Count
+
+
+
File profiles
+
@snapshot.Options.FileProfiles.Count
+
+
+
Contracts
+
@snapshot.Options.ContractDefinitions.Count
+
+
+
+ +
+
+
Runtime
+
4
+
Authentication, file processing, transaction generation, and polling.
+
+
+
Merchants
+
@snapshot.Options.Merchants.Count
+
Schedules, estates, and enabled flags.
+
+
+
File profiles
+
@snapshot.Options.FileProfiles.Count
+
Body, header, and trailer field tables.
+
+
+
Contracts
+
@snapshot.Options.ContractDefinitions.Count
+
Contract-to-profile mappings.
+
+
+
File status
+
@snapshot.Options.FileStatusPolling.PollIntervalSeconds s
+
Polling cadence for completion checks.
+
+
+
Transactions
+
@($"{snapshot.Options.TransactionGeneration.MinimumTransactionsPerContract}-{snapshot.Options.TransactionGeneration.MaximumTransactionsPerContract}")
+
Generated transaction range per contract.
+
+
+ +
+
+
+

Runtime settings

+

Authentication, file-processing, generation, and polling values.

+
+

Open runtime settings

+
+ +
+
+

Merchants

+

Schedule and estate definitions for each merchant.

+
+

Open merchants

+
+
+ +
+
+
+

File profiles

+

Profiles and their field tables for file generation.

+
+

Open file profiles

+
+ +
+
+

Contract mappings

+

Contract IDs mapped to file profiles.

+
+

Open contracts

+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + loaded = true; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsContracts.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsContracts.razor new file mode 100644 index 0000000..9db20e7 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsContracts.razor @@ -0,0 +1,210 @@ +@page "/ops/config/contracts" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - Contracts + +@if (!loaded) +{ +
+

Loading contracts...

+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
Contracts
+

Contract mappings

+

Select a contract from the table to edit it in the detail pane.

+
+
+
+
Contracts
+
@snapshot.Options.ContractDefinitions.Count
+
+
+
File profiles
+
@snapshot.Options.FileProfiles.Count
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
+ +
+
+
+

Existing contracts

+

Select a row to edit it.

+
+ @if (snapshot.Options.ContractDefinitions.Count == 0) + { +
No contracts are configured yet.
+ } + else + { +
+ + + + + + + + + @foreach (var contract in snapshot.Options.ContractDefinitions) + { + var isSelected = string.Equals(contract.ContractId, selectedContractId, StringComparison.OrdinalIgnoreCase); + + + + + } + +
Contract IDFile profile ID
+ @contract.ContractId + @contract.FileProfileId
+
+ } +
+ +
+
+
+

@(selectedContract?.ContractId ?? "Contract details")

+

@(selectedContract is null ? "Select a contract on the left." : selectedContract.FileProfileId)

+
+ + @if (selectedContract is null) + { +
Pick a contract from the table to edit it here.
+ } + else + { +
+ + +
+ + +
+
+ +
+
+ } +
+ +
+
+

Add contract

+

Create a new contract-to-profile mapping.

+
+
+ + +
+ + +
+
+ + Back to configuration hub +
+
+
+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + private string? statusMessage; + private string selectedContractId = string.Empty; + + private ContractDefinitionOptions? selectedContract => snapshot.Options.ContractDefinitions.FirstOrDefault(contract => + contract.ContractId.Equals(selectedContractId, StringComparison.OrdinalIgnoreCase)); + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + selectedContractId = GetQueryValue("contractId") ?? snapshot.Options.ContractDefinitions.FirstOrDefault()?.ContractId ?? string.Empty; + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private string BuildSelectionHref(string contractId) => + $"/ops/config/contracts?contractId={Uri.EscapeDataString(contractId)}"; + + private string BuildReturnUrl(string contractId) => + BuildSelectionHref(contractId); + + private string? GetQueryValue(string key) + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + query = query.TrimStart('?'); + foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var pieces = part.Split('=', 2); + if (pieces.Length == 2 && pieces[0].Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return Uri.UnescapeDataString(pieces[1]); + } + } + + return null; + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "Contract saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfile.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfile.razor new file mode 100644 index 0000000..d4f3388 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfile.razor @@ -0,0 +1,307 @@ +@page "/ops/config/file-profiles/{FileProfileId}" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - File Profile + +@if (!loaded) +{ +
+

Loading file profile...

+
+} +else if (profile is null) +{ +
+
+
+
File profile
+

Profile not found

+

The requested file profile does not exist.

+

← Back to file profiles

+
+
+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
File profile
+

@profile.FileProfileId

+

@profile.FileProcessorFileProfileId

+

Manage the file profile metadata and the three field tables that define the file layout.

+
+
+
+
Format
+
@profile.Format
+
+
+
Fields
+
@profile.Fields.Count
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
+ +
+
+ + +
+

Profile metadata

+

The profile ID stays as the row key; update the layout properties as needed.

+
+
+ + + + + + + + + + +
+
+ + Back to file profiles +
+
+
+ +
+
+
+

Body fields

+

These are the file columns used by the main file layout.

+
+ @RenderFieldSection("body", profile.Fields) +
+ +
+
+

Header fields

+

Header rows for delimited files.

+
+ @RenderFieldSection("header", profile.Delimited.HeaderFields) +
+
+ +
+
+

Trailer fields

+

Trailer rows for delimited files.

+
+ @RenderFieldSection("trailer", profile.Delimited.TrailerFields) +
+
+} + +@code { + [Parameter] + public string FileProfileId { get; set; } = string.Empty; + + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private FileProfileOptions? profile; + private bool loaded; + private string? statusMessage; + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + profile = snapshot.Options.FileProfiles.FirstOrDefault(entry => entry.FileProfileId.Equals(FileProfileId, StringComparison.OrdinalIgnoreCase)); + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private RenderFragment RenderFieldSection(string section, IReadOnlyList fields) => builder => + { + if (fields.Count == 0) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "empty-state"); + builder.AddContent(2, "No fields are configured for this section yet."); + builder.CloseElement(); + } + + var sequence = 0; + foreach (var field in fields.Select((field, index) => new { field, index })) + { + builder.OpenElement(sequence++, "article"); + builder.AddAttribute(sequence++, "class", "field-card"); + + builder.OpenElement(sequence++, "form"); + builder.AddAttribute(sequence++, "method", "post"); + builder.AddAttribute(sequence++, "action", $"/api/configuration/file-profiles/{Uri.EscapeDataString(profile!.FileProfileId)}/fields/{section}"); + builder.AddAttribute(sequence++, "class", "config-form"); + + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "type", "hidden"); + builder.AddAttribute(sequence++, "name", "ReturnUrl"); + builder.AddAttribute(sequence++, "value", $"/ops/config/file-profiles/{Uri.EscapeDataString(profile!.FileProfileId)}"); + builder.CloseElement(); + + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "type", "hidden"); + builder.AddAttribute(sequence++, "name", "OriginalSortOrder"); + builder.AddAttribute(sequence++, "value", field.index); + builder.CloseElement(); + + builder.OpenElement(sequence++, "div"); + builder.AddAttribute(sequence++, "class", "form-grid"); + AddFieldInput(builder, ref sequence, "Name", field.field.Name); + AddFieldInput(builder, ref sequence, "Source", field.field.Source); + AddFieldInput(builder, ref sequence, "Format", field.field.Format); + AddFieldInput(builder, ref sequence, "Value", field.field.Value); + builder.CloseElement(); + + builder.OpenElement(sequence++, "div"); + builder.AddAttribute(sequence++, "class", "form-actions"); + builder.OpenElement(sequence++, "button"); + builder.AddAttribute(sequence++, "type", "submit"); + builder.AddAttribute(sequence++, "class", "primary-button"); + builder.AddContent(sequence++, "Save field"); + builder.CloseElement(); + builder.OpenElement(sequence++, "button"); + builder.AddAttribute(sequence++, "type", "submit"); + builder.AddAttribute(sequence++, "name", "Delete"); + builder.AddAttribute(sequence++, "value", "true"); + builder.AddAttribute(sequence++, "class", "secondary-button"); + builder.AddContent(sequence++, "Delete"); + builder.CloseElement(); + builder.CloseElement(); + + builder.CloseElement(); + builder.CloseElement(); + } + + builder.OpenElement(sequence++, "article"); + builder.AddAttribute(sequence++, "class", "field-card"); + builder.OpenElement(sequence++, "form"); + builder.AddAttribute(sequence++, "method", "post"); + builder.AddAttribute(sequence++, "action", $"/api/configuration/file-profiles/{Uri.EscapeDataString(profile!.FileProfileId)}/fields/{section}"); + builder.AddAttribute(sequence++, "class", "config-form"); + + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "type", "hidden"); + builder.AddAttribute(sequence++, "name", "ReturnUrl"); + builder.AddAttribute(sequence++, "value", $"/ops/config/file-profiles/{Uri.EscapeDataString(profile!.FileProfileId)}"); + builder.CloseElement(); + + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "type", "hidden"); + builder.AddAttribute(sequence++, "name", "OriginalSortOrder"); + builder.AddAttribute(sequence++, "value", string.Empty); + builder.CloseElement(); + + builder.OpenElement(sequence++, "div"); + builder.AddAttribute(sequence++, "class", "form-grid"); + AddFieldInput(builder, ref sequence, "Name", string.Empty); + AddFieldInput(builder, ref sequence, "Source", string.Empty); + AddFieldInput(builder, ref sequence, "Format", string.Empty); + AddFieldInput(builder, ref sequence, "Value", string.Empty); + builder.CloseElement(); + + builder.OpenElement(sequence++, "div"); + builder.AddAttribute(sequence++, "class", "form-actions"); + builder.OpenElement(sequence++, "button"); + builder.AddAttribute(sequence++, "type", "submit"); + builder.AddAttribute(sequence++, "class", "primary-button"); + builder.AddContent(sequence++, "Add field"); + builder.CloseElement(); + builder.CloseElement(); + + builder.CloseElement(); + builder.CloseElement(); + }; + + private static void AddFieldInput(RenderTreeBuilder builder, ref int sequence, string label, string? value) + { + builder.OpenElement(sequence++, "label"); + builder.OpenElement(sequence++, "span"); + builder.AddContent(sequence++, label); + builder.CloseElement(); + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "name", label); + builder.AddAttribute(sequence++, "class", "text-input"); + builder.AddAttribute(sequence++, "value", value ?? string.Empty); + builder.CloseElement(); + builder.CloseElement(); + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "File profile saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfiles.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfiles.razor new file mode 100644 index 0000000..3c07dec --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfiles.razor @@ -0,0 +1,299 @@ +@page "/ops/config/file-profiles" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - File Profiles + +@if (!loaded) +{ +
+

Loading file profiles...

+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
File profiles
+

Layouts and field tables

+

Select a profile from the table to edit its metadata. Use the detail link to open the field tables for the selected profile.

+
+
+
+
Profiles
+
@snapshot.Options.FileProfiles.Count
+
+
+
Delimited
+
@snapshot.Options.FileProfiles.Count(profile => profile.Format.Equals(FileProfileFormats.Delimited, StringComparison.OrdinalIgnoreCase))
+
+
+
JSON
+
@snapshot.Options.FileProfiles.Count(profile => profile.Format.Equals(FileProfileFormats.Json, StringComparison.OrdinalIgnoreCase))
+
+
+
+ +
+
+
+

Existing profiles

+

Select a row to edit it.

+
+ @if (snapshot.Options.FileProfiles.Count == 0) + { +
No file profiles are configured yet.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var profile in snapshot.Options.FileProfiles) + { + var isSelected = string.Equals(profile.FileProfileId, selectedProfileId, StringComparison.OrdinalIgnoreCase); + + + + + + + + } + +
File profileProcessor profileFormatLayoutFields
+ @profile.FileProfileId + @profile.FileProcessorFileProfileId@profile.Format@DescribeLayout(profile)@profile.Fields.Count
+
+ } +
+ +
+
+
+

@(selectedProfile?.FileProfileId ?? "Profile details")

+

@(selectedProfile is null ? "Select a profile on the left." : selectedProfile.FileProcessorFileProfileId)

+
+ + @if (selectedProfile is null) + { +
Pick a profile from the table to edit it here.
+ } + else + { +
+ + +
+ + + + + + + + + + +
+
+ + Open field tables +
+
+ } +
+ +
+
+

Add profile

+

Create a new profile, then open it to manage fields.

+
+
+ + +
+ + + + + + +
+
+ + Back to configuration hub +
+
+
+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + private string? statusMessage; + private string selectedProfileId = string.Empty; + + private FileProfileOptions? selectedProfile => snapshot.Options.FileProfiles.FirstOrDefault(profile => + profile.FileProfileId.Equals(selectedProfileId, StringComparison.OrdinalIgnoreCase)); + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + selectedProfileId = GetQueryValue("fileProfileId") ?? snapshot.Options.FileProfiles.FirstOrDefault()?.FileProfileId ?? string.Empty; + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private string BuildSelectionHref(string fileProfileId) => + $"/ops/config/file-profiles?fileProfileId={Uri.EscapeDataString(fileProfileId)}"; + + private string BuildReturnUrl(string fileProfileId) => + BuildSelectionHref(fileProfileId); + + private string DescribeLayout(FileProfileOptions profile) + { + var parts = new List { $"{profile.Format} / {profile.FileExtension}" }; + + if (profile.Format.Equals(FileProfileFormats.Delimited, StringComparison.OrdinalIgnoreCase)) + { + parts.Add($"delimiter '{profile.Delimited.Delimiter}'"); + parts.Add(profile.Delimited.IncludeHeader ? "header row" : "no header row"); + } + + if (profile.Format.Equals(FileProfileFormats.Json, StringComparison.OrdinalIgnoreCase)) + { + parts.Add(profile.Json.WriteIndented ? "indented JSON" : "compact JSON"); + if (!string.IsNullOrWhiteSpace(profile.Json.RootPropertyName)) + { + parts.Add($"root '{profile.Json.RootPropertyName}'"); + } + } + + return string.Join(", ", parts); + } + + private string? GetQueryValue(string key) + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + query = query.TrimStart('?'); + foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var pieces = part.Split('=', 2); + if (pieces.Length == 2 && pieces[0].Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return Uri.UnescapeDataString(pieces[1]); + } + } + + return null; + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "File profile saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor new file mode 100644 index 0000000..a9165a9 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor @@ -0,0 +1,261 @@ +@page "/ops/config/merchants" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - Merchants + +@if (!loaded) +{ +
+

Loading merchants...

+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
Merchants
+

Schedule and estate management

+

Select a merchant from the table to edit it in the detail pane. The table stays focused on scanning; the editor handles changes.

+
+
+
+
Merchants
+
@snapshot.Options.Merchants.Count
+
+
+
Enabled
+
@snapshot.Options.Merchants.Count(merchant => merchant.Enabled)
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
+ +
+
+
+

Existing merchants

+

Select a row to edit it.

+
+ @if (snapshot.Options.Merchants.Count == 0) + { +
No merchants are configured yet.
+ } + else + { +
+ + + + + + + + + + + @foreach (var merchant in snapshot.Options.Merchants) + { + var isSelected = string.Equals(merchant.MerchantId, selectedMerchantId, StringComparison.OrdinalIgnoreCase); + + + + + + + } + +
MerchantEnabledEstateRun times
+ @merchant.Name
+ @merchant.MerchantId +
@((merchant.Enabled) ? "Enabled" : "Disabled")@merchant.EstateId@FormatRunTimes(merchant)
+
+ } +
+ +
+
+
+

@(selectedMerchant?.Name ?? "Merchant details")

+

@(selectedMerchant is null ? "Select a merchant on the left." : selectedMerchant.MerchantId)

+
+ + @if (selectedMerchant is null) + { +
Pick a merchant from the table to edit it here.
+ } + else + { +
+ + +
+ + + + + + +
+
+ +
+
+ } +
+ +
+
+

Add merchant

+

Create a new merchant entry.

+
+
+ + +
+ + + + + + +
+
+ + Back to configuration hub +
+
+
+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + private string? statusMessage; + private string selectedMerchantId = string.Empty; + + private MerchantOptions? selectedMerchant => snapshot.Options.Merchants.FirstOrDefault(merchant => + merchant.MerchantId.Equals(selectedMerchantId, StringComparison.OrdinalIgnoreCase)); + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + selectedMerchantId = GetQueryValue("merchantId") ?? snapshot.Options.Merchants.FirstOrDefault()?.MerchantId ?? string.Empty; + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private string BuildSelectionHref(string merchantId) => + $"/ops/config/merchants?merchantId={Uri.EscapeDataString(merchantId)}"; + + private string BuildReturnUrl(string merchantId) => + BuildSelectionHref(merchantId); + + private string FormatRunTimes(MerchantOptions merchant) + { + var runTimes = merchant.RunTimesUtc.Count > 0 ? merchant.RunTimesUtc : [merchant.RunAtUtc]; + return string.Join(", ", runTimes); + } + + private string? GetQueryValue(string key) + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + query = query.TrimStart('?'); + foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var pieces = part.Split('=', 2); + if (pieces.Length == 2 && pieces[0].Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return Uri.UnescapeDataString(pieces[1]); + } + } + + return null; + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "Merchant saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } + + private static string BadgeClass(string tone) => $"badge {tone}"; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsOverview.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsOverview.razor new file mode 100644 index 0000000..d2694f1 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsOverview.razor @@ -0,0 +1,215 @@ +@page "/ops" +@inject IOperationsDashboardService Dashboard + +Merchant File Processor - Operations + +@if (model is null) +{ +
+

Loading operations data...

+
+} +else +{ +
+
+
+
Operations console
+

Merchant File Processor

+

Live configuration, scheduling, and processing visibility for the worker, driven directly from the loaded appsettings and database state.

+
+
+
+
Environment
+
@model.EnvironmentName
+
+
+
Generated
+
@model.GeneratedUtc.ToString("u")
+
+
+
Database
+
@model.ConnectionStringSummary
+
+
+
+ +
+ @foreach (var metric in model.Metrics) + { +
+
@metric.Label
+
@metric.Value
+
@metric.Detail
+
+ } +
+ +
+
+

Merchant schedule

+

Schedules are evaluated in UTC and aligned to the configured merchant run slots.

+
+
+ + + + + + + + + + + + + + + + + @foreach (var merchant in model.Merchants) + { + + + + + + + + + + + + + } + +
MerchantEnabledEstateRun times (UTC)Last merchant runNext runSuccessFailedPending checksStatus
+ @merchant.MerchantName
+ @merchant.MerchantId +
@((merchant.Enabled) ? "Enabled" : "Disabled")@merchant.EstateId@merchant.RunTimesUtc@((merchant.LastMerchantRunUtc?.ToString("u")) ?? "Never")@((merchant.NextScheduledUtc?.ToString("u")) ?? "Disabled")@merchant.SuccessfulFiles@merchant.FailedFiles@merchant.PendingStatusChecksView status
+
+
+ +
+
+
+

File profiles

+

These profiles drive file construction for each contract mapping.

+
+
+ + + + + + + + + + + @foreach (var profile in model.FileProfiles) + { + + + + + + + } + +
ProfileFormatLayoutFields
+ @profile.FileProfileId
+ @profile.FileProcessorFileProfileId +
@profile.Format@profile.LayoutSummary
.@profile.FileExtension@if (!string.IsNullOrWhiteSpace(profile.ContentType)) { / @profile.ContentType }
@profile.FieldCount total
@profile.HeaderFieldCount header, @profile.TrailerFieldCount trailer
+
+
+ +
+
+

Contract mappings

+

Each contract resolves to the file profile used at generation time.

+
+
+ + + + + + + + + @foreach (var contract in model.Contracts) + { + + + + + } + +
ContractFile profile
@contract.ContractId@contract.FileProfileId
+
+
+
+ +
+
+

Recent merchant runs

+

Latest completed runs pulled from the local audit database.

+
+
+ + + + + + + + + + + + @foreach (var run in model.RecentRuns) + { + + + + + + + + } + +
MerchantScheduledCompletedStatusError
+ @run.MerchantName
+ @run.MerchantId +
@run.ScheduledRunUtc.ToString("u")@run.CompletedUtc.ToString("u")@run.Status@run.ErrorMessage
+
+
+
+} + +@code { + private OperationsDashboardModel? model; + + protected override async Task OnInitializedAsync() + { + this.model = await Dashboard.GetDashboardModelAsync(CancellationToken.None); + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private static string RunTone(string status) + { + if (status.Equals(MerchantRunStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(MerchantRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuns.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuns.razor new file mode 100644 index 0000000..aab79ad --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuns.razor @@ -0,0 +1,111 @@ +@page "/ops/runs" +@inject IOperationsDashboardService Dashboard + +Merchant File Processor - Run history + +@if (model is null) +{ +
+

Loading run history...

+
+} +else +{ +
+
+
+
Run history
+

Merchant execution audit trail

+

Each row captures the scheduled slot, completion time, and final outcome for a merchant processing cycle.

+
+
+
+
Generated
+
@model.GeneratedUtc.ToString("u")
+
+
+
Recent runs
+
@model.RecentRuns.Count
+
+
+
Successful
+
@SuccessfulRuns
+
+
+
Failed
+
@FailedRuns
+
+
+
+ +
+
+

Run records

+

Most recent entries appear first, with the latest processing outcome at the top.

+
+
+ + + + + + + + + + + + @foreach (var run in model.RecentRuns) + { + + + + + + + + } + +
MerchantScheduledCompletedStatusError
+ @run.MerchantName
+ @run.MerchantId +
@run.ScheduledRunUtc.ToString("u")@run.CompletedUtc.ToString("u")@run.Status@RenderError(run.ErrorMessage)
+
+
+
+} + +@code { + private OperationsDashboardModel? model; + + protected override async Task OnInitializedAsync() + { + this.model = await Dashboard.GetDashboardModelAsync(CancellationToken.None); + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private int SuccessfulRuns => + model?.RecentRuns.Count(run => run.Status.Equals(MerchantRunStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) ?? 0; + + private int FailedRuns => + model?.RecentRuns.Count(run => run.Status.Equals(MerchantRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) ?? 0; + + private static string RunTone(string status) + { + if (status.Equals(MerchantRunStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(MerchantRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } + + private static string RenderError(string? errorMessage) => + string.IsNullOrWhiteSpace(errorMessage) ? "No error recorded" : errorMessage; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor new file mode 100644 index 0000000..f9ec76b --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor @@ -0,0 +1,129 @@ +@page "/ops/config/runtime" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - Runtime Settings + +@if (!loaded) +{ +
+

Loading runtime settings...

+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
Runtime settings
+

Authentication and worker cadence

+

These settings feed the service at startup and are stored as individual SQLite rows rather than a JSON blob.

+
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
Authentication
+
@snapshot.Options.Authentication.ClientId
+
+
+
Polling
+
@snapshot.Options.FileStatusPolling.PollIntervalSeconds seconds
+
+
+
+ +
+
+

Edit runtime settings

+

Saving replaces the runtime rows in SQLite and refreshes the live in-memory snapshot.

+
+
+ +
+ + + + + + + + +
+
+ + Back to configuration hub +
+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + private string? statusMessage; + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "Runtime settings saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusFile.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusFile.razor new file mode 100644 index 0000000..94406d8 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusFile.razor @@ -0,0 +1,170 @@ +@page "/status/{MerchantId}/files/{FileId:long}" +@inject IFileStatusReportService Reports + +Merchant File Processor - File Status + +@if (!loaded) +{ +
+

Loading file details...

+
+} +else if (report is null) +{ +
+
+
+
File details
+

File not found

+

The selected file could not be found for the current merchant.

+

← Back to all merchants

+
+
+
+} +else +{ +
+
+
+
File details
+

@report.File.FileName

+

@report.Merchant.MerchantName / @report.Merchant.MerchantId

+

This page shows the full audit trail for one generated merchant file and its individual line statuses.

+
+
+
+
Processed
+
@report.File.ProcessedUtc.ToString("u")
+
+
+
Status
+
@report.File.Status
+
+
+
Processing
+
@((report.File.ProcessingCompleted) ? "Complete" : "Pending")
+
+
+
Lines
+
@report.FileLines.Count
+
+
+
+ +
+
+
+

File summary

+

Generated file metadata and status tracking.

+
+
+
Merchant
@report.Merchant.MerchantName
+
Contract
@report.File.ContractName
+
File profile
@report.File.FileProfileId
+
Format
@report.File.Format
+
File name
@(string.IsNullOrWhiteSpace(report.File.FileName) ? "(not generated)" : report.File.FileName)
+
Record count
@report.File.RecordCount
+
Total amount
@report.File.TotalAmount.ToString("0.00")
+
Last status check
@((report.File.LastStatusCheckUtc?.ToString("u")) ?? "Never")
+
+
+ + +
+ +
+
+

File lines

+

Line-level processing statuses captured by the polling worker.

+
+ @if (report.FileLines.Count == 0) + { +
No file content has been recorded for this file yet.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var line in report.FileLines) + { + + + + + + + + } + +
LineContentStatusTransactionRejection
@line.LineNumber@line.Content@line.ProcessingStatus@line.TransactionId@line.RejectionReason
+
+ } +
+ +
+
+

Error details

+
+
@(string.IsNullOrWhiteSpace(report.File.ErrorMessage) ? "No error recorded." : report.File.ErrorMessage)
+
+
+} + +@code { + [Parameter] + public string MerchantId { get; set; } = string.Empty; + + [Parameter] + public long FileId { get; set; } + + private FileDetailReport? report; + private bool loaded; + + protected override async Task OnParametersSetAsync() + { + this.loaded = false; + this.report = await Reports.GetFileReportAsync(MerchantId, FileId, CancellationToken.None); + this.loaded = true; + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private static string LineTone(string status) + { + if (status.Equals(FileLineStatuses.Successful, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(FileLineStatuses.Failed, StringComparison.OrdinalIgnoreCase) || + status.Equals(FileLineStatuses.Rejected, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + if (status.Equals(FileLineStatuses.Ignored, StringComparison.OrdinalIgnoreCase)) + { + return "neutral"; + } + + return "warn"; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusMerchant.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusMerchant.razor new file mode 100644 index 0000000..c01c808 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusMerchant.razor @@ -0,0 +1,157 @@ +@page "/status/{MerchantId}" +@inject IFileStatusReportService Reports + +Merchant File Processor - Merchant Status + +@if (!loaded) +{ +
+

Loading merchant status...

+
+} +else if (report is null) +{ +
+
+
+
Merchant status
+

Merchant not found

+

The selected merchant ID does not exist in the current configuration.

+

← Back to all merchants

+
+
+
+} +else +{ +
+
+
+
Merchant status
+

@report.Merchant.MerchantName

+

@report.Merchant.MerchantId

+

This merchant-specific page shows the run history and file activity for the selected merchant.

+
+
+
+
Enabled
+
@((report.Merchant.Enabled) ? "Yes" : "No")
+
+
+
Successful files
+
@report.Merchant.SuccessfulFilesSent
+
+
+
Failed files
+
@report.Merchant.FailedFiles
+
+
+
Recent files
+
@report.RecentFiles.Count
+
+
+
+ +
+
+
+

Merchant summary

+

Current aggregated counts from the audit trail.

+
+
+
Last processed
@((report.Merchant.LastProcessedUtc?.ToString("u")) ?? "Never")
+
Next scheduled send
@((report.Merchant.NextScheduledUtc?.ToString("u")) ?? "Disabled")
+
Success count
@report.Merchant.SuccessfulFilesSent
+
Failure count
@report.Merchant.FailedFiles
+
+
+ + +
+ +
+
+

Recent file activity

+

Latest file records for this merchant.

+
+
+ + + + + + + + + + + + @if (report.RecentFiles.Count == 0) + { + + } + else + { + @foreach (var file in report.RecentFiles) + { + + + + + + + + } + } + +
ProcessedContractStatusProfileDetails
No file activity has been recorded for this merchant yet.
@file.ProcessedUtc.ToString("u") + @file.ContractName
+ @file.ContractId +
@RenderStatus(file)@file.FileProfileIdView file
+
+
+
+} + +@code { + [Parameter] + public string MerchantId { get; set; } = string.Empty; + + private MerchantDetailReport? report; + private bool loaded; + + protected override async Task OnParametersSetAsync() + { + this.loaded = false; + this.report = await Reports.GetMerchantReportAsync(MerchantId, CancellationToken.None); + this.loaded = true; + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private static string FileTone(string status) + { + if (status.Equals(FileSendStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(FileSendStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } + + private static string RenderStatus(FileStatusRow file) => + file.Status == FileSendStatuses.Succeeded + ? $"{file.Status} / {(file.ProcessingCompleted ? "Complete" : "Pending")}" + : file.Status; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusOverview.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusOverview.razor new file mode 100644 index 0000000..86c283b --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusOverview.razor @@ -0,0 +1,165 @@ +@page "/status" +@inject IFileStatusReportService Reports + +Merchant File Processor - Status + +@if (report is null) +{ +
+

Loading status board...

+
+} +else +{ +
+
+
+
Status board
+

Merchant file activity

+

Live operational view of merchant file processing, including merchant rollups and the latest file activity.

+
+
+
+
Generated
+
@report.GeneratedUtc.ToString("u")
+
+
+
Merchants
+
@report.MerchantSummaries.Count
+
+
+
Enabled
+
@report.MerchantSummaries.Count(merchant => merchant.Enabled)
+
+
+
Recent files
+
@report.RecentFiles.Count
+
+
+
+ +
+
+

Merchants

+

Success and failure counts are aggregated from the local file audit store.

+
+
+ + + + + + + + + + + + + + @if (report.MerchantSummaries.Count == 0) + { + + } + else + { + @foreach (var merchant in report.MerchantSummaries) + { + + + + + + + + + + } + } + +
MerchantEnabledSuccessful filesFailed filesLast processedNext scheduled sendDetails
No merchants are configured.
+ @merchant.MerchantName
+ @merchant.MerchantId +
@((merchant.Enabled) ? "Enabled" : "Disabled")@merchant.SuccessfulFilesSent@merchant.FailedFiles@((merchant.LastProcessedUtc?.ToString("u")) ?? "Never")@((merchant.NextScheduledUtc?.ToString("u")) ?? "Disabled")View details
+
+
+ +
+
+

Recent file activity

+

Latest files recorded by the processor, sorted by processing time.

+
+
+ + + + + + + + + + + + + @if (report.RecentFiles.Count == 0) + { + + } + else + { + @foreach (var file in report.RecentFiles) + { + + + + + + + + + } + } + +
ProcessedMerchantContractStatusProfileDetails
No recent file activity has been recorded yet.
@file.ProcessedUtc.ToString("u") + @file.MerchantName
+ @file.MerchantId +
+ @file.ContractName
+ @file.ContractId +
@RenderStatus(file)@file.FileProfileIdView file
+
+
+
+} + +@code { + private FileStatusReport? report; + + protected override async Task OnInitializedAsync() + { + this.report = await Reports.GetReportAsync(CancellationToken.None); + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private static string FileTone(string status) + { + if (status.Equals(FileSendStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(FileSendStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } + + private static string RenderStatus(FileStatusRow file) => + file.Status == FileSendStatuses.Succeeded + ? $"{file.Status} / {(file.ProcessingCompleted ? "Complete" : "Pending")}" + : file.Status; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationState.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationState.cs new file mode 100644 index 0000000..7e9f133 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationState.cs @@ -0,0 +1,38 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed record MerchantProcessingConfigurationSnapshot( + MerchantProcessingOptions Options, + string Json, + DateTimeOffset UpdatedUtc); + +public interface IMerchantProcessingConfigurationState +{ + MerchantProcessingOptions Current { get; } + + string CurrentJson { get; } + + DateTimeOffset UpdatedUtc { get; } + + bool IsLoaded { get; } + + void Set(MerchantProcessingConfigurationSnapshot snapshot); +} + +public sealed class MerchantProcessingConfigurationState : IMerchantProcessingConfigurationState +{ + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + + public MerchantProcessingOptions Current => this.snapshot.Options; + + public string CurrentJson => this.snapshot.Json; + + public DateTimeOffset UpdatedUtc => this.snapshot.UpdatedUtc; + + public bool IsLoaded { get; private set; } + + public void Set(MerchantProcessingConfigurationSnapshot snapshot) + { + this.snapshot = snapshot; + this.IsLoaded = true; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs new file mode 100644 index 0000000..86ed253 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs @@ -0,0 +1,528 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using TransactionProcessing.MerchantFileProcessor.Persistence; + +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public interface IMerchantProcessingConfigurationStore +{ + Task GetCurrentSnapshotAsync(CancellationToken cancellationToken); + + Task SaveJsonAsync(string configurationJson, CancellationToken cancellationToken); + + Task SaveAsync(MerchantProcessingOptions options, CancellationToken cancellationToken); +} + +public sealed class MerchantProcessingConfigurationStore( + IDbContextFactory dbContextFactory, + IConfiguration configuration, + IMerchantProcessingConfigurationState state, + JsonSerializerOptions jsonSerializerOptions) : IMerchantProcessingConfigurationStore +{ + private readonly SemaphoreSlim loadLock = new(1, 1); + + public async Task GetCurrentSnapshotAsync(CancellationToken cancellationToken) + { + if (state.IsLoaded) + { + return new MerchantProcessingConfigurationSnapshot(state.Current, state.CurrentJson, state.UpdatedUtc); + } + + await this.EnsureLoadedAsync(cancellationToken); + return new MerchantProcessingConfigurationSnapshot(state.Current, state.CurrentJson, state.UpdatedUtc); + } + + public async Task SaveJsonAsync(string configurationJson, CancellationToken cancellationToken) + { + var options = JsonSerializer.Deserialize(configurationJson, jsonSerializerOptions) + ?? throw new InvalidOperationException("The supplied configuration JSON could not be deserialised."); + + return await this.SaveAsync(options, cancellationToken); + } + + public async Task SaveAsync(MerchantProcessingOptions options, CancellationToken cancellationToken) + { + if (!MerchantProcessingOptionsValidator.Validate(options)) + { + throw new InvalidOperationException("MerchantProcessing configuration is invalid."); + } + + var updatedUtc = DateTimeOffset.UtcNow; + var json = JsonSerializer.Serialize(options, jsonSerializerOptions); + var snapshot = new MerchantProcessingConfigurationSnapshot(options, json, updatedUtc); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + + await ClearConfigurationTablesAsync(dbContext, cancellationToken); + await PersistSingletonsAsync(dbContext, options, updatedUtc, cancellationToken); + await PersistFileProfilesAsync(dbContext, options.FileProfiles, updatedUtc, cancellationToken); + await PersistContractsAsync(dbContext, options.ContractDefinitions, updatedUtc, cancellationToken); + await PersistMerchantsAsync(dbContext, options.Merchants, updatedUtc, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + state.Set(snapshot); + return snapshot; + } + + public async Task EnsureLoadedAsync(CancellationToken cancellationToken) + { + if (state.IsLoaded) + { + return; + } + + await loadLock.WaitAsync(cancellationToken); + try + { + if (state.IsLoaded) + { + return; + } + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var snapshot = await TryLoadFromRelationalTablesAsync(dbContext, cancellationToken) + ?? await TryLoadFromLegacyConfigurationAsync(dbContext, cancellationToken) + ?? BuildBootstrapSnapshot(); + + if (!MerchantProcessingOptionsValidator.Validate(snapshot.Options)) + { + throw new InvalidOperationException("No valid MerchantProcessing configuration was found in SQLite or the bootstrap configuration."); + } + + if (!await HasNormalizedConfigurationAsync(dbContext, cancellationToken)) + { + await this.SaveAsync(snapshot.Options, cancellationToken); + return; + } + + state.Set(snapshot); + } + finally + { + loadLock.Release(); + } + } + + private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() + { + var seedOptions = configuration.GetSection(MerchantProcessingOptions.SectionName).Get() ?? new MerchantProcessingOptions(); + var updatedUtc = DateTimeOffset.UtcNow; + var json = JsonSerializer.Serialize(seedOptions, jsonSerializerOptions); + return new MerchantProcessingConfigurationSnapshot(seedOptions, json, updatedUtc); + } + + private async Task TryLoadFromRelationalTablesAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) + { + var authentication = await dbContext.MerchantProcessingAuthenticationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); + var fileProcessing = await dbContext.MerchantProcessingFileProcessingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); + var transactionGeneration = await dbContext.MerchantProcessingTransactionGenerationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); + var fileStatusPolling = await dbContext.MerchantProcessingFileStatusPollingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); + + var fileProfiles = await dbContext.MerchantProcessingFileProfileRecords.AsNoTracking() + .OrderBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + var contractDefinitions = await dbContext.MerchantProcessingContractDefinitionRecords.AsNoTracking() + .OrderBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + var merchants = await dbContext.MerchantProcessingMerchantRecords.AsNoTracking() + .OrderBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + if (authentication is null && fileProcessing is null && transactionGeneration is null && fileStatusPolling is null && + fileProfiles.Count == 0 && contractDefinitions.Count == 0 && merchants.Count == 0) + { + return null; + } + + if (authentication is null || fileProcessing is null || transactionGeneration is null || fileStatusPolling is null) + { + return null; + } + + var profileIds = fileProfiles.ToDictionary(record => record.Id); + var fileProfileFields = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileFieldRecords, cancellationToken); + var headerFieldRecords = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileHeaderFieldRecords, cancellationToken); + var trailerFieldRecords = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileTrailerFieldRecords, cancellationToken); + var merchantRunTimes = await LoadRunTimesAsync(dbContext, cancellationToken); + + var options = new MerchantProcessingOptions + { + Authentication = new AuthenticationOptions + { + ClientId = authentication.ClientId, + ClientSecret = authentication.ClientSecret, + Scope = authentication.Scope, + Audience = authentication.Audience + }, + FileProcessing = new FileProcessingOptions + { + UserId = fileProcessing.UserId + }, + TransactionGeneration = new TransactionGenerationOptions + { + MinimumTransactionsPerContract = transactionGeneration.MinimumTransactionsPerContract, + MaximumTransactionsPerContract = transactionGeneration.MaximumTransactionsPerContract + }, + FileStatusPolling = new FileStatusPollingOptions + { + PollIntervalSeconds = fileStatusPolling.PollIntervalSeconds + } + }; + + options.FileProfiles.AddRange(fileProfiles.Select(record => + { + fileProfileFields.TryGetValue(record.Id, out var bodyFields); + headerFieldRecords.TryGetValue(record.Id, out var headerFields); + trailerFieldRecords.TryGetValue(record.Id, out var trailerFields); + + return new FileProfileOptions + { + FileProfileId = record.FileProfileId, + FileProcessorFileProfileId = record.FileProcessorFileProfileId, + Format = record.Format, + FileExtension = record.FileExtension, + FileNamePattern = record.FileNamePattern, + ContentType = record.ContentType, + Delimited = new DelimitedFileProfileOptions + { + Delimiter = record.Delimiter ?? ",", + IncludeHeader = record.IncludeHeader, + HeaderFields = [.. (headerFields ?? [])], + TrailerFields = [.. (trailerFields ?? [])] + }, + Json = new JsonFileProfileOptions + { + WriteIndented = record.WriteIndented, + RootPropertyName = record.RootPropertyName + }, + Fields = [.. (bodyFields ?? [])] + }; + })); + + options.ContractDefinitions.AddRange(contractDefinitions.Select(record => new ContractDefinitionOptions + { + ContractId = record.ContractId, + FileProfileId = record.FileProfileId + })); + + options.Merchants.AddRange(merchants.Select(record => + { + merchantRunTimes.TryGetValue(record.Id, out var runTimes); + + return new MerchantOptions + { + Name = record.Name, + Enabled = record.Enabled, + EstateId = record.EstateId, + MerchantId = record.MerchantId, + RunAtUtc = record.RunAtUtc, + RunTimesUtc = [.. (runTimes ?? [])] + }; + })); + + if (!MerchantProcessingOptionsValidator.Validate(options)) + { + return null; + } + + var updatedUtc = MaxUtc( + [authentication.UpdatedUtc], + [fileProcessing.UpdatedUtc], + [transactionGeneration.UpdatedUtc], + [fileStatusPolling.UpdatedUtc], + fileProfiles.Select(record => record.UpdatedUtc), + contractDefinitions.Select(record => record.UpdatedUtc), + merchants.Select(record => record.UpdatedUtc)); + + var json = JsonSerializer.Serialize(options, jsonSerializerOptions); + return new MerchantProcessingConfigurationSnapshot(options, json, updatedUtc); + } + + private async Task TryLoadFromLegacyConfigurationAsync( + MerchantFileProcessorDbContext dbContext, + CancellationToken cancellationToken) + { + var record = await dbContext.MerchantProcessingConfigurationRecords.AsNoTracking() + .FirstOrDefaultAsync(entry => entry.Id == 1, cancellationToken); + + if (record is null) + { + return null; + } + + var options = JsonSerializer.Deserialize(record.ConfigurationJson, jsonSerializerOptions) + ?? throw new InvalidOperationException("Stored MerchantProcessing configuration could not be deserialised."); + + var updatedUtc = record.UpdatedUtc; + var json = JsonSerializer.Serialize(options, jsonSerializerOptions); + return new MerchantProcessingConfigurationSnapshot(options, json, updatedUtc); + } + + private async Task HasNormalizedConfigurationAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) + { + return await dbContext.MerchantProcessingAuthenticationRecords.AnyAsync(record => record.Id == 1, cancellationToken) || + await dbContext.MerchantProcessingFileProcessingRecords.AnyAsync(record => record.Id == 1, cancellationToken) || + await dbContext.MerchantProcessingTransactionGenerationRecords.AnyAsync(record => record.Id == 1, cancellationToken) || + await dbContext.MerchantProcessingFileStatusPollingRecords.AnyAsync(record => record.Id == 1, cancellationToken) || + await dbContext.MerchantProcessingFileProfileRecords.AnyAsync(cancellationToken) || + await dbContext.MerchantProcessingContractDefinitionRecords.AnyAsync(cancellationToken) || + await dbContext.MerchantProcessingMerchantRecords.AnyAsync(cancellationToken); + } + + private static async Task>> LoadFieldRecordsAsync( + IQueryable query, + CancellationToken cancellationToken) + where TEntity : MerchantProcessingFileProfileFieldRecordBase + { + var records = await query.AsNoTracking() + .OrderBy(record => record.FileProfileRecordId) + .ThenBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + return records + .GroupBy(record => record.FileProfileRecordId) + .ToDictionary( + group => group.Key, + group => group.Select(record => new FileFieldOptions + { + Name = record.Name, + Source = record.Source ?? string.Empty, + Format = record.Format, + Value = record.Value + }).ToList()); + } + + private static async Task>> LoadRunTimesAsync( + MerchantFileProcessorDbContext dbContext, + CancellationToken cancellationToken) + { + var merchantIdLookup = await dbContext.MerchantProcessingMerchantRecords.AsNoTracking() + .Select(record => new { record.Id, record.MerchantId }) + .ToDictionaryAsync(record => record.Id, record => record.MerchantId, cancellationToken); + + var records = await dbContext.MerchantProcessingMerchantRunTimeRecords.AsNoTracking() + .OrderBy(record => record.MerchantRecordId) + .ThenBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + return records + .Where(record => merchantIdLookup.ContainsKey(record.MerchantRecordId)) + .GroupBy(record => record.MerchantRecordId) + .ToDictionary( + group => group.Key, + group => group.Select(record => record.RunTimeUtc).ToList()); + } + + private static async Task PersistSingletonsAsync( + MerchantFileProcessorDbContext dbContext, + MerchantProcessingOptions options, + DateTimeOffset updatedUtc, + CancellationToken cancellationToken) + { + dbContext.MerchantProcessingAuthenticationRecords.Add(new MerchantProcessingAuthenticationRecord + { + Id = 1, + ClientId = options.Authentication.ClientId, + ClientSecret = options.Authentication.ClientSecret, + Scope = options.Authentication.Scope, + Audience = options.Authentication.Audience, + UpdatedUtc = updatedUtc + }); + + dbContext.MerchantProcessingFileProcessingRecords.Add(new MerchantProcessingFileProcessingRecord + { + Id = 1, + UserId = options.FileProcessing.UserId, + UpdatedUtc = updatedUtc + }); + + dbContext.MerchantProcessingTransactionGenerationRecords.Add(new MerchantProcessingTransactionGenerationRecord + { + Id = 1, + MinimumTransactionsPerContract = options.TransactionGeneration.MinimumTransactionsPerContract, + MaximumTransactionsPerContract = options.TransactionGeneration.MaximumTransactionsPerContract, + UpdatedUtc = updatedUtc + }); + + dbContext.MerchantProcessingFileStatusPollingRecords.Add(new MerchantProcessingFileStatusPollingRecord + { + Id = 1, + PollIntervalSeconds = options.FileStatusPolling.PollIntervalSeconds, + UpdatedUtc = updatedUtc + }); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task PersistFileProfilesAsync( + MerchantFileProcessorDbContext dbContext, + IReadOnlyList fileProfiles, + DateTimeOffset updatedUtc, + CancellationToken cancellationToken) + { + var profileRecords = fileProfiles + .Select((profile, index) => new MerchantProcessingFileProfileRecord + { + SortOrder = index, + FileProfileId = profile.FileProfileId, + FileProcessorFileProfileId = profile.FileProcessorFileProfileId, + Format = profile.Format, + FileExtension = profile.FileExtension, + FileNamePattern = profile.FileNamePattern, + ContentType = profile.ContentType, + Delimiter = profile.Delimited.Delimiter, + IncludeHeader = profile.Delimited.IncludeHeader, + WriteIndented = profile.Json.WriteIndented, + RootPropertyName = profile.Json.RootPropertyName, + UpdatedUtc = updatedUtc + }) + .ToArray(); + + dbContext.MerchantProcessingFileProfileRecords.AddRange(profileRecords); + await dbContext.SaveChangesAsync(cancellationToken); + + var bodyFields = new List(); + var headerFields = new List(); + var trailerFields = new List(); + + foreach (var profileRecord in profileRecords) + { + var profile = fileProfiles[profileRecord.SortOrder]; + + bodyFields.AddRange(profile.Fields.Select((field, index) => new MerchantProcessingFileProfileFieldRecord + { + FileProfileRecordId = profileRecord.Id, + SortOrder = index, + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value, + UpdatedUtc = updatedUtc + })); + + headerFields.AddRange(profile.Delimited.HeaderFields.Select((field, index) => new MerchantProcessingFileProfileHeaderFieldRecord + { + FileProfileRecordId = profileRecord.Id, + SortOrder = index, + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value, + UpdatedUtc = updatedUtc + })); + + trailerFields.AddRange(profile.Delimited.TrailerFields.Select((field, index) => new MerchantProcessingFileProfileTrailerFieldRecord + { + FileProfileRecordId = profileRecord.Id, + SortOrder = index, + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value, + UpdatedUtc = updatedUtc + })); + } + + dbContext.MerchantProcessingFileProfileFieldRecords.AddRange(bodyFields); + dbContext.MerchantProcessingFileProfileHeaderFieldRecords.AddRange(headerFields); + dbContext.MerchantProcessingFileProfileTrailerFieldRecords.AddRange(trailerFields); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task PersistContractsAsync( + MerchantFileProcessorDbContext dbContext, + IReadOnlyList contracts, + DateTimeOffset updatedUtc, + CancellationToken cancellationToken) + { + var records = contracts + .Select((contract, index) => new MerchantProcessingContractDefinitionRecord + { + SortOrder = index, + ContractId = contract.ContractId, + FileProfileId = contract.FileProfileId, + UpdatedUtc = updatedUtc + }) + .ToArray(); + + dbContext.MerchantProcessingContractDefinitionRecords.AddRange(records); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task PersistMerchantsAsync( + MerchantFileProcessorDbContext dbContext, + IReadOnlyList merchants, + DateTimeOffset updatedUtc, + CancellationToken cancellationToken) + { + var merchantRecords = merchants + .Select((merchant, index) => + { + var runTimes = merchant.RunTimesUtc.Count > 0 ? merchant.RunTimesUtc : [merchant.RunAtUtc]; + return new MerchantProcessingMerchantRecord + { + SortOrder = index, + Name = merchant.Name, + Enabled = merchant.Enabled, + EstateId = merchant.EstateId, + MerchantId = merchant.MerchantId, + RunAtUtc = runTimes.FirstOrDefault() ?? merchant.RunAtUtc, + UpdatedUtc = updatedUtc + }; + }) + .ToArray(); + + dbContext.MerchantProcessingMerchantRecords.AddRange(merchantRecords); + await dbContext.SaveChangesAsync(cancellationToken); + + var runTimeRecords = new List(); + foreach (var merchantRecord in merchantRecords) + { + var merchant = merchants[merchantRecord.SortOrder]; + var runTimes = merchant.RunTimesUtc.Count > 0 ? merchant.RunTimesUtc : [merchant.RunAtUtc]; + + runTimeRecords.AddRange(runTimes.Select((runTime, index) => new MerchantProcessingMerchantRunTimeRecord + { + MerchantRecordId = merchantRecord.Id, + SortOrder = index, + RunTimeUtc = runTime, + UpdatedUtc = updatedUtc + })); + } + + dbContext.MerchantProcessingMerchantRunTimeRecords.AddRange(runTimeRecords); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task ClearConfigurationTablesAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingMerchantRunTimes;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingMerchants;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProfileTrailerFields;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProfileHeaderFields;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProfileFields;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProfiles;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingContractDefinitions;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingAuthenticationRecords;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProcessingRecords;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingTransactionGenerationRecords;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileStatusPollingRecords;", cancellationToken); + } + + private static DateTimeOffset MaxUtc(params IEnumerable?[] sources) + { + var values = sources + .Where(source => source is not null) + .SelectMany(source => source!) + .ToArray(); + + return values.Length == 0 ? DateTimeOffset.UtcNow : values.Max(); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs index 1e888a4..22feb77 100644 --- a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs @@ -4,10 +4,6 @@ namespace TransactionProcessing.MerchantFileProcessor.Configuration; public static class MerchantProcessingOptionsValidator { public static bool Validate(MerchantProcessingOptions options) { - if (options.Merchants.Count == 0) { - return false; - } - if (string.IsNullOrWhiteSpace(options.Authentication.ClientId) || string.IsNullOrWhiteSpace(options.Authentication.ClientSecret)) { return false; } diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs index 2d5ed5f..8ab1e11 100644 --- a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs @@ -22,7 +22,7 @@ GeneratedFile Build( } public sealed class TransactionFileGenerationService( - MerchantProcessingOptions options, + IMerchantProcessingConfigurationState configurationState, ITransactionGenerator transactionGenerator, IEnumerable builders) : ITransactionFileGenerationService { @@ -60,6 +60,7 @@ public GeneratedFile BuildFile(MerchantOptions merchant, ContractOptions contrac private bool TryResolveFileProfile(string contractId, out FileProfileOptions? fileProfile) { + var options = configurationState.Current; var contractDefinition = options.ContractDefinitions.FirstOrDefault(definition => definition.ContractId.Equals(contractId, StringComparison.OrdinalIgnoreCase)); diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs index a07968d..76f7999 100644 --- a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs @@ -42,7 +42,7 @@ IReadOnlyList GenerateTransactions( } public sealed class RandomTransactionGenerator( - MerchantProcessingOptions options) : ITransactionGenerator + IMerchantProcessingConfigurationState configurationState) : ITransactionGenerator { public IReadOnlyList GenerateTransactions( MerchantOptions merchant, @@ -59,6 +59,7 @@ public IReadOnlyList GenerateTransactions( $"Contract '{contract.ContractId}' for merchant '{merchant.MerchantId}' does not contain any fixed-value products."); } + var options = configurationState.Current; var transactionCount = Random.Shared.Next( options.TransactionGeneration.MinimumTransactionsPerContract, options.TransactionGeneration.MaximumTransactionsPerContract + 1); diff --git a/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs b/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs index 3976fa8..0d0d7f5 100644 --- a/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs +++ b/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs @@ -5,7 +5,7 @@ namespace TransactionProcessing.MerchantFileProcessor; public sealed class FileStatusPollingWorker( - MerchantProcessingOptions options, + IMerchantProcessingConfigurationState configurationState, IAccessTokenProvider accessTokenProvider, IFileProcessingClient fileProcessingClient, IFileStatusStore fileStatusStore) : BackgroundService @@ -13,7 +13,7 @@ public sealed class FileStatusPollingWorker( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var logger = LocalLogger.For(); - var pollInterval = options.FileStatusPolling.GetPollInterval(); + var pollInterval = configurationState.Current.FileStatusPolling.GetPollInterval(); logger.LogInformation($"File status polling worker started with interval of {pollInterval.TotalSeconds:0} seconds"); while (!stoppingToken.IsCancellationRequested) diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs index 296c54f..1e3118d 100644 --- a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs @@ -8,6 +8,30 @@ public sealed class MerchantFileProcessorDbContext(DbContextOptions FileSendRecordLineStatuses => this.Set(); + public DbSet MerchantProcessingAuthenticationRecords => this.Set(); + + public DbSet MerchantProcessingFileProcessingRecords => this.Set(); + + public DbSet MerchantProcessingTransactionGenerationRecords => this.Set(); + + public DbSet MerchantProcessingFileStatusPollingRecords => this.Set(); + + public DbSet MerchantProcessingFileProfileRecords => this.Set(); + + public DbSet MerchantProcessingFileProfileFieldRecords => this.Set(); + + public DbSet MerchantProcessingFileProfileHeaderFieldRecords => this.Set(); + + public DbSet MerchantProcessingFileProfileTrailerFieldRecords => this.Set(); + + public DbSet MerchantProcessingContractDefinitionRecords => this.Set(); + + public DbSet MerchantProcessingMerchantRecords => this.Set(); + + public DbSet MerchantProcessingMerchantRunTimeRecords => this.Set(); + + public DbSet MerchantProcessingConfigurationRecords => this.Set(); + public DbSet MerchantRunRecords => this.Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -47,6 +71,135 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingAuthenticationRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.ClientId).HasMaxLength(128).IsRequired(); + entity.Property(record => record.ClientSecret).HasMaxLength(256).IsRequired(); + entity.Property(record => record.Scope).HasMaxLength(512); + entity.Property(record => record.Audience).HasMaxLength(512); + entity.Property(record => record.UpdatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProcessingRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.UserId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.UpdatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingTransactionGenerationRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.UpdatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileStatusPollingRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.UpdatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProfiles"); + entity.HasKey(record => record.Id); + entity.Property(record => record.FileProfileId).HasMaxLength(128).IsRequired(); + entity.Property(record => record.FileProcessorFileProfileId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.Format).HasMaxLength(32).IsRequired(); + entity.Property(record => record.FileExtension).HasMaxLength(32).IsRequired(); + entity.Property(record => record.FileNamePattern).HasMaxLength(260); + entity.Property(record => record.ContentType).HasMaxLength(128); + entity.Property(record => record.Delimiter).HasMaxLength(8); + entity.Property(record => record.RootPropertyName).HasMaxLength(128); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => record.FileProfileId).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProfileFields"); + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).HasMaxLength(128).IsRequired(); + entity.Property(record => record.Source).HasMaxLength(128); + entity.Property(record => record.Format).HasMaxLength(64); + entity.Property(record => record.Value).HasMaxLength(256); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => new { record.FileProfileRecordId, record.SortOrder }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProfileHeaderFields"); + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).HasMaxLength(128).IsRequired(); + entity.Property(record => record.Source).HasMaxLength(128); + entity.Property(record => record.Format).HasMaxLength(64); + entity.Property(record => record.Value).HasMaxLength(256); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => new { record.FileProfileRecordId, record.SortOrder }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProfileTrailerFields"); + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).HasMaxLength(128).IsRequired(); + entity.Property(record => record.Source).HasMaxLength(128); + entity.Property(record => record.Format).HasMaxLength(64); + entity.Property(record => record.Value).HasMaxLength(256); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => new { record.FileProfileRecordId, record.SortOrder }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingContractDefinitions"); + entity.HasKey(record => record.Id); + entity.Property(record => record.ContractId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.FileProfileId).HasMaxLength(128).IsRequired(); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => record.ContractId).IsUnique(); + entity.HasIndex(record => record.FileProfileId); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingMerchants"); + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).HasMaxLength(256).IsRequired(); + entity.Property(record => record.EstateId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.MerchantId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.RunAtUtc).HasMaxLength(16).IsRequired(); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => record.MerchantId).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingMerchantRunTimes"); + entity.HasKey(record => record.Id); + entity.Property(record => record.RunTimeUtc).HasMaxLength(16).IsRequired(); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => new { record.MerchantRecordId, record.SortOrder }); + entity.HasOne() + .WithMany() + .HasForeignKey(record => record.MerchantRecordId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingConfigurationRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.ConfigurationJson).IsRequired(); + entity.Property(record => record.UpdatedUtc); + }); + modelBuilder.Entity(entity => { entity.ToTable("MerchantRunRecords"); @@ -58,4 +211,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(record => new { record.MerchantId, record.ScheduledRunUtc, record.CompletedUtc }); }); } -} \ No newline at end of file +} diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs new file mode 100644 index 0000000..468b6f9 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs @@ -0,0 +1,145 @@ +namespace TransactionProcessing.MerchantFileProcessor.Persistence; + +public sealed class MerchantProcessingAuthenticationRecord +{ + public int Id { get; set; } + + public string ClientId { get; set; } = string.Empty; + + public string ClientSecret { get; set; } = string.Empty; + + public string? Scope { get; set; } + + public string? Audience { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingFileProcessingRecord +{ + public int Id { get; set; } + + public string UserId { get; set; } = string.Empty; + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingTransactionGenerationRecord +{ + public int Id { get; set; } + + public int MinimumTransactionsPerContract { get; set; } + + public int MaximumTransactionsPerContract { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingFileStatusPollingRecord +{ + public int Id { get; set; } + + public int PollIntervalSeconds { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingFileProfileRecord +{ + public int Id { get; set; } + + public int SortOrder { get; set; } + + public string FileProfileId { get; set; } = string.Empty; + + public string FileProcessorFileProfileId { get; set; } = string.Empty; + + public string Format { get; set; } = string.Empty; + + public string FileExtension { get; set; } = string.Empty; + + public string? FileNamePattern { get; set; } + + public string? ContentType { get; set; } + + public string? Delimiter { get; set; } + + public bool IncludeHeader { get; set; } + + public bool WriteIndented { get; set; } + + public string? RootPropertyName { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public abstract class MerchantProcessingFileProfileFieldRecordBase +{ + public int Id { get; set; } + + public int FileProfileRecordId { get; set; } + + public int SortOrder { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Source { get; set; } + + public string? Format { get; set; } + + public string? Value { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingFileProfileFieldRecord : MerchantProcessingFileProfileFieldRecordBase; + +public sealed class MerchantProcessingFileProfileHeaderFieldRecord : MerchantProcessingFileProfileFieldRecordBase; + +public sealed class MerchantProcessingFileProfileTrailerFieldRecord : MerchantProcessingFileProfileFieldRecordBase; + +public sealed class MerchantProcessingContractDefinitionRecord +{ + public int Id { get; set; } + + public int SortOrder { get; set; } + + public string ContractId { get; set; } = string.Empty; + + public string FileProfileId { get; set; } = string.Empty; + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingMerchantRecord +{ + public int Id { get; set; } + + public int SortOrder { get; set; } + + public string Name { get; set; } = string.Empty; + + public bool Enabled { get; set; } = true; + + public string EstateId { get; set; } = string.Empty; + + public string MerchantId { get; set; } = string.Empty; + + public string RunAtUtc { get; set; } = "02:00:00"; + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingMerchantRunTimeRecord +{ + public int Id { get; set; } + + public int MerchantRecordId { get; set; } + + public int SortOrder { get; set; } + + public string RunTimeUtc { get; set; } = string.Empty; + + public DateTimeOffset UpdatedUtc { get; set; } +} + diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationRecord.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationRecord.cs new file mode 100644 index 0000000..80f0cc2 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationRecord.cs @@ -0,0 +1,10 @@ +namespace TransactionProcessing.MerchantFileProcessor.Persistence; + +public sealed class MerchantProcessingConfigurationRecord +{ + public int Id { get; set; } + + public string ConfigurationJson { get; set; } = string.Empty; + + public DateTimeOffset UpdatedUtc { get; set; } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Program.cs b/TransactionProcessing.MerchantFileProcessor/Program.cs index 28d4a16..f240934 100644 --- a/TransactionProcessing.MerchantFileProcessor/Program.cs +++ b/TransactionProcessing.MerchantFileProcessor/Program.cs @@ -41,6 +41,7 @@ .SetBasePath(contentRoot) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddJsonFile("merchant-processing.bootstrap.json", optional: true, reloadOnChange: true) .AddJsonFile("hosting.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables() .AddCommandLine(args); @@ -64,17 +65,9 @@ options.ServiceName = "Merchant File Processor"; }); - var merchantProcessingOptions = builder.Configuration - .GetSection(MerchantProcessingOptions.SectionName) - .Get() ?? new MerchantProcessingOptions(); - - if (!MerchantProcessingOptionsValidator.Validate(merchantProcessingOptions)) - { - throw new InvalidOperationException("MerchantProcessing configuration is invalid."); - } - - builder.Services.AddSingleton(merchantProcessingOptions); builder.Services.AddSingleton(frameworkLoggingOptions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton>(sp => { @@ -94,12 +87,18 @@ }; }); - var connectionString = BuildConnectionString( - builder.Configuration.GetConnectionString("MerchantFileProcessor"), - contentRoot); + var configuredConnectionString = builder.Configuration.GetConnectionString("MerchantFileProcessor"); + + if (string.IsNullOrWhiteSpace(configuredConnectionString)) + { + throw new InvalidOperationException("ConnectionStrings:MerchantFileProcessor must be configured."); + } + + var connectionString = BuildConnectionString(configuredConnectionString, contentRoot); builder.Services.AddHttpClient(); builder.Services.AddHttpContextAccessor(); + builder.Services.AddRazorComponents(); builder.Services.AddDbContextFactory(options => options.UseSqlite(connectionString)); builder.Services.RegisterHttpClient(); @@ -124,6 +123,7 @@ builder.Services.AddSingleton>(_ => (str, type) => StringSerialiser.DeserializeObject(str, type)); var serialiserSettings = SystemTextJsonSerializer.GetDefaultJsonSerializerOptions(); builder.Services.AddSingleton(serialiserSettings); + builder.Services.AddSingleton(); var app = builder.Build(); @@ -139,6 +139,9 @@ await using var dbContext = await dbContextFactory.CreateDbContextAsync(); await dbContext.Database.EnsureCreatedAsync(); await EnsurePersistenceSchemaAsync(dbContext, CancellationToken.None); + + var configurationStore = scope.ServiceProvider.GetRequiredService(); + await configurationStore.GetCurrentSnapshotAsync(CancellationToken.None); } var appSettingsPath = Path.Combine(contentRoot, "appsettings.json"); @@ -147,7 +150,11 @@ SharedLogger.LogWarning($"appsettings.json was not found at {appSettingsPath}. Using environment-specific configuration and environment variables."); } + app.UseStaticFiles(); + app.UseAntiforgery(); app.MapReportingEndpoints(); + app.MapConfigurationManagementEndpoints(); + app.MapRazorComponents(); await app.RunAsync(); } @@ -161,18 +168,19 @@ LogManager.Shutdown(); } -static string BuildConnectionString(string? configuredConnectionString, string contentRoot) +static string BuildConnectionString(string configuredConnectionString, string contentRoot) { - if (string.IsNullOrWhiteSpace(configuredConnectionString)) + const string dataSourcePrefix = "Data Source="; + + if (!configuredConnectionString.StartsWith(dataSourcePrefix, StringComparison.OrdinalIgnoreCase) && + !Path.IsPathRooted(configuredConnectionString)) { - return $"Data Source={Path.Combine(contentRoot, "merchant-file-processor.db")}"; + configuredConnectionString = Path.Combine(contentRoot, configuredConnectionString); } - const string dataSourcePrefix = "Data Source="; - if (!configuredConnectionString.StartsWith(dataSourcePrefix, StringComparison.OrdinalIgnoreCase)) { - return configuredConnectionString; + return $"{dataSourcePrefix}{configuredConnectionString}"; } var filePath = configuredConnectionString[dataSourcePrefix.Length..].Trim(); @@ -313,7 +321,195 @@ CompletedUtc TEXT NOT NULL """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingAuthenticationRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingAuthenticationRecords PRIMARY KEY, + ClientId TEXT NOT NULL, + ClientSecret TEXT NOT NULL, + Scope TEXT NULL, + Audience TEXT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProcessingRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProcessingRecords PRIMARY KEY, + UserId TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingTransactionGenerationRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingTransactionGenerationRecords PRIMARY KEY, + MinimumTransactionsPerContract INTEGER NOT NULL, + MaximumTransactionsPerContract INTEGER NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileStatusPollingRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileStatusPollingRecords PRIMARY KEY, + PollIntervalSeconds INTEGER NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfiles ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfiles PRIMARY KEY AUTOINCREMENT, + SortOrder INTEGER NOT NULL, + FileProfileId TEXT NOT NULL, + FileProcessorFileProfileId TEXT NOT NULL, + Format TEXT NOT NULL, + FileExtension TEXT NOT NULL, + FileNamePattern TEXT NULL, + ContentType TEXT NULL, + Delimiter TEXT NULL, + IncludeHeader INTEGER NOT NULL, + WriteIndented INTEGER NOT NULL, + RootPropertyName TEXT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfileFields ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfileFields PRIMARY KEY AUTOINCREMENT, + FileProfileRecordId INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + Name TEXT NOT NULL, + Source TEXT NULL, + Format TEXT NULL, + Value TEXT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_MerchantProcessingFileProfileFields_MerchantProcessingFileProfiles_FileProfileRecordId + FOREIGN KEY (FileProfileRecordId) REFERENCES MerchantProcessingFileProfiles (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfileHeaderFields ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfileHeaderFields PRIMARY KEY AUTOINCREMENT, + FileProfileRecordId INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + Name TEXT NOT NULL, + Source TEXT NULL, + Format TEXT NULL, + Value TEXT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_MerchantProcessingFileProfileHeaderFields_MerchantProcessingFileProfiles_FileProfileRecordId + FOREIGN KEY (FileProfileRecordId) REFERENCES MerchantProcessingFileProfiles (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfileTrailerFields ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfileTrailerFields PRIMARY KEY AUTOINCREMENT, + FileProfileRecordId INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + Name TEXT NOT NULL, + Source TEXT NULL, + Format TEXT NULL, + Value TEXT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_MerchantProcessingFileProfileTrailerFields_MerchantProcessingFileProfiles_FileProfileRecordId + FOREIGN KEY (FileProfileRecordId) REFERENCES MerchantProcessingFileProfiles (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingContractDefinitions ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingContractDefinitions PRIMARY KEY AUTOINCREMENT, + SortOrder INTEGER NOT NULL, + ContractId TEXT NOT NULL, + FileProfileId TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingMerchants ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingMerchants PRIMARY KEY AUTOINCREMENT, + SortOrder INTEGER NOT NULL, + Name TEXT NOT NULL, + Enabled INTEGER NOT NULL, + EstateId TEXT NOT NULL, + MerchantId TEXT NOT NULL, + RunAtUtc TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingMerchantRunTimes ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingMerchantRunTimes PRIMARY KEY AUTOINCREMENT, + MerchantRecordId INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + RunTimeUtc TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_MerchantProcessingMerchantRunTimes_MerchantProcessingMerchants_MerchantRecordId + FOREIGN KEY (MerchantRecordId) REFERENCES MerchantProcessingMerchants (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingConfigurationRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingConfigurationRecords PRIMARY KEY AUTOINCREMENT, + ConfigurationJson TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( "CREATE INDEX IF NOT EXISTS IX_MerchantRunRecords_MerchantId_ScheduledRunUtc_CompletedUtc ON MerchantRunRecords (MerchantId, ScheduledRunUtc, CompletedUtc);", cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE UNIQUE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfiles_FileProfileId ON MerchantProcessingFileProfiles (FileProfileId);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfileFields_FileProfileRecordId_SortOrder ON MerchantProcessingFileProfileFields (FileProfileRecordId, SortOrder);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfileHeaderFields_FileProfileRecordId_SortOrder ON MerchantProcessingFileProfileHeaderFields (FileProfileRecordId, SortOrder);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfileTrailerFields_FileProfileRecordId_SortOrder ON MerchantProcessingFileProfileTrailerFields (FileProfileRecordId, SortOrder);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE UNIQUE INDEX IF NOT EXISTS IX_MerchantProcessingContractDefinitions_ContractId ON MerchantProcessingContractDefinitions (ContractId);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE UNIQUE INDEX IF NOT EXISTS IX_MerchantProcessingMerchants_MerchantId ON MerchantProcessingMerchants (MerchantId);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingMerchantRunTimes_MerchantRecordId_SortOrder ON MerchantProcessingMerchantRunTimes (MerchantRecordId, SortOrder);", + cancellationToken); } diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..23e3ef8 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs @@ -0,0 +1,409 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using Microsoft.Extensions.Primitives; + +namespace TransactionProcessing.MerchantFileProcessor.Reporting; + +public static class ConfigurationManagementEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapConfigurationManagementEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost("/api/configuration/runtime", async ( + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, "/ops/config/runtime"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + options = new MerchantProcessingOptions + { + Authentication = new AuthenticationOptions + { + ClientId = form["ClientId"].ToString(), + ClientSecret = form["ClientSecret"].ToString(), + Scope = GetOptional(form, "Scope"), + Audience = GetOptional(form, "Audience") + }, + FileProcessing = new FileProcessingOptions + { + UserId = form["UserId"].ToString() + }, + TransactionGeneration = new TransactionGenerationOptions + { + MinimumTransactionsPerContract = ParseInt(form["MinimumTransactionsPerContract"], 5), + MaximumTransactionsPerContract = ParseInt(form["MaximumTransactionsPerContract"], 25) + }, + FileStatusPolling = new FileStatusPollingOptions + { + PollIntervalSeconds = ParseInt(form["PollIntervalSeconds"], 30) + }, + ContractDefinitions = options.ContractDefinitions, + FileProfiles = options.FileProfiles, + Merchants = options.Merchants + }; + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + endpoints.MapPost("/api/configuration/merchants", async ( + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, "/ops/config/merchants"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + var originalMerchantId = GetOptional(form, "OriginalMerchantId"); + var merchantId = GetRequired(form, "MerchantId"); + var merchantIndex = options.Merchants.FindIndex(entry => + string.Equals(entry.MerchantId, originalMerchantId ?? merchantId, StringComparison.OrdinalIgnoreCase)); + + var merchant = new MerchantOptions + { + Name = GetRequired(form, "Name"), + Enabled = ParseBool(form["Enabled"]), + EstateId = GetRequired(form, "EstateId"), + MerchantId = merchantId, + RunAtUtc = GetOptional(form, "RunAtUtc") ?? "02:00:00", + RunTimesUtc = ParseRunTimes(GetOptional(form, "RunTimesUtc")) + }; + + if (merchantIndex >= 0) + { + options.Merchants[merchantIndex] = merchant; + } + else + { + options.Merchants.Add(merchant); + } + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + endpoints.MapPost("/api/configuration/contracts", async ( + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, "/ops/config/contracts"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + var originalContractId = GetOptional(form, "OriginalContractId"); + var contractId = GetRequired(form, "ContractId"); + var contractIndex = options.ContractDefinitions.FindIndex(entry => + string.Equals(entry.ContractId, originalContractId ?? contractId, StringComparison.OrdinalIgnoreCase)); + + var contract = new ContractDefinitionOptions + { + ContractId = contractId, + FileProfileId = GetRequired(form, "FileProfileId") + }; + + if (contractIndex >= 0) + { + options.ContractDefinitions[contractIndex] = contract; + } + else + { + options.ContractDefinitions.Add(contract); + } + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + endpoints.MapPost("/api/configuration/file-profiles", async ( + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, "/ops/config/file-profiles"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + var originalFileProfileId = GetOptional(form, "OriginalFileProfileId"); + var fileProfileId = GetRequired(form, "FileProfileId"); + var profileIndex = options.FileProfiles.FindIndex(entry => + string.Equals(entry.FileProfileId, originalFileProfileId ?? fileProfileId, StringComparison.OrdinalIgnoreCase)); + + var existingProfile = profileIndex >= 0 ? options.FileProfiles[profileIndex] : new FileProfileOptions(); + var profile = new FileProfileOptions + { + FileProfileId = fileProfileId, + FileProcessorFileProfileId = GetRequired(form, "FileProcessorFileProfileId"), + Format = GetRequired(form, "Format"), + FileExtension = GetRequired(form, "FileExtension"), + FileNamePattern = GetOptional(form, "FileNamePattern"), + ContentType = GetOptional(form, "ContentType"), + Delimited = new DelimitedFileProfileOptions + { + Delimiter = GetOptional(form, "Delimiter") ?? ",", + IncludeHeader = ParseBool(form["IncludeHeader"]), + HeaderFields = existingProfile.Delimited.HeaderFields, + TrailerFields = existingProfile.Delimited.TrailerFields + }, + Json = new JsonFileProfileOptions + { + WriteIndented = ParseBool(form["WriteIndented"]), + RootPropertyName = GetOptional(form, "RootPropertyName") + }, + Fields = existingProfile.Fields + }; + + if (profileIndex >= 0) + { + options.FileProfiles[profileIndex] = profile; + } + else + { + options.FileProfiles.Add(profile); + } + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + endpoints.MapPost("/api/configuration/file-profiles/{fileProfileId}/fields/{section}", async ( + string fileProfileId, + string section, + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + JsonSerializerOptions jsonSerializerOptions, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, $"/ops/config/file-profiles/{Uri.EscapeDataString(fileProfileId)}"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + var profileIndex = options.FileProfiles.FindIndex(entry => entry.FileProfileId.Equals(fileProfileId, StringComparison.OrdinalIgnoreCase)); + + if (profileIndex < 0) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString("File profile not found.")}"); + } + + var profile = options.FileProfiles[profileIndex]; + var fields = GetFieldList(profile, section); + var originalSortOrder = ParseNullableInt(form["OriginalSortOrder"]); + + if (ParseBool(form["Delete"])) + { + if (originalSortOrder.HasValue) + { + if (originalSortOrder.Value >= 0 && originalSortOrder.Value < fields.Count) + { + fields.RemoveAt(originalSortOrder.Value); + } + } + } + else + { + var field = originalSortOrder.HasValue + ? (originalSortOrder.Value >= 0 && originalSortOrder.Value < fields.Count ? fields[originalSortOrder.Value] : null) + : null; + + if (field is null) + { + field = new FileFieldOptions(); + } + + var updatedField = new FileFieldOptions + { + Name = GetRequired(form, "Name"), + Source = GetOptional(form, "Source") ?? string.Empty, + Format = GetOptional(form, "Format"), + Value = GetOptional(form, "Value") + }; + + if (originalSortOrder.HasValue && originalSortOrder.Value >= 0 && originalSortOrder.Value < fields.Count) + { + fields[originalSortOrder.Value] = updatedField; + } + else + { + fields.Add(updatedField); + } + } + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + return endpoints; + } + + private static List GetFieldList(FileProfileOptions profile, string section) + { + return section.ToLowerInvariant() switch + { + "body" or "fields" => profile.Fields, + "header" => profile.Delimited.HeaderFields, + "trailer" => profile.Delimited.TrailerFields, + _ => throw new InvalidOperationException("Unknown file profile field section.") + }; + } + + private static MerchantProcessingOptions Clone(MerchantProcessingOptions options) => + new() + { + Authentication = new AuthenticationOptions + { + ClientId = options.Authentication.ClientId, + ClientSecret = options.Authentication.ClientSecret, + Scope = options.Authentication.Scope, + Audience = options.Authentication.Audience + }, + FileProcessing = new FileProcessingOptions + { + UserId = options.FileProcessing.UserId + }, + TransactionGeneration = new TransactionGenerationOptions + { + MinimumTransactionsPerContract = options.TransactionGeneration.MinimumTransactionsPerContract, + MaximumTransactionsPerContract = options.TransactionGeneration.MaximumTransactionsPerContract + }, + FileStatusPolling = new FileStatusPollingOptions + { + PollIntervalSeconds = options.FileStatusPolling.PollIntervalSeconds + }, + ContractDefinitions = options.ContractDefinitions + .Select(contract => new ContractDefinitionOptions + { + ContractId = contract.ContractId, + FileProfileId = contract.FileProfileId + }) + .ToList(), + FileProfiles = options.FileProfiles + .Select(profile => new FileProfileOptions + { + FileProfileId = profile.FileProfileId, + FileProcessorFileProfileId = profile.FileProcessorFileProfileId, + Format = profile.Format, + FileExtension = profile.FileExtension, + FileNamePattern = profile.FileNamePattern, + ContentType = profile.ContentType, + Delimited = new DelimitedFileProfileOptions + { + Delimiter = profile.Delimited.Delimiter, + IncludeHeader = profile.Delimited.IncludeHeader, + HeaderFields = profile.Delimited.HeaderFields + .Select(field => new FileFieldOptions + { + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value + }) + .ToList(), + TrailerFields = profile.Delimited.TrailerFields + .Select(field => new FileFieldOptions + { + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value + }) + .ToList() + }, + Json = new JsonFileProfileOptions + { + WriteIndented = profile.Json.WriteIndented, + RootPropertyName = profile.Json.RootPropertyName + }, + Fields = profile.Fields + .Select(field => new FileFieldOptions + { + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value + }) + .ToList() + }) + .ToList(), + Merchants = options.Merchants + .Select(merchant => new MerchantOptions + { + Name = merchant.Name, + Enabled = merchant.Enabled, + EstateId = merchant.EstateId, + MerchantId = merchant.MerchantId, + RunAtUtc = merchant.RunAtUtc, + RunTimesUtc = merchant.RunTimesUtc.ToList() + }) + .ToList() + }; + + private static string GetReturnUrl(IFormCollection form, string defaultValue) => + string.IsNullOrWhiteSpace(form["ReturnUrl"]) ? defaultValue : form["ReturnUrl"].ToString(); + + private static string GetRequired(IFormCollection form, string key) => + form[key].ToString().Trim(); + + private static string? GetOptional(IFormCollection form, string key) + { + var value = form[key].ToString().Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static bool ParseBool(StringValues value) => + value.Count > 0 && bool.TryParse(value.ToString(), out var parsed) && parsed; + + private static int ParseInt(StringValues value, int defaultValue) => + int.TryParse(value.ToString(), out var parsed) ? parsed : defaultValue; + + private static int? ParseNullableInt(StringValues value) => + int.TryParse(value.ToString(), out var parsed) ? parsed : null; + + private static List ParseRunTimes(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs index 8f064b5..16af43a 100644 --- a/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs @@ -11,6 +11,10 @@ public interface IFileStatusReportService { Task GetReportAsync(CancellationToken cancellationToken); + Task GetMerchantReportAsync(string merchantId, CancellationToken cancellationToken); + + Task GetFileReportAsync(string merchantId, long fileId, CancellationToken cancellationToken); + Task RenderHtmlAsync(CancellationToken cancellationToken); Task RenderMerchantHtmlAsync(string merchantId, CancellationToken cancellationToken); @@ -64,7 +68,7 @@ public sealed record FileLineStatusRow(int LineNumber, public sealed class FileStatusReportService( IDbContextFactory dbContextFactory, - MerchantProcessingOptions options) : IFileStatusReportService + IMerchantProcessingConfigurationState configurationState) : IFileStatusReportService { public async Task GetReportAsync(CancellationToken cancellationToken) { @@ -92,6 +96,7 @@ public async Task GetReportAsync(CancellationToken cancellatio var aggregateLookup = aggregates.ToDictionary(item => item.MerchantId, StringComparer.OrdinalIgnoreCase); + var options = configurationState.Current; var merchantSummaries = options.Merchants .OrderBy(merchant => merchant.MerchantId, StringComparer.OrdinalIgnoreCase) .Select(merchant => @@ -264,7 +269,7 @@ public async Task RenderHtmlAsync(CancellationToken cancellationToken) return html.ToString(); } - private async Task GetMerchantReportAsync(string merchantId, CancellationToken cancellationToken) + public async Task GetMerchantReportAsync(string merchantId, CancellationToken cancellationToken) { var summary = (await this.GetReportAsync(cancellationToken)) .MerchantSummaries @@ -303,7 +308,7 @@ public async Task RenderHtmlAsync(CancellationToken cancellationToken) return new MerchantDetailReport(DateTimeOffset.UtcNow, summary, recentFiles); } - private async Task GetFileReportAsync(string merchantId, long fileId, CancellationToken cancellationToken) + public async Task GetFileReportAsync(string merchantId, long fileId, CancellationToken cancellationToken) { var merchantReport = await this.GetMerchantReportAsync(merchantId, cancellationToken); if (merchantReport is null) diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/OperationsDashboardService.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/OperationsDashboardService.cs new file mode 100644 index 0000000..2fb9748 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/OperationsDashboardService.cs @@ -0,0 +1,755 @@ +using System.Net; +using System.Text; +using Microsoft.EntityFrameworkCore; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.Persistence; +using TransactionProcessing.MerchantFileProcessor.Services; + +namespace TransactionProcessing.MerchantFileProcessor.Reporting; + +public interface IOperationsDashboardService +{ + Task GetDashboardModelAsync(CancellationToken cancellationToken); + + Task RenderDashboardHtmlAsync(CancellationToken cancellationToken); + + Task RenderConfigurationHtmlAsync(CancellationToken cancellationToken); + + Task RenderRunsHtmlAsync(CancellationToken cancellationToken); +} + +public sealed record OperationsMetric(string Label, string Value, string Detail); + +public sealed record MerchantOperationsRow( + string MerchantId, + string MerchantName, + bool Enabled, + string EstateId, + string RunTimesUtc, + DateTimeOffset? LastMerchantRunUtc, + DateTimeOffset? NextScheduledUtc, + int SuccessfulFiles, + int FailedFiles, + int PendingStatusChecks); + +public sealed record FileProfileRow( + string FileProfileId, + string FileProcessorFileProfileId, + string Format, + string FileExtension, + string? ContentType, + string LayoutSummary, + int FieldCount, + int HeaderFieldCount, + int TrailerFieldCount, + string FieldMap); + +public sealed record ContractMappingRow( + string ContractId, + string FileProfileId); + +public sealed record RunHistoryRow( + string MerchantId, + string MerchantName, + DateTimeOffset ScheduledRunUtc, + DateTimeOffset CompletedUtc, + string Status, + string? ErrorMessage); + +public sealed record OperationsDashboardModel( + DateTimeOffset GeneratedUtc, + string EnvironmentName, + string ConnectionStringSummary, + string AuthenticationSummary, + string FileProcessingSummary, + string TransactionGenerationSummary, + string PollingSummary, + string LoggingSummary, + IReadOnlyList Metrics, + IReadOnlyList Merchants, + IReadOnlyList FileProfiles, + IReadOnlyList Contracts, + IReadOnlyList RecentRuns); + +public sealed class OperationsDashboardService( + IDbContextFactory dbContextFactory, + IMerchantProcessingConfigurationState configurationState, + FrameworkLoggingOptions frameworkLoggingOptions, + IHostEnvironment hostEnvironment, + IConfiguration configuration) : IOperationsDashboardService +{ + public Task GetDashboardModelAsync(CancellationToken cancellationToken) => + this.BuildDashboardModelAsync(cancellationToken); + + public async Task RenderDashboardHtmlAsync(CancellationToken cancellationToken) + { + var model = await this.BuildDashboardModelAsync(cancellationToken); + var html = new StringBuilder(); + + AppendDocumentStart(html, "Merchant File Processor Operations", "Management and operations"); + AppendNavigation(html, ("Overview", "/ops"), ("Configuration", "/ops/config"), ("Run history", "/ops/runs"), ("Status board", "/status")); + + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine("
Operations console
"); + html.AppendLine("

Merchant File Processor

"); + html.AppendLine("

Live configuration, scheduling, and processing visibility for the worker, using the same runtime settings that power the service.

"); + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine($"
Environment
{Encode(model.EnvironmentName)}
"); + html.AppendLine($"
Generated
{Encode(model.GeneratedUtc.ToString("u"))}
"); + html.AppendLine($"
Connection
{Encode(model.ConnectionStringSummary)}
"); + html.AppendLine("
"); + html.AppendLine("
"); + + AppendMetrics(html, model.Metrics); + + html.AppendLine("
"); + html.AppendLine("
Merchant schedule
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var merchant in model.Merchants) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (model.Merchants.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantEnabledEstateRun times (UTC)Last merchant runNext runSuccessFailedPending checksStatus
{Encode(merchant.MerchantName)}
{Encode(merchant.MerchantId)}
{RenderBadge(merchant.Enabled ? "Enabled" : "Disabled", merchant.Enabled ? "good" : "neutral")}{Encode(merchant.EstateId)}{Encode(merchant.RunTimesUtc)}{Encode(merchant.LastMerchantRunUtc?.ToString("u") ?? "Never")}{Encode(merchant.NextScheduledUtc?.ToString("u") ?? "Disabled")}{merchant.SuccessfulFiles}{merchant.FailedFiles}{merchant.PendingStatusChecks}View status
No merchants are configured.
"); + html.AppendLine("
"); + + html.AppendLine("
"); + AppendFileProfilesSection(html, model.FileProfiles); + AppendContractsSection(html, model.Contracts); + html.AppendLine("
"); + + html.AppendLine("
"); + html.AppendLine("
Recent merchant runs
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var run in model.RecentRuns) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (model.RecentRuns.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantScheduledCompletedStatusError
{Encode(run.MerchantName)}
{Encode(run.MerchantId)}
{Encode(run.ScheduledRunUtc.ToString("u"))}{Encode(run.CompletedUtc.ToString("u"))}{RenderBadge(run.Status, RunTone(run.Status))}{Encode(run.ErrorMessage ?? string.Empty)}
No merchant runs have been recorded yet.
"); + html.AppendLine("
"); + + AppendDocumentEnd(html); + return html.ToString(); + } + + public async Task RenderConfigurationHtmlAsync(CancellationToken cancellationToken) + { + var model = await this.BuildDashboardModelAsync(cancellationToken); + var html = new StringBuilder(); + + AppendDocumentStart(html, "Merchant File Processor Configuration", "Appsettings-driven runtime inventory"); + AppendNavigation(html, ("Overview", "/ops"), ("Configuration", "/ops/config"), ("Run history", "/ops/runs"), ("Status board", "/status")); + + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine("
Configuration inventory
"); + html.AppendLine("

Appsettings review

"); + html.AppendLine("

This page mirrors the effective settings loaded at startup, so operators can validate what the worker is using without opening the deployment package.

"); + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine($"
Authentication
{Encode(model.AuthenticationSummary)}
"); + html.AppendLine($"
File processing
{Encode(model.FileProcessingSummary)}
"); + html.AppendLine($"
Polling
{Encode(model.PollingSummary)}
"); + html.AppendLine("
"); + html.AppendLine("
"); + + html.AppendLine("
"); + AppendSummaryPanel(html, "Runtime summary", new[] + { + ("Environment", model.EnvironmentName), + ("Database", model.ConnectionStringSummary), + ("Logging", model.LoggingSummary), + ("Transaction generation", model.TransactionGenerationSummary), + ("Authentication", model.AuthenticationSummary), + ("File processing", model.FileProcessingSummary), + ("Polling", model.PollingSummary) + }); + + AppendMerchantConfigPanel(html); + html.AppendLine("
"); + + html.AppendLine("
"); + AppendFileProfilesSection(html, model.FileProfiles); + AppendContractsSection(html, model.Contracts); + html.AppendLine("
"); + + html.AppendLine("
"); + html.AppendLine("
Merchant definitions
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var merchant in model.Merchants) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (model.Merchants.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantEnabledEstateMerchant IDRun schedule
{Encode(merchant.MerchantName)}{RenderBadge(merchant.Enabled ? "Enabled" : "Disabled", merchant.Enabled ? "good" : "neutral")}{Encode(merchant.EstateId)}{Encode(merchant.MerchantId)}{Encode(merchant.RunTimesUtc)}
No merchants are configured.
"); + html.AppendLine("
"); + + AppendDocumentEnd(html); + return html.ToString(); + } + + public async Task RenderRunsHtmlAsync(CancellationToken cancellationToken) + { + var model = await this.BuildDashboardModelAsync(cancellationToken); + var html = new StringBuilder(); + + AppendDocumentStart(html, "Merchant File Processor Runs", "Recent run history"); + AppendNavigation(html, ("Overview", "/ops"), ("Configuration", "/ops/config"), ("Run history", "/ops/runs"), ("Status board", "/status")); + + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine("
Run history
"); + html.AppendLine("

Merchant execution audit trail

"); + html.AppendLine("

Each record shows the scheduled slot, completion time, and final outcome for a merchant processing cycle.

"); + html.AppendLine("
"); + html.AppendLine("
"); + + html.AppendLine("
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var run in model.RecentRuns) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (model.RecentRuns.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantScheduledCompletedStatusError
{Encode(run.MerchantName)}
{Encode(run.MerchantId)}
{Encode(run.ScheduledRunUtc.ToString("u"))}{Encode(run.CompletedUtc.ToString("u"))}{RenderBadge(run.Status, RunTone(run.Status))}{Encode(run.ErrorMessage ?? string.Empty)}
No merchant runs are recorded yet.
"); + html.AppendLine("
"); + + AppendDocumentEnd(html); + return html.ToString(); + } + + private async Task BuildDashboardModelAsync(CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var options = configurationState.Current; + + var fileSendRows = await dbContext.FileSendRecords + .Select(record => new + { + record.MerchantId, + MerchantName = record.MerchantName ?? record.MerchantId, + record.Status, + record.ProcessedUtc, + record.ProcessingCompleted + }) + .ToListAsync(cancellationToken); + + var merchantRunRows = await dbContext.MerchantRunRecords + .Select(record => new + { + record.MerchantId, + MerchantName = record.MerchantName ?? record.MerchantId, + record.ScheduledRunUtc, + record.CompletedUtc, + record.Status, + record.ErrorMessage + }) + .ToListAsync(cancellationToken); + + var pendingStatusChecks = await dbContext.FileSendRecords.CountAsync(record => + record.Status == FileSendStatuses.Succeeded && + !record.ProcessingCompleted && + !string.IsNullOrWhiteSpace(record.EstateId) && + !string.IsNullOrWhiteSpace(record.FileProcessorFileId), + cancellationToken); + + var recentRuns = merchantRunRows + .OrderByDescending(record => record.CompletedUtc) + .Take(50) + .Select(record => new RunHistoryRow( + record.MerchantId, + record.MerchantName, + record.ScheduledRunUtc, + record.CompletedUtc, + record.Status, + record.ErrorMessage)) + .ToArray(); + + var merchantLookup = fileSendRows + .GroupBy(record => record.MerchantId, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => new + { + SuccessfulFiles = group.Count(record => record.Status == FileSendStatuses.Succeeded), + FailedFiles = group.Count(record => record.Status == FileSendStatuses.Failed), + LastProcessedUtc = group.Max(record => (DateTimeOffset?)record.ProcessedUtc) + }, + StringComparer.OrdinalIgnoreCase); + + var runLookup = merchantRunRows + .GroupBy(record => record.MerchantId, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(record => record.CompletedUtc).First(), + StringComparer.OrdinalIgnoreCase); + + var pendingLookup = await dbContext.FileSendRecords + .Where(record => + record.Status == FileSendStatuses.Succeeded && + !record.ProcessingCompleted && + !string.IsNullOrWhiteSpace(record.MerchantId)) + .GroupBy(record => record.MerchantId) + .Select(group => new + { + MerchantId = group.Key, + PendingCount = group.Count() + }) + .ToListAsync(cancellationToken); + + var pendingLookupMap = pendingLookup.ToDictionary(item => item.MerchantId, item => item.PendingCount, StringComparer.OrdinalIgnoreCase); + + var merchants = options.Merchants + .OrderBy(merchant => merchant.MerchantId, StringComparer.OrdinalIgnoreCase) + .Select(merchant => + { + merchantLookup.TryGetValue(merchant.MerchantId, out var fileSummary); + runLookup.TryGetValue(merchant.MerchantId, out var runSummary); + pendingLookupMap.TryGetValue(merchant.MerchantId, out var pendingCount); + + return new MerchantOperationsRow( + merchant.MerchantId, + string.IsNullOrWhiteSpace(merchant.Name) ? merchant.MerchantId : merchant.Name, + merchant.Enabled, + merchant.EstateId, + string.Join(", ", merchant.GetDailyRunTimesUtc().Select(runTime => runTime.ToString("HH:mm:ss"))), + runSummary?.CompletedUtc, + GetNextScheduledUtc(merchant, DateTimeOffset.UtcNow), + fileSummary?.SuccessfulFiles ?? 0, + fileSummary?.FailedFiles ?? 0, + pendingCount); + }) + .ToArray(); + + var fileProfiles = options.FileProfiles + .OrderBy(profile => profile.FileProfileId, StringComparer.OrdinalIgnoreCase) + .Select(profile => new FileProfileRow( + profile.FileProfileId, + profile.FileProcessorFileProfileId, + profile.Format, + profile.FileExtension, + profile.ContentType, + DescribeLayout(profile), + profile.Fields.Count, + profile.Delimited.HeaderFields.Count, + profile.Delimited.TrailerFields.Count, + DescribeFieldMap(profile.Fields))) + .ToArray(); + + var contracts = options.ContractDefinitions + .OrderBy(contract => contract.ContractId, StringComparer.OrdinalIgnoreCase) + .Select(contract => new ContractMappingRow(contract.ContractId, contract.FileProfileId)) + .ToArray(); + + var metrics = new[] + { + new OperationsMetric("Merchants", options.Merchants.Count.ToString(), $"{options.Merchants.Count(merchant => merchant.Enabled)} enabled"), + new OperationsMetric("Contracts", options.ContractDefinitions.Count.ToString(), "Contract to file-profile mappings"), + new OperationsMetric("File profiles", options.FileProfiles.Count.ToString(), "Delimited and JSON builders"), + new OperationsMetric("Pending checks", pendingStatusChecks.ToString(), "Files awaiting completion polling"), + new OperationsMetric("Run interval", $"{options.FileStatusPolling.PollIntervalSeconds}s", "Status polling cadence"), + new OperationsMetric("Transaction range", $"{options.TransactionGeneration.MinimumTransactionsPerContract}-{options.TransactionGeneration.MaximumTransactionsPerContract}", "Synthetic transaction batch size") + }; + + return new OperationsDashboardModel( + DateTimeOffset.UtcNow, + hostEnvironment.EnvironmentName, + ResolveConnectionStringSummary(), + DescribeAuthentication(), + DescribeFileProcessing(), + DescribeTransactionGeneration(), + DescribePolling(), + DescribeLogging(), + metrics, + merchants, + fileProfiles, + contracts, + recentRuns); + } + + private string ResolveConnectionStringSummary() + { + var connectionString = configuration.GetConnectionString("MerchantFileProcessor"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + return "merchant-file-processor.db"; + } + + const string dataSourcePrefix = "Data Source="; + if (!connectionString.StartsWith(dataSourcePrefix, StringComparison.OrdinalIgnoreCase)) + { + return connectionString; + } + + var path = connectionString[dataSourcePrefix.Length..].Trim(); + return Path.IsPathRooted(path) ? path : Path.Combine(hostEnvironment.ContentRootPath, path); + } + + private string DescribeAuthentication() => + string.IsNullOrWhiteSpace(configurationState.Current.Authentication.ClientId) + ? "Not configured" + : $"Client ID {Mask(configurationState.Current.Authentication.ClientId)} and secret {MaskSecret(configurationState.Current.Authentication.ClientSecret)}"; + + private string DescribeFileProcessing() => + string.IsNullOrWhiteSpace(configurationState.Current.FileProcessing.UserId) + ? "Not configured" + : $"Processing user {Mask(configurationState.Current.FileProcessing.UserId)}"; + + private string DescribeTransactionGeneration() => + $"{configurationState.Current.TransactionGeneration.MinimumTransactionsPerContract} to {configurationState.Current.TransactionGeneration.MaximumTransactionsPerContract} transactions per contract"; + + private string DescribePolling() => + $"{configurationState.Current.FileStatusPolling.PollIntervalSeconds} second poll interval"; + + private string DescribeLogging() => + $"EF Core trace {(frameworkLoggingOptions.EnableEfCoreCommandTrace ? "on" : "off")}, HTTP trace {(frameworkLoggingOptions.EnableHttpClientTrace ? "on" : "off")}"; + + private static string DescribeLayout(FileProfileOptions profile) + { + var parts = new List + { + $"{profile.Format} / {profile.FileExtension}" + }; + + if (profile.Format.Equals(FileProfileFormats.Delimited, StringComparison.OrdinalIgnoreCase)) + { + parts.Add($"delimiter '{profile.Delimited.Delimiter}'"); + parts.Add(profile.Delimited.IncludeHeader ? "header row" : "no header row"); + } + + if (profile.Format.Equals(FileProfileFormats.Json, StringComparison.OrdinalIgnoreCase)) + { + parts.Add(profile.Json.WriteIndented ? "indented JSON" : "compact JSON"); + if (!string.IsNullOrWhiteSpace(profile.Json.RootPropertyName)) + { + parts.Add($"root '{profile.Json.RootPropertyName}'"); + } + } + + return string.Join(", ", parts); + } + + private static string DescribeFieldMap(IEnumerable fields) => + string.Join(", ", fields.Select(field => + { + if (!string.IsNullOrWhiteSpace(field.Value)) + { + return $"{field.Name}={field.Value}"; + } + + var format = string.IsNullOrWhiteSpace(field.Format) ? string.Empty : $" ({field.Format})"; + return $"{field.Name}<-{field.Source}{format}"; + })); + + private static DateTimeOffset? GetNextScheduledUtc(MerchantOptions merchant, DateTimeOffset nowUtc) + { + if (!merchant.Enabled) + { + return null; + } + + var runTimes = merchant.GetDailyRunTimesUtc(); + var nextRunDate = DateOnly.FromDateTime(nowUtc.UtcDateTime); + + foreach (var runTime in runTimes) + { + var nextRun = new DateTimeOffset( + nextRunDate.Year, + nextRunDate.Month, + nextRunDate.Day, + runTime.Hour, + runTime.Minute, + runTime.Second, + TimeSpan.Zero); + + if (nextRun > nowUtc) + { + return nextRun; + } + } + + var firstRunTime = runTimes[0]; + var tomorrow = nextRunDate.AddDays(1); + return new DateTimeOffset( + tomorrow.Year, + tomorrow.Month, + tomorrow.Day, + firstRunTime.Hour, + firstRunTime.Minute, + firstRunTime.Second, + TimeSpan.Zero); + } + + private static void AppendNavigation(StringBuilder html, params (string Label, string Href)[] links) + { + html.AppendLine(" "); + } + + private static void AppendMetrics(StringBuilder html, IReadOnlyList metrics) + { + html.AppendLine("
"); + foreach (var metric in metrics) + { + html.AppendLine("
"); + html.AppendLine($"
{Encode(metric.Label)}
"); + html.AppendLine($"
{Encode(metric.Value)}
"); + html.AppendLine($"
{Encode(metric.Detail)}
"); + html.AppendLine("
"); + } + html.AppendLine("
"); + } + + private static void AppendSummaryPanel(StringBuilder html, string title, IEnumerable<(string Label, string Value)> items) + { + html.AppendLine("
"); + html.AppendLine($"
{Encode(title)}
"); + html.AppendLine("
"); + + foreach (var item in items) + { + html.AppendLine($"
{Encode(item.Label)}
"); + html.AppendLine($"
{Encode(item.Value)}
"); + } + + html.AppendLine("
"); + html.AppendLine("
"); + } + + private static void AppendMerchantConfigPanel(StringBuilder html) + { + html.AppendLine("
"); + html.AppendLine("
Configuration summary
"); + html.AppendLine("

This dashboard reads the effective runtime settings after binding appsettings, environment overrides, and command-line values.

"); + html.AppendLine("
    "); + html.AppendLine("
  • Authentication values are masked and only shown as configured or not configured.
  • "); + html.AppendLine("
  • SQLite is used for the local audit trail and status tables.
  • "); + html.AppendLine("
  • Merchant schedules are interpreted in UTC and backed by the worker loop.
  • "); + html.AppendLine("
"); + html.AppendLine("
"); + } + + private static void AppendFileProfilesSection(StringBuilder html, IReadOnlyList fileProfiles) + { + html.AppendLine("
"); + html.AppendLine("
File profiles
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var profile in fileProfiles) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (fileProfiles.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
ProfileFormatLayoutFieldsMapping
{Encode(profile.FileProfileId)}
{Encode(profile.FileProcessorFileProfileId)}
{RenderBadge(profile.Format, "neutral")}{Encode(profile.LayoutSummary)}
.{Encode(profile.FileExtension)}{(string.IsNullOrWhiteSpace(profile.ContentType) ? string.Empty : $" / {Encode(profile.ContentType)}")}
{profile.FieldCount} total
{profile.HeaderFieldCount} header, {profile.TrailerFieldCount} trailer
{Encode(profile.FieldMap)}
No file profiles are configured.
"); + html.AppendLine("
"); + } + + private static void AppendContractsSection(StringBuilder html, IReadOnlyList contracts) + { + html.AppendLine("
"); + html.AppendLine("
Contract mappings
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var contract in contracts) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (contracts.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
ContractFile profile
{Encode(contract.ContractId)}{Encode(contract.FileProfileId)}
No contract definitions are configured.
"); + html.AppendLine("
"); + } + + private static void AppendDocumentStart(StringBuilder html, string title, string subtitle) + { + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine($" {WebUtility.HtmlEncode(title)}"); + html.AppendLine(" "); + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine("
"); + html.AppendLine($"
{WebUtility.HtmlEncode(subtitle)}
"); + html.AppendLine($"
{WebUtility.HtmlEncode(title)}
"); + } + + private static void AppendDocumentEnd(StringBuilder html) + { + html.AppendLine("
"); + html.AppendLine(""); + html.AppendLine(""); + } + + private static string RenderBadge(string value, string tone) => + $"{Encode(value)}"; + + private static string RunTone(string status) + { + if (status.Equals(MerchantRunStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(MerchantRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } + + private static string Encode(string value) => WebUtility.HtmlEncode(value); + + private static string Mask(string value) + { + var trimmed = value.Trim(); + return trimmed.Length <= 8 ? trimmed : $"{trimmed[..4]}...{trimmed[^4..]}"; + } + + private static string MaskSecret(string value) => + string.IsNullOrWhiteSpace(value) ? "not set" : "configured"; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs index 2b264a7..803f87e 100644 --- a/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs @@ -1,31 +1,36 @@ +using TransactionProcessing.MerchantFileProcessor.Configuration; + namespace TransactionProcessing.MerchantFileProcessor.Reporting; public static class ReportingEndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapReportingEndpoints(this IEndpointRouteBuilder endpoints) { - endpoints.MapGet("/", () => Results.Redirect("/status")); + endpoints.MapGet("/", () => Results.Redirect("/ops")); endpoints.MapGet("/api/status", async (IFileStatusReportService reportService, CancellationToken cancellationToken) => Results.Json(await reportService.GetReportAsync(cancellationToken))); - endpoints.MapGet("/status", async (IFileStatusReportService reportService, CancellationToken cancellationToken) => - Results.Content(await reportService.RenderHtmlAsync(cancellationToken), "text/html")); - - endpoints.MapGet("/status/{merchantId}", async (string merchantId, IFileStatusReportService reportService, CancellationToken cancellationToken) => + endpoints.MapGet("/api/configuration", async (IMerchantProcessingConfigurationStore configurationStore, CancellationToken cancellationToken) => { - var html = await reportService.RenderMerchantHtmlAsync(merchantId, cancellationToken); - return html is null - ? Results.NotFound($"Merchant '{merchantId}' was not found.") - : Results.Content(html, "text/html"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + return Results.Content(snapshot.Json, "application/json"); }); - endpoints.MapGet("/status/{merchantId}/files/{fileId:long}", async (string merchantId, long fileId, IFileStatusReportService reportService, CancellationToken cancellationToken) => + endpoints.MapPost("/api/configuration", async (HttpRequest request, IMerchantProcessingConfigurationStore configurationStore, CancellationToken cancellationToken) => { - var html = await reportService.RenderFileHtmlAsync(merchantId, fileId, cancellationToken); - return html is null - ? Results.NotFound($"File '{fileId}' was not found for merchant '{merchantId}'.") - : Results.Content(html, "text/html"); + var form = await request.ReadFormAsync(cancellationToken); + var configurationJson = form["configurationJson"].ToString(); + + try + { + await configurationStore.SaveJsonAsync(configurationJson, cancellationToken); + return Results.Redirect("/ops/config?saved=1"); + } + catch (Exception) + { + return Results.Redirect($"/ops/config?error={Uri.EscapeDataString("Configuration save failed.")}"); + } }); return endpoints; diff --git a/TransactionProcessing.MerchantFileProcessor/Routes.razor b/TransactionProcessing.MerchantFileProcessor/Routes.razor new file mode 100644 index 0000000..172c844 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Routes.razor @@ -0,0 +1,11 @@ + + + + + + + +

Page not found.

+
+
+
diff --git a/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj b/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj index 308c58a..52ba432 100644 --- a/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj +++ b/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj @@ -27,5 +27,8 @@ PreserveNewest + + PreserveNewest + diff --git a/TransactionProcessing.MerchantFileProcessor/Worker.cs b/TransactionProcessing.MerchantFileProcessor/Worker.cs index ec273a6..375861a 100644 --- a/TransactionProcessing.MerchantFileProcessor/Worker.cs +++ b/TransactionProcessing.MerchantFileProcessor/Worker.cs @@ -3,15 +3,16 @@ namespace TransactionProcessing.MerchantFileProcessor; -public sealed class Worker(MerchantProcessingOptions options, +public sealed class Worker(IMerchantProcessingConfigurationState configurationState, IMerchantProcessingService merchantProcessingService, IFileStatusStore fileStatusStore) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LocalLogger.Context logger = LocalLogger.For(); - logger.LogInformation($"Merchant processor started with {options.Merchants.Count} merchant schedules"); + logger.LogInformation("Merchant processor started"); while (!stoppingToken.IsCancellationRequested) { + MerchantProcessingOptions options = configurationState.Current; MerchantOptions[] enabledMerchants = options.Merchants.Where(merchant => merchant.Enabled).ToArray(); if (enabledMerchants.Length == 0) { diff --git a/TransactionProcessing.MerchantFileProcessor/_Imports.razor b/TransactionProcessing.MerchantFileProcessor/_Imports.razor new file mode 100644 index 0000000..1f007cf --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Globalization +@using System.Net +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Rendering +@using TransactionProcessing.MerchantFileProcessor.Configuration +@using TransactionProcessing.MerchantFileProcessor.Reporting +@using TransactionProcessing.MerchantFileProcessor.Services diff --git a/TransactionProcessing.MerchantFileProcessor/appsettings.json b/TransactionProcessing.MerchantFileProcessor/appsettings.json index 72f946e..ba0fb90 100644 --- a/TransactionProcessing.MerchantFileProcessor/appsettings.json +++ b/TransactionProcessing.MerchantFileProcessor/appsettings.json @@ -10,131 +10,8 @@ "EnableHttpClientTrace": false }, "ConnectionStrings": { - "MerchantFileProcessor": "Data Source=merchant-file-processor.db" + "MerchantFileProcessor": "merchant-file-processor.db" }, "ApiConfiguration": { - - }, - "MerchantProcessing": { - "Authentication": { - "ClientId": "", - "ClientSecret": "" - }, - "FileProcessing": { - "UserId": "" - }, - "TransactionGeneration": { - "MinimumTransactionsPerContract": 5, - "MaximumTransactionsPerContract": 25 - }, - "FileStatusPolling": { - "PollIntervalSeconds": 30 - }, - "ContractDefinitions": [ - - ], - "FileProfiles": [ - { - "FileProfileId": "safaricom", - "FileProcessorFileProfileId": "", - "Format": "delimited", - "FileExtension": "csv", - "ContentType": "text/csv", - "Delimited": { - "Delimiter": ",", - "IncludeHeader": false, - "HeaderFields": [ - { - "Name": "RecordType", - "Value": "H" - }, - { - "Name": "ProcessingDate", - "Source": "processingDateUtc", - "Format": "yyyy-MM-dd" - } - ], - "TrailerFields": [ - { - "Name": "RecordType", - "Value": "T" - }, - { - "Name": "RecordCount", - "Source": "recordCount" - } - ] - }, - "Fields": [ - { - "Name": "RecordType", - "Value": "D" - }, - { - "Name": "RecipientMobileNumber", - "Source": "recipientMobileNumber" - }, - { - "Name": "TotalAmount", - "Source": "totalAmount", - "Format": "0.00" - } - ] - }, - { - "FileProfileId": "voucher", - "FileProcessorFileProfileId": "", - "Format": "delimited", - "FileExtension": "csv", - "ContentType": "text/csv", - "Delimited": { - "Delimiter": ",", - "IncludeHeader": false, - "HeaderFields": [ - { - "Name": "RecordType", - "Value": "H" - }, - { - "Name": "ProcessingDate", - "Source": "processingDateUtc", - "Format": "yyyy-MM-dd" - } - ], - "TrailerFields": [ - { - "Name": "RecordType", - "Value": "T" - }, - { - "Name": "RecordCount", - "Source": "recordCount" - } - ] - }, - "Fields": [ - { - "Name": "RecordType", - "Value": "D" - }, - { - "Name": "Issuer", - "Source": "contractIssuer" - }, - { - "Name": "RecipientMobileNumber", - "Source": "recipientMobileNumber" - }, - { - "Name": "TotalAmount", - "Source": "totalAmount", - "Format": "0.00" - } - ] - } - ], - "Merchants": [ - - ] } } diff --git a/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json b/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json index 50c514c..da75673 100644 --- a/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json +++ b/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json @@ -9,143 +9,5 @@ "SecurityService": "https://127.0.0.1:5001", "TransactionProcessorApi": "http://127.0.0.1:5002", "FileProcessorApi": "http://127.0.0.1:5009" - }, - "MerchantProcessing": { - "Authentication": { - "ClientId": "serviceClient", - "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543" - }, - "FileProcessing": { - "UserId": "a51791a8-2731-490c-a8ac-639461f2a41f" - }, - "TransactionGeneration": { - "MinimumTransactionsPerContract": 5, - "MaximumTransactionsPerContract": 25 - }, - "FileStatusPolling": { - "PollIntervalSeconds": 30 - }, - "ContractDefinitions": [ - { - "ContractId": "052A6CA5-47FC-4844-ADCA-7D44EFD8F21C", - "FileProfileId": "safaricom" - }, - { - "ContractId": "9114770B-D188-4F32-9259-B9BD29472370", - "FileProfileId": "voucher" - } - ], - "FileProfiles": [ - { - "FileProfileId": "safaricom", - "FileProcessorFileProfileId": "B2A59ABF-293D-4A6B-B81B-7007503C3476", - "Format": "delimited", - "FileExtension": "csv", - "ContentType": "text/csv", - "Delimited": { - "Delimiter": ",", - "IncludeHeader": false, - "HeaderFields": [ - { - "Name": "RecordType", - "Value": "H" - }, - { - "Name": "ProcessingDate", - "Source": "processingDateUtc", - "Format": "yyyy-MM-dd" - } - ], - "TrailerFields": [ - { - "Name": "RecordType", - "Value": "T" - }, - { - "Name": "RecordCount", - "Source": "recordCount" - } - ] - }, - "Fields": [ - { - "Name": "RecordType", - "Value": "D" - }, - { - "Name": "RecipientMobileNumber", - "Source": "recipientMobileNumber" - }, - { - "Name": "TotalAmount", - "Source": "totalAmount", - "Format": "0.00" - } - ] - }, - { - "FileProfileId": "voucher", - "FileProcessorFileProfileId": "8806EDBC-3ED6-406B-9E5F-A9078356BE99", - "Format": "delimited", - "FileExtension": "csv", - "ContentType": "text/csv", - "Delimited": { - "Delimiter": ",", - "IncludeHeader": false, - "HeaderFields": [ - { - "Name": "RecordType", - "Value": "H" - }, - { - "Name": "ProcessingDate", - "Source": "processingDateUtc", - "Format": "yyyy-MM-dd" - } - ], - "TrailerFields": [ - { - "Name": "RecordType", - "Value": "T" - }, - { - "Name": "RecordCount", - "Source": "recordCount" - } - ] - }, - "Fields": [ - { - "Name": "RecordType", - "Value": "D" - }, - { - "Name": "Issuer", - "Source": "contractIssuer" - }, - { - "Name": "RecipientMobileNumber", - "Source": "recipientMobileNumber" - }, - { - "Name": "TotalAmount", - "Source": "totalAmount", - "Format": "0.00" - } - ] - } - ], - "Merchants": [ - { - "Name": "Test Merchant 1", - "EstateId": "435613ac-a468-47a3-ac4f-649d89764c22", - "MerchantId": "ab1c99fb-1c6c-4694-9a32-b71be5d1da33", - "RunTimesUtc": [ - "09:02:00", - "15:02:00" - ], - "Enabled": true - } - ] } } diff --git a/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json b/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json new file mode 100644 index 0000000..333f2f7 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json @@ -0,0 +1,140 @@ +{ + "MerchantProcessing": { + "Authentication": { + "ClientId": "serviceClient", + "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543" + }, + "FileProcessing": { + "UserId": "a51791a8-2731-490c-a8ac-639461f2a41f" + }, + "TransactionGeneration": { + "MinimumTransactionsPerContract": 5, + "MaximumTransactionsPerContract": 25 + }, + "FileStatusPolling": { + "PollIntervalSeconds": 30 + }, + "ContractDefinitions": [ + { + "ContractId": "052A6CA5-47FC-4844-ADCA-7D44EFD8F21C", + "FileProfileId": "safaricom" + }, + { + "ContractId": "9114770B-D188-4F32-9259-B9BD29472370", + "FileProfileId": "voucher" + } + ], + "FileProfiles": [ + { + "FileProfileId": "safaricom", + "FileProcessorFileProfileId": "B2A59ABF-293D-4A6B-B81B-7007503C3476", + "Format": "delimited", + "FileExtension": "csv", + "ContentType": "text/csv", + "Delimited": { + "Delimiter": ",", + "IncludeHeader": false, + "HeaderFields": [ + { + "Name": "RecordType", + "Value": "H" + }, + { + "Name": "ProcessingDate", + "Source": "processingDateUtc", + "Format": "yyyy-MM-dd" + } + ], + "TrailerFields": [ + { + "Name": "RecordType", + "Value": "T" + }, + { + "Name": "RecordCount", + "Source": "recordCount" + } + ] + }, + "Fields": [ + { + "Name": "RecordType", + "Value": "D" + }, + { + "Name": "RecipientMobileNumber", + "Source": "recipientMobileNumber" + }, + { + "Name": "TotalAmount", + "Source": "totalAmount", + "Format": "0.00" + } + ] + }, + { + "FileProfileId": "voucher", + "FileProcessorFileProfileId": "8806EDBC-3ED6-406B-9E5F-A9078356BE99", + "Format": "delimited", + "FileExtension": "csv", + "ContentType": "text/csv", + "Delimited": { + "Delimiter": ",", + "IncludeHeader": false, + "HeaderFields": [ + { + "Name": "RecordType", + "Value": "H" + }, + { + "Name": "ProcessingDate", + "Source": "processingDateUtc", + "Format": "yyyy-MM-dd" + } + ], + "TrailerFields": [ + { + "Name": "RecordType", + "Value": "T" + }, + { + "Name": "RecordCount", + "Source": "recordCount" + } + ] + }, + "Fields": [ + { + "Name": "RecordType", + "Value": "D" + }, + { + "Name": "Issuer", + "Source": "contractIssuer" + }, + { + "Name": "RecipientMobileNumber", + "Source": "recipientMobileNumber" + }, + { + "Name": "TotalAmount", + "Source": "totalAmount", + "Format": "0.00" + } + ] + } + ], + "Merchants": [ + { + "Name": "Test Merchant 1", + "EstateId": "435613ac-a468-47a3-ac4f-649d89764c22", + "MerchantId": "ab1c99fb-1c6c-4694-9a32-b71be5d1da33", + "RunTimesUtc": [ + "09:02:00", + "15:02:00" + ], + "Enabled": true + } + ] + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/wwwroot/ops.css b/TransactionProcessing.MerchantFileProcessor/wwwroot/ops.css new file mode 100644 index 0000000..237c499 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/wwwroot/ops.css @@ -0,0 +1,648 @@ +:root { + color-scheme: dark; + --bg: #08111f; + --bg-soft: #0d1728; + --panel: rgba(11, 20, 36, 0.92); + --panel-strong: rgba(16, 28, 48, 0.98); + --border: rgba(151, 180, 225, 0.18); + --text: #edf3ff; + --muted: #9db0cc; + --accent: #7cc4ff; + --accent-2: #8ef3c1; + --good: #67de98; + --bad: #ff7b8b; + --warn: #ffd271; +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + min-height: 100%; + background: + radial-gradient(circle at top left, rgba(80, 131, 255, 0.24), transparent 32%), + radial-gradient(circle at top right, rgba(67, 216, 163, 0.14), transparent 26%), + linear-gradient(180deg, #07101e 0%, #08111f 55%, #050b14 100%); + color: var(--text); + font-family: Segoe UI, Arial, sans-serif; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.app-shell { + display: grid; + grid-template-columns: 290px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + padding: 28px 22px; + border-right: 1px solid var(--border); + background: rgba(4, 8, 15, 0.72); + backdrop-filter: blur(12px); +} + +.brand { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 28px; +} + +.brand-mark { + display: grid; + place-items: center; + width: 52px; + height: 52px; + border-radius: 16px; + background: linear-gradient(135deg, rgba(124, 196, 255, 0.2), rgba(142, 243, 193, 0.15)); + border: 1px solid var(--border); + color: var(--text); + font-weight: 800; + letter-spacing: 0.08em; +} + +.brand-title { + font-size: 1.06rem; + font-weight: 700; +} + +.brand-subtitle { + color: var(--muted); + font-size: 0.85rem; +} + +.sidebar-hero { + margin-top: 4px; +} + +.sidebar-hero-title { + font-size: 1.05rem; + font-weight: 800; + margin-bottom: 8px; +} + +.nav-group-label { + margin: 6px 0 4px; + color: var(--accent-2); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + font-weight: 700; +} + +.sidebar-nav { + display: grid; + gap: 8px; +} + +.sidebar-nav-spaced { + margin-top: 20px; +} + +.nav-link { + display: block; + padding: 12px 14px; + border: 1px solid transparent; + border-radius: 14px; + color: var(--text); + background: rgba(255, 255, 255, 0.02); +} + +.nav-link:hover { + background: rgba(255, 255, 255, 0.05); + text-decoration: none; +} + +.nav-link.active { + background: linear-gradient(135deg, rgba(124, 196, 255, 0.18), rgba(142, 243, 193, 0.09)); + border-color: rgba(124, 196, 255, 0.22); +} + +.sidebar-card { + margin-top: 22px; + padding: 18px; + border: 1px solid var(--border); + border-radius: 18px; + background: linear-gradient(180deg, var(--panel) 0%, var(--panel-strong) 100%); +} + +.sidebar-card-label { + color: var(--accent-2); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + margin-bottom: 10px; +} + +.sidebar-card p { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.content { + padding: 26px; +} + +.content-header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: end; + margin-bottom: 18px; + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: 22px; + background: linear-gradient(180deg, rgba(11, 20, 36, 0.86) 0%, rgba(16, 28, 48, 0.92) 100%); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(10px); +} + +.content-header h1 { + margin: 8px 0 8px; + font-size: clamp(1.8rem, 2.8vw, 2.6rem); + line-height: 1.06; +} + +.content-header p { + margin: 0; + max-width: 72ch; + color: var(--muted); + line-height: 1.5; +} + +.content-header-links { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.quick-link { + display: inline-flex; + align-items: center; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--text); +} + +.quick-link:hover { + text-decoration: none; + background: rgba(255, 255, 255, 0.06); +} + +.page-shell { + display: grid; + gap: 18px; +} + +.hero-card, +.panel, +.metric-card { + border: 1px solid var(--border); + border-radius: 22px; + background: linear-gradient(180deg, var(--panel) 0%, var(--panel-strong) 100%); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22); + backdrop-filter: blur(10px); +} + +.hero-card { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(320px, 0.9fr); + gap: 18px; + padding: 24px; +} + +.hero-copy h1 { + margin: 8px 0 10px; + font-size: clamp(2rem, 3vw, 3.1rem); + line-height: 1.02; +} + +.hero-copy p { + margin: 0; + max-width: 72ch; + color: var(--muted); + font-size: 1.02rem; + line-height: 1.55; +} + +.eyebrow { + color: var(--accent-2); + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.74rem; + font-weight: 700; +} + +.hero-panel { + display: grid; + gap: 12px; + padding: 6px 0 0; +} + +.field-label { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + margin-bottom: 4px; +} + +.field-value { + font-size: 1rem; + line-height: 1.45; +} + +.metric-grid, +.two-col { + display: grid; + gap: 16px; +} + +.metric-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.metric-card { + padding: 18px; + min-height: 114px; +} + +.metric-label { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; +} + +.metric-value { + margin: 8px 0 6px; + font-size: 1.9rem; + font-weight: 800; +} + +.metric-detail { + color: var(--muted); + line-height: 1.4; +} + +.panel { + padding: 18px; +} + +.config-item { + display: grid; + gap: 16px; +} + +.hidden-forms { + display: none; +} + +.notice-panel { + color: var(--accent-2); + border-color: rgba(142, 243, 193, 0.24); +} + +.section-header { + display: grid; + gap: 4px; + margin-bottom: 14px; +} + +.section-header h2 { + margin: 0; + font-size: 1.05rem; +} + +.section-header p { + margin: 0; + color: var(--muted); +} + +.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.master-detail { + display: grid; + grid-template-columns: minmax(360px, 1.1fr) minmax(420px, 0.9fr); + gap: 18px; + align-items: start; +} + +.detail-stack { + display: grid; + gap: 18px; +} + +.table-wrap { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 11px 10px; + text-align: left; + vertical-align: top; + border-top: 1px solid rgba(151, 180, 225, 0.14); +} + +.data-table th { + border-top: none; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.11em; + font-size: 0.74rem; +} + +.data-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.82rem; + font-weight: 700; +} + +.badge.good { + color: var(--good); + background: rgba(103, 222, 152, 0.12); + border-color: rgba(103, 222, 152, 0.24); +} + +.badge.bad { + color: var(--bad); + background: rgba(255, 123, 139, 0.12); + border-color: rgba(255, 123, 139, 0.24); +} + +.badge.neutral { + color: var(--accent); + background: rgba(124, 196, 255, 0.12); + border-color: rgba(124, 196, 255, 0.22); +} + +.badge.warn { + color: var(--warn); + background: rgba(255, 210, 113, 0.12); + border-color: rgba(255, 210, 113, 0.22); +} + +.summary-grid { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); + gap: 10px 14px; + margin: 0; +} + +.summary-grid dt { + color: var(--muted); +} + +.summary-grid dd { + margin: 0; +} + +.notes-list { + margin: 0; + padding-left: 20px; + color: var(--muted); + line-height: 1.6; +} + +.notes-list li + li { + margin-top: 8px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.form-grid label { + display: grid; + gap: 8px; + color: var(--muted); +} + +.form-grid label span { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.11em; + font-size: 0.72rem; + font-weight: 700; +} + +.span-2 { + grid-column: span 2; +} + +.text-input { + width: 100%; + min-height: 42px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(0, 0, 0, 0.22); + color: var(--text); + font: inherit; +} + +.text-input:focus { + outline: 2px solid rgba(124, 196, 255, 0.45); + outline-offset: 2px; +} + +.text-input.mono { + font-family: Consolas, Menlo, Monaco, monospace; +} + +.config-form { + display: grid; + gap: 14px; +} + +.field-card { + padding: 14px; + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.02); +} + +.field-card + .field-card { + margin-top: 12px; +} + +.config-table td, +.config-table th { + vertical-align: middle; +} + +.config-table .text-input { + min-height: 38px; + padding: 8px 10px; +} + +.selector-table tbody tr { + cursor: pointer; +} + +.selector-table tbody tr.selected { + background: rgba(124, 196, 255, 0.08); +} + +.table-actions { + white-space: nowrap; +} + +.empty-state { + display: grid; + place-items: center; + min-height: 84px; + padding: 16px; + border: 1px dashed rgba(151, 180, 225, 0.22); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + color: var(--muted); + text-align: center; + line-height: 1.5; +} + +.mono { + font-family: Consolas, Menlo, Monaco, monospace; +} + +.smallwrap { + white-space: normal; + word-break: break-word; +} + +.muted { + color: var(--muted); +} + +.error-block { + margin: 0; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 16px; + background: rgba(0, 0, 0, 0.18); + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--text); +} + +.config-editor { + width: 100%; + min-height: 520px; + padding: 16px; + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(0, 0, 0, 0.22); + color: var(--text); + font-family: Consolas, Menlo, Monaco, monospace; + font-size: 0.9rem; + line-height: 1.5; + resize: vertical; +} + +.config-editor:focus { + outline: 2px solid rgba(124, 196, 255, 0.45); + outline-offset: 2px; +} + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.primary-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text); + font-weight: 700; +} + +.primary-button { + background: linear-gradient(135deg, rgba(124, 196, 255, 0.22), rgba(142, 243, 193, 0.14)); +} + +.secondary-button { + background: rgba(255, 255, 255, 0.03); +} + +.secondary-button:hover, +.primary-button:hover { + text-decoration: none; + background-color: rgba(255, 255, 255, 0.06); +} + +@media (max-width: 1200px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .metric-grid, + .two-col, + .master-detail, + .hero-card { + grid-template-columns: 1fr; + } + + .content-header { + flex-direction: column; + align-items: start; + } + + .metric-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .content { + padding: 16px; + } + + .metric-grid, + .summary-grid { + grid-template-columns: 1fr; + } +}