Compare commits

...

22 Commits

Author SHA1 Message Date
612022862a Update assembly and file version to 1.1.0.1
Updated `<FileVersion>` and `<AssemblyVersion>` in the
`ECMJobRunner.WebCron.csproj` file from `1.1.0.0` to `1.1.0.1`.
This reflects a minor revision, likely for a small update or bug fix.
2026-08-05 09:35:09 +02:00
94c2495515 Add IIS publish profile for .NET 8.0 web application
Added a new `IISProfile.pubxml` configuration file to enable
publishing the web application as a package. Configured the
build to use the `Release` configuration and `Any CPU`
platform. Set the target framework to `.NET 8.0` and defined
the IIS deployment path as `JobRunner.WebCron`. The package
is created as a single file and includes the `App_Data`
folder. Specified the desktop build package location and
added a unique project GUID for identification.
2026-08-05 09:30:20 +02:00
d7c8607fbf Fix conditional logic and update exception message
Updated the `else if` condition to use a logical AND (`&&`)
instead of a logical OR (`||`) to ensure the condition checks
if `result.ReturnValue` is both not null and not equal to 0.

Aligned the exception message with the updated logic to
indicate that the expected value is "null or 0" instead of
just "null." This change improves correctness and prevents
unintended behavior.
2026-08-05 09:27:48 +02:00
8d4c08fbec Bump version to 1.1.0 in project file
Updated versioning details in `ECMJobRunner.WebCron.csproj`:
- `<Version>` updated from `1.0.0` to `1.1.0`.
- `<FileVersion>` updated from `1.0.0.0` to `1.1.0.0`.
- `<AssemblyVersion>` updated from `1.0.0.0` to `1.1.0.0`.
- `<InformationalVersion>` updated from `1.0.0` to `1.1.0`.
2026-08-04 20:37:23 +02:00
bc9f234810 Add global exception handling middleware
Introduced `ExceptionHandlingMiddleware` to handle exceptions
globally in the application. The middleware captures exceptions
thrown during the request pipeline, logs them, and returns
appropriate HTTP responses in JSON format.

Key features:
- Handles `JobException` with a 400 Bad Request response.
- Handles unhandled exceptions with a 500 Internal Server Error.
- Logs warnings for `JobException` and errors for unhandled
  exceptions.
- Sets response `ContentType` to `application/json` and writes
  error details as JSON.

Added necessary `using` directives for required namespaces.
2026-08-04 20:37:11 +02:00
545648fe87 Update condition to handle non-zero ReturnValue cases
Modified the `else if` condition in `MainQueryExecutionBehavior.cs` to check if `result.ReturnValue` is either not null or not equal to 0. This ensures that the `JobSqlException` is thrown for additional scenarios where `result.ReturnValue` is non-zero, improving error handling and robustness.
2026-08-04 20:36:56 +02:00
dd9e6a710b feat(history): add ProfileHistory creation with job execution results
TriggeringProfileJobCommand:
- Add RecActionResult property to store ReC action execution results
- Convert handler to primary constructor with ISender injection
- Create ProfileHistory after successful job execution
- Include detailed execution metrics in history (TotalActionCount, ActionExceptionCount, BatchId)
- Add required using statements for ProfileHistories and ValueObjects

ProfileWorkerOptionsValidator:
- Fix XML documentation reference to ValidateOptionsResult.Fail(string)
2026-08-04 16:34:30 +02:00
5b67035a07 refactor(time): change DateTime from UTC to local time
- Change HealthCheckHtmlGenerator to use DateTime.Now instead of DateTime.UtcNow
- Change ProfileWorker health check timestamps to use DateTime.Now
- Change Program health check endpoint to use DateTime.Now
- Ensures consistent local time usage across the application
2026-08-04 16:34:16 +02:00
5b865c0442 fix(sql): change query execution to use ToList before FirstOrDefault
- Change from FirstOrDefaultAsync to ToListAsync + FirstOrDefault
- Ensures proper query execution for both EF6 (.NET Framework 4.8) and EF Core (.NET 8.0)
- Prevents potential query execution issues
- Add using System.Linq directive
2026-08-04 16:34:04 +02:00
111281ac08 feat(logging): improve exception handling and add comprehensive logging
JobExceptionHandlingBehavior:
- Add ILogger for diagnostic output
- Change ResultText to include full exception details (ToString())
- Log JobException with warning level including ProfileId, JobName, ProcessName, BatchId
- Return default instead of re-throwing to allow graceful handling

ReCRequestExecutionBehavior:
- Convert to primary constructor pattern
- Add ILogger for request tracking
- Store RecActionResult in command for later use
- Log successful ReC requests with detailed metrics (TotalActionCount, ActionExceptionCount)
- Improve error handling and logging
2026-08-04 16:33:53 +02:00
2067ffdf2e chore(deps): downgrade ReC.Client from 2.0.0-beta to 1.0.0
- Downgrade ReC.Client to stable version 1.0.0 in Application project
- Downgrade ReC.Client to stable version 1.0.0 in Tests project
2026-08-04 16:33:38 +02:00
f75524f85d feat(config): add DexJobOptions configuration system and update placeholder pattern
- Add SectionName constant to DexJobOptions
- Update placeholder pattern to {#INT#BATCH_ID}
- Add DexJob configuration section to appsettings.json
- Add IConfiguration parameter to DependencyInjection for options binding
- Add Microsoft.Extensions.Options.ConfigurationExtensions package for .NET Framework 4.8
- Improve code documentation and move class into namespace
2026-08-04 16:33:26 +02:00
73db8fbd27 Add JobExceptionHandlingBehavior and improve mappings
Introduced `JobExceptionHandlingBehavior` to handle exceptions, log errors, and rethrow them during MediatR pipeline execution. Updated `DependencyInjection.cs` to register the new behavior and added a `recClientApiUrl` parameter for API configuration.

Enhanced `ProfileMappingProfile.cs` and `GetProfileQuery.cs` with XML documentation for better readability. Improved case-insensitive filtering in `GetProfileQuery` with conditional compilation for .NET version compatibility.

Modified `CreateProfileHistoryCommand.cs` to use a non-nullable `AddedWho` property. Added missing `using` directive in `GetProfileQuery.cs` for compatibility. These changes improve code quality, maintainability, and functionality.
2026-08-03 13:37:01 +02:00
90916f6d03 Refactor exceptions to include profileId context
Refactored `JobException` and derived classes (`InactiveProfileException`, `JobHttpException`, `JobSqlException`) to include `profileId` as a required parameter in their constructors. This ensures consistent context for exceptions related to job execution.

Updated behaviors (`CheckQueryExecutionBehavior`, `MainQueryExecutionBehavior`, `ReCRequestExecutionBehavior`) to use the new constructors, passing `profileId` where applicable. Improved exception messages for better debugging context.

Simplified property initialization and enhanced XML documentation for clarity.
2026-08-03 13:36:42 +02:00
eb060aa54e Add CreateProfileHistoryCommand and mapping profiles
Introduce `CreateProfileHistoryCommand` to handle the creation of profile execution history records, including properties for `ProfileId`, `Result`, `ResultText`, and `AddedWho`.

Add `CreateProfileHistoryCommandHandler` to process the command and persist the data using `IProfileHistoryRepository`.

Define AutoMapper mappings in `MappingProfiles` to map `CreateProfileHistoryCommand` to the `ProfileHistory` entity, with specific configurations for ignored and mapped properties.
2026-08-03 12:08:54 +02:00
45a7086c9a Refactor repository pattern and modernize codebase
Removed `SaveChangesAsync` from `IRepository` and `Repository` to centralize transaction management in `DbContext`. Updated `CfgProfileRepository` to use `Context` property instead of `_context`. Refactored `Repository` class to use C# 9.0 record-like constructor syntax and replaced private fields with properties (`Context`, `DbSet`, `Mapper`).

Replaced `Any()` with `Count == 0` for null checks and updated `FindAsync` to use C# 11 object array syntax. Added conditional compilation for framework-specific differences. These changes improve readability, consistency, and leverage modern C# features.
2026-08-03 12:07:09 +02:00
1110728741 Refactor DEXJob to ProfileJob across the codebase
Renamed namespaces, classes, and commands from `DEXJob` to `ProfileJob` to align with the new "Profiles" context. Updated pipeline behaviors (`CheckQueryExecutionBehavior`, `MainQueryExecutionBehavior`, `ReCRequestExecutionBehavior`) to handle `TriggeringProfileJobCommand`.

Refactored unit tests to reflect the new naming convention, including mock setups and assertions. Updated `DtoExtensions` to return `TriggeringProfileJobBatchCommand`. Adjusted queries and dependency injection to use the new `Profiles` namespace.

Performed general refactoring to replace all references to "DEXJob" with "ProfileJob" in method names, variables, and documentation for consistency and clarity.
2026-08-03 11:36:45 +02:00
a698f8daae Refactor namespaces for DEX job behaviors
Updated namespaces for `CheckQueryExecutionBehavior`,
`MainQueryExecutionBehavior`, and `ReCRequestExecutionBehavior`
from `ECMJobRunner.Application.Behaviors` to
`ECMJobRunner.Application.DEXJob.Commands.Behaviors` to better
align with the `DEXJob.Commands` context.

Updated `DependencyInjection.cs` and test files to reference the
new namespace. Added `using Microsoft.Extensions.Options;` to
support the Options pattern in the updated files.
2026-08-03 11:17:27 +02:00
afdeb8839c Add solution folders and organize project structure
Added two new solution folders, `src` and `test`, to the solution
file (`ECM.JobRunner.sln`) to improve project organization.
Nested existing projects under these folders:
- Projects `{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}`,
  `{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}`,
  `{D97CD489-10D7-432C-9921-196F2E0505FF}`, and
  `{C60BC965-D293-EA64-B153-1941F0648DF4}` were moved under `src`.
- Project `{F64352B6-32BB-4BDE-90FD-FB77482D44E0}` was moved under `test`.

Introduced a `NestedProjects` section in the solution file to define
the hierarchy. No changes were made to existing project configurations
or build settings.
2026-08-03 11:14:52 +02:00
dc2aaa245f Add ResultType enum and Result property in ProfileHistory
Introduced a new `ResultType` enum to represent job execution
result types, including `Ok`, `Error`, `Warning`, and `Unknown`.
Added a `Result` property in the `ProfileHistory` class as a
wrapper around the `ResultId` property, with logic to map
`ResultId` to `ResultType` values. Updated `ProfileHistory.cs`
to include necessary namespaces.
2026-08-03 11:04:10 +02:00
2d7c54ceae Refactor JobException message handling
Renamed the `Message` method to `CreateMessage` for clarity and updated its usage in the `JobException` constructor. Simplified the error message format by removing visual separator lines, resulting in cleaner and more concise output.
2026-08-03 10:34:35 +02:00
af3c8be133 Add AutoMapper and update database connection string
Updated `DependencyInjection.cs` to register AutoMapper with all profiles from the assembly, improving object mapping setup.

Modified `appsettings.json` to update the connection string for `SDD-VMP04-SQL17`, including a new server name and a more secure password, reflecting a move to a different environment or configuration.
2026-08-03 09:52:13 +02:00
38 changed files with 700 additions and 366 deletions

View File

@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Tests", "ECMJo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.WebCron", "ECMJobRunner.WebCron\ECMJobRunner.WebCron.csproj", "{C60BC965-D293-EA64-B153-1941F0648DF4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{7704FD14-0546-4ABA-AA37-5EFA6BD7908D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -43,6 +47,13 @@ Global
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{D97CD489-10D7-432C-9921-196F2E0505FF} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{F64352B6-32BB-4BDE-90FD-FB77482D44E0} = {7704FD14-0546-4ABA-AA37-5EFA6BD7908D}
{C60BC965-D293-EA64-B153-1941F0648DF4} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F4408662-65F4-4D6A-8E12-770DED292847}
EndGlobalSection

View File

@@ -6,11 +6,9 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Exception thrown when attempting to execute a job for an inactive profile
/// Extends JobException with profile-specific context
/// </summary>
public class InactiveProfileException : JobException
{
/// <summary>
/// <remarks>
/// Initializes a new instance of InactiveProfileException
/// </summary>
/// </remarks>
/// <param name="profileId">ID of the inactive profile</param>
/// <param name="profileName">Name of the inactive profile (nullable)</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
@@ -20,8 +18,8 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// - Attempting to execute individual jobs from an inactive profile
/// - Profile is deactivated during execution
/// </remarks>
public InactiveProfileException(long profileId, string? profileName, string batchId)
: base(
public class InactiveProfileException(long profileId, string? profileName, string batchId) : JobException(
profileId,
jobName: "Profile Execution",
processName: "Profile Active Status Validation",
batchId: batchId,
@@ -30,18 +28,9 @@ namespace ECMJobRunner.Application.Common.Exceptions
("Profile ID", profileId.ToString(), false),
("Profile Name", profileName, true))
{
ProfileId = profileId;
ProfileName = profileName;
}
/// <summary>
/// Gets the ID of the inactive profile
/// </summary>
public long ProfileId { get; }
/// <summary>
/// Gets the name of the inactive profile (nullable)
/// </summary>
public string? ProfileName { get; }
public string? ProfileName { get; } = profileName;
}
}

View File

@@ -7,11 +7,10 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Base exception class for job execution failures
/// Provides a flexible structure for capturing job context and detailed error information
/// </summary>
public class JobException : Exception
{
/// <summary>
/// <remarks>
/// Initializes a new instance of JobException with detailed context information
/// </summary>
/// </remarks>
/// <param name="profileId">Identifier of the profile associated with the job</param>
/// <param name="jobName">Name of the job that failed (e.g., "SQL Main Query", "ReC Request")</param>
/// <param name="processName">Name of the process/stage being executed (e.g., "MainQueryExecution", "CheckQueryValidation")</param>
/// <param name="batchId">Unique batch identifier for tracking the execution</param>
@@ -24,10 +23,12 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// - Value: String value of the detail (nullable)
/// - IgnoreIfNull: If true, the detail is omitted from the message when value is null
/// </remarks>
public JobException(string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
: base(
Message(jobName,
public class JobException(long profileId, string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
: Exception(
CreateMessage(jobName,
[
("Profile Id", profileId.ToString(), false),
("Job Name", jobName, false),
("Process Name", processName, false),
("Batch Id", batchId, false),
("Reason", reason, true),
@@ -35,25 +36,25 @@ namespace ECMJobRunner.Application.Common.Exceptions
]),
innerException)
{
JobName = jobName;
ProcessName = processName;
BatchId = batchId;
}
/// <summary>
/// Gets the profile identifier associated with the job execution
/// </summary>
public long ProfileId { get; } = profileId;
/// <summary>
/// Gets the name of the job that failed
/// </summary>
public string JobName { get; }
public string JobName { get; } = jobName;
/// <summary>
/// Gets the name of the process/stage that was being executed when the failure occurred
/// </summary>
public string ProcessName { get; }
public string ProcessName { get; } = processName;
/// <summary>
/// Gets the unique batch identifier for tracking the execution
/// </summary>
public string BatchId { get; }
public string BatchId { get; } = batchId;
/// <summary>
/// Generates a formatted error message with job context and details
@@ -65,26 +66,22 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Message format:
/// <code>
/// {jobName} could not be completed.
/// ─────────────────────────────────────────
/// Process Name: {processName}
/// Batch Id: {batchId}
/// {additional details...}
/// ─────────────────────────────────────────
/// </code>
/// Details with IgnoreIfNull=true are omitted when their value is null.
/// </remarks>
internal static string Message(string jobName, IEnumerable<(string Name, string? Value, bool IgnoreIfNull)> details)
internal static string CreateMessage(string jobName, IEnumerable<(string Name, string? Value, bool IgnoreIfNull)> details)
{
var message = new System.Text.StringBuilder();
message.AppendLine($"{jobName} could not be completed.");
message.AppendLine("─────────────────────────────────────────");
foreach (var (name, value, ignoreNullValue) in details)
{
if (ignoreNullValue && value is null)
continue;
message.AppendLine($" {name}: {value}");
}
message.AppendLine("─────────────────────────────────────────");
return message.ToString();
}
}

View File

@@ -6,11 +6,10 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Exception for HTTP client-related job failures (e.g., ReC API calls, REST requests)
/// Extends JobException with client library and method context
/// </summary>
public class JobHttpException : JobException
{
/// <summary>
/// <remarks>
/// Initializes a new instance of JobHttpException with HTTP client context
/// </summary>
/// </remarks>
/// <param name="profileId">Identifier of the profile associated with the job</param>
/// <param name="jobName">Name of the job that failed (e.g., "ReC Request", "API Call")</param>
/// <param name="processName">Name of the process/stage being executed</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
@@ -25,22 +24,18 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// - HTTP client timeout/network issues
/// - Authentication/authorization failures
/// </remarks>
public JobHttpException(string jobName, string processName, string batchId, string? reason, string? clientLibrary, string? clientMethod, Exception? innerException) : base(jobName, processName, batchId, reason, innerException,
("Client Library", clientLibrary, true),
("Client Method", clientMethod, true))
public class JobHttpException(long profileId, string jobName, string processName, string batchId, string? reason, string? clientLibrary, string? clientMethod, Exception? innerException)
: JobException(profileId, jobName, processName, batchId, reason, innerException, ("Client Library", clientLibrary, true), ("Client Method", clientMethod, true))
{
ClientLibrary = clientLibrary;
ClientMethod = clientMethod;
}
/// <summary>
/// Gets the name of the HTTP client library that was used (e.g., "ReC.Client", "HttpClient")
/// </summary>
public string? ClientLibrary { get; }
public string? ClientLibrary { get; } = clientLibrary;
/// <summary>
/// Gets the name of the client method that failed (e.g., "ExecuteAsync", "PostAsync")
/// </summary>
public string? ClientMethod { get; }
public string? ClientMethod { get; } = clientMethod;
}
}

View File

@@ -6,11 +6,10 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Exception for SQL query execution failures
/// Extends JobException with SQL query context for debugging
/// </summary>
public class JobSqlException : JobException
{
/// <summary>
/// <remarks>
/// Initializes a new instance of JobSqlException with SQL query context
/// </summary>
/// </remarks>
/// <param name="profileId">Identifier of the profile associated with the job</param>
/// <param name="jobName">Name of the job that failed (e.g., "Main Query Execution", "Check Query")</param>
/// <param name="processName">Name of the process/stage being executed</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
@@ -28,11 +27,9 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// The Query property can be logged for debugging but should be handled carefully
/// to avoid exposing sensitive data in production logs.
/// </remarks>
public JobSqlException(string jobName, string processName, string batchId, string? reason, string? query, Exception? innerException) : base(jobName, processName, batchId, reason, innerException,
("Query", query, true))
public class JobSqlException(long profileId, string jobName, string processName, string batchId, string? reason, string? query, Exception? innerException)
: JobException(profileId, jobName, processName, batchId, reason, innerException, ("Query", query, true))
{
Query = query;
}
/// <summary>
/// Gets the SQL query that failed (nullable)
@@ -41,6 +38,6 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// This property is marked as virtual to allow derived classes to customize query handling
/// (e.g., sanitizing sensitive data, truncating long queries)
/// </remarks>
public virtual string? Query { get; }
public virtual string? Query { get; } = query;
}
}

View File

@@ -10,6 +10,9 @@ namespace ECMJobRunner.Application.Common.Mapping
/// </summary>
public class ProfileMappingProfile : Profile
{
/// <summary>
/// Configures AutoMapper mappings for <see cref="ECMJobRunner.Domain.Entities.CfgProfile"/> and <see cref="ECMJobRunner.Domain.Entities.ProfileSqlJob"/> entities
/// </summary>
public ProfileMappingProfile()
{
// CfgProfile -> CfgProfileDto

View File

@@ -1,13 +1,18 @@
using ECMJobRunner.Application.Common.Constants;
using System.Text.RegularExpressions;
namespace ECMJobRunner.Application.Common.Options
{
namespace ECMJobRunner.Application.Common.Options;
/// <summary>
/// Configuration options for DEX job execution
/// </summary>
public class DexJobOptions
{
/// <summary>
/// The configuration section name used to bind this options class from application settings
/// </summary>
public const string SectionName = "DexJob";
/// <summary>
/// Error handling options for DEX job operations
/// </summary>
@@ -92,7 +97,7 @@ namespace ECMJobRunner.Application.Common.Options
/// </summary>
public PlaceHolder BatchId { get; set; } = new()
{
Pattern = "#INT#BATCH_ID",
Pattern = "{#INT#BATCH_ID}",
RegexOptions = RegexOptions.IgnoreCase
};
}
@@ -102,4 +107,3 @@ namespace ECMJobRunner.Application.Common.Options
/// </summary>
public PlaceHolderOptions Placeholders { get; set; } = new();
}
}

View File

@@ -1,5 +1,7 @@
using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.Profiles.Commands.Behaviors;
using MediatR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ReC.Client;
using System.Reflection;
@@ -16,8 +18,10 @@ namespace ECMJobRunner.Application
/// Registers MediatR, pipeline behaviors, and AutoMapper
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="recClientApiUrl">The base URL for the ReC client API</param>
/// <param name="configuration">The application configuration</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddJobRunnerServices(this IServiceCollection services, string recClientApiUrl)
public static IServiceCollection AddJobRunnerServices(this IServiceCollection services, string recClientApiUrl, IConfiguration configuration)
{
var assembly = Assembly.GetExecutingAssembly();
@@ -27,13 +31,20 @@ namespace ECMJobRunner.Application
#else
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
#endif
// Register AutoMapper with all profiles from this assembly
services.AddAutoMapper(assembly);
services.AddRecClient(recClientApiUrl, opt =>
{
opt.LogSuccessfulRequests = true;
});
// Configure DexJobOptions from appsettings.json
services.Configure<DexJobOptions>(configuration.GetSection(DexJobOptions.SectionName));
// Register pipeline behaviors in execution order
// Order matters: MainQuery -> CheckQuery -> ReCRequest
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(JobExceptionHandlingBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MainQueryExecutionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckQueryExecutionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ReCRequestExecutionBehavior<,>));

View File

@@ -17,7 +17,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
<PackageReference Include="ReC.Client" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
@@ -31,6 +31,8 @@
<!-- MediatR for .NET Framework 4.8 -->
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<!-- Options configuration binding for .NET Framework 4.8 -->
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">

View File

@@ -0,0 +1,60 @@
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Domain.ValueObjects;
using MediatR;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.ProfileHistories.Commands
{
/// <summary>
/// Command to create a new profile execution history record
/// </summary>
public class CreateProfileHistoryCommand : IRequest<Unit>
{
/// <summary>
/// Foreign key to the related profile
/// </summary>
public long ProfileId { get; set; }
/// <summary>
/// Execution result type
/// </summary>
public ResultType Result { get; set; }
/// <summary>
/// Result text/message
/// </summary>
#if NET
public required string ResultText { get; set; }
#else
public string ResultText { get; set; } = null!;
#endif
/// <summary>
/// Created by (max 50 chars)
/// </summary>
[JsonIgnore]
public string AddedWho { get; set; } = null!;
}
/// <summary>
/// Handler for <see cref="CreateProfileHistoryCommand"/>
/// </summary>
/// <remarks>
/// Constructor
/// </remarks>
public class CreateProfileHistoryCommandHandler(IProfileHistoryRepository Repository) : IRequestHandler<CreateProfileHistoryCommand, Unit>
{
/// <summary>
/// Handles the command by persisting a new <see cref="ProfileHistory"/> record
/// </summary>
public async Task<Unit> Handle(CreateProfileHistoryCommand request, CancellationToken cancellationToken)
{
await Repository.AddAsync(request, cancellationToken);
return Unit.Value;
}
}
}

View File

@@ -0,0 +1,28 @@
using ECMJobRunner.Application.ProfileHistories.Commands;
using ECMJobRunner.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.ProfileHistories;
/// <summary>
///
/// </summary>
public class MappingProfiles : AutoMapper.Profile
{
/// <summary>
///
/// </summary>
public MappingProfiles()
{
CreateMap<CreateProfileHistoryCommand, ProfileHistory>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.AddedWhen, opt => opt.MapFrom(_ => DateTime.Now))
.ForMember(dest => dest.ChangedWho, opt => opt.Ignore())
.ForMember(dest => dest.ChangedWhen, opt => opt.Ignore())
.ForMember(dest => dest.CfgProfile, opt => opt.Ignore());
}
}

View File

@@ -3,7 +3,7 @@ using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;
@@ -11,7 +11,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that executes the check SQL query for DEX jobs
@@ -46,7 +46,7 @@ namespace ECMJobRunner.Application.Behaviors
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
if (request is TriggeringProfileJobCommand command)
{
await ExecuteCheckQueryAsync(command, cancellationToken);
}
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Application.Behaviors
return await next();
}
private async Task ExecuteCheckQueryAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
private async Task ExecuteCheckQueryAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(command.Job.SqlCheckQuery))
{
@@ -71,6 +71,7 @@ namespace ECMJobRunner.Application.Behaviors
{
if (result is null)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
@@ -79,6 +80,7 @@ namespace ECMJobRunner.Application.Behaviors
innerException: null);
else if (result.ReturnValue <= 0)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
@@ -91,6 +93,7 @@ namespace ECMJobRunner.Application.Behaviors
{
if (_options.Error.CheckQuery.OnExecution == ErrorAction.Stop)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
@@ -103,6 +106,7 @@ namespace ECMJobRunner.Application.Behaviors
else if (_options.Error.CheckQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
{
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName:"Triggering DEX",
processName:"Check Query",
batchId:command.BatchId,

View File

@@ -0,0 +1,51 @@
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.ProfileHistories.Commands;
using ECMJobRunner.Domain.ValueObjects;
using MediatR;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors;
/// <summary>
/// Pipeline behavior that catches <see cref="ECMJobRunner.Application.Common.Exceptions.JobException"/> exceptions,
/// persists a profile history error record and re-throws the exception
/// </summary>
/// <typeparam name="TRequest">The type of the MediatR request</typeparam>
/// <typeparam name="TResponse">The type of the MediatR response</typeparam>
/// <param name="Sender">MediatR sender used to dispatch the <see cref="ECMJobRunner.Application.ProfileHistories.Commands.CreateProfileHistoryCommand"/></param>
/// <param name="Logger">Logger for diagnostic output.</param>
public class JobExceptionHandlingBehavior<TRequest, TResponse>(ISender Sender, ILogger<JobExceptionHandlingBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
/// <summary>
/// Handles the pipeline behavior
/// Executes main query if request is TriggeringDEXJobCommand
/// </summary>
#if NET48
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
#else
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
try
{
return await next();
}
catch (JobException ex)
{
var cmd = new CreateProfileHistoryCommand()
{
ProfileId = ex.ProfileId,
Result = ResultType.Ok,
ResultText = ex.ToString(),
AddedWho = "ECMJobRunner"
};
await Sender.Send(cmd, cancellationToken);
Logger.LogWarning(ex, "JobException caught in JobExceptionHandlingBehavior for ProfileId {ProfileId}, JobName {JobName}, ProcessName {ProcessName}, BatchId {BatchId}", ex.ProfileId, ex.JobName, ex.ProcessName, ex.BatchId);
return default!;
}
}
}

View File

@@ -3,7 +3,7 @@ using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;
@@ -11,7 +11,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that executes the main SQL query for DEX jobs
@@ -46,7 +46,7 @@ namespace ECMJobRunner.Application.Behaviors
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
if (request is TriggeringProfileJobCommand command)
{
await ExecuteMainQueryAsync(command, cancellationToken);
}
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Application.Behaviors
return await next();
}
private async Task ExecuteMainQueryAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
private async Task ExecuteMainQueryAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(command.Job.SqlMainQuery))
{
@@ -71,18 +71,20 @@ namespace ECMJobRunner.Application.Behaviors
{
if (result is null)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: "Main Query returned nothing.",
query: sqlMainQuery,
innerException: null);
else if (result.ReturnValue is not null)
else if (result.ReturnValue is not null && result.ReturnValue != 0)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was null.",
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was null or 0.",
query: sqlMainQuery, innerException: null);
}
}
@@ -90,6 +92,7 @@ namespace ECMJobRunner.Application.Behaviors
{
if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
@@ -101,6 +104,7 @@ namespace ECMJobRunner.Application.Behaviors
else if (_options.Error.MainQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
{
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,

View File

@@ -1,8 +1,9 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ReC.Client;
using ReC.Client.Api;
@@ -10,7 +11,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that sends ReC HTTP request for DEX jobs
@@ -18,22 +19,15 @@ namespace ECMJobRunner.Application.Behaviors
/// </summary>
/// <typeparam name="TRequest">The request type</typeparam>
/// <typeparam name="TResponse">The response type</typeparam>
public class ReCRequestExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
/// <remarks>
/// Initializes a new instance of ReCRequestExecutionBehavior
/// </remarks>
/// <param name="ReCClient">ReC client for HTTP requests</param>
/// <param name="options">DEX job configuration options</param>
public class ReCRequestExecutionBehavior<TRequest, TResponse>(ReCClient ReCClient, IOptions<DexJobOptions> options, ILogger<ReCRequestExecutionBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ReCClient _reCClient;
private readonly DexJobOptions _options;
/// <summary>
/// Initializes a new instance of ReCRequestExecutionBehavior
/// </summary>
/// <param name="reCClient">ReC client for HTTP requests</param>
/// <param name="options">DEX job configuration options</param>
public ReCRequestExecutionBehavior(ReCClient reCClient, IOptions<DexJobOptions> options)
{
_reCClient = reCClient;
_options = options.Value;
}
private readonly DexJobOptions Options = options.Value;
/// <summary>
/// Handles the pipeline behavior
@@ -45,7 +39,7 @@ namespace ECMJobRunner.Application.Behaviors
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
if (request is TriggeringProfileJobCommand command)
{
await SendReCRequestAsync(command, cancellationToken);
}
@@ -53,20 +47,30 @@ namespace ECMJobRunner.Application.Behaviors
return await next();
}
private async Task SendReCRequestAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
private async Task SendReCRequestAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
try
{
await _reCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
command.RecActionResult = await ReCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
{
BatchId = command.BatchId,
}, cancel);
Logger.LogInformation(
"ReC request completed successfully. Profile ID: {ProfileId} | Job name: {JobName} | Batch ID: {BatchId} | Total action count: {TotalActionCount} | Action exception count: {ActionExceptionCount}",
command.Job.ProfileId,
command.Job.Name,
command.BatchId,
command.RecActionResult?.TotalActionCount ?? 0,
command.RecActionResult?.ActionExceptionCount ?? 0);
}
catch (Exception ex)
{
if (_options.Error.ReCRequest.OnSending == ErrorAction.Stop)
if (Options.Error.ReCRequest.OnSending == ErrorAction.Stop)
{
throw new JobHttpException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "ReC Http Request",
batchId: command.BatchId,

View File

@@ -6,13 +6,13 @@ using System;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Commands
namespace ECMJobRunner.Application.Profiles.Commands
{
/// <summary>
/// Command to trigger DEX job batch for a profile
/// Executes all SQL jobs associated with a profile ID
/// </summary>
public class TriggeringDEXJobBatchCommand : IRequest<Unit>
public class TriggeringProfileJobBatchCommand : IRequest<Unit>
{
/// <summary>
/// Profile ID to trigger all associated SQL jobs
@@ -26,7 +26,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// Validates that the profile is active before execution
/// </summary>
public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender)
: IRequestHandler<TriggeringDEXJobBatchCommand, Unit>
: IRequestHandler<TriggeringProfileJobBatchCommand, Unit>
{
/// <summary>
/// Handles the TriggeringDEXJobBatchCommand
@@ -36,7 +36,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Unit value indicating completion</returns>
/// <exception cref="InactiveProfileException">Thrown when the profile is not active</exception>
public async Task<Unit> Handle(TriggeringDEXJobBatchCommand request, CancellationToken cancellationToken)
public async Task<Unit> Handle(TriggeringProfileJobBatchCommand request, CancellationToken cancellationToken)
{
var batchId = CreateBatchId();
@@ -59,7 +59,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
foreach (var job in jobs)
{
await sender.Send(new TriggeringDEXJobCommand
await sender.Send(new TriggeringProfileJobCommand
{
Job = job,
BatchId = batchId

View File

@@ -1,9 +1,13 @@
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Application.ProfileHistories.Commands;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Domain.ValueObjects;
using MediatR;
using ReC.Client.Api;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Commands
namespace ECMJobRunner.Application.Profiles.Commands
{
/// <summary>
/// Command to trigger a single DEX job execution
@@ -12,7 +16,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// 2. CheckQueryExecutionBehavior - validates with check SQL query
/// 3. ReCRequestExecutionBehavior - invokes ReC HTTP request
/// </summary>
public class TriggeringDEXJobCommand : IRequest<Unit>
public class TriggeringProfileJobCommand : IRequest<Unit>
{
/// <summary>
/// The SQL job to execute
@@ -23,6 +27,8 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// Unique batch identifier for this execution
/// </summary>
public string BatchId { get; set; } = null!;
internal BatchRecActionViewResponse? RecActionResult { get; set; }
}
/// <summary>
@@ -30,7 +36,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// All execution logic is delegated to pipeline behaviors
/// This handler simply returns completion after behaviors execute
/// </summary>
public class TriggeringDEXJobCommandHandler : IRequestHandler<TriggeringDEXJobCommand, Unit>
public class TriggeringDEXJobCommandHandler(ISender Sender) : IRequestHandler<TriggeringProfileJobCommand, Unit>
{
/// <summary>
/// Handles the TriggeringDEXJobCommand
@@ -39,13 +45,24 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// <param name="request">The command containing job and batch ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Unit value indicating completion</returns>
public Task<Unit> Handle(TriggeringDEXJobCommand request, CancellationToken cancellationToken)
public async Task<Unit> Handle(TriggeringProfileJobCommand request, CancellationToken cancellationToken)
{
// All execution logic is handled by pipeline behaviors:
// - MainQueryExecutionBehavior
// - CheckQueryExecutionBehavior
// - ReCRequestExecutionBehavior
return Task.FromResult(Unit.Value);
var cmd = new CreateProfileHistoryCommand
{
Result = ResultType.Ok,
ResultText = $"Job '{request.Job.Name}' erfolgreich abgeschlossen. | Batch-ID: {request.BatchId} | Verarbeitete Aktionen: {request.RecActionResult?.TotalActionCount ?? 0} | Fehlgeschlagene Aktionen: {request.RecActionResult?.ActionExceptionCount ?? 0}",
ProfileId = request.Job.ProfileId,
AddedWho = "ECMJobRunner"
};
await Sender.Send(cmd, cancellationToken);
return Unit.Value;
}
}
}

View File

@@ -2,12 +2,13 @@ using AutoMapper;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Domain.Interfaces;
using MediatR;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Queries
namespace ECMJobRunner.Application.Profiles.Queries
{
/// <summary>
/// Query to retrieve profiles with flexible filtering options
@@ -58,12 +59,23 @@ namespace ECMJobRunner.Application.DEXJob.Queries
private readonly ICfgProfileRepository _profileRepository;
private readonly IMapper _mapper;
/// <summary>
/// Constructor
/// </summary>
/// <param name="profileRepository">Repository for profile data access</param>
/// <param name="mapper">AutoMapper instance for entity-to-DTO mapping</param>
public GetProfileQueryHandler(ICfgProfileRepository profileRepository, IMapper mapper)
{
_profileRepository = profileRepository;
_mapper = mapper;
}
/// <summary>
/// Handles the <see cref="GetProfileQuery"/> by retrieving and mapping profiles
/// </summary>
/// <param name="request">The query containing optional filter parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of matched profiles mapped to <see cref="CfgProfileDto"/></returns>
public async Task<List<CfgProfileDto>> Handle(GetProfileQuery request, CancellationToken cancellationToken)
{
IEnumerable<Domain.Entities.CfgProfile> profiles;
@@ -112,8 +124,11 @@ namespace ECMJobRunner.Application.DEXJob.Queries
if (!string.IsNullOrWhiteSpace(request.ProfileName))
{
var searchName = request.ProfileName.ToLowerInvariant();
profiles = profiles.Where(p => p.ProfileName.ToLowerInvariant().Contains(searchName));
#if NET
profiles = profiles.Where(p => p.ProfileName.Contains(request.ProfileName, StringComparison.OrdinalIgnoreCase));
#else
profiles = profiles.Where(p => p.ProfileName.IndexOf(request.ProfileName!, StringComparison.OrdinalIgnoreCase) >= 0);
#endif
}
}

View File

@@ -1,3 +1,4 @@
using ECMJobRunner.Domain.ValueObjects;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -35,6 +36,18 @@ namespace ECMJobRunner.Domain.Entities
[Column("RESULT_ID")]
public byte ResultId { get; set; }
/// <summary>
/// Gets or sets the result type of the job execution.
/// This property is not mapped to a database column; it wraps <see cref="ResultId"/>.
/// If <see cref="ResultId"/> does not correspond to a defined <see cref="ResultType"/> value, returns <see cref="ResultType.Unknown"/>.
/// </summary>
[NotMapped]
public ResultType Result
{
get => Enum.IsDefined(typeof(ResultType), ResultId) ? (ResultType)ResultId : ResultType.Unknown;
set => ResultId = (byte)value;
}
/// <summary>
/// Result text/message
/// </summary>

View File

@@ -90,10 +90,5 @@ namespace ECMJobRunner.Domain.Interfaces
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if entity was found and deleted, false otherwise</returns>
Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Save all changes asynchronously
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -0,0 +1,27 @@
namespace ECMJobRunner.Domain.ValueObjects;
/// <summary>
/// Represents the result type of a job execution.
/// </summary>
public enum ResultType : byte
{
/// <summary>
/// The operation completed successfully.
/// </summary>
Ok = 0,
/// <summary>
/// The operation encountered an error.
/// </summary>
Error = 1,
/// <summary>
/// The operation completed with warnings.
/// </summary>
Warning = 2,
/// <summary>
/// The operation result is unknown.
/// </summary>
Unknown = 255
}

View File

@@ -31,7 +31,7 @@ namespace ECMJobRunner.Infrastructure.Repositories
/// </summary>
public async Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default)
{
return await _context.CfgProfiles
return await Context.CfgProfiles
.Include(p => p.SqlJobs)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
@@ -41,7 +41,7 @@ namespace ECMJobRunner.Infrastructure.Repositories
/// </summary>
public async Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default)
{
return await _context.CfgProfiles
return await Context.CfgProfiles
.Include(p => p.SqlJobs)
.Where(p => p.Active)
.ToListAsync(cancellationToken);

View File

@@ -20,145 +20,144 @@ namespace ECMJobRunner.Infrastructure.Repositories
/// Uses AutoMapper for DTO mapping
/// </summary>
/// <typeparam name="TEntity">Entity type</typeparam>
public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
/// <remarks>
/// Constructor
/// </remarks>
public class Repository<TEntity>(JobRunnerDbContext context, IMapper mapper) : IRepository<TEntity> where TEntity : class
{
/// <summary>
/// Database context
/// </summary>
protected readonly JobRunnerDbContext _context;
protected readonly JobRunnerDbContext Context = context ?? throw new ArgumentNullException(nameof(context));
/// <summary>
/// DbSet for the entity
/// </summary>
protected readonly DbSet<TEntity> _dbSet;
protected readonly DbSet<TEntity> DbSet = context.Set<TEntity>();
/// <summary>
/// AutoMapper instance for DTO mapping
/// </summary>
protected readonly IMapper _mapper;
/// <summary>
/// Constructor
/// </summary>
public Repository(JobRunnerDbContext context, IMapper mapper)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_dbSet = context.Set<TEntity>();
}
protected readonly IMapper Mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
/// <inheritdoc/>
public virtual async Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
#if NET48
return await _dbSet.FindAsync(cancellationToken, id);
return await DbSet.FindAsync(cancellationToken, id);
#else
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
return await DbSet.FindAsync([id], cancellationToken);
#endif
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _dbSet.ToListAsync(cancellationToken);
return await DbSet.ToListAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.Where(predicate).ToListAsync(cancellationToken);
return await DbSet.Where(predicate).ToListAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
return await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
#if NETFRAMEWORK
if (dto == null) throw new ArgumentNullException(nameof(dto));
var entity = _mapper.Map<TEntity>(dto);
#if NET48
_dbSet.Add(entity);
await Task.CompletedTask;
#else
await _dbSet.AddAsync(entity, cancellationToken);
#endif
await SaveChangesAsync(cancellationToken);
var entity = Mapper.Map<TEntity>(dto);
#if NET48
DbSet.Add(entity);
#else
await DbSet.AddAsync(entity, cancellationToken);
#endif
await Context.SaveChangesAsync(cancellationToken);
return entity;
}
/// <inheritdoc/>
public virtual async Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class
{
#if NETFRAMEWORK
if (dtos == null) throw new ArgumentNullException(nameof(dtos));
var dtoList = dtos.ToList();
if (!dtoList.Any())
return 0;
var entities = _mapper.Map<List<TEntity>>(dtoList);
#if NET48
_dbSet.AddRange(entities);
await Task.CompletedTask;
#else
await _dbSet.AddRangeAsync(entities, cancellationToken);
#endif
return await SaveChangesAsync(cancellationToken);
var dtoList = dtos.ToList();
if (dtoList.Count == 0)
return 0;
var entities = Mapper.Map<List<TEntity>>(dtoList);
#if NET48
DbSet.AddRange(entities);
#else
await DbSet.AddRangeAsync(entities, cancellationToken);
#endif
return await Context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
#if NETFRAMEWORK
if (dto == null) throw new ArgumentNullException(nameof(dto));
#endif
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
if (!entities.Any())
if (entities.Count == 0)
return 0;
foreach (var entity in entities)
{
_mapper.Map(dto, entity);
Mapper.Map(dto, entity);
}
return await SaveChangesAsync(cancellationToken);
return await Context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
#if NETFRAMEWORK
if (dto == null) throw new ArgumentNullException(nameof(dto));
#endif
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
if (entity == null)
return false;
_mapper.Map(dto, entity);
Mapper.Map(dto, entity);
await SaveChangesAsync(cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc/>
public virtual async Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
if (!entities.Any())
if (entities.Count == 0)
return 0;
_dbSet.RemoveRange(entities);
DbSet.RemoveRange(entities);
return await SaveChangesAsync(cancellationToken);
return await Context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
@@ -169,20 +168,10 @@ namespace ECMJobRunner.Infrastructure.Repositories
if (entity == null)
return false;
_dbSet.Remove(entity);
DbSet.Remove(entity);
await SaveChangesAsync(cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc/>
public virtual async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
#if NET48
return await _context.SaveChangesAsync();
#else
return await _context.SaveChangesAsync(cancellationToken);
#endif
}
}
}

View File

@@ -1,6 +1,5 @@
#if NET48
using System.Data.Entity;
using System.Linq;
#else
using Microsoft.EntityFrameworkCore;
#endif
@@ -8,6 +7,7 @@ using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
namespace ECMJobRunner.Infrastructure.Services
{
@@ -41,16 +41,16 @@ namespace ECMJobRunner.Infrastructure.Services
// Entity Framework 6 implementation
var result = await _context.Database
.SqlQuery<TResult>(sql)
.FirstOrDefaultAsync(cancellationToken);
.ToListAsync(cancellationToken);
return result;
return result.FirstOrDefault();
#else
// Entity Framework Core implementation
var result = await _context.Database
.SqlQueryRaw<TResult>(sql)
.FirstOrDefaultAsync(cancellationToken);
.ToListAsync(cancellationToken);
return result;
return result.FirstOrDefault();
#endif
}
}

View File

@@ -1,10 +1,10 @@
using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.DEXJob.Commands.Behaviors;
using ECMJobRunner.Domain.Entities;
using FluentAssertions;
using MediatR;
@@ -21,7 +21,7 @@ namespace ECMJobRunner.Tests.Application
{
private readonly Mock<ISQLExecutor> _mockExecutor;
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
private readonly CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
private readonly CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit> _behavior;
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
public CheckQueryExecutionBehaviorTests()
@@ -29,7 +29,7 @@ namespace ECMJobRunner.Tests.Application
_mockExecutor = new Mock<ISQLExecutor>();
_mockOptions = new Mock<IOptions<DexJobOptions>>();
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
_behavior = new CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_behavior = new CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
}
@@ -38,7 +38,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithValidCheckQuery_ExecutesSuccessfully()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
@@ -68,7 +68,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithPositiveReturnValue_DoesNotThrow(int returnValue)
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
@@ -96,7 +96,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithZeroOrNegativeReturnValue_ThrowsDEXJobException(int returnValue)
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
@@ -122,7 +122,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNullCheckQuery_IgnoresByDefault()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = null },
BatchId = "20260711143025123456"
@@ -157,8 +157,8 @@ namespace ECMJobRunner.Tests.Application
};
_mockOptions.Setup(o => o.Value).Returns(options);
var behavior = new CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringDEXJobCommand
var behavior = new CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = null },
BatchId = "20260711143025123456"
@@ -181,7 +181,7 @@ namespace ECMJobRunner.Tests.Application
{
// Arrange
var batchId = "20260711143025123456";
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table WHERE BatchId = '#INT#BATCH_ID'" },
BatchId = batchId
@@ -209,7 +209,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNullResult_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"

View File

@@ -1,10 +1,10 @@
using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.DEXJob.Commands.Behaviors;
using ECMJobRunner.Domain.Entities;
using FluentAssertions;
using MediatR;
@@ -21,7 +21,7 @@ namespace ECMJobRunner.Tests.Application
{
private readonly Mock<ISQLExecutor> _mockExecutor;
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
private readonly MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
private readonly MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit> _behavior;
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
public MainQueryExecutionBehaviorTests()
@@ -29,7 +29,7 @@ namespace ECMJobRunner.Tests.Application
_mockExecutor = new Mock<ISQLExecutor>();
_mockOptions = new Mock<IOptions<DexJobOptions>>();
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
_behavior = new MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_behavior = new MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
}
@@ -38,7 +38,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithValidMainQuery_ExecutesSuccessfully()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
@@ -65,7 +65,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNullReturnValue_DoesNotThrow()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
@@ -90,7 +90,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNonNullReturnValue_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
@@ -116,7 +116,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNullMainQuery_IgnoresByDefault()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = null },
BatchId = "20260711143025123456"
@@ -151,8 +151,8 @@ namespace ECMJobRunner.Tests.Application
};
_mockOptions.Setup(o => o.Value).Returns(options);
var behavior = new MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringDEXJobCommand
var behavior = new MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = null },
BatchId = "20260711143025123456"
@@ -175,7 +175,7 @@ namespace ECMJobRunner.Tests.Application
{
// Arrange
var batchId = "20260711143025123456";
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')" },
BatchId = batchId
@@ -203,7 +203,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithExecutionError_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INVALID SQL" },
BatchId = "20260711143025123456"

View File

@@ -46,7 +46,7 @@ namespace ECMJobRunner.Tests.Application
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(jobs);
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
var result = await _handler.Handle(command, CancellationToken.None);
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Tests.Application
// Assert
result.Should().Be(Unit.Value);
_mockSender.Verify(
s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()),
Times.Exactly(2),
"Should send command for each job");
}
@@ -68,7 +68,7 @@ namespace ECMJobRunner.Tests.Application
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<ProfileSqlJob>());
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
var result = await _handler.Handle(command, CancellationToken.None);
@@ -76,7 +76,7 @@ namespace ECMJobRunner.Tests.Application
// Assert
result.Should().Be(Unit.Value);
_mockSender.Verify(
s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()),
Times.Never,
"Should not send any commands when no jobs found");
}
@@ -115,13 +115,13 @@ namespace ECMJobRunner.Tests.Application
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { job });
TriggeringDEXJobCommand? capturedCommand = null;
TriggeringProfileJobCommand? capturedCommand = null;
_mockSender
.Setup(s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()))
.Callback<IRequest<Unit>, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringDEXJobCommand)
.Setup(s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()))
.Callback<IRequest<Unit>, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringProfileJobCommand)
.ReturnsAsync(Unit.Value);
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
await _handler.Handle(command, CancellationToken.None);

View File

@@ -51,7 +51,7 @@
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<!-- ReC.Client for HTTP mocking -->
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
<PackageReference Include="ReC.Client" Version="1.0.0" />
</ItemGroup>
<!-- .NET 8.0 specific packages -->

View File

@@ -9,10 +9,10 @@
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>ECMJobRunner.WebCron</Product>
<Version>1.0.0</Version>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<InformationalVersion>1.0.0</InformationalVersion>
<Version>1.1.0</Version>
<FileVersion>1.1.0.1</FileVersion>
<AssemblyVersion>1.1.0.1</AssemblyVersion>
<InformationalVersion>1.1.0</InformationalVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<PackageTags>digital data job runner</PackageTags>
<UserSecretsId>cf893b96-c71a-4a96-a6a7-40004249e1a3</UserSecretsId>

View File

@@ -1,5 +1,5 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
namespace ECMJobRunner.WebCron.Extensions;
@@ -27,10 +27,10 @@ public static class DtoExtensions
/// Converts a profile DTO to a DEX job batch command.
/// </summary>
/// <param name="profile">The profile configuration DTO.</param>
/// <returns>A <see cref="TriggeringDEXJobBatchCommand"/> ready to execute the profile job.</returns>
public static TriggeringDEXJobBatchCommand ToJob(this CfgProfileDto profile)
/// <returns>A <see cref="TriggeringProfileJobBatchCommand"/> ready to execute the profile job.</returns>
public static TriggeringProfileJobBatchCommand ToJob(this CfgProfileDto profile)
{
return new TriggeringDEXJobBatchCommand
return new TriggeringProfileJobBatchCommand
{
ProfileId = profile.Id,
};

View File

@@ -112,7 +112,7 @@ public static class HealthCheckHtmlGenerator
<h1 class=""mb-2"">");
sb.Append(icon);
sb.Append($@" Health Check Status</h1>
<p class=""text-muted mb-0"">Last checked: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</p>
<p class=""text-muted mb-0"">Last checked: {DateTime.Now:yyyy-MM-dd HH:mm:ss} UTC</p>
</div>
<div>
<h2><span class=""badge {badgeClass} fs-3"">{report.Status}</span></h2>

View File

@@ -0,0 +1,74 @@
using ECMJobRunner.Application.Common.Exceptions;
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace ECMJobRunner.WebCron.Middleware;
/// <summary>
/// Middleware for handling exceptions globally in the application.
/// Captures exceptions thrown during the request pipeline execution,
/// logs them, and returns an appropriate HTTP response with a JSON error details.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
/// </remarks>
/// <param name="Next">The next middleware in the request pipeline.</param>
/// <param name="Logger">The logger instance for logging exceptions.</param>
public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
{
/// <summary>
/// Invokes the middleware to handle the HTTP request.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InvokeAsync(HttpContext context)
{
try
{
await Next(context); // Continue down the pipeline
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex, Logger);
}
}
/// <summary>
/// Handles exceptions by logging them and writing an appropriate JSON response.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="logger">The logger instance for logging the exception.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
private static async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger)
{
context.Response.ContentType = "application/json";
ValidationProblemDetails details;
switch (exception)
{
case JobException jobEx:
logger.LogWarning(jobEx, "Job exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
details = new()
{
Title = "Job Exception",
Detail = jobEx.Message
};
break;
default:
logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
details = new()
{
Title = "Internal Server Error",
Detail = "An unexpected error occurred. Please try again later."
};
break;
}
if (details is not null)
await context.Response.WriteAsJsonAsync(details);
}
}

View File

@@ -55,7 +55,7 @@ internal class ProfileWorkerOptionsValidator : IValidateOptions<ProfileWorkerOpt
/// <param name="options">The options to validate.</param>
/// <returns>
/// <see cref="ValidateOptionsResult.Success"/> if valid,
/// or <see cref="ValidateOptionsResult.Fail"/> with error message if invalid.
/// or <see cref="ValidateOptionsResult.Fail(string)"/> with error message if invalid.
/// </returns>
/// <remarks>
/// Validation rules:

View File

@@ -1,4 +1,4 @@
using ECMJobRunner.Application.DEXJob.Queries;
using ECMJobRunner.Application.Profiles.Queries;
using ECMJobRunner.WebCron.Extensions;
using Hangfire;
using MediatR;

View File

@@ -67,7 +67,7 @@ public class ProfileWorker(
await work.ExecuteAsync(stoppingToken);
// Success - update health state
_lastSuccessfulRun = DateTime.UtcNow;
_lastSuccessfulRun = DateTime.Now;
_consecutiveFailures = 0;
_lastException = null;
@@ -151,7 +151,7 @@ public class ProfileWorker(
));
}
var timeSinceLastSuccess = DateTime.UtcNow - _lastSuccessfulRun.Value;
var timeSinceLastSuccess = DateTime.Now - _lastSuccessfulRun.Value;
// Adaptive multiplier: 3x for fast intervals (<10s), 1.5x for slower intervals
// This ensures faster problem detection when using longer intervals (e.g., 60s)

View File

@@ -2,6 +2,7 @@ using ECMJobRunner.Application;
using ECMJobRunner.Infrastructure;
using ECMJobRunner.WebCron;
using ECMJobRunner.WebCron.HealthCheck;
using ECMJobRunner.WebCron.Middleware;
using ECMJobRunner.WebCron.ProfileWorker;
using Hangfire;
using Hangfire.SqlServer;
@@ -65,7 +66,7 @@ builder.Services.AddJobRunnerInfrastructure(cnnStr);
var recClientApiUrl = builder.Configuration.GetValue<string>("ReC:ApiUrl")
?? throw new InvalidOperationException("ReC:ApiUrl not found.");
builder.Services.AddJobRunnerServices(recClientApiUrl);
builder.Services.AddJobRunnerServices(recClientApiUrl, builder.Configuration);
// Get Hangfire storage configuration
var useInMemory = builder.Configuration.GetValue<bool>("Hangfire:InMemory");
@@ -105,7 +106,7 @@ builder.Services.AddSwaggerGen();
// Add health checks
builder.Services.AddHealthChecks()
.AddCheck<ProfileWorker>("profile-worker", tags: new[] { "ready", "worker" });
.AddCheck<ProfileWorker>("profile-worker", tags: ["ready", "worker"]);
// Add Serilog.UI with SQLite provider - use same path from configuration
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
@@ -123,6 +124,8 @@ builder.Services.AddSerilogUi(logUIOpt =>
var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
@@ -171,7 +174,7 @@ app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks
var result = System.Text.Json.JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
timestamp = DateTime.UtcNow,
timestamp = DateTime.Now,
checks = report.Entries.Select(e => new
{
name = e.Key,

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<WebPublishMethod>Package</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish />
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>c60bc965-d293-ea64-b153-1941f0648df4</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\JobRunner\PreRelease\WebCron\net8\$(Version)\JobRunner.WebCron.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>JobRunner.WebCron</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"SDD-VMP04-SQL17": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
"SDD-VMP04-SQL17": "Server=SDD-VHP04-SQL19\\DD_TESTING01;Database=DD_ECM;User Id=sa;Password=123456789dD!;Encrypt=false;TrustServerCertificate=True;"
},
"AllowedHosts": "*",
"HostingOptions": {
@@ -23,5 +23,28 @@
},
"ProfileWorker": {
"IntervalMS": 60000
},
"DexJob": {
"Error": {
"MainQuery": {
"OnExecution": "Stop",
"IfNullOrWhiteSpace": "Ignore",
"OnUnexpectedResult": "Stop"
},
"CheckQuery": {
"OnExecution": "Stop",
"IfNullOrWhiteSpace": "Ignore",
"OnUnexpectedResult": "Stop"
},
"ReCRequest": {
"OnSending": "Stop"
}
},
"Placeholders": {
"BatchId": {
"Pattern": "{#INT#BATCH_ID}",
"RegexOptions": "IgnoreCase"
}
}
}
}