Compare commits

...

79 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
8327c0fd0b Update cache query parameter in HealthCheckHtmlGenerator
Modified the `HealthCheckHtmlGenerator` class to update the
query parameter name in the URLs for the CSS and JavaScript
files. Changed `?{CacheId}` to `?cache={CacheId}` for both
`health-ui.css` and `health-ui.js` to ensure consistency and
improve clarity or compatibility.
2026-07-13 14:21:42 +02:00
98f8dc8710 Add cache-busting to health check UI assets
Introduced a `CacheId` field in `HealthCheckHtmlGenerator` to append unique query strings to CSS and JS file references. This ensures updated assets are fetched by bypassing browser cache after application restarts. Removed static asset references in favor of dynamic ones.
2026-07-13 14:21:07 +02:00
5f251c1209 Reduce auto-refresh countdown from 10s to 3s
Updated the `HealthCheckHtmlGenerator` class to change the HTML auto-refresh countdown display from 10 seconds to 3 seconds. Adjusted the `countdown` variable in `health-ui.js` to initialize with 3 seconds, ensuring consistency between the frontend logic and the displayed countdown timer.
2026-07-13 14:14:53 +02:00
9537954626 Improve ProfileWorker health-check logic and initialization
Refactor the `ProfileWorker` class to enhance health-check handling, including support for an uninitialized state. The `_lastSuccessfulRun` field was changed to a nullable `DateTime?` to represent when the service has not yet completed its first successful run.

Introduce a new health state, **Degraded (Initializing)**, to indicate the service is starting up. Update the `CheckHealthAsync` method to handle this state and return a `HealthCheckResult.Degraded` with relevant metadata.

Refine health-check logic to safely access `_lastSuccessfulRun.Value` only when initialized. Update documentation to clarify health status conditions and retain adaptive multiplier logic for detecting issues based on interval length.

Enhance `HealthCheckResult` metadata with additional details, including `LastSuccessfulRun`, time since last success, and consecutive failures. Improve comments and descriptions for better clarity.
2026-07-13 14:03:49 +02:00
275b06fedb Update health-check logic with adaptive thresholds
Replaced the fixed 3x interval multiplier with an adaptive threshold
based on the configured interval duration. Health states (`Healthy`,
`Degraded`, `Unhealthy`) were redefined to reflect this change.

- Adaptive multiplier: 3x for fast intervals (<10s), 1.5x for slower
  intervals (≥10s), ensuring faster problem detection for longer
  intervals and tolerance for jitter in shorter intervals.
- Updated `CheckHealthAsync` to calculate `maxAllowedDelay` using the
  adaptive multiplier.
- Added `Multiplier` and `MaxAllowedDelay` to diagnostic data for
  better monitoring and debugging.
- Improved documentation and comments to explain the new logic.
2026-07-13 13:54:58 +02:00
bc881bf70f Add XML documentation for improved code clarity
Added detailed XML documentation across multiple files to enhance
code maintainability and readability. Key updates include:

- Documented `AllowAllDashboardAuthorizationFilter` to clarify
  its development-only usage.
- Added comments to `DtoExtensions` for Hangfire job ID generation
  and DTO-to-command conversion methods.
- Enhanced `HealthCheckHtmlGenerator` with detailed descriptions
  of HTML generation methods and utility functions.
- Documented `DependencyInjection` and `ProfileWorkerOptionsValidator`
  to explain service registration and configuration validation.
- Updated `ProfileCache` with comments on thread-safe operations.
- Added documentation to `ProfileWork` for profile synchronization
  logic and execution flow.
- Enhanced `ProfileWorker` with health check logic and background
  service execution details.
- Documented `ProfileWorkerOptions` configuration properties.

These changes aim to improve developer understanding and ensure
best practices are followed in production environments.
2026-07-13 13:37:12 +02:00
84b95c53e4 Update ProfileWorker interval in appsettings.json
Increased the `IntervalMS` value in the `ProfileWorker` section of `appsettings.json` from 1000ms (1 second) to 60000ms (60 seconds). This change reduces the execution frequency of the `ProfileWorker` process, likely to optimize performance or reduce resource usage.
2026-07-13 13:25:57 +02:00
511fb159c4 Update Hangfire Dashboard Authorization syntax
Updated the `Authorization` property in `app.UseHangfireDashboard`
to use modern C# collection initialization syntax (square brackets)
instead of the older array initialization syntax (curly braces).
This change ensures consistency with modern C# conventions.
2026-07-13 13:23:07 +02:00
4af0ed7d14 Update project metadata and enable documentation file
Replaced `<TargetFramework>` with `<TargetFrameworks>` for future multi-targeting support. Added `<GenerateDocumentationFile>` to enable XML documentation generation. Included metadata properties such as `<PackageId>`, `<Authors>`, `<Company>`, and versioning details to enhance package identity. Added `<PackageTags>` for discoverability and `<UserSecretsId>` for secure development secrets management.
2026-07-13 13:19:42 +02:00
0fd6a97968 Refactor health check HTML generation logic
Centralized health check HTML generation into a new static
`HealthCheckHtmlGenerator` class to improve modularity and
maintainability. This class encapsulates the logic for generating
HTML, including navigation, overall status, summary cards,
individual checks, and refresh information.

Replaced the inline `GenerateHealthCheckHtml` method in `Program.cs`
with the new `HealthCheckHtmlGenerator.Generate` method, removing
redundant code and improving separation of concerns. Updated
`Program.cs` to include the necessary namespace.

Simplified the `/health-ui` endpoint to use the new utility class,
reducing code duplication and improving readability.
2026-07-13 13:10:55 +02:00
cec9cfe2ed Refactor Health UI and enable static file support
- Added `app.UseStaticFiles()` to serve static files from `wwwroot`.
- Changed root path redirection to `/health-ui`.
- Moved inline CSS/JS from `Program.cs` to `health-ui.css` and `health-ui.js`.
- Updated navigation links in the health check HTML.
- Enhanced Health UI with improved styles and animations.
- Introduced countdown auto-refresh in `health-ui.js`.
- Updated `launchSettings.json` to start at the root URL.
2026-07-13 13:08:22 +02:00
eb2514f1fc Add health check UI and Serilog dashboard integration
Introduced a `/health-ui` route with a custom HTML-based health check UI, leveraging `HealthCheckService`. Added `GenerateHealthCheckHtml` to dynamically render health check results with Bootstrap styling, auto-refresh, and detailed status reporting.

Enabled Serilog self-diagnostics and integrated `app.UseSerilogUi()` for a Serilog log dashboard. Enhanced user experience with navigation links, animations, and responsive design. Improved observability and monitoring capabilities.
2026-07-13 13:02:11 +02:00
8a05d86285 feat: Add health check monitoring for ProfileWorker
ProfileWorker changes:
- Implement IHealthCheck interface
- Replace _workCount with DateTime-based tracking
- Track _lastSuccessfulRun, _consecutiveFailures, _lastException
- Graceful shutdown handling (OperationCanceledException)
- Health states: Healthy, Degraded (1-2 failures), Unhealthy (3x interval)

DependencyInjection changes:
- Register ProfileWorker as singleton (for health check access)
- Use factory pattern for IHostedService registration

Program.cs changes:
- Add health check service with ProfileWorker
- Map /health endpoint (full JSON response with all checks)
- Map /health/ready endpoint (filtered by 'ready' tag)
- Custom JSON response writer with detailed metrics
2026-07-13 12:19:11 +02:00
b48e3d1823 config: Add ProfileWorker configuration section
- ProfileWorker:IntervalMS = 1000 (1 second sync interval)
- Configurable via appsettings.json with IOptions pattern
- Validated at startup via ProfileWorkerOptionsValidator
2026-07-13 11:59:59 +02:00
41b08e3454 refactor: Use AddProfileWorker extension method in Program.cs
- Replace direct AddHostedService<ProfileManager> with AddProfileWorker()
- Enables IOptions configuration and validation at startup
- Add using directive for ECMJobRunner.WebCron.ProfileWorker namespace
2026-07-13 11:59:52 +02:00
2fd99694b6 refactor: Remove monolithic ProfileManager
Replaced by modular ProfileWorker namespace with better separation of concerns.
2026-07-13 11:59:44 +02:00
66fad12e63 feat: Add ProfileWork with Hangfire job synchronization
- ProfileWork.cs: Business logic for DB-to-Hangfire sync
- Fetches active profiles from database via MediatR
- Registers/updates Hangfire recurring jobs with local timezone
- Removes stale jobs (deleted from DB)
- O(n) performance optimization using HashSet for lookups
- Uses ProfileWorker's stopping token for graceful shutdown

Job lifecycle:
- ProfileWorker stops → All running jobs cancelled via token closure
- Jobs use RecurringJobOptions with TimeZoneInfo.Local
- Automatic retry support via Hangfire [AutomaticRetry] attribute
2026-07-13 11:59:38 +02:00
f67c321380 refactor: Extract ProfileWorker into modular components
- Create ProfileWorker namespace with 4 separate components
- ProfileWorker.cs: BackgroundService orchestrator with IOptions support
- ProfileWorkerOptions.cs: Configuration model with validation
- ProfileCache.cs: Thread-safe cache using composition pattern
- DependencyInjection.cs: Service registration with IValidateOptions

Benefits:
- Separation of concerns (orchestration, config, state, DI)
- IOptions pattern for appsettings.json configuration
- Startup validation for configuration errors
- Better testability and maintainability
2026-07-13 11:59:23 +02:00
cba1aa85d0 feat: Add root path redirect to Hangfire dashboard
- Map root path (/) to redirect to /hangfire dashboard
- Improves user experience by providing direct access to main dashboard
- Users accessing the application root will be automatically redirected to Hangfire
2026-07-13 09:51:53 +02:00
def4d7b7c7 refactor: Move log directory configuration to Application section
- Move LogDirectory from Logging section to Application section in appsettings.json
- Update Program.cs to read from Application:LogDirectory instead of Logging:LogDirectory
- Fix JSON schema validation warning for non-standard Logging properties
- Application section now holds custom application-specific settings
- Standard Logging and Serilog sections remain clean and schema-compliant
2026-07-13 09:51:01 +02:00
d61d14145a feat: Add Serilog SQLite sink and Serilog.UI dashboard with centralized log directory configuration
- Add Serilog.Sinks.SQLite v7.0.0 for structured logging to SQLite database
- Add Serilog.UI v3.2.0 and Serilog.UI.SqliteProvider v1.1.0 for web-based log viewer
- Add Serilog.Settings.Configuration v10.0.1 for configuration support
- Configure SQLite log storage at E:\LogFiles\Digital Data\ECMJobRunner.WebCron\logs.db
- Add Serilog.UI dashboard at /serilog-ui endpoint
- Centralize log directory configuration in appsettings.json (Logging:LogDirectory)
- Configure Serilog programmatically with Console and SQLite sinks
- Remove deprecated Serilog configuration from appsettings files (now using code-based config)
- Enable Serilog self-diagnostics for troubleshooting
- Both Serilog sink and Serilog.UI use same SQLite database path from configuration
2026-07-12 17:33:19 +02:00
8b0f97c330 feat: Add Hangfire cronjob system with web dashboard, Windows Service support, and Serilog file/console logging
- Add Hangfire packages (AspNetCore, Core, InMemory, SqlServer) with configurable storage (InMemory vs SQL Server)
- Configure Hangfire dashboard at /hangfire with AllowAllDashboardAuthorizationFilter (no auth for development)
- Add Microsoft.Extensions.Hosting.WindowsServices package with conditional UseWindowsService() based on HostingOptions:UseWindowsService config
- Create ProfileManager BackgroundService with IServiceScopeFactory for scoped service resolution per iteration
- Create AllowAllDashboardAuthorizationFilter for Hangfire dashboard access
- Create DtoExtensions with JobId() and ToJob() helper methods
- Configure Serilog with file sink (Production: Logs/log-.txt, daily rolling, 30 day retention) and console sink (Development)
- Add Serilog enrichers: FromLogContext, WithMachineName, WithThreadId
- Update appsettings.json with Hangfire:InMemory flag, HostingOptions:UseWindowsService flag, and Serilog configuration
- Create appsettings.Development.json with console-specific Serilog configuration
2026-07-12 04:49:22 +02:00
d50232b0bb feat: Add Windows Service hosting support
- Add Microsoft.Extensions.Hosting.WindowsServices package
- Add HostingOptions:UseWindowsService configuration flag to appsettings.json
- Configure Program.cs to optionally run as Windows Service based on config
- IIS hosting continues to work by default (UseWindowsService=false)
- Delete obsolete Worker.cs that was conflicting with ProfileManager
2026-07-12 00:23:59 +02:00
59bef13f5d fix: Resolve scoped service dependency issue in ProfileManager
- Replace IMediator constructor injection with IServiceScopeFactory
- Create new scope in ExecuteAsync loop to resolve scoped services (ISQLExecutor)
- Fixes: Cannot resolve scoped service from root provider error
- Enables ProfileManager to properly execute MediatR queries with scoped dependencies
2026-07-12 00:22:33 +02:00
475679c6e2 feat: Update Infrastructure DependencyInjection
- Update Infrastructure layer service registrations for Hangfire compatibility
2026-07-12 00:04:51 +02:00
3a1e16a7a0 feat: Update Application layer for Hangfire integration
- Add CronSchedule property to CfgProfileDto for scheduling support
- Create ProfileSqlJobDto for SQL job execution data transfer
- Update TriggeringDEXJobBatchCommand to accept ProfileId and Jobs collection
- Make TriggeringDEXJobBatchCommand public for Hangfire job activation
- Update Application DependencyInjection with required service registrations
2026-07-12 00:04:40 +02:00
7efc77ca51 feat: Add AllowAllDashboardAuthorizationFilter for development
- Create authorization filter to bypass Hangfire dashboard authentication
- Allow unrestricted access to /hangfire dashboard for local development
- Note: Should be replaced with proper authentication in production
2026-07-12 00:04:25 +02:00
ad2ab741ff feat: Add ProfileManager for automatic Hangfire job scheduling
- Create ProfileManager background service that polls database every 1 second
- Query active profiles with cron schedules via MediatR GetProfileQuery
- Auto-create/update Hangfire recurring jobs using IRecurringJobManager
- Jobs execute TriggeringDEXJobBatchCommand via IMediator dependency injection
- Add profile caching with schedule change detection to avoid redundant updates
- Create DtoExtensions with JobId() and ToJob() helper methods for profile-to-command conversion
2026-07-12 00:04:13 +02:00
eb7adc741a feat: Add Hangfire infrastructure with dynamic storage configuration
- Install Hangfire packages (Core, AspNetCore, SqlServer, InMemory)
- Configure Hangfire in Program.cs with boolean InMemory flag from appsettings
- Add Hangfire dashboard at /hangfire route
- Update appsettings.json with Hangfire:InMemory configuration
- Update launchSettings.json with new launch URL
- Remove obsolete Worker.cs background service
2026-07-12 00:04:00 +02:00
8255f1f6aa feat(application): add inactive profile validation with custom exception
- Create InactiveProfileException for profile active status validation
- Update TriggeringDEXJobBatchCommand to check profile active status before execution
- Add ICfgProfileRepository dependency to batch command handler
- Throw InactiveProfileException when attempting to execute inactive profiles
- Add profile not found validation with descriptive error message
- Reorganize commands under DEXJob/Commands directory structure
2026-07-11 19:16:35 +02:00
eaf24e05ee refactor(application): consolidate query architecture with AutoMapper integration
- Add AutoMapper profile for CfgProfile and ProfileSqlJob entity-to-DTO mappings
- Consolidate GetProfileByIdQuery and GetAllActiveProfilesQuery into unified GetProfileQuery
- Implement flexible filtering with nullable query options (Id, Active, TypeId, ProfileName)
- Add IncludeSqlJobs option for optimized SQL job loading
- Move CfgProfileDto to Common/Dtos for better architecture alignment
- Add comprehensive unit tests for GetProfileQuery with multiple filter scenarios
- Move ISQLExecutor interface from Domain to Application layer
- Add GetByIdWithSqlJobsAsync and GetAllActiveWithSqlJobsAsync to repository
2026-07-11 19:14:51 +02:00
3f924b75b0 chore(application): remove old ISQLExecutor interface location
- Delete ECMJobRunner.Application/Common/Interfaces/ISQLExecutor.cs
- Interface moved to ECMJobRunner.Domain/Interfaces/ (committed in 26ea1db)
2026-07-11 16:45:15 +02:00
52c0614975 test(application): add comprehensive unit tests for DEX job pipeline
- Add TriggeringDEXJobBatchCommandTests
  - Test batch command execution with multiple profiles
  - Verify batch ID generation and propagation
  - Mock ISQLExecutor and IRecClient dependencies
- Add MainQueryExecutionBehaviorTests
  - Test main query execution success path
  - Test SQL exception handling (JobSqlException)
  - Verify MainQueryResults population
- Add CheckQueryExecutionBehaviorTests
  - Test check query validation (ErrorAction.SkipInsert)
  - Test skip behavior for zero results
  - Test SQL exception handling
- All tests pass on both net480 and net8.0 frameworks
- Update ECM.JobRunner.sln with test project reference
- Upgrade Microsoft.Extensions.DependencyInjection to 8.0.1 (ReC.Client requirement)
2026-07-11 16:44:53 +02:00
5d2f128cc7 feat(application): add dependency injection configuration & documentation
- Implement DependencyInjection.cs with AddApplication() extension
  - Register MediatR with assembly scanning
  - Register pipeline behaviors in execution order:
    1. CheckQueryExecutionBehavior
    2. MainQueryExecutionBehavior
    3. ReCRequestExecutionBehavior
  - Conditional package versions (MediatR 9.0.0/12.4.1)
- Add comprehensive README.md
  - Architecture overview & CQRS pipeline flow
  - Configuration guide (DexJobOptions, ErrorAction)
  - DI setup examples (AddApplication, AddInfrastructure)
  - Exception handling patterns
  - Multi-targeting notes & troubleshooting
- Update project file with MediatR.Extensions.DependencyInjection
2026-07-11 16:44:39 +02:00
63a16410ea feat(application): implement MediatR pipeline behaviors for DEX job stages
- Add MainQueryExecutionBehavior
  - Execute main SQL query via ISQLExecutor
  - Populate command.MainQueryResults
  - Throw JobSqlException on failure
- Add CheckQueryExecutionBehavior
  - Execute check SQL query via ISQLExecutor
  - Validate ErrorAction.SkipInsert for zero results
  - Throw JobSqlException on failure/validation error
- Add ReCRequestExecutionBehavior
  - Execute ReC API requests for each main query result
  - Batch ID tracking, error handling
  - Throw JobHttpException on API failures
- Conditional compilation for MediatR signature differences
  - net480: Handle(request, cancellationToken, next)
  - net8.0: Handle(request, next, cancellationToken)
- Refactor TriggeringDEXJobCommand handler: delegate all logic to behaviors
2026-07-11 16:44:25 +02:00
222a5e24bf refactor(application): redesign exception hierarchy with flexible base class
- Replace DEXJobException with JobException base class
  - Flexible params-based detail collection
  - Automatic message formatting with visual separators
  - Optional null-handling for contextual details
- Add JobHttpException for HTTP client failures
  - Properties: ClientLibrary, ClientMethod
  - Use case: ReC API, REST requests
- Add JobSqlException for SQL query failures
  - Property: Query (virtual for customization)
  - Use case: Main/Check query execution
- Comprehensive XML documentation for all exception classes
2026-07-11 16:44:10 +02:00
26ea1db09f feat(infrastructure): implement ISQLExecutor with EF6/EF Core support
- Move ISQLExecutor interface from Application to Domain layer
  - Follow Clean Architecture: Infrastructure references Domain, not Application
  - Namespace: ECMJobRunner.Domain.Interfaces
- Implement SQLExecutor service with conditional compilation
  - EF6 (net480): Database.SqlQuery<T>()
  - EF Core (net8.0): Database.SqlQueryRaw<T>()
- Register ISQLExecutor in DI (AddInfrastructure, AddInfrastructureInMemory)
  - Scoped lifetime (aligns with DbContext)
2026-07-11 16:43:57 +02:00
4767bcfbe1 Add .NET 8.0 web app with minimal API and background worker
Introduced a new .NET 8.0 web application project with a minimal API setup.
Configured Swagger for API documentation and added a `Worker` class as a
background service for periodic tasks.

Added `launchSettings.json` to define development profiles for HTTP, HTTPS,
and IIS Express. Configured logging levels in `appsettings.json` and
`appsettings.Development.json`.

Included `Swashbuckle.AspNetCore` package for Swagger integration and
configured the HTTP request pipeline to support development and production
environments.
2026-07-11 12:56:58 +02:00
2af17ec870 feat: implement DEX job triggering commands with CQRS pattern
TriggeringDEXJobBatchCommand:
- Orchestrates batch job execution for a profile
- Creates unique 20-character timestamp-based batch ID
- Executes all SQL jobs sequentially for given profile ID
- Uses MediatR ISender to trigger individual job commands

TriggeringDEXJobCommand:
- Executes single DEX job with three-stage pipeline:
  1. Main Query: executes primary SQL with batch ID placeholder replacement
  2. Check Query: validates execution with return value check (> 0)
  3. ReC Request: invokes ReC API with batch ID reference
- Configurable error handling per stage via DexJobOptions
- Regex-based placeholder replacement for dynamic batch ID injection
- Returns Unit for void-like MediatR command pattern

Both commands include:
- Comprehensive XML documentation
- Primary constructor injection (modern C# syntax)
- Proper async/await with CancellationToken support
- Integration with ISQLExecutor and ReCClient abstractions
2026-07-11 12:38:42 +02:00
8a1a579501 feat: add DEX job exception handling and configuration
Exceptions:
- DEXJobException: custom exception with formatted error messages
  * Includes query name, batch ID, SQL query, and detailed error info
  * Two constructors: with inner exception or reason message
  * Comprehensive XML documentation

Options:
- DexJobOptions: hierarchical configuration for DEX job execution
  * Error handling per stage (MainQuery, CheckQuery, ReCRequest)
  * Granular error actions (OnExecution, IfNullOrWhiteSpace, OnUnexpectedResult)
  * Placeholder configuration with regex pattern support
  * Default BatchId placeholder: #INT#BATCH_ID (case-insensitive)
  * Fully documented with XML comments
2026-07-11 12:38:31 +02:00
7e267b427e feat: add DEX job common infrastructure
Add foundational types for DEX job execution:

Constants:
- ErrorAction enum (Ignore, Stop) for error handling strategy

DTOs:
- CheckQueryResult: check query execution result with ReturnValue
- MainQueryResult: main query execution result with ReturnValue

Interfaces:
- ISQLExecutor: abstraction for SQL query execution and DTO mapping

All types include comprehensive XML documentation
2026-07-11 12:38:21 +02:00
30a2a4ce3b build: add ReC.Client package reference
- Add ReC.Client v2.0.0-beta for DEX job HTTP request integration
2026-07-11 12:38:13 +02:00
46ad02f624 refactor: remove legacy projects from solution
- Remove ECM.JobRunner.Web project
- Remove ECM.JobRunner.Common (VB.NET) project
- Remove ECM.JobRunner.Windows (VB.NET) project
- Clean Architecture implementation focus
2026-07-11 12:38:06 +02:00
ee3af3e462 Add test infrastructure and repository tests
Introduced `CfgProfileDto` for testing and added a `FakeDataGenerator` utility for generating test data. Updated `ECMJobRunner.Tests.csproj` to support both `.NET Framework 4.8` and `.NET 8.0`, enabling nullable reference types and adding dependencies for testing frameworks, DI, and AutoMapper.

Added `TestFixture` to set up dependency injection and in-memory databases for tests. Created `TestMappingProfile` for AutoMapper configurations. Implemented `CfgProfileRepositoryTests` to validate repository methods, including `GetByIdAsync`, `GetAllAsync`, `AddAsync`, and `FindAsync`.

Enhanced test maintainability with `FluentAssertions` and realistic data generation using `Bogus`.
2026-07-09 16:09:19 +02:00
bd6c1c1309 Update AutoMapper versions and suppress NU1903 warning
- Suppressed AutoMapper vulnerability warning (NU1903) as acceptable.
- Updated comments for AutoMapper references to clarify alignment with Infrastructure.
- Added `AutoMapper.Extensions.Microsoft.DependencyInjection`:
  - Version 8.1.1 for .NET Framework 4.8.
  - Version 12.0.1 for .NET 8.
- Downgraded AutoMapper for .NET 8 from 13.0.1 to 12.0.1.
2026-07-09 15:58:52 +02:00
9bae0ab95c Refactor Repository and update project dependencies
Added `SaveChangesAsync` to `IRepository` for async persistence.
Introduced AutoMapper and DbContext dependencies in `Repository`.
Simplified EF Core operations by removing `#if NET48` logic.
Replaced `Task.Run` with direct EF Core calls for .NET Framework.
Updated AutoMapper version for .NET 8 and added EF Core testing.
Suppressed AutoMapper vulnerability warning in project file.
Performed general cleanup and improved maintainability.
2026-07-09 15:58:33 +02:00
574e5ed209 Add in-memory DB support for .NET 4.8 and .NET 8 testing
Introduced a `DbConnection` constructor in `JobRunnerDbContext`
for Entity Framework 6 to enable in-memory testing with the
Effort library. Added conditional compilation to support both
.NET Framework 4.8 and .NET 8.

Implemented `AddInfrastructureInMemory` in `DependencyExtension`
to register services with in-memory databases:
- For .NET 4.8, uses Effort's transient connection.
- For .NET 8, uses EF Core's in-memory provider.

Registered AutoMapper in both methods for object mapping. Added
`EntityMappingProfile` to define AutoMapper configurations,
enforcing explicit DTO-to-entity mappings. These changes improve
testability and maintainability across .NET versions.
2026-07-09 15:45:18 +02:00
5eca5f1d4b Remove SaveChangesAsync from IRepository interface
The `SaveChangesAsync` method was removed from the `IRepository`
interface in the `ECMJobRunner.Domain.Interfaces` namespace.
This method previously allowed saving all changes asynchronously
with an optional `CancellationToken` parameter.
2026-07-09 15:44:40 +02:00
65a51482dd Add ECMJobRunner.Tests and enhance DI for multi-frameworks
Added a new `ECMJobRunner.Tests` project to the solution for unit
testing, including build configurations for Debug and Release.

Enhanced the `DependencyExtension` class to support dependency
injection for both .NET Framework 4.8 (EF6) and .NET 8 (EF Core)
using conditional compilation. Registered `JobRunnerDbContext`
differently based on the target framework and added AutoMapper
registration for both frameworks.

Updated `ECMJobRunner.Infrastructure.csproj` to include new
package references for DI and AutoMapper in .NET Framework 4.8.
Improved code structure and readability by removing redundant
directives and aligning DI patterns across frameworks.
2026-07-09 13:48:55 +02:00
eb9c5c323a Update AGENTS.md and csproj for ECMJobRunner.Application
Added detailed documentation to AGENTS.md, including project overview, architecture, components, and usage examples for the ECMJobRunner.Application layer. Documented target frameworks (.NET Framework 4.8 and .NET 8.0) and their dependencies (AutoMapper and MediatR).

Updated ECMJobRunner.Application.csproj:
- Changed `<Product>` tag to reflect the correct project name.
- Added a project reference to ECMJobRunner.Domain.
- Introduced conditional NuGet package references for framework-specific dependencies.
2026-07-09 13:35:24 +02:00
7d53b599de Add Infrastructure layer with EF6/EF Core support
Introduced a robust Infrastructure layer for the ECMJobRunner system:
- Added `AGENTS.md` with detailed project documentation.
- Implemented generic repository and unit-of-work patterns.
- Added `CfgProfileRepository`, `ProfileSqlJobRepository`, and `ProfileHistoryRepository`.
- Integrated AutoMapper for DTO-to-entity mapping.
- Added multi-framework support for .NET Framework 4.8 (EF6) and .NET 8.0 (EF Core) using conditional compilation.
- Updated `ECMJobRunner.Infrastructure.csproj` with metadata fixes and dependencies.
- Introduced dependency injection extension for .NET 8.0.
- Enhanced project structure and database context with entity mappings.
2026-07-09 13:35:02 +02:00
ff6faa1e5b Add JobRunnerDbContext with multi-framework support
Introduce `JobRunnerDbContext` to support both EF6 (.NET 4.8)
and EF Core (.NET 8) using conditional compilation. Add `DbSet`
properties for `CfgProfiles`, `ProfileSqlJobs`, and
`ProfileHistories`. Provide constructors for both frameworks
to handle connection strings or options. Ensure compatibility
with multiple runtime environments.
2026-07-09 13:34:03 +02:00
3e007ccfeb Refactor domain layer to follow Clean Architecture
Updated the domain layer to align with Clean Architecture principles:
- Removed Entity Framework dependencies from the project.
- Updated AGENTS.md to document the new architecture.
- Introduced repository interfaces for data access abstraction.
- Added a generic IRepository interface for CRUD operations.
- Implemented entity-specific repositories for Profile, ProfileSqlJob, and ProfileHistory.
- Ensured the domain layer is infrastructure-independent with pure POCOs.
- Updated project structure and documentation for clarity.
2026-07-09 13:33:39 +02:00
ce47653831 Refactor Profile entity and update property constraints
Renamed `Profile` to `CfgProfile` across the codebase for clarity and consistency. Updated property constraints in `CfgProfile.cs`, `ProfileHistory.cs`, and `ProfileSqlJob.cs` to include maximum length validations. Changed navigation properties in `CfgProfile.cs` to use `IEnumerable` and made them nullable. Updated `ForeignKey` attributes in `ProfileHistory.cs` and `ProfileSqlJob.cs` to reference `CfgProfile`. Improved property descriptions for better documentation.
2026-07-09 13:23:46 +02:00
be8e58df84 Add ECMJobRunner.Domain entities and documentation
Introduced the `ECMJobRunner.Domain` project to support both
.NET Framework 4.8 and .NET 8.0. Added `Profile`,
`ProfileSqlJob`, and `ProfileHistory` entities to map to the
database schema, including relationships and audit fields.

Updated `ECMJobRunner.Domain.csproj` to enable multi-targeting
and added dependencies for Entity Framework 6.5.1 and EF Core
8.0.11. Enabled nullable reference types and set the language
version to `latest`.

Added comprehensive documentation in `AGENTS.md` detailing the
project structure, entities, database schema, and build
instructions.
2026-07-09 12:45:09 +02:00
26dc58d82e Add Application and Infrastructure projects to solution
Two new projects, `ECMJobRunner.Application` and
`ECMJobRunner.Infrastructure`, were added to the solution file
(`ECM.JobRunner.sln`). Build configurations for these projects
were also added.

`ECMJobRunner.Application.csproj` targets both .NET Framework 4.8
and .NET 8.0, with properties such as `LangVersion`, `Nullable`,
and `PackageId` defined.

`ECMJobRunner.Infrastructure.csproj` also targets .NET Framework
4.8 and .NET 8.0. It includes conditional dependencies:
- For `net480`, it references `EntityFramework` 6.5.1.
- For `net8.0`, it references `Microsoft.EntityFrameworkCore`
  and related packages (version 8.0.11).
2026-07-09 11:48:10 +02:00
19c415da01 Add ECMJobRunner.Domain project to solution
A new project, `ECMJobRunner.Domain`, has been added to the solution file (`ECM.JobRunner.sln`). The project targets both .NET Framework 4.8 (`net480`) and .NET 8.0 (`net8.0`) and includes metadata for NuGet packaging, such as `PackageId`, `Authors`, and `RepositoryUrl`.

Build configurations for `Debug|Any CPU` and `Release|Any CPU` have been added to the solution configuration. The project file (`ECMJobRunner.Domain.csproj`) also specifies the generation of XML documentation files.
2026-07-09 10:52:57 +02:00
74 changed files with 6368 additions and 15 deletions

View File

@@ -3,11 +3,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.4.33103.184
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ECM.JobRunner.Web", "ECM.JobRunner.Web\ECM.JobRunner.Web.csproj", "{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Domain", "ECMJobRunner.Domain\ECMJobRunner.Domain.csproj", "{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "ECM.JobRunner.Common", "ECM.JobRunner.Common\ECM.JobRunner.Common.vbproj", "{7EACF04E-525B-40C8-9207-B83FB847B090}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Infrastructure", "ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj", "{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "ECM.JobRunner.Windows", "ECM.JobRunner.Windows\ECM.JobRunner.Windows.vbproj", "{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Application", "ECMJobRunner.Application\ECMJobRunner.Application.csproj", "{D97CD489-10D7-432C-9921-196F2E0505FF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Tests", "ECMJobRunner.Tests\ECMJobRunner.Tests.csproj", "{F64352B6-32BB-4BDE-90FD-FB77482D44E0}"
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
@@ -15,22 +23,37 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Release|Any CPU.Build.0 = Release|Any CPU
{7EACF04E-525B-40C8-9207-B83FB847B090}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EACF04E-525B-40C8-9207-B83FB847B090}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EACF04E-525B-40C8-9207-B83FB847B090}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EACF04E-525B-40C8-9207-B83FB847B090}.Release|Any CPU.Build.0 = Release|Any CPU
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Release|Any CPU.Build.0 = Release|Any CPU
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Release|Any CPU.Build.0 = Release|Any CPU
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Release|Any CPU.Build.0 = Release|Any CPU
{D97CD489-10D7-432C-9921-196F2E0505FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D97CD489-10D7-432C-9921-196F2E0505FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D97CD489-10D7-432C-9921-196F2E0505FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D97CD489-10D7-432C-9921-196F2E0505FF}.Release|Any CPU.Build.0 = Release|Any CPU
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Release|Any CPU.Build.0 = Release|Any CPU
{C60BC965-D293-EA64-B153-1941F0648DF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C60BC965-D293-EA64-B153-1941F0648DF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C60BC965-D293-EA64-B153-1941F0648DF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C60BC965-D293-EA64-B153-1941F0648DF4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
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

@@ -0,0 +1,186 @@
# ECMJobRunner.Application
## Project Overview
ECMJobRunner.Application is the **application layer** for the ECM Job Runner system following **Clean Architecture** principles. This project contains:
- **Application services** and business logic
- **DTOs (Data Transfer Objects)** for external communication
- **AutoMapper profiles** for entity-DTO mapping
- **MediatR handlers** for CQRS pattern (Commands/Queries)
- **Validators** for business rules
**Key Principle**: This layer orchestrates application workflows, coordinates domain logic, and implements use cases.
## Target Frameworks
- **.NET Framework 4.8** (`net480`)
- AutoMapper 10.1.1
- MediatR 9.0.0
- **.NET 8.0** (`net8.0`)
- AutoMapper 13.0.1
- MediatR 12.4.1
The project uses conditional NuGet package references to support both frameworks.
## Architecture
This project follows **Clean Architecture** principles:
- **Depends on Domain layer** (uses entities and repository interfaces)
- **Independent of Infrastructure** (uses dependency injection for repositories)
- **CQRS pattern** via MediatR (Commands and Queries)
- **AutoMapper** for DTO mapping
- **Validation** for business rules
## Project Structure
```
ECMJobRunner.Application/
├── DTOs/ # Data Transfer Objects (to be created)
├── MappingProfiles/ # AutoMapper profiles (to be created)
├── Commands/ # MediatR command handlers (to be created)
├── Queries/ # MediatR query handlers (to be created)
├── Validators/ # Business rule validators (to be created)
├── Services/ # Application services (to be created)
├── ECMJobRunner.Application.csproj
└── AGENTS.md # This file
```
## Planned Components
### DTOs (Data Transfer Objects)
Will contain data transfer objects for external communication:
- `ProfileDto` - Profile data transfer object
- `ProfileSqlJobDto` - SQL job data transfer object
- `ProfileHistoryDto` - History data transfer object
### AutoMapper Profiles
Will contain mapping configurations:
- `ProfileMappingProfile` - Maps between entities and DTOs
### MediatR Handlers
**Commands** (write operations):
- `CreateProfileCommand` / `CreateProfileCommandHandler`
- `UpdateProfileCommand` / `UpdateProfileCommandHandler`
- `DeleteProfileCommand` / `DeleteProfileCommandHandler`
**Queries** (read operations):
- `GetProfileByIdQuery` / `GetProfileByIdQueryHandler`
- `GetAllProfilesQuery` / `GetAllProfilesQueryHandler`
- `GetActiveProfilesQuery` / `GetActiveProfilesQueryHandler`
### Validators
Will contain business rule validation:
- `CreateProfileCommandValidator`
- `UpdateProfileCommandValidator`
### Services
Application services orchestrating business workflows:
- `ProfileService` - Profile management service
- `JobExecutionService` - Job execution orchestration
## Dependencies
### .NET Framework 4.8 (`net480`)
- **ECMJobRunner.Domain** (project reference)
- **AutoMapper 10.1.1** (NuGet package)
- **MediatR 9.0.0** (NuGet package)
### .NET 8.0 (`net8.0`)
- **ECMJobRunner.Domain** (project reference)
- **AutoMapper 13.0.1** (NuGet package)
- **MediatR 12.4.1** (NuGet package)
## CQRS Pattern with MediatR
The application uses the **CQRS (Command Query Responsibility Segregation)** pattern:
- **Commands**: Modify state (Create, Update, Delete)
- **Queries**: Read state (Get, List, Find)
**Benefits:**
- Clear separation of read/write operations
- Easier to test and maintain
- Better scalability
- Loose coupling
## AutoMapper Configuration
AutoMapper is used to map between domain entities and DTOs:
**Example mapping:**
```csharp
public class ProfileMappingProfile : Profile
{
public ProfileMappingProfile()
{
CreateMap<Profile, ProfileDto>();
CreateMap<CreateProfileCommand, Profile>();
}
}
```
## Building the Project
```bash
dotnet build ECMJobRunner.Application.csproj
```
For specific framework:
```bash
dotnet build ECMJobRunner.Application.csproj -f net8.0
dotnet build ECMJobRunner.Application.csproj -f net480
```
## Known Warnings
AutoMapper versions have known security vulnerabilities (NU1903):
- AutoMapper 10.1.1 (for .NET Framework 4.8)
- AutoMapper 13.0.1 (for .NET 8.0)
**Note**: These are the highest compatible versions for the respective frameworks. The vulnerabilities are related to expression compilation and should be evaluated based on your security requirements.
## Usage Example
```csharp
// Using MediatR to get a profile
var query = new GetProfileByIdQuery { Id = 123 };
var profileDto = await mediator.Send(query);
// Using MediatR to create a profile
var command = new CreateProfileCommand
{
ProfileName = "New Profile",
Active = true,
TypeId = 2
};
var newProfileId = await mediator.Send(command);
```
## Development Notes
- **Clean Architecture**: Application layer uses domain interfaces, not implementations
- **Dependency Injection**: Infrastructure implementations injected at runtime
- **CQRS**: Separate models for read and write operations
- **Validation**: Business rules validated before command execution
- **Mapping**: AutoMapper handles entity-DTO conversion
## Future Enhancements
1. Add FluentValidation for complex validation rules
2. Add caching layer for frequently accessed data
3. Add logging with Serilog
4. Add application events for cross-cutting concerns
5. Add background job scheduling integration
## Related Projects
- **ECMJobRunner.Domain**: Contains entities and repository interfaces
- **ECMJobRunner.Infrastructure**: Provides repository implementations
## Company Information
**Author**: Digital Data GmbH
**Copyright**: 2026
**Repository**: http://git.dd:3000/AppStd/ECMJobRunner.git

View File

@@ -0,0 +1,18 @@
namespace ECMJobRunner.Application.Common.Constants
{
/// <summary>
/// Defines actions to take when an error occurs during job execution
/// </summary>
public enum ErrorAction
{
/// <summary>
/// Ignore the error and continue execution
/// </summary>
Ignore,
/// <summary>
/// Stop execution and throw an exception
/// </summary>
Stop,
}
}

View File

@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
namespace ECMJobRunner.Application.Common.Dtos
{
/// <summary>
/// Data Transfer Object for CfgProfile entity
/// Used for querying and returning profile data
/// </summary>
public record CfgProfileDto
{
/// <summary>
/// Profile ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// Active / Inactive switch
/// </summary>
public bool Active { get; set; }
/// <summary>
/// Profile name
/// </summary>
public string ProfileName { get; set; } = null!;
/// <summary>
/// Profile type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
/// </summary>
public byte TypeId { get; set; }
/// <summary>
/// Schedule in Cron format
/// </summary>
public string Schedule { get; set; } = null!;
/// <summary>
/// Optional description
/// </summary>
public string? Comment { get; set; }
/// <summary>
/// Created by
/// </summary>
public string AddedWho { get; set; } = null!;
/// <summary>
/// Created at
/// </summary>
public DateTime AddedWhen { get; set; }
/// <summary>
/// Modified by
/// </summary>
public string? ChangedWho { get; set; }
/// <summary>
/// Modified at
/// </summary>
public DateTime? ChangedWhen { get; set; }
/// <summary>
/// SQL Jobs associated with this profile
/// </summary>
public List<ProfileSqlJobDto>? SqlJobs { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace ECMJobRunner.Application.Common.Dtos
{
/// <summary>
/// Result of a check query execution
/// </summary>
public class CheckQueryResult
{
/// <summary>
/// Return value from the check query (expected: > 0 for success)
/// </summary>
[Column("Return Value")]
public int? ReturnValue { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace ECMJobRunner.Application.Common.Dtos
{
/// <summary>
/// Result of a main query execution
/// </summary>
public class MainQueryResult
{
/// <summary>
/// Return value from the main query (expected: null for success)
/// </summary>
[Column("Return Value")]
public int? ReturnValue { get; set; }
}
}

View File

@@ -0,0 +1,53 @@
namespace ECMJobRunner.Application.Common.Dtos
{
/// <summary>
/// Data Transfer Object for ProfileSqlJob entity
/// </summary>
public class ProfileSqlJobDto
{
/// <summary>
/// SQL Job ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// Profile ID (foreign key)
/// </summary>
public long ProfileId { get; set; }
/// <summary>
/// Active / Inactive switch
/// </summary>
public bool Active { get; set; }
/// <summary>
/// Execution sequence order
/// </summary>
public short Sequence { get; set; }
/// <summary>
/// Job name
/// </summary>
public string? Name { get; set; }
/// <summary>
/// SQL query for pre-check validation
/// </summary>
public string? SqlCheckQuery { get; set; }
/// <summary>
/// Main SQL query to execute
/// </summary>
public string? SqlMainQuery { get; set; }
/// <summary>
/// API command to execute
/// </summary>
public string? ApiCommand { get; set; }
/// <summary>
/// Optional description
/// </summary>
public string? Comment { get; set; }
}
}

View File

@@ -0,0 +1,36 @@
using System;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Exception thrown when attempting to execute a job for an inactive profile
/// Extends JobException with profile-specific context
/// </summary>
/// <remarks>
/// Initializes a new instance of InactiveProfileException
/// </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>
/// <remarks>
/// Use this exception when:
/// - Attempting to trigger DEX job batch for an inactive profile
/// - Attempting to execute individual jobs from an inactive profile
/// - Profile is deactivated during execution
/// </remarks>
public class InactiveProfileException(long profileId, string? profileName, string batchId) : JobException(
profileId,
jobName: "Profile Execution",
processName: "Profile Active Status Validation",
batchId: batchId,
reason: "The profile is marked as inactive and cannot be executed",
innerException: null,
("Profile ID", profileId.ToString(), false),
("Profile Name", profileName, true))
{
/// <summary>
/// Gets the name of the inactive profile (nullable)
/// </summary>
public string? ProfileName { get; } = profileName;
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Base exception class for job execution failures
/// Provides a flexible structure for capturing job context and detailed error information
/// </summary>
/// <remarks>
/// Initializes a new instance of JobException with detailed context information
/// </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>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
/// <param name="details">Additional contextual details as name-value pairs with optional null-handling</param>
/// <remarks>
/// The details parameter accepts tuples with:
/// - Name: Display name of the detail
/// - Value: String value of the detail (nullable)
/// - IgnoreIfNull: If true, the detail is omitted from the message when value is null
/// </remarks>
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),
..details
]),
innerException)
{
/// <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; } = jobName;
/// <summary>
/// Gets the name of the process/stage that was being executed when the failure occurred
/// </summary>
public string ProcessName { get; } = processName;
/// <summary>
/// Gets the unique batch identifier for tracking the execution
/// </summary>
public string BatchId { get; } = batchId;
/// <summary>
/// Generates a formatted error message with job context and details
/// </summary>
/// <param name="jobName">Name of the job that failed</param>
/// <param name="details">Collection of name-value pairs with optional null-handling</param>
/// <returns>Formatted multi-line error message with visual separators</returns>
/// <remarks>
/// 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 CreateMessage(string jobName, IEnumerable<(string Name, string? Value, bool IgnoreIfNull)> details)
{
var message = new System.Text.StringBuilder();
message.AppendLine($"{jobName} could not be completed.");
foreach (var (name, value, ignoreNullValue) in details)
{
if (ignoreNullValue && value is null)
continue;
message.AppendLine($" {name}: {value}");
}
return message.ToString();
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Exception for HTTP client-related job failures (e.g., ReC API calls, REST requests)
/// Extends JobException with client library and method context
/// </summary>
/// <remarks>
/// Initializes a new instance of JobHttpException with HTTP client context
/// </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>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="clientLibrary">Name of the HTTP client library used (e.g., "ReC.Client", "HttpClient") (nullable)</param>
/// <param name="clientMethod">Name of the client method that failed (e.g., "ExecuteAsync", "PostAsync") (nullable)</param>
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
/// <remarks>
/// Use this exception for HTTP-related failures such as:
/// - ReC API request failures
/// - REST API communication errors
/// - HTTP client timeout/network issues
/// - Authentication/authorization failures
/// </remarks>
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))
{
/// <summary>
/// Gets the name of the HTTP client library that was used (e.g., "ReC.Client", "HttpClient")
/// </summary>
public string? ClientLibrary { get; } = clientLibrary;
/// <summary>
/// Gets the name of the client method that failed (e.g., "ExecuteAsync", "PostAsync")
/// </summary>
public string? ClientMethod { get; } = clientMethod;
}
}

View File

@@ -0,0 +1,43 @@
using System;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Exception for SQL query execution failures
/// Extends JobException with SQL query context for debugging
/// </summary>
/// <remarks>
/// Initializes a new instance of JobSqlException with SQL query context
/// </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>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="query">The SQL query that failed (nullable, for debugging purposes)</param>
/// <param name="innerException">The underlying SQL exception (nullable)</param>
/// <remarks>
/// Use this exception for SQL-related failures such as:
/// - Main query execution errors
/// - Check query validation failures
/// - Database connection issues
/// - SQL syntax errors
/// - Query timeout exceptions
///
/// The Query property can be logged for debugging but should be handled carefully
/// to avoid exposing sensitive data in production logs.
/// </remarks>
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))
{
/// <summary>
/// Gets the SQL query that failed (nullable)
/// </summary>
/// <remarks>
/// 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; } = query;
}
}

View File

@@ -0,0 +1,20 @@
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Common.Interfaces
{
/// <summary>
/// Interface for executing SQL queries and mapping results to DTOs
/// </summary>
public interface ISQLExecutor
{
/// <summary>
/// Executes a SQL query and maps the result to the specified type
/// </summary>
/// <typeparam name="TResult">The type to map the query result to</typeparam>
/// <param name="sql">The SQL query to execute</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The mapped result or null if no result</returns>
Task<TResult?> ExecuteQueryAsync<TResult>(string sql, CancellationToken cancellationToken = default);
}
}

View File

@@ -0,0 +1,26 @@
using AutoMapper;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Domain.Entities;
namespace ECMJobRunner.Application.Common.Mapping
{
/// <summary>
/// AutoMapper profile for CfgProfile and ProfileSqlJob mappings
/// Maps domain entities to DTOs
/// </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
CreateMap<CfgProfile, CfgProfileDto>()
.ForMember(dest => dest.SqlJobs, opt => opt.MapFrom(src => src.SqlJobs));
// ProfileSqlJob -> ProfileSqlJobDto
CreateMap<ProfileSqlJob, ProfileSqlJobDto>();
}
}
}

View File

@@ -0,0 +1,109 @@
using ECMJobRunner.Application.Common.Constants;
using System.Text.RegularExpressions;
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>
public record DexJobErrorHandlingOptions
{
/// <summary>
/// Error handling options for SQL query execution
/// </summary>
public record SqlQueryErrorHandlingOptions
{
/// <summary>
/// Action to take when query execution fails
/// </summary>
public ErrorAction OnExecution { get; set; } = ErrorAction.Stop;
/// <summary>
/// Action to take when query is null or whitespace
/// </summary>
public ErrorAction IfNullOrWhiteSpace { get; set; } = ErrorAction.Ignore;
/// <summary>
/// Action to take when query returns unexpected result
/// </summary>
public ErrorAction OnUnexpectedResult { get; set; } = ErrorAction.Stop;
}
/// <summary>
/// Error handling options for HTTP requests
/// </summary>
public record HttpRequestErrorHandlingOptions
{
/// <summary>
/// Action to take when HTTP request fails
/// </summary>
public ErrorAction OnSending { get; set; } = ErrorAction.Stop;
}
/// <summary>
/// Error handling for main SQL query
/// </summary>
public SqlQueryErrorHandlingOptions MainQuery { get; set; } = new();
/// <summary>
/// Error handling for check SQL query
/// </summary>
public SqlQueryErrorHandlingOptions CheckQuery { get; set; } = new();
/// <summary>
/// Error handling for ReC HTTP request
/// </summary>
public HttpRequestErrorHandlingOptions ReCRequest { get; set; } = new();
}
/// <summary>
/// Error handling configuration
/// </summary>
public DexJobErrorHandlingOptions Error { get; set; } = new();
/// <summary>
/// Placeholder configuration for dynamic value replacement
/// </summary>
public record PlaceHolderOptions
{
/// <summary>
/// Configuration for a single placeholder
/// </summary>
public record PlaceHolder
{
/// <summary>
/// Regex pattern to match the placeholder
/// </summary>
public string Pattern { get; set; } = null!;
/// <summary>
/// Regex options for pattern matching
/// </summary>
public RegexOptions RegexOptions { get; set; } = RegexOptions.IgnoreCase;
}
/// <summary>
/// BatchId placeholder configuration
/// </summary>
public PlaceHolder BatchId { get; set; } = new()
{
Pattern = "{#INT#BATCH_ID}",
RegexOptions = RegexOptions.IgnoreCase
};
}
/// <summary>
/// Placeholder configuration
/// </summary>
public PlaceHolderOptions Placeholders { get; set; } = new();
}

View File

@@ -0,0 +1,55 @@
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;
namespace ECMJobRunner.Application
{
/// <summary>
/// Extension methods for configuring Application layer services
/// </summary>
public static class DependencyInjection
{
/// <summary>
/// Adds Application layer services to the dependency injection container
/// 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, IConfiguration configuration)
{
var assembly = Assembly.GetExecutingAssembly();
// Register MediatR with all handlers from this assembly
#if NET48
services.AddMediatR(assembly);
#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<,>));
return services;
}
}
}

View File

@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net480;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
<PackageId>ECMJobRunner.Application</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>ECMJobRunner.Application</Product>
<Copyright>Copyright 2026</Copyright>
<RepositoryUrl>http://git.dd:3000/AppStd/ECMJobRunner.git</RepositoryUrl>
<PackageTags>digital data ecm job runner application</PackageTags>
<!-- Suppress AutoMapper vulnerability warning (known issue, acceptable for this project) -->
<NoWarn>$(NoWarn);NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ReC.Client" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
<!-- AutoMapper for .NET Framework 4.8 (matching Infrastructure) -->
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<!-- 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'">
<!-- AutoMapper for .NET 8 (matching Infrastructure) -->
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<!-- MediatR for .NET 8 -->
<PackageReference Include="MediatR" Version="12.4.1" />
</ItemGroup>
</Project>

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

@@ -0,0 +1,120 @@
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.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that executes the check SQL query for DEX jobs
/// Runs after MainQueryExecutionBehavior and validates query results
/// </summary>
/// <typeparam name="TRequest">The request type</typeparam>
/// <typeparam name="TResponse">The response type</typeparam>
public class CheckQueryExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ISQLExecutor _executor;
private readonly DexJobOptions _options;
/// <summary>
/// Initializes a new instance of CheckQueryExecutionBehavior
/// </summary>
/// <param name="executor">SQL executor for query execution</param>
/// <param name="options">DEX job configuration options</param>
public CheckQueryExecutionBehavior(ISQLExecutor executor, IOptions<DexJobOptions> options)
{
_executor = executor;
_options = options.Value;
}
/// <summary>
/// Handles the pipeline behavior
/// Executes check 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
{
if (request is TriggeringProfileJobCommand command)
{
await ExecuteCheckQueryAsync(command, cancellationToken);
}
return await next();
}
private async Task ExecuteCheckQueryAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(command.Job.SqlCheckQuery))
{
var sqlCheckQuery = Regex.Replace(
command.Job.SqlCheckQuery!,
_options.Placeholders.BatchId.Pattern,
command.BatchId,
_options.Placeholders.BatchId.RegexOptions);
try
{
var result = await _executor.ExecuteQueryAsync<CheckQueryResult>(sqlCheckQuery, cancel);
if (_options.Error.CheckQuery.OnUnexpectedResult == ErrorAction.Stop)
{
if (result is null)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
reason: "Check Query returned nothing.",
query: sqlCheckQuery,
innerException: null);
else if (result.ReturnValue <= 0)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was any value greater than 0.",
query: sqlCheckQuery,
innerException: null);
}
}
catch (Exception ex)
{
if (_options.Error.CheckQuery.OnExecution == ErrorAction.Stop)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
reason: null,
query: sqlCheckQuery,
innerException: ex
);
}
}
else if (_options.Error.CheckQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
{
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName:"Triggering DEX",
processName:"Check Query",
batchId:command.BatchId,
reason: "SQL Check Query is null or empty",
query: null,
innerException: null
);
}
}
}
}

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

@@ -0,0 +1,117 @@
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.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that executes the main SQL query for DEX jobs
/// Runs before the command handler and validates query results
/// </summary>
/// <typeparam name="TRequest">The request type</typeparam>
/// <typeparam name="TResponse">The response type</typeparam>
public class MainQueryExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ISQLExecutor _executor;
private readonly DexJobOptions _options;
/// <summary>
/// Initializes a new instance of MainQueryExecutionBehavior
/// </summary>
/// <param name="executor">SQL executor for query execution</param>
/// <param name="options">DEX job configuration options</param>
public MainQueryExecutionBehavior(ISQLExecutor executor, IOptions<DexJobOptions> options)
{
_executor = executor;
_options = options.Value;
}
/// <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
{
if (request is TriggeringProfileJobCommand command)
{
await ExecuteMainQueryAsync(command, cancellationToken);
}
return await next();
}
private async Task ExecuteMainQueryAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(command.Job.SqlMainQuery))
{
var sqlMainQuery = Regex.Replace(
command.Job.SqlMainQuery!,
_options.Placeholders.BatchId.Pattern,
command.BatchId,
_options.Placeholders.BatchId.RegexOptions);
try
{
var result = await _executor.ExecuteQueryAsync<MainQueryResult>(sqlMainQuery, cancel);
if (_options.Error.MainQuery.OnUnexpectedResult == ErrorAction.Stop)
{
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 && 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 or 0.",
query: sqlMainQuery, innerException: null);
}
}
catch (Exception ex)
{
if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: null,
query: sqlMainQuery,
innerException: ex);
}
}
else if (_options.Error.MainQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
{
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: "SQL Check Query is null or empty",
query: null,
innerException: null);
}
}
}
}

View File

@@ -0,0 +1,85 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ReC.Client;
using ReC.Client.Api;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that sends ReC HTTP request for DEX jobs
/// Runs after CheckQueryExecutionBehavior and invokes ReC API
/// </summary>
/// <typeparam name="TRequest">The request type</typeparam>
/// <typeparam name="TResponse">The response type</typeparam>
/// <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 DexJobOptions Options = options.Value;
/// <summary>
/// Handles the pipeline behavior
/// Sends ReC request 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
{
if (request is TriggeringProfileJobCommand command)
{
await SendReCRequestAsync(command, cancellationToken);
}
return await next();
}
private async Task SendReCRequestAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
try
{
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)
{
throw new JobHttpException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "ReC Http Request",
batchId: command.BatchId,
reason: null,
clientLibrary: "ReC.Client",
clientMethod: "RecActions.InvokeAsync",
innerException: ex);
}
}
}
}
}

View File

@@ -0,0 +1,92 @@
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Domain.Interfaces;
using MediatR;
using System;
using System.Threading;
using System.Threading.Tasks;
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 TriggeringProfileJobBatchCommand : IRequest<Unit>
{
/// <summary>
/// Profile ID to trigger all associated SQL jobs
/// </summary>
public long ProfileId { get; set; }
}
/// <summary>
/// Handler for TriggeringDEXJobBatchCommand
/// Retrieves all SQL jobs for a profile and executes them sequentially
/// Validates that the profile is active before execution
/// </summary>
public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender)
: IRequestHandler<TriggeringProfileJobBatchCommand, Unit>
{
/// <summary>
/// Handles the TriggeringDEXJobBatchCommand
/// Creates a unique batch ID and triggers individual job commands
/// </summary>
/// <param name="request">The command containing the profile ID</param>
/// <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(TriggeringProfileJobBatchCommand request, CancellationToken cancellationToken)
{
var batchId = CreateBatchId();
// Retrieve the profile to check if it's active
var profile = await profileRepo.GetByIdAsync(request.ProfileId, cancellationToken);
// Check if profile exists and is active
if (profile == null)
{
throw new InvalidOperationException($"Profile with ID {request.ProfileId} not found.");
}
if (!profile.Active)
{
throw new InactiveProfileException(profile.Id, profile.ProfileName, batchId);
}
// Retrieve all jobs for the profile
var jobs = await jobRepo.FindAsync(j => j.ProfileId == request.ProfileId, cancellationToken);
foreach (var job in jobs)
{
await sender.Send(new TriggeringProfileJobCommand
{
Job = job,
BatchId = batchId
}, cancellationToken);
}
return Unit.Value;
}
/// <summary>
/// Creates a unique batch ID based on current timestamp
/// </summary>
/// <returns>20-character timestamp string (yyyyMMddHHmmssfffffff truncated to 20 chars)</returns>
public static string CreateBatchId()
{
// Format: "yyyy" : Year (4 digits)
// "MM" : Month (2 digits)
// "dd" : Day (2 digits)
// "HH" : Hour (24-hour format, 2 digits)
// "mm" : Minute (2 digits)
// "ss" : Second (2 digits)
// "fffffff": Fractions of a second / 100-nanoseconds (7 digits)
string fullBatchId = DateTime.Now.ToString("yyyyMMddHHmmssfffffff");
// Limit to 20 characters as per specification
return fullBatchId.Substring(0, 20);
}
}
}

View File

@@ -0,0 +1,68 @@
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.Profiles.Commands
{
/// <summary>
/// Command to trigger a single DEX job execution
/// Execution logic is handled by pipeline behaviors:
/// 1. MainQueryExecutionBehavior - executes main SQL query
/// 2. CheckQueryExecutionBehavior - validates with check SQL query
/// 3. ReCRequestExecutionBehavior - invokes ReC HTTP request
/// </summary>
public class TriggeringProfileJobCommand : IRequest<Unit>
{
/// <summary>
/// The SQL job to execute
/// </summary>
public ProfileSqlJob Job { get; set; } = null!;
/// <summary>
/// Unique batch identifier for this execution
/// </summary>
public string BatchId { get; set; } = null!;
internal BatchRecActionViewResponse? RecActionResult { get; set; }
}
/// <summary>
/// Handler for TriggeringDEXJobCommand
/// All execution logic is delegated to pipeline behaviors
/// This handler simply returns completion after behaviors execute
/// </summary>
public class TriggeringDEXJobCommandHandler(ISender Sender) : IRequestHandler<TriggeringProfileJobCommand, Unit>
{
/// <summary>
/// Handles the TriggeringDEXJobCommand
/// Returns immediately as behaviors perform all work
/// </summary>
/// <param name="request">The command containing job and batch ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Unit value indicating completion</returns>
public async Task<Unit> Handle(TriggeringProfileJobCommand request, CancellationToken cancellationToken)
{
// All execution logic is handled by pipeline behaviors:
// - MainQueryExecutionBehavior
// - CheckQueryExecutionBehavior
// - ReCRequestExecutionBehavior
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

@@ -0,0 +1,139 @@
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.Profiles.Queries
{
/// <summary>
/// Query to retrieve profiles with flexible filtering options
/// All query options are nullable - when no filters are specified, returns all profiles
/// </summary>
public class GetProfileQuery : IRequest<List<CfgProfileDto>>
{
/// <summary>
/// Profile ID to retrieve (optional)
/// When specified, returns only the profile with this ID
/// </summary>
public long? Id { get; set; }
/// <summary>
/// Filter by active status (optional)
/// When null, returns both active and inactive profiles
/// When true, returns only active profiles
/// When false, returns only inactive profiles
/// </summary>
public bool? Active { get; set; }
/// <summary>
/// Filter by profile type (optional)
/// When specified, returns only profiles with this type
/// Type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
/// </summary>
public byte? TypeId { get; set; }
/// <summary>
/// Filter by profile name (optional)
/// When specified, returns profiles with matching name (case-insensitive contains)
/// </summary>
public string? ProfileName { get; set; }
/// <summary>
/// Include associated SQL jobs in the result (optional)
/// Default: true
/// </summary>
public bool IncludeSqlJobs { get; set; } = true;
}
/// <summary>
/// Handler for GetProfileQuery
/// Retrieves profiles with optional filtering and uses AutoMapper for DTO mapping
/// </summary>
public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, List<CfgProfileDto>>
{
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;
// If ID is specified, get single profile by ID
if (request.Id.HasValue)
{
var profile = request.IncludeSqlJobs
? await _profileRepository.GetByIdWithSqlJobsAsync(request.Id.Value, cancellationToken)
: await _profileRepository.GetByIdAsync(request.Id.Value, cancellationToken);
profiles = profile != null ? new[] { profile } : [];
}
// Otherwise, get profiles with filters
else
{
// Get all profiles with SQL jobs if requested
if (request.IncludeSqlJobs)
{
// If Active filter is specified and true, use optimized method
if (request.Active.HasValue && request.Active.Value)
{
profiles = await _profileRepository.GetAllActiveWithSqlJobsAsync(cancellationToken);
}
else
{
// Generic query with filters
profiles = await _profileRepository.FindAsync(p => true, cancellationToken);
}
}
else
{
profiles = await _profileRepository.GetAllAsync(cancellationToken);
}
// Apply filters
if (request.Active.HasValue)
{
profiles = profiles.Where(p => p.Active == request.Active.Value);
}
if (request.TypeId.HasValue)
{
profiles = profiles.Where(p => p.TypeId == request.TypeId.Value);
}
if (!string.IsNullOrWhiteSpace(request.ProfileName))
{
#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
}
}
// Use AutoMapper to map entities to DTOs
return _mapper.Map<List<CfgProfileDto>>(profiles.ToList());
}
}
}

View File

@@ -0,0 +1,373 @@
# ECMJobRunner.Application - DEX Job Integration
## Overview
This document describes the **DEX Job triggering system** implemented in `ECMJobRunner.Application` using the **CQRS pattern with MediatR** and **Pipeline Behaviors**.
## Architecture
### Commands
#### `TriggeringDEXJobBatchCommand`
Orchestrates batch job execution for a profile.
**Properties:**
- `ProfileId` (int): Profile ID to trigger all associated SQL jobs
**Handler:**
- Creates a unique 20-character timestamp-based batch ID
- Retrieves all SQL jobs for the profile
- Executes each job sequentially using `TriggeringDEXJobCommand`
**Usage:**
```csharp
var command = new TriggeringDEXJobBatchCommand { ProfileId = 123 };
await mediator.Send(command);
```
#### `TriggeringDEXJobCommand`
Executes a single DEX job with three-stage pipeline.
**Properties:**
- `Job` (ProfileSqlJob): The SQL job to execute
- `BatchId` (string): Unique batch identifier
**Execution Pipeline:**
1. **MainQueryExecutionBehavior**: Executes main SQL query with batch ID placeholder replacement
2. **CheckQueryExecutionBehavior**: Validates execution with return value check (> 0)
3. **ReCRequestExecutionBehavior**: Invokes ReC API with batch ID reference
**Handler:**
- Empty handler - all logic delegated to pipeline behaviors
**Usage:**
```csharp
var command = new TriggeringDEXJobCommand
{
Job = profileSqlJob,
BatchId = "20260711143025123456"
};
await mediator.Send(command);
```
### Pipeline Behaviors
#### Execution Order
1. `MainQueryExecutionBehavior<,>` - SQL main query execution
2. `CheckQueryExecutionBehavior<,>` - SQL check query validation
3. `ReCRequestExecutionBehavior<,>` - ReC HTTP request
Each behavior:
- Checks if request is `TriggeringDEXJobCommand`
- Executes its stage logic
- Calls `next()` to continue pipeline
## Configuration
### appsettings.json
```json
{
"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"
}
}
}
}
```
### Configuration Options
#### `DexJobOptions`
Root configuration object for DEX job execution.
**Properties:**
- `Error` (DexJobErrorHandlingOptions): Error handling configuration
- `Placeholders` (PlaceHolderOptions): Placeholder replacement configuration
#### `DexJobErrorHandlingOptions`
Hierarchical error handling per stage.
**Properties:**
- `MainQuery` (SqlQueryErrorHandlingOptions): Main query error handling
- `CheckQuery` (SqlQueryErrorHandlingOptions): Check query error handling
- `ReCRequest` (HttpRequestErrorHandlingOptions): ReC request error handling
#### `SqlQueryErrorHandlingOptions`
Error handling for SQL query execution.
**Properties:**
- `OnExecution` (ErrorAction): Action when query execution fails (default: `Stop`)
- `IfNullOrWhiteSpace` (ErrorAction): Action when query is null/empty (default: `Ignore`)
- `OnUnexpectedResult` (ErrorAction): Action when result is unexpected (default: `Stop`)
**Error Actions:**
- `Ignore`: Continue execution
- `Stop`: Throw `DEXJobException`
#### `HttpRequestErrorHandlingOptions`
Error handling for HTTP requests.
**Properties:**
- `OnSending` (ErrorAction): Action when HTTP request fails (default: `Stop`)
#### `PlaceHolderOptions`
Configuration for dynamic placeholder replacement.
**Properties:**
- `BatchId` (PlaceHolder): Batch ID placeholder configuration
- `Pattern` (string): Regex pattern (default: `#INT#BATCH_ID`)
- `RegexOptions` (RegexOptions): Regex options (default: `IgnoreCase`)
### Expected Query Results
#### Main Query (`SqlMainQuery`)
**Expected Result:**
- `ReturnValue` should be **null**
- If not null: throws `DEXJobException` (when `OnUnexpectedResult` = `Stop`)
**Example SQL:**
```sql
INSERT INTO TBJR_OUT_PROFILE_HISTORY (BatchId, ProfileId, CreatedAt)
VALUES ('#INT#BATCH_ID', 123, GETDATE())
```
#### Check Query (`SqlCheckQuery`)
**Expected Result:**
- `ReturnValue` should be **> 0**
- If ≤ 0: throws `DEXJobException` (when `OnUnexpectedResult` = `Stop`)
**Example SQL:**
```sql
SELECT COUNT(*) AS [Return Value]
FROM TBJR_OUT_PROFILE_HISTORY
WHERE BatchId = '#INT#BATCH_ID'
```
### Placeholder Replacement
The `#INT#BATCH_ID` placeholder in SQL queries is replaced with the actual batch ID using regex:
**Before:**
```sql
INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')
```
**After:**
```sql
INSERT INTO Table (BatchId) VALUES ('20260711143025123456')
```
## Dependency Injection
### Setup in Startup.cs / Program.cs
```csharp
using ECMJobRunner.Application;
using ECMJobRunner.Application.Common.Options;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
// Add Application layer services
services.AddApplication();
// Configure DexJobOptions from appsettings.json
services.Configure<DexJobOptions>(configuration.GetSection("DexJob"));
// Register dependencies
services.AddSingleton<ISQLExecutor, SqlExecutor>(); // Your implementation
services.AddSingleton<ReCClient>(); // ReC client configuration
```
### Required Dependencies
The following interfaces must be implemented in your Infrastructure layer:
1. **`ISQLExecutor`**: SQL query execution and DTO mapping
```csharp
public interface ISQLExecutor
{
Task<TResult?> ExecuteQueryAsync<TResult>(string sql, CancellationToken cancellationToken = default);
}
```
2. **`IProfileSqlJobRepository`**: Repository for ProfileSqlJob entities
```csharp
public interface IProfileSqlJobRepository : IRepository
{
Task<IEnumerable<ProfileSqlJob>> FindAsync(Expression<Func<ProfileSqlJob, bool>> predicate, CancellationToken cancellationToken);
}
```
3. **`ReCClient`**: ReC HTTP client (from `ReC.Client` NuGet package)
## Exception Handling
### `DEXJobException`
Custom exception thrown when DEX job operations fail.
**Properties:**
- `QueryName` (string): Name of the query that failed
- `BatchId` (string): Batch ID associated with the operation
- `SqlQuery` (string?): SQL query that was executed (nullable)
- `Message` (string): Formatted error message with query details
**Constructors:**
1. With inner exception:
```csharp
new DEXJobException("SQL Main Query", batchId, sqlQuery, innerException)
```
2. With reason message:
```csharp
new DEXJobException("SQL Check Query", batchId, sqlQuery, "Check Query returned nothing.")
```
**Example Error Message:**
```
[SQL Execution Failure] Query 'SQL Main Query' could not be completed for Batch '20260711143025123456'.
─────────────────────────────────────────
Query:
INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')
Root Cause:
Timeout expired. The timeout period elapsed prior to completion of the operation.
─────────────────────────────────────────
```
## Multi-Targeting Support
The project targets:
- **.NET Framework 4.8** (`net480`) - MediatR 9.0.0
- **.NET 8.0** (`net8.0`) - MediatR 12.4.1
Pipeline behaviors use conditional compilation for MediatR signature differences:
```csharp
#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
```
## Testing
Unit tests should mock:
- `ISQLExecutor` - for SQL query execution
- `IOptions<DexJobOptions>` - for configuration
- `ReCClient` - for HTTP requests
- `IProfileSqlJobRepository` - for data access
**Example test structure:**
```csharp
[Fact]
public async Task Handle_WithValidBatchCommand_ExecutesAllJobs()
{
// Arrange
var mockRepo = new Mock<IProfileSqlJobRepository>();
var mockSender = new Mock<ISender>();
mockRepo.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ProfileSqlJob> { job1, job2 });
var handler = new TriggeringDEXJobBatchCommandHandler(mockRepo.Object, mockSender.Object);
var command = new TriggeringDEXJobBatchCommand { ProfileId = 123 };
// Act
await handler.Handle(command, CancellationToken.None);
// Assert
mockSender.Verify(s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
```
## Batch ID Format
Batch IDs are 20-character timestamp strings:
**Format:** `yyyyMMddHHmmssfffffff` (truncated to 20 chars)
**Example:** `20260711143025123456`
**Components:**
- `yyyy`: Year (4 digits)
- `MM`: Month (2 digits)
- `dd`: Day (2 digits)
- `HH`: Hour - 24-hour format (2 digits)
- `mm`: Minute (2 digits)
- `ss`: Second (2 digits)
- `fffffff`: Fractions of a second / 100-nanoseconds (7 digits) - truncated to 6 digits
## Troubleshooting
### Common Issues
#### 1. "Main Query returned nothing"
**Cause:** Main query execution did not return any result.
**Solution:** Check if SQL query is valid and returns expected structure with `[Return Value]` column.
#### 2. "The query unexpectedly returned the value X. The expected value was null."
**Cause:** Main query returned non-null value indicating an error.
**Solution:** Check SQL query logic and ensure it returns NULL on success.
#### 3. "Check Query returned nothing"
**Cause:** Check query did not return any result.
**Solution:** Verify check query syntax and ensure it returns a `[Return Value]` column.
#### 4. "The query unexpectedly returned the value X. The expected value was any value greater than 0."
**Cause:** Check query returned ≤ 0 indicating validation failure.
**Solution:** Verify main query executed successfully and check query logic is correct.
#### 5. "SQL Main Query is null or empty"
**Cause:** `SqlMainQuery` property is null or whitespace.
**Solution:** Set `Error.MainQuery.IfNullOrWhiteSpace = Ignore` in configuration if optional.
### Debug Configuration
For detailed error information, set all error actions to `Stop`:
```json
{
"DexJob": {
"Error": {
"MainQuery": {
"OnExecution": "Stop",
"IfNullOrWhiteSpace": "Stop",
"OnUnexpectedResult": "Stop"
},
"CheckQuery": {
"OnExecution": "Stop",
"IfNullOrWhiteSpace": "Stop",
"OnUnexpectedResult": "Stop"
},
"ReCRequest": {
"OnSending": "Stop"
}
}
}
}
```
## Company Information
**Author:** Digital Data GmbH
**Copyright:** 2026
**Repository:** http://git.dd:3000/AppStd/ECMJobRunner.git

View File

@@ -0,0 +1,169 @@
# ECMJobRunner.Domain
## Project Overview
ECMJobRunner.Domain is the **domain layer** for the ECM Job Runner system following **Clean Architecture** principles. This project contains:
- **Entity models** that represent the business domain
- **Repository interfaces** for data access abstraction
- **Domain logic** (currently none, pure data entities)
**Key Principle**: This layer has **NO** external dependencies - it's the core of the application.
## Target Frameworks
- **.NET Framework 4.8** (`net480`)
- **.NET 8.0** (`net8.0`)
The project is multi-targeted to support both legacy .NET Framework applications and modern .NET 8 applications.
## Architecture
This project follows **Clean Architecture** principles:
- **No infrastructure dependencies** (no Entity Framework, no database concerns)
- **Pure domain entities** without ORM attributes
- **Repository pattern interfaces** for data access abstraction
- **Dependency inversion** - infrastructure depends on domain, not vice versa
## Project Structure
```
ECMJobRunner.Domain/
├── Entities/
│ ├── Profile.cs # Job configuration profile entity
│ ├── ProfileSqlJob.cs # SQL job configuration entity
│ └── ProfileHistory.cs # Job execution history entity
├── Interfaces/
│ ├── IRepository.cs # Generic repository interface
│ ├── IProfileRepository.cs # Profile-specific repository
│ ├── IProfileSqlJobRepository.cs
│ ├── IProfileHistoryRepository.cs
│ └── IUnitOfWork.cs # Unit of Work pattern interface
├── ECMJobRunner.Domain.csproj
└── AGENTS.md # This file
```
## Entities
### Profile
Represents a job runner profile configuration.
**Properties:**
- `Id` (long): Primary key
- `Active` (bool): Enable/disable switch
- `ProfileName` (string, max 150): Name of the profile
- `TypeId` (byte): Profile type (0=ADSync, 1=GraphQL, 2=SQL-Job, 3=SQL and REST-Job)
- `Schedule` (string, max 150): Cron format schedule
- `Comment` (string?, max 500): Optional description
- `AddedWho`, `AddedWhen`, `ChangedWho`, `ChangedWhen`: Audit fields
**Navigation Properties:**
- `SqlJobs` (IEnumerable<ProfileSqlJob>?): Associated SQL jobs
- `ProfileHistories` (IEnumerable<ProfileHistory>?): Execution history
### ProfileSqlJob
Represents individual SQL jobs within a profile.
**Properties:**
- `Id` (long): Primary key
- `ProfileId` (long): Foreign key to Profile
- `Active` (bool): Enable/disable switch
- `Sequence` (short): Execution order within the profile
- `Name` (string?, max 150): Optional job name
- `SqlCheckQuery` (string?): SQL query for pre-check
- `SqlMainQuery` (string?): Main SQL query
- `ApiCommand` (string?): API command to execute
- `Comment` (string?, max 500): Optional description
- Audit fields
**Navigation Properties:**
- `Profile` (Profile?): Associated profile
### ProfileHistory
Stores execution history and results of job profiles.
**Properties:**
- `Id` (long): Primary key
- `ProfileId` (long): Foreign key to Profile
- `ResultId` (byte): Result status (0=OK, 1=ERROR, 2=WARNING)
- `ResultText` (string): Result message/details
- Audit fields
**Navigation Properties:**
- `Profile` (Profile?): Associated profile
## Repository Interfaces
### IRepository<TEntity>
Generic repository interface providing CRUD operations:
- `GetById(id)`, `GetByIdAsync(id)`
- `GetAll()`, `GetAllAsync()`
- `Find(predicate)`, `FindAsync(predicate)`
- `SingleOrDefault(predicate)`, `SingleOrDefaultAsync(predicate)`
- `Add(entity)`, `AddRange(entities)`
- `Update(entity)`, `Remove(entity)`, `RemoveRange(entities)`
### Entity-Specific Repositories
- `IProfileRepository : IRepository<Profile>`
- `IProfileSqlJobRepository : IRepository<ProfileSqlJob>`
- `IProfileHistoryRepository : IRepository<ProfileHistory>`
### IUnitOfWork
Manages transactions and provides access to all repositories:
- `Profiles`: IProfileRepository
- `ProfileSqlJobs`: IProfileSqlJobRepository
- `ProfileHistories`: IProfileHistoryRepository
- `SaveChanges()`, `SaveChangesAsync()`
## Database Schema Source
The entities map to SQL Server tables located at:
```
M:\Datenbank\[DD_ECM]-Database\JobRunner\
```
**Table Mappings** (handled in Infrastructure layer):
- `Profile``dbo.TBJR_CFG_PROFILE`
- `ProfileSqlJob``dbo.TBJR_CFG_PROFILE_SQLJOB`
- `ProfileHistory``dbo.TBJR_OUT_PROFILE_HISTORY`
## Naming Conventions
- **Entity Classes**: Clean Pascal case (e.g., `Profile` instead of `TBJR_CFG_PROFILE`)
- **Properties**: Pascal case (e.g., `ProfileName` instead of `PROFILE_NAME`)
- **No ORM attributes** - entities are pure POCOs
- **Navigation properties** are nullable `IEnumerable<T>?` (loaded only when explicitly included)
## Dependencies
**NONE** - This is a core domain layer with zero external dependencies.
## Building the Project
```bash
dotnet build ECMJobRunner.Domain.csproj
```
For specific framework:
```bash
dotnet build ECMJobRunner.Domain.csproj -f net8.0
dotnet build ECMJobRunner.Domain.csproj -f net480
```
## Development Notes
- **Clean Architecture**: Domain layer is independent of infrastructure concerns
- **Navigation properties**: Nullable IEnumerable - populated only when explicitly loaded via `.Include()`
- **No ORM attributes**: Pure POCOs - mapping is done in Infrastructure layer
- **Repository pattern**: All data access through interfaces
- **Unit of Work pattern**: Transaction management abstraction
## Related Projects
- **ECMJobRunner.Infrastructure**: Implements repositories and DbContext
- **ECMJobRunner.Application**: Application layer with business logic
## Company Information
**Author**: Digital Data GmbH
**Copyright**: 2026
**Repository**: http://git.dd:3000/AppStd/ECMJobRunner.git

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net480;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
<PackageId>ECMJobRunner.Domain</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>ECMJobRunner.Domain</Product>
<Copyright>Copyright 2026</Copyright>
<RepositoryUrl>http://git.dd:3000/AppStd/ECMJobRunner.git</RepositoryUrl>
<PackageTags>digital data ecm job runner domain</PackageTags>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
<!-- System.ComponentModel.DataAnnotations for .NET Framework 4.8 -->
<Reference Include="System.ComponentModel.DataAnnotations" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ECMJobRunner.Domain.Entities
{
/// <summary>
/// Job Runner Configuration Profile Entity
/// Represents a job runner profile configuration
/// Type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
/// </summary>
[Table("TBJR_CFG_PROFILE", Schema = "dbo")]
public class CfgProfile
{
/// <summary>
/// Primary Key
/// </summary>
[Key]
[Column("PK_TBJR_CFG_PROFILE_ID")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
/// <summary>
/// Active / Inactive switch
/// </summary>
[Required]
[Column("ACTIVE")]
public bool Active { get; set; } = true;
/// <summary>
/// Profile name (max 150 chars)
/// </summary>
[Required]
[Column("PROFILE_NAME")]
[MaxLength(150)]
public string ProfileName { get; set; } = null!;
/// <summary>
/// Profile type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
/// </summary>
[Required]
[Column("TYPE_ID")]
public byte TypeId { get; set; }
/// <summary>
/// Schedule in Cron format (max 150 chars)
/// </summary>
[Required]
[Column("SCHEDULE")]
[MaxLength(150)]
public string Schedule { get; set; } = "0 30 4 ? * MON-SAT";
/// <summary>
/// Optional description (max 500 chars)
/// </summary>
[Column("COMMENT")]
[MaxLength(500)]
public string? Comment { get; set; }
/// <summary>
/// Created by (max 50 chars)
/// </summary>
[Required]
[Column("ADDED_WHO")]
[MaxLength(50)]
public string AddedWho { get; set; } = "DEFAULT";
/// <summary>
/// Created at
/// </summary>
[Required]
[Column("ADDED_WHEN")]
public DateTime AddedWhen { get; set; } = DateTime.Now;
/// <summary>
/// Modified by (max 50 chars)
/// </summary>
[Column("CHANGED_WHO")]
[MaxLength(50)]
public string? ChangedWho { get; set; }
/// <summary>
/// Modified at
/// </summary>
[Column("CHANGED_WHEN")]
public DateTime? ChangedWhen { get; set; }
// Navigation properties
/// <summary>
/// SQL Jobs associated with this profile
/// </summary>
public virtual IEnumerable<ProfileSqlJob>? SqlJobs { get; set; }
/// <summary>
/// Profile execution history
/// </summary>
public virtual IEnumerable<ProfileHistory>? ProfileHistories { get; set; }
}
}

View File

@@ -0,0 +1,93 @@
using ECMJobRunner.Domain.ValueObjects;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ECMJobRunner.Domain.Entities
{
/// <summary>
/// Profile Execution History
/// Stores the execution history and results of job runner profiles
/// Result ID: 0 = OK; 1 = ERROR; 2 = WARNING
/// </summary>
[Table("TBJR_OUT_PROFILE_HISTORY", Schema = "dbo")]
public class ProfileHistory
{
/// <summary>
/// Primary Key
/// </summary>
[Key]
[Column("PK_TBJR_OUT_PROFILE_HISTORY_ID")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
/// <summary>
/// Foreign Key to Profile
/// </summary>
[Required]
[Column("FK_TBJR_CFG_PROFILE_ID")]
[ForeignKey(nameof(CfgProfile))]
public long ProfileId { get; set; }
/// <summary>
/// Result ID: 0 = OK; 1 = ERROR; 2 = WARNING
/// </summary>
[Required]
[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>
[Required]
[Column("RESULT_TEXT")]
public string ResultText { get; set; } = string.Empty;
/// <summary>
/// Created by (max 50 chars)
/// </summary>
[Required]
[Column("ADDED_WHO")]
[MaxLength(50)]
public string AddedWho { get; set; } = "DEFAULT";
/// <summary>
/// Created at
/// </summary>
[Required]
[Column("ADDED_WHEN")]
public DateTime AddedWhen { get; set; } = DateTime.Now;
/// <summary>
/// Modified by (max 50 chars)
/// </summary>
[Column("CHANGED_WHO")]
[MaxLength(50)]
public string? ChangedWho { get; set; }
/// <summary>
/// Modified at
/// </summary>
[Column("CHANGED_WHEN")]
public DateTime? ChangedWhen { get; set; }
// Navigation properties
/// <summary>
/// Associated Profile
/// </summary>
public virtual CfgProfile? CfgProfile { get; set; }
}
}

View File

@@ -0,0 +1,111 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ECMJobRunner.Domain.Entities
{
/// <summary>
/// SQL Job Configuration for Profile
/// Represents individual SQL jobs within a job runner profile
/// </summary>
[Table("TBJR_CFG_PROFILE_SQLJOB", Schema = "dbo")]
public class ProfileSqlJob
{
/// <summary>
/// Primary Key
/// </summary>
[Key]
[Column("PK_TBJR_CFG_PROFILE_SQLJOB_ID")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
/// <summary>
/// Foreign Key to Profile
/// </summary>
[Required]
[Column("FK_TBJR_CFG_PROFILE_ID")]
[ForeignKey(nameof(CfgProfile))]
public long ProfileId { get; set; }
/// <summary>
/// Active / Inactive switch
/// </summary>
[Required]
[Column("ACTIVE")]
public bool Active { get; set; } = true;
/// <summary>
/// Sequence order within the profile
/// </summary>
[Required]
[Column("SEQUENCE")]
public short Sequence { get; set; } = 0;
/// <summary>
/// Optional name (max 150 chars)
/// </summary>
[Column("NAME")]
[MaxLength(150)]
public string? Name { get; set; }
/// <summary>
/// SQL Check Query
/// </summary>
[Column("SQL_CHECK_QUERY")]
public string? SqlCheckQuery { get; set; }
/// <summary>
/// SQL Main Query
/// </summary>
[Column("SQL_MAIN_QUERY")]
public string? SqlMainQuery { get; set; }
/// <summary>
/// API Command
/// </summary>
[Column("API_COMMAND")]
public string? ApiCommand { get; set; }
/// <summary>
/// Optional description (max 500 chars)
/// </summary>
[Column("COMMENT")]
[MaxLength(500)]
public string? Comment { get; set; }
/// <summary>
/// Created by (max 50 chars)
/// </summary>
[Required]
[Column("ADDED_WHO")]
[MaxLength(50)]
public string AddedWho { get; set; } = "DEFAULT";
/// <summary>
/// Created at
/// </summary>
[Required]
[Column("ADDED_WHEN")]
public DateTime AddedWhen { get; set; } = DateTime.Now;
/// <summary>
/// Modified by (max 50 chars)
/// </summary>
[Column("CHANGED_WHO")]
[MaxLength(50)]
public string? ChangedWho { get; set; }
/// <summary>
/// Modified at
/// </summary>
[Column("CHANGED_WHEN")]
public DateTime? ChangedWhen { get; set; }
// Navigation properties
/// <summary>
/// Associated Profile
/// </summary>
public virtual CfgProfile? CfgProfile { get; set; }
}
}

View File

@@ -0,0 +1,28 @@
using ECMJobRunner.Domain.Entities;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Domain.Interfaces
{
/// <summary>
/// Repository interface for CfgProfile entity
/// </summary>
public interface ICfgProfileRepository : IRepository<CfgProfile>
{
/// <summary>
/// Gets a profile by ID with associated SQL jobs eagerly loaded
/// </summary>
/// <param name="id">Profile ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Profile with SQL jobs or null if not found</returns>
Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all active profiles with associated SQL jobs eagerly loaded
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of active profiles with SQL jobs</returns>
Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -0,0 +1,12 @@
using ECMJobRunner.Domain.Entities;
namespace ECMJobRunner.Domain.Interfaces
{
/// <summary>
/// Repository interface for ProfileHistory entity
/// </summary>
public interface IProfileHistoryRepository : IRepository<ProfileHistory>
{
// Add custom ProfileHistory-specific methods here if needed
}
}

View File

@@ -0,0 +1,12 @@
using ECMJobRunner.Domain.Entities;
namespace ECMJobRunner.Domain.Interfaces
{
/// <summary>
/// Repository interface for ProfileSqlJob entity
/// </summary>
public interface IProfileSqlJobRepository : IRepository<ProfileSqlJob>
{
// Add custom ProfileSqlJob-specific methods here if needed
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Domain.Interfaces
{
/// <summary>
/// Generic repository interface for data access operations
/// </summary>
/// <typeparam name="TEntity">Entity type</typeparam>
public interface IRepository<TEntity> where TEntity : class
{
/// <summary>
/// Get entity by ID asynchronously
/// </summary>
Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// Get all entities asynchronously
/// </summary>
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Find entities by predicate asynchronously
/// </summary>
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Get single entity by predicate asynchronously
/// </summary>
Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Add new entity from DTO asynchronously
/// Maps DTO to entity and adds it
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <param name="dto">DTO containing values for new entity</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Created entity</returns>
Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class;
/// <summary>
/// Add multiple entities from DTOs asynchronously
/// Maps DTOs to entities and adds them
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <param name="dtos">DTOs containing values for new entities</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Number of entities added</returns>
Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class;
/// <summary>
/// Update entities matching predicate with DTO values asynchronously
/// Maps DTO properties onto matching entities
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <param name="predicate">Predicate to find entities</param>
/// <param name="dto">DTO containing values to update</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Number of entities updated</returns>
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class;
/// <summary>
/// Update single entity matching predicate with DTO values asynchronously
/// Throws exception if multiple entities match
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <param name="predicate">Predicate to find entity</param>
/// <param name="dto">DTO containing values to update</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if entity was found and updated, false otherwise</returns>
Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class;
/// <summary>
/// Delete entities matching predicate asynchronously (hard delete)
/// </summary>
/// <param name="predicate">Predicate to find entities to delete</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Number of entities deleted</returns>
Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Delete single entity matching predicate asynchronously (hard delete)
/// Throws exception if multiple entities match
/// </summary>
/// <param name="predicate">Predicate to find entity to delete</param>
/// <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);
}
}

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

@@ -0,0 +1,205 @@
# ECMJobRunner.Infrastructure
## Project Overview
ECMJobRunner.Infrastructure is the **infrastructure layer** for the ECM Job Runner system following **Clean Architecture** principles. This project contains:
- **DbContext implementation** with Entity Framework
- **Repository implementations** for data access
- **Entity configurations** (EF mappings)
- **Database connection management**
**Key Principle**: This layer implements the interfaces defined in the Domain layer and handles all database concerns.
## Target Frameworks
- **.NET Framework 4.8** (`net480`) - Uses **Entity Framework 6.5.1**
- **.NET 8.0** (`net8.0`) - Uses **Entity Framework Core 8.0.11**
The project uses conditional compilation to support both EF6 and EF Core with the same codebase.
## Architecture
This project follows **Clean Architecture** principles:
- **Depends on Domain layer** (implements domain interfaces)
- **No dependencies from Domain** (dependency inversion)
- **Conditional compilation** for EF6 vs EF Core differences
- **Repository pattern** implementation
- **Unit of Work pattern** implementation
## Project Structure
```
ECMJobRunner.Infrastructure/
├── Data/
│ └── JobRunnerDbContext.cs # DbContext with conditional compilation
├── Repositories/
│ ├── Repository.cs # Generic repository implementation
│ ├── ProfileRepository.cs # Profile-specific repository
│ ├── ProfileSqlJobRepository.cs
│ ├── ProfileHistoryRepository.cs
│ └── UnitOfWork.cs # Unit of Work implementation
├── ECMJobRunner.Infrastructure.csproj
└── AGENTS.md # This file
```
## Database Context
### JobRunnerDbContext
Multi-targeted DbContext supporting both EF6 and EF Core:
**DbSets:**
- `Profiles`: DbSet<Profile>
- `ProfileSqlJobs`: DbSet<ProfileSqlJob>
- `ProfileHistories`: DbSet<ProfileHistory>
**Configuration:**
- Connection string via constructor parameter
- Conditional compilation directives (`#if NET48` / `#else`)
- Fluent API configurations in `OnModelCreating`
**Table Mappings:**
- `Profile``dbo.TBJR_CFG_PROFILE`
- `ProfileSqlJob``dbo.TBJR_CFG_PROFILE_SQLJOB`
- `ProfileHistory``dbo.TBJR_OUT_PROFILE_HISTORY`
**Column Mappings:** (Examples)
- `Profile.ProfileName``PROFILE_NAME`
- `ProfileSqlJob.SqlCheckQuery``SQL_CHECK_QUERY`
- All navigation properties configured with relationships
## Repository Implementations
### Repository<TEntity>
Generic repository implementing `IRepository<TEntity>` with full CRUD operations.
**Key Methods:**
- Synchronous: `GetById`, `GetAll`, `Find`, `SingleOrDefault`, `Add`, `Update`, `Remove`
- Asynchronous: `GetByIdAsync`, `GetAllAsync`, `FindAsync`, `SingleOrDefaultAsync`
### Entity-Specific Repositories
- `ProfileRepository : Repository<Profile>, IProfileRepository`
- `ProfileSqlJobRepository : Repository<ProfileSqlJob>, IProfileSqlJobRepository`
- `ProfileHistoryRepository : Repository<ProfileHistory>, IProfileHistoryRepository`
These can be extended with entity-specific query methods as needed.
### UnitOfWork
Implements `IUnitOfWork` interface:
- Manages DbContext lifecycle
- Provides repository instances
- Handles transaction management via `SaveChanges`/`SaveChangesAsync`
## Connection String
Default connection string (configured in consuming applications):
```
Server=SDD-VMP04-SQL17\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;
```
## Database Schema Source
SQL scripts and schema definitions are located at:
```
M:\Datenbank\[DD_ECM]-Database\JobRunner\
```
## Dependencies
### .NET Framework 4.8 (`net480`)
- **ECMJobRunner.Domain** (project reference)
- **EntityFramework 6.5.1** (NuGet package)
### .NET 8.0 (`net8.0`)
- **ECMJobRunner.Domain** (project reference)
- **Microsoft.EntityFrameworkCore 8.0.11** (NuGet package)
- **Microsoft.EntityFrameworkCore.SqlServer 8.0.11** (NuGet package)
## Conditional Compilation
The project uses `#if NET48` / `#else` directives to handle differences between EF6 and EF Core:
**EF6 (.NET Framework 4.8):**
```csharp
#if NET48
using System.Data.Entity;
public class JobRunnerDbContext : DbContext
#endif
```
**EF Core (.NET 8.0):**
```csharp
#if !NET48
using Microsoft.EntityFrameworkCore;
public class JobRunnerDbContext : DbContext
#endif
```
## Building the Project
```bash
dotnet build ECMJobRunner.Infrastructure.csproj
```
For specific framework:
```bash
dotnet build ECMJobRunner.Infrastructure.csproj -f net8.0
dotnet build ECMJobRunner.Infrastructure.csproj -f net480
```
## Entity Framework Differences
### DbContext Constructor
- **EF6**: Accepts connection string directly
- **EF Core**: Requires `DbContextOptions<T>`
### Configuration
- **EF6**: `DbModelBuilder` in `OnModelCreating`
- **EF Core**: `ModelBuilder` in `OnModelCreating`
### Querying
- **EF6**: `DbSet<T>.AsNoTracking()` extension
- **EF Core**: Same API, built-in support
### Async Operations
- **EF6**: Limited async support
- **EF Core**: Full async/await support
## Development Notes
- **Nullable navigation properties**: `IEnumerable<T>?` to support defensive programming
- **No lazy loading**: Navigation properties loaded explicitly via `.Include()`
- **Transaction management**: Handled by Unit of Work pattern
- **Repository pattern**: Encapsulates EF-specific code
- **Clean Architecture**: Infrastructure depends on Domain, not vice versa
## Usage Example
```csharp
// Create DbContext (connection string from configuration)
var connectionString = ConfigurationManager.ConnectionStrings["JobRunner"].ConnectionString;
var context = new JobRunnerDbContext(connectionString);
// Use Unit of Work
using var unitOfWork = new UnitOfWork(context);
// Query profiles
var activeProfiles = await unitOfWork.Profiles
.FindAsync(p => p.Active);
// Add new profile
var profile = new Profile { ProfileName = "Test", Active = true };
unitOfWork.Profiles.Add(profile);
await unitOfWork.SaveChangesAsync();
```
## Related Projects
- **ECMJobRunner.Domain**: Contains entities and repository interfaces
- **ECMJobRunner.Application**: Application layer consuming repositories
## Company Information
**Author**: Digital Data GmbH
**Copyright**: 2026
**Repository**: http://git.dd:3000/AppStd/ECMJobRunner.git

View File

@@ -0,0 +1,61 @@
#if NET48
using System.Data.Entity;
using System.Data.Common;
#else
using Microsoft.EntityFrameworkCore;
#endif
using ECMJobRunner.Domain.Entities;
namespace ECMJobRunner.Infrastructure.Data
{
/// <summary>
/// Entity Framework DbContext for ECM Job Runner
/// </summary>
public class JobRunnerDbContext : DbContext
{
/// <summary>
/// Profile entities
/// </summary>
public DbSet<CfgProfile> CfgProfiles { get; set; } = null!;
/// <summary>
/// ProfileSqlJob entities
/// </summary>
public DbSet<ProfileSqlJob> ProfileSqlJobs { get; set; } = null!;
/// <summary>
/// ProfileHistory entities
/// </summary>
public DbSet<ProfileHistory> ProfileHistories { get; set; } = null!;
#if NET48
/// <summary>
/// Default constructor for Entity Framework 6 (.NET Framework 4.8)
/// </summary>
public JobRunnerDbContext() : base("name=JobRunnerConnection")
{
}
/// <summary>
/// Constructor with connection string for Entity Framework 6
/// </summary>
public JobRunnerDbContext(string connectionString) : base(connectionString)
{
}
/// <summary>
/// Constructor with DbConnection for Entity Framework 6 (used for InMemory testing with Effort)
/// </summary>
public JobRunnerDbContext(DbConnection connection) : base(connection, contextOwnsConnection: true)
{
}
#else
/// <summary>
/// Constructor for Entity Framework Core (.NET 8)
/// </summary>
public JobRunnerDbContext(DbContextOptions<JobRunnerDbContext> options) : base(options)
{
}
#endif
}
}

View File

@@ -0,0 +1,114 @@
#if NET48
using System.Data.Entity;
using Microsoft.Extensions.DependencyInjection;
#else
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
#endif
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using ECMJobRunner.Infrastructure.Repositories;
using ECMJobRunner.Infrastructure.Services;
namespace ECMJobRunner.Infrastructure
{
/// <summary>
/// Dependency injection extension methods for Infrastructure layer
/// </summary>
public static class DependencyExtension
{
/// <summary>
/// Add Infrastructure services to dependency injection container
/// </summary>
/// <param name="services">Service collection</param>
/// <param name="connectionString">Database connection string</param>
/// <returns>Service collection for chaining</returns>
public static IServiceCollection AddJobRunnerInfrastructure(this IServiceCollection services, string connectionString)
{
#if NET48
// Register DbContext as scoped for EF6 - same pattern as EF Core
// Each scope (request) gets its own DbContext instance
services.AddScoped(provider => new JobRunnerDbContext(connectionString));
#else
// Register DbContext as scoped for EF Core
services.AddDbContext<JobRunnerDbContext>(options =>
options.UseSqlServer(connectionString));
#endif
// Register AutoMapper
services.AddAutoMapper(typeof(DependencyExtension).Assembly);
// Register repositories as scoped (same lifetime as DbContext)
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<ICfgProfileRepository, CfgProfileRepository>();
services.AddScoped<IProfileSqlJobRepository, ProfileSqlJobRepository>();
services.AddScoped<IProfileHistoryRepository, ProfileHistoryRepository>();
// Register SQL executor as scoped
services.AddScoped<ISQLExecutor, SQLExecutor>();
return services;
}
#if NET48
/// <summary>
/// Add Infrastructure services with InMemory database for testing (.NET Framework 4.8 - uses Effort)
/// </summary>
/// <param name="services">Service collection</param>
/// <returns>Service collection for chaining</returns>
public static IServiceCollection AddInfrastructureInMemory(this IServiceCollection services)
{
// Register DbContext as scoped with Effort InMemory connection
services.AddScoped(provider =>
{
var connection = Effort.DbConnectionFactory.CreateTransient();
return new JobRunnerDbContext(connection);
});
// Register AutoMapper
services.AddAutoMapper(typeof(DependencyExtension).Assembly);
// Register repositories as scoped (same lifetime as DbContext)
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<ICfgProfileRepository, CfgProfileRepository>();
services.AddScoped<IProfileSqlJobRepository, ProfileSqlJobRepository>();
services.AddScoped<IProfileHistoryRepository, ProfileHistoryRepository>();
// Register SQL executor as scoped
services.AddScoped<ISQLExecutor, SQLExecutor>();
return services;
}
#else
/// <summary>
/// Add Infrastructure services with InMemory database for testing (.NET 8 - uses EF Core InMemory)
/// </summary>
/// <param name="services">Service collection</param>
/// <param name="databaseName">Optional database name (default: random GUID)</param>
/// <returns>Service collection for chaining</returns>
public static IServiceCollection AddInfrastructureInMemory(this IServiceCollection services, string? databaseName = null)
{
var dbName = databaseName ?? $"TestDb_{System.Guid.NewGuid()}";
// Register DbContext as scoped with InMemory database
services.AddDbContext<JobRunnerDbContext>(options =>
options.UseInMemoryDatabase(dbName));
// Register AutoMapper
services.AddAutoMapper(typeof(DependencyExtension).Assembly);
// Register repositories as scoped (same lifetime as DbContext)
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<ICfgProfileRepository, CfgProfileRepository>();
services.AddScoped<IProfileSqlJobRepository, ProfileSqlJobRepository>();
services.AddScoped<IProfileHistoryRepository, ProfileHistoryRepository>();
// Register SQL executor as scoped
services.AddScoped<ISQLExecutor, SQLExecutor>();
return services;
}
#endif
}
}

View File

@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net480;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
<PackageId>ECMJobRunner.Infrastructure</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>ECMJobRunner.Infrastructure</Product>
<Copyright>Copyright 2026</Copyright>
<RepositoryUrl>http://git.dd:3000/AppStd/ECMJobRunner.git</RepositoryUrl>
<PackageTags>digital data ecm job runner infrastructure</PackageTags>
<!-- Suppress AutoMapper vulnerability warning (known issue, acceptable for this project) -->
<NoWarn>$(NoWarn);NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
<!-- Entity Framework 6 for .NET Framework 4.8 -->
<PackageReference Include="EntityFramework" Version="6.5.1" />
<!-- Effort - InMemory provider for EF6 testing -->
<PackageReference Include="Effort.EF6" Version="2.2.16" />
<!-- AutoMapper for .NET Framework 4.8 -->
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<!-- Dependency Injection for .NET Framework 4.8 -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<!-- Entity Framework Core 8 for .NET 8 -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
<!-- InMemory provider for EF Core testing -->
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<!-- AutoMapper for .NET 8 - version 12.0.1 matches Extensions package -->
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
using AutoMapper;
using ECMJobRunner.Domain.Entities;
namespace ECMJobRunner.Infrastructure.Mapping
{
/// <summary>
/// AutoMapper profile for entity mappings
/// </summary>
public class EntityMappingProfile : Profile
{
/// <summary>
/// Constructor configuring AutoMapper mappings for all entities
/// </summary>
public EntityMappingProfile()
{
// Note: object -> Entity mappings are intentionally removed
// AutoMapper cannot map from object type due to reflection limitations
// Callers should use explicit DTO types and create specific mappings
// Example: CreateMap<YourDto, CfgProfile>() in your own AutoMapper profile
}
}
}

View File

@@ -0,0 +1,50 @@
using AutoMapper;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
#if NET48
using System.Data.Entity;
#else
using Microsoft.EntityFrameworkCore;
#endif
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// CfgProfile repository implementation
/// </summary>
public class CfgProfileRepository : Repository<CfgProfile>, ICfgProfileRepository
{
/// <summary>
/// Constructor
/// </summary>
public CfgProfileRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
{
}
/// <summary>
/// Gets a profile by ID with associated SQL jobs eagerly loaded
/// </summary>
public async Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default)
{
return await Context.CfgProfiles
.Include(p => p.SqlJobs)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
/// <summary>
/// Gets all active profiles with associated SQL jobs eagerly loaded
/// </summary>
public async Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default)
{
return await Context.CfgProfiles
.Include(p => p.SqlJobs)
.Where(p => p.Active)
.ToListAsync(cancellationToken);
}
}
}

View File

@@ -0,0 +1,22 @@
using AutoMapper;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// ProfileHistory repository implementation
/// </summary>
public class ProfileHistoryRepository : Repository<ProfileHistory>, IProfileHistoryRepository
{
/// <summary>
/// Constructor
/// </summary>
public ProfileHistoryRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
{
}
// Entity-specific methods can be added here in the future
}
}

View File

@@ -0,0 +1,22 @@
using AutoMapper;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// ProfileSqlJob repository implementation
/// </summary>
public class ProfileSqlJobRepository : Repository<ProfileSqlJob>, IProfileSqlJobRepository
{
/// <summary>
/// Constructor
/// </summary>
public ProfileSqlJobRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
{
}
// Entity-specific methods can be added here in the future
}
}

View File

@@ -0,0 +1,177 @@
#if NET48
using System.Data.Entity;
#else
using Microsoft.EntityFrameworkCore;
#endif
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// Generic repository implementation for Entity Framework
/// Uses AutoMapper for DTO mapping
/// </summary>
/// <typeparam name="TEntity">Entity type</typeparam>
/// <remarks>
/// Constructor
/// </remarks>
public class Repository<TEntity>(JobRunnerDbContext context, IMapper mapper) : IRepository<TEntity> where TEntity : class
{
/// <summary>
/// Database context
/// </summary>
protected readonly JobRunnerDbContext Context = context ?? throw new ArgumentNullException(nameof(context));
/// <summary>
/// DbSet for the entity
/// </summary>
protected readonly DbSet<TEntity> DbSet = context.Set<TEntity>();
/// <summary>
/// AutoMapper instance for DTO mapping
/// </summary>
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);
#else
return await DbSet.FindAsync([id], cancellationToken);
#endif
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
{
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);
}
/// <inheritdoc/>
public virtual async Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
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));
#endif
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));
#endif
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);
if (entities.Count == 0)
return 0;
foreach (var entity in entities)
{
Mapper.Map(dto, entity);
}
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);
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);
if (entities.Count == 0)
return 0;
DbSet.RemoveRange(entities);
return await Context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
if (entity == null)
return false;
DbSet.Remove(entity);
await Context.SaveChangesAsync(cancellationToken);
return true;
}
}
}

View File

@@ -0,0 +1,57 @@
#if NET48
using System.Data.Entity;
#else
using Microsoft.EntityFrameworkCore;
#endif
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
namespace ECMJobRunner.Infrastructure.Services
{
/// <summary>
/// Entity Framework implementation of ISQLExecutor
/// Executes raw SQL queries and maps results to DTOs
/// </summary>
public class SQLExecutor : ISQLExecutor
{
private readonly JobRunnerDbContext _context;
/// <summary>
/// Initializes a new instance of SQLExecutor
/// </summary>
/// <param name="context">The database context</param>
public SQLExecutor(JobRunnerDbContext context)
{
_context = context;
}
/// <summary>
/// Executes a SQL query and maps the result to the specified type
/// </summary>
/// <typeparam name="TResult">The type to map the query result to</typeparam>
/// <param name="sql">The SQL query to execute</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The mapped result or null if no result</returns>
public async Task<TResult?> ExecuteQueryAsync<TResult>(string sql, CancellationToken cancellationToken = default)
{
#if NET48
// Entity Framework 6 implementation
var result = await _context.Database
.SqlQuery<TResult>(sql)
.ToListAsync(cancellationToken);
return result.FirstOrDefault();
#else
// Entity Framework Core implementation
var result = await _context.Database
.SqlQueryRaw<TResult>(sql)
.ToListAsync(cancellationToken);
return result.FirstOrDefault();
#endif
}
}
}

View File

@@ -0,0 +1,234 @@
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;
using Microsoft.Extensions.Options;
using Moq;
using System;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class CheckQueryExecutionBehaviorTests
{
private readonly Mock<ISQLExecutor> _mockExecutor;
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
private readonly CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit> _behavior;
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
public CheckQueryExecutionBehaviorTests()
{
_mockExecutor = new Mock<ISQLExecutor>();
_mockOptions = new Mock<IOptions<DexJobOptions>>();
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
_behavior = new CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
}
[Fact]
public async Task Handle_WithValidCheckQuery_ExecutesSuccessfully()
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CheckQueryResult { ReturnValue = 5 });
// Act
#if NET48
var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
_mockNext.Verify(n => n(), Times.Once);
}
[Theory]
[InlineData(1)]
[InlineData(10)]
[InlineData(100)]
public async Task Handle_WithPositiveReturnValue_DoesNotThrow(int returnValue)
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CheckQueryResult { ReturnValue = returnValue });
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().NotThrowAsync();
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-10)]
public async Task Handle_WithZeroOrNegativeReturnValue_ThrowsDEXJobException(int returnValue)
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CheckQueryResult { ReturnValue = returnValue });
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage($"*unexpectedly returned the value {returnValue}*");
}
[Fact]
public async Task Handle_WithNullCheckQuery_IgnoresByDefault()
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = null },
BatchId = "20260711143025123456"
};
// Act
#if NET48
var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
_mockNext.Verify(n => n(), Times.Once);
}
[Fact]
public async Task Handle_WithNullCheckQueryAndStopOption_ThrowsDEXJobException()
{
// Arrange
var options = new DexJobOptions
{
Error = new DexJobOptions.DexJobErrorHandlingOptions
{
CheckQuery = new DexJobOptions.DexJobErrorHandlingOptions.SqlQueryErrorHandlingOptions
{
IfNullOrWhiteSpace = ErrorAction.Stop
}
}
};
_mockOptions.Setup(o => o.Value).Returns(options);
var behavior = new CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = null },
BatchId = "20260711143025123456"
};
// Act
#if NET48
Func<Task> act = async () => await behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*SQL Check Query is null or empty*");
}
[Fact]
public async Task Handle_WithBatchIdPlaceholder_ReplacesCorrectly()
{
// Arrange
var batchId = "20260711143025123456";
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table WHERE BatchId = '#INT#BATCH_ID'" },
BatchId = batchId
};
string? capturedSql = null;
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, CancellationToken>((sql, _) => capturedSql = sql)
.ReturnsAsync(new CheckQueryResult { ReturnValue = 1 });
// Act
#if NET48
await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
capturedSql.Should().Contain(batchId);
capturedSql.Should().NotContain("#INT#BATCH_ID");
}
[Fact]
public async Task Handle_WithNullResult_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CheckQueryResult?)null);
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*Check Query returned nothing*");
}
}
}

View File

@@ -0,0 +1,302 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.DEXJob.Queries;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using FluentAssertions;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class GetProfileByIdQueryTests
{
private readonly Mock<ICfgProfileRepository> _mockProfileRepo;
private readonly GetProfileByIdQueryHandler _handler;
public GetProfileByIdQueryTests()
{
_mockProfileRepo = new Mock<ICfgProfileRepository>();
_handler = new GetProfileByIdQueryHandler(_mockProfileRepo.Object);
}
[Fact]
public async Task Handle_WithValidProfileId_ReturnsProfile()
{
// Arrange
var profileId = 123L;
var profile = new CfgProfile
{
Id = profileId,
Active = true,
ProfileName = "Test Profile",
TypeId = 2,
Schedule = "0 30 4 ? * MON-SAT",
Comment = "Test comment",
AddedWho = "TestUser",
AddedWhen = DateTime.Now,
SqlJobs = new List<ProfileSqlJob>
{
new ProfileSqlJob
{
Id = 1,
ProfileId = profileId,
Active = true,
Sequence = 1,
Name = "Job 1",
SqlMainQuery = "SELECT 1",
SqlCheckQuery = "SELECT COUNT(*) FROM Table1"
}
}
};
_mockProfileRepo
.Setup(r => r.GetByIdWithSqlJobsAsync(profileId, It.IsAny<CancellationToken>()))
.ReturnsAsync(profile);
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = true };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(profileId);
result.ProfileName.Should().Be("Test Profile");
result.Active.Should().BeTrue();
result.TypeId.Should().Be(2);
result.SqlJobs.Should().NotBeNull();
result.SqlJobs!.Count.Should().Be(1);
result.SqlJobs[0].Name.Should().Be("Job 1");
}
[Fact]
public async Task Handle_WithNonExistentProfileId_ReturnsNull()
{
// Arrange
var profileId = 999L;
_mockProfileRepo
.Setup(r => r.GetByIdWithSqlJobsAsync(profileId, It.IsAny<CancellationToken>()))
.ReturnsAsync((CfgProfile?)null);
var query = new GetProfileByIdQuery { ProfileId = profileId };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task Handle_WithIncludeSqlJobsFalse_DoesNotLoadSqlJobs()
{
// Arrange
var profileId = 123L;
var profile = new CfgProfile
{
Id = profileId,
Active = true,
ProfileName = "Test Profile",
TypeId = 2,
Schedule = "0 30 4 ? * MON-SAT",
AddedWho = "TestUser",
AddedWhen = DateTime.Now
};
_mockProfileRepo
.Setup(r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()))
.ReturnsAsync(profile);
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = false };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.SqlJobs.Should().BeNull();
_mockProfileRepo.Verify(
r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()),
Times.Once);
_mockProfileRepo.Verify(
r => r.GetByIdWithSqlJobsAsync(It.IsAny<long>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Handle_MapsAllProperties_Correctly()
{
// Arrange
var profileId = 123L;
var addedWhen = new DateTime(2026, 1, 1, 10, 0, 0);
var changedWhen = new DateTime(2026, 1, 2, 14, 30, 0);
var profile = new CfgProfile
{
Id = profileId,
Active = false,
ProfileName = "Inactive Profile",
TypeId = 3,
Schedule = "0 0 12 * * ?",
Comment = "Detailed comment",
AddedWho = "Admin",
AddedWhen = addedWhen,
ChangedWho = "Editor",
ChangedWhen = changedWhen
};
_mockProfileRepo
.Setup(r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()))
.ReturnsAsync(profile);
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = false };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(profileId);
result.Active.Should().BeFalse();
result.ProfileName.Should().Be("Inactive Profile");
result.TypeId.Should().Be(3);
result.Schedule.Should().Be("0 0 12 * * ?");
result.Comment.Should().Be("Detailed comment");
result.AddedWho.Should().Be("Admin");
result.AddedWhen.Should().Be(addedWhen);
result.ChangedWho.Should().Be("Editor");
result.ChangedWhen.Should().Be(changedWhen);
}
}
public class GetAllActiveProfilesQueryTests
{
private readonly Mock<ICfgProfileRepository> _mockProfileRepo;
private readonly GetAllActiveProfilesQueryHandler _handler;
public GetAllActiveProfilesQueryTests()
{
_mockProfileRepo = new Mock<ICfgProfileRepository>();
_handler = new GetAllActiveProfilesQueryHandler(_mockProfileRepo.Object);
}
[Fact]
public async Task Handle_ReturnsAllActiveProfiles()
{
// Arrange
var profiles = new List<CfgProfile>
{
new CfgProfile
{
Id = 1,
Active = true,
ProfileName = "Profile 1",
TypeId = 2,
Schedule = "0 30 4 ? * MON-SAT",
AddedWho = "User1",
AddedWhen = DateTime.Now,
SqlJobs = new List<ProfileSqlJob>
{
new ProfileSqlJob { Id = 1, ProfileId = 1, Sequence = 1, Name = "Job 1" }
}
},
new CfgProfile
{
Id = 2,
Active = true,
ProfileName = "Profile 2",
TypeId = 3,
Schedule = "0 0 12 * * ?",
AddedWho = "User2",
AddedWhen = DateTime.Now,
SqlJobs = new List<ProfileSqlJob>
{
new ProfileSqlJob { Id = 2, ProfileId = 2, Sequence = 1, Name = "Job 2" }
}
}
};
_mockProfileRepo
.Setup(r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(profiles);
var query = new GetAllActiveProfilesQuery { IncludeSqlJobs = true };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(2);
result[0].ProfileName.Should().Be("Profile 1");
result[1].ProfileName.Should().Be("Profile 2");
result[0].SqlJobs.Should().NotBeNull();
result[0].SqlJobs!.Count.Should().Be(1);
result[1].SqlJobs.Should().NotBeNull();
result[1].SqlJobs!.Count.Should().Be(1);
}
[Fact]
public async Task Handle_WithNoActiveProfiles_ReturnsEmptyList()
{
// Arrange
_mockProfileRepo
.Setup(r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<CfgProfile>());
var query = new GetAllActiveProfilesQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task Handle_WithIncludeSqlJobsFalse_DoesNotLoadSqlJobs()
{
// Arrange
var profiles = new List<CfgProfile>
{
new CfgProfile
{
Id = 1,
Active = true,
ProfileName = "Profile 1",
TypeId = 2,
Schedule = "0 30 4 ? * MON-SAT",
AddedWho = "User1",
AddedWhen = DateTime.Now
}
};
_mockProfileRepo
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<CfgProfile, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(profiles);
var query = new GetAllActiveProfilesQuery { IncludeSqlJobs = false };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(1);
result[0].SqlJobs.Should().BeNull();
_mockProfileRepo.Verify(
r => r.FindAsync(It.IsAny<Expression<Func<CfgProfile, bool>>>(), It.IsAny<CancellationToken>()),
Times.Once);
_mockProfileRepo.Verify(
r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()),
Times.Never);
}
}
}

View File

@@ -0,0 +1,252 @@
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;
using Microsoft.Extensions.Options;
using Moq;
using System;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class MainQueryExecutionBehaviorTests
{
private readonly Mock<ISQLExecutor> _mockExecutor;
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
private readonly MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit> _behavior;
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
public MainQueryExecutionBehaviorTests()
{
_mockExecutor = new Mock<ISQLExecutor>();
_mockOptions = new Mock<IOptions<DexJobOptions>>();
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
_behavior = new MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
}
[Fact]
public async Task Handle_WithValidMainQuery_ExecutesSuccessfully()
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MainQueryResult { ReturnValue = null });
// Act
#if NET48
var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
_mockNext.Verify(n => n(), Times.Once);
}
[Fact]
public async Task Handle_WithNullReturnValue_DoesNotThrow()
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MainQueryResult { ReturnValue = null });
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().NotThrowAsync();
}
[Fact]
public async Task Handle_WithNonNullReturnValue_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MainQueryResult { ReturnValue = 1 });
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*unexpectedly returned the value 1*");
}
[Fact]
public async Task Handle_WithNullMainQuery_IgnoresByDefault()
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = null },
BatchId = "20260711143025123456"
};
// Act
#if NET48
var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
_mockNext.Verify(n => n(), Times.Once);
}
[Fact]
public async Task Handle_WithNullMainQueryAndStopOption_ThrowsDEXJobException()
{
// Arrange
var options = new DexJobOptions
{
Error = new DexJobOptions.DexJobErrorHandlingOptions
{
MainQuery = new DexJobOptions.DexJobErrorHandlingOptions.SqlQueryErrorHandlingOptions
{
IfNullOrWhiteSpace = ErrorAction.Stop
}
}
};
_mockOptions.Setup(o => o.Value).Returns(options);
var behavior = new MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = null },
BatchId = "20260711143025123456"
};
// Act
#if NET48
Func<Task> act = async () => await behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*SQL Main Query is null or empty*");
}
[Fact]
public async Task Handle_WithBatchIdPlaceholder_ReplacesCorrectly()
{
// Arrange
var batchId = "20260711143025123456";
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')" },
BatchId = batchId
};
string? capturedSql = null;
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, CancellationToken>((sql, _) => capturedSql = sql)
.ReturnsAsync(new MainQueryResult { ReturnValue = null });
// Act
#if NET48
await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
capturedSql.Should().Contain(batchId);
capturedSql.Should().NotContain("#INT#BATCH_ID");
}
[Fact]
public async Task Handle_WithExecutionError_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INVALID SQL" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SQL error"));
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*SQL Main Query*");
}
[Fact]
public async Task Handle_WithNonDEXJobCommand_SkipsExecution()
{
// Arrange
var otherCommand = new OtherCommand();
var behavior = new MainQueryExecutionBehavior<OtherCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var mockNext = new Mock<RequestHandlerDelegate<Unit>>();
mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
// Act
#if NET48
var result = await behavior.Handle(otherCommand, CancellationToken.None, mockNext.Object);
#else
var result = await behavior.Handle(otherCommand, mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
mockNext.Verify(n => n(), Times.Once);
}
private class OtherCommand : IRequest<Unit> { }
}
}

View File

@@ -0,0 +1,136 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using FluentAssertions;
using MediatR;
using Microsoft.Extensions.Options;
using Moq;
using ReC.Client;
using ReC.Client.Api;
using System;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class TriggeringDEXJobBatchCommandTests
{
private readonly Mock<IProfileSqlJobRepository> _mockJobRepo;
private readonly Mock<ISender> _mockSender;
private readonly TriggeringDEXJobBatchCommandHandler _handler;
public TriggeringDEXJobBatchCommandTests()
{
_mockJobRepo = new Mock<IProfileSqlJobRepository>();
_mockSender = new Mock<ISender>();
_handler = new TriggeringDEXJobBatchCommandHandler(_mockJobRepo.Object, _mockSender.Object);
}
[Fact]
public async Task Handle_WithValidProfileId_ExecutesAllJobs()
{
// Arrange
var profileId = 123;
var jobs = new[]
{
new ProfileSqlJob { Id = 1, ProfileId = profileId, SqlMainQuery = "SELECT 1", SqlCheckQuery = "SELECT 2" },
new ProfileSqlJob { Id = 2, ProfileId = profileId, SqlMainQuery = "SELECT 3", SqlCheckQuery = "SELECT 4" }
};
_mockJobRepo
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(jobs);
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().Be(Unit.Value);
_mockSender.Verify(
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()),
Times.Exactly(2),
"Should send command for each job");
}
[Fact]
public async Task Handle_WithNoJobs_CompletesWithoutError()
{
// Arrange
var profileId = 999;
_mockJobRepo
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<ProfileSqlJob>());
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().Be(Unit.Value);
_mockSender.Verify(
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()),
Times.Never,
"Should not send any commands when no jobs found");
}
[Fact]
public void CreateBatchId_ReturnsValidFormat()
{
// Act
var batchId = TriggeringDEXJobBatchCommandHandler.CreateBatchId();
// Assert
batchId.Should().NotBeNullOrEmpty();
batchId.Should().HaveLength(20, "BatchId should be exactly 20 characters");
batchId.Should().MatchRegex(@"^\d{20}$", "BatchId should contain only digits");
}
[Fact]
public void CreateBatchId_GeneratesUniqueBatchIds()
{
// Act
var batchId1 = TriggeringDEXJobBatchCommandHandler.CreateBatchId();
var batchId2 = TriggeringDEXJobBatchCommandHandler.CreateBatchId();
// Assert
batchId1.Should().NotBe(batchId2, "Consecutive batch IDs should be different");
}
[Fact]
public async Task Handle_PassesBatchIdToCommands()
{
// Arrange
var profileId = 123;
var job = new ProfileSqlJob { Id = 1, ProfileId = profileId, SqlMainQuery = "SELECT 1" };
_mockJobRepo
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { job });
TriggeringProfileJobCommand? capturedCommand = null;
_mockSender
.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 TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
capturedCommand.Should().NotBeNull();
capturedCommand!.BatchId.Should().NotBeNullOrEmpty();
capturedCommand.BatchId.Should().HaveLength(20);
capturedCommand.Job.Should().Be(job);
}
}
}

View File

@@ -0,0 +1,164 @@
using ECMJobRunner.Application.Common.Exceptions;
using FluentAssertions;
using System;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class TriggeringDEXJobCommandExceptionTests
{
[Fact]
public void JobSqlException_HasCorrectMessage()
{
// Arrange
var jobName = "Main Query Execution";
var processName = "MainQueryExecution";
var batchId = "12345678901234567890";
var reason = "SQL syntax error";
var query = "SELECT * FROM NonExistentTable";
// Act
var exception = new JobSqlException(
jobName,
processName,
batchId,
reason,
query,
null);
// Assert
exception.JobName.Should().Be(jobName);
exception.ProcessName.Should().Be(processName);
exception.BatchId.Should().Be(batchId);
exception.Query.Should().Be(query);
exception.Message.Should().Contain(jobName);
exception.Message.Should().Contain(processName);
exception.Message.Should().Contain(batchId);
exception.Message.Should().Contain(reason);
exception.Message.Should().Contain(query);
}
[Fact]
public void JobSqlException_WithInnerException_PreservesInnerException()
{
// Arrange
var innerException = new InvalidOperationException("Database connection failed");
var exception = new JobSqlException(
"Main Query",
"Execution",
"12345678901234567890",
"Connection error",
"SELECT 1",
innerException);
// Assert
exception.InnerException.Should().Be(innerException);
exception.InnerException!.Message.Should().Be("Database connection failed");
}
[Fact]
public void JobHttpException_HasCorrectMessage()
{
// Arrange
var jobName = "ReC Request Execution";
var processName = "ReCRequestExecution";
var batchId = "12345678901234567890";
var reason = "HTTP 500 Internal Server Error";
var clientLibrary = "ReC.Client";
var clientMethod = "ExecuteAsync";
// Act
var exception = new JobHttpException(
jobName,
processName,
batchId,
reason,
clientLibrary,
clientMethod,
null);
// Assert
exception.JobName.Should().Be(jobName);
exception.ProcessName.Should().Be(processName);
exception.BatchId.Should().Be(batchId);
exception.ClientLibrary.Should().Be(clientLibrary);
exception.ClientMethod.Should().Be(clientMethod);
exception.Message.Should().Contain(jobName);
exception.Message.Should().Contain(processName);
exception.Message.Should().Contain(batchId);
exception.Message.Should().Contain(reason);
exception.Message.Should().Contain(clientLibrary);
exception.Message.Should().Contain(clientMethod);
}
[Fact]
public void JobHttpException_WithInnerException_PreservesInnerException()
{
// Arrange
var innerException = new TimeoutException("Request timed out");
var exception = new JobHttpException(
"ReC Request",
"Execution",
"12345678901234567890",
"Timeout error",
"ReC.Client",
"ExecuteAsync",
innerException);
// Assert
exception.InnerException.Should().Be(innerException);
exception.InnerException!.Message.Should().Be("Request timed out");
}
[Fact]
public void JobException_OmitsNullValues_WhenIgnoreIfNullIsTrue()
{
// Arrange & Act
var exception = new JobException(
"Test Job",
"Test Process",
"12345678901234567890",
null, // reason is null
null,
("Custom Detail", "Value", false));
// Assert
exception.Message.Should().NotContain("Reason:"); // Should be omitted because it's null and IgnoreIfNull=true
exception.Message.Should().Contain("Custom Detail: Value");
}
[Fact]
public void JobException_IncludesNullValues_WhenIgnoreIfNullIsFalse()
{
// Arrange & Act
var exception = new JobException(
"Test Job",
"Test Process",
"12345678901234567890",
null,
null,
("Custom Detail", null, false)); // IgnoreIfNull=false
// Assert
exception.Message.Should().Contain("Custom Detail:"); // Should be included even though value is null
}
[Fact]
public void JobException_FormatsMessageWithSeparators()
{
// Arrange & Act
var exception = new JobException(
"Test Job",
"Test Process",
"12345678901234567890",
"Test reason",
null);
// Assert
exception.Message.Should().Contain("─────────────────────────────────────────");
exception.Message.Should().Contain("Test Job could not be completed.");
exception.Message.Should().Contain("Process Name: Test Process");
exception.Message.Should().Contain("Batch Id: 12345678901234567890");
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace ECMJobRunner.Tests.DTOs
{
/// <summary>
/// DTO for CfgProfile entity used in tests
/// </summary>
public class CfgProfileDto
{
public bool Active { get; set; }
public string ProfileName { get; set; } = string.Empty;
public byte TypeId { get; set; }
public string? Schedule { get; set; }
public string? Comment { get; set; }
public string AddedWho { get; set; } = string.Empty;
public DateTime AddedWhen { get; set; }
public string ChangedWho { get; set; } = string.Empty;
public DateTime ChangedWhen { get; set; }
}
}

View File

@@ -0,0 +1,85 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net480;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Suppress AutoMapper vulnerability warning for .NET Framework 4.8 -->
<NoWarn>$(NoWarn);NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- Test Framework - Common for both frameworks -->
<PackageReference Include="coverlet.collector" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- Fluent Assertions for Better Test Readability -->
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<!-- Mocking Framework -->
<PackageReference Include="Moq" Version="4.20.72" />
<!-- Fake Data Generation (common for both frameworks) -->
<PackageReference Include="Bogus" Version="35.6.1" />
</ItemGroup>
<!-- .NET Framework 4.8 specific packages -->
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
<!-- Dependency Injection & Hosting for .NET Framework 4.8 -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<!-- AutoMapper for .NET Framework 4.8 (matching Infrastructure) -->
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<!-- MediatR for .NET Framework 4.8 -->
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<!-- ReC.Client for HTTP mocking -->
<PackageReference Include="ReC.Client" Version="1.0.0" />
</ItemGroup>
<!-- .NET 8.0 specific packages -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<!-- Dependency Injection & Hosting for .NET 8 -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<!-- AutoMapper for .NET 8 (matching Infrastructure) -->
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<!-- MediatR for .NET 8 -->
<PackageReference Include="MediatR" Version="12.4.1" />
<!-- ReC.Client for HTTP mocking -->
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Project References -->
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
<ProjectReference Include="..\ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj" />
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,79 @@
using Bogus;
using ECMJobRunner.Domain.Entities;
namespace ECMJobRunner.Tests.Infrastructure;
/// <summary>
/// Fake data generator using Bogus library
/// </summary>
public static class FakeDataGenerator
{
private static int _profileIdCounter = 1;
private static int _sqlJobIdCounter = 1;
private static int _historyIdCounter = 1;
/// <summary>
/// Reset ID counters (call at the beginning of each test)
/// </summary>
public static void ResetIdCounters()
{
_profileIdCounter = 1;
_sqlJobIdCounter = 1;
_historyIdCounter = 1;
}
/// <summary>
/// Generate fake CfgProfile
/// </summary>
public static Faker<CfgProfile> CfgProfileFaker()
{
return new Faker<CfgProfile>()
.RuleFor(p => p.Id, f => _profileIdCounter++)
.RuleFor(p => p.Active, f => f.Random.Bool())
.RuleFor(p => p.ProfileName, f => f.Commerce.ProductName())
.RuleFor(p => p.TypeId, f => f.Random.Byte(0, 3))
.RuleFor(p => p.Schedule, f => $"{f.Random.Int(0, 59)} {f.Random.Int(0, 23)} * * *")
.RuleFor(p => p.Comment, f => f.Lorem.Sentence())
.RuleFor(p => p.AddedWho, f => f.Internet.UserName())
.RuleFor(p => p.AddedWhen, f => f.Date.Past())
.RuleFor(p => p.ChangedWho, f => f.Internet.UserName())
.RuleFor(p => p.ChangedWhen, f => f.Date.Recent());
}
/// <summary>
/// Generate fake ProfileSqlJob
/// </summary>
public static Faker<ProfileSqlJob> ProfileSqlJobFaker(long? profileId = null)
{
return new Faker<ProfileSqlJob>()
.RuleFor(j => j.Id, f => _sqlJobIdCounter++)
.RuleFor(j => j.ProfileId, f => profileId ?? f.Random.Long(1, 100))
.RuleFor(j => j.Active, f => f.Random.Bool())
.RuleFor(j => j.Sequence, f => f.Random.Short(1, 100))
.RuleFor(j => j.Name, f => f.Hacker.Verb())
.RuleFor(j => j.SqlCheckQuery, f => $"SELECT COUNT(*) FROM {f.Database.Type()}")
.RuleFor(j => j.SqlMainQuery, f => $"SELECT * FROM {f.Database.Type()}")
.RuleFor(j => j.ApiCommand, f => f.Internet.Url())
.RuleFor(j => j.Comment, f => f.Lorem.Sentence())
.RuleFor(j => j.AddedWho, f => f.Internet.UserName())
.RuleFor(j => j.AddedWhen, f => f.Date.Past())
.RuleFor(j => j.ChangedWho, f => f.Internet.UserName())
.RuleFor(j => j.ChangedWhen, f => f.Date.Recent());
}
/// <summary>
/// Generate fake ProfileHistory
/// </summary>
public static Faker<ProfileHistory> ProfileHistoryFaker(long? profileId = null)
{
return new Faker<ProfileHistory>()
.RuleFor(h => h.Id, f => _historyIdCounter++)
.RuleFor(h => h.ProfileId, f => profileId ?? f.Random.Long(1, 100))
.RuleFor(h => h.ResultId, f => f.Random.Byte(0, 2))
.RuleFor(h => h.ResultText, f => f.Lorem.Paragraph())
.RuleFor(h => h.AddedWho, f => f.Internet.UserName())
.RuleFor(h => h.AddedWhen, f => f.Date.Past())
.RuleFor(h => h.ChangedWho, f => f.Internet.UserName())
.RuleFor(h => h.ChangedWhen, f => f.Date.Recent());
}
}

View File

@@ -0,0 +1,81 @@
using AutoMapper;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure;
using ECMJobRunner.Infrastructure.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace ECMJobRunner.Tests.Infrastructure;
/// <summary>
/// Base test fixture with Dependency Injection and In-Memory Database
/// Supports both .NET Framework 4.8 (EF6 with Effort) and .NET 8 (EF Core InMemory)
/// </summary>
public class TestFixture : IDisposable
{
private readonly IHost _host;
private IServiceScope? _scope;
public IServiceProvider Services => _scope?.ServiceProvider ?? _host.Services;
public JobRunnerDbContext DbContext => Services.GetRequiredService<JobRunnerDbContext>();
public IMapper Mapper => Services.GetRequiredService<IMapper>();
public ICfgProfileRepository ProfileRepository => Services.GetRequiredService<ICfgProfileRepository>();
public IProfileSqlJobRepository SqlJobRepository => Services.GetRequiredService<IProfileSqlJobRepository>();
public IProfileHistoryRepository HistoryRepository => Services.GetRequiredService<IProfileHistoryRepository>();
public TestFixture()
{
_host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Use AddInfrastructureInMemory extension for both frameworks
services.AddInfrastructureInMemory();
// Register test-specific AutoMapper profiles
services.AddAutoMapper(typeof(TestFixture).Assembly);
})
.Build();
// Create a scope for scoped services
_scope = _host.Services.CreateScope();
#if !NET48
// Ensure database is created (EF Core only)
DbContext.Database.EnsureCreated();
#endif
}
/// <summary>
/// Create a new service scope (useful for testing scoped lifetime)
/// </summary>
public IServiceScope CreateScope()
{
return _host.Services.CreateScope();
}
/// <summary>
/// Reset the current scope (creates a new DbContext)
/// </summary>
public void ResetScope()
{
_scope?.Dispose();
_scope = _host.Services.CreateScope();
}
/// <summary>
/// Clear all data from database
/// </summary>
public async Task ClearDatabaseAsync()
{
DbContext.ProfileHistories.RemoveRange(DbContext.ProfileHistories);
DbContext.ProfileSqlJobs.RemoveRange(DbContext.ProfileSqlJobs);
DbContext.CfgProfiles.RemoveRange(DbContext.CfgProfiles);
await DbContext.SaveChangesAsync();
}
public void Dispose()
{
_scope?.Dispose();
_host?.Dispose();
}
}

View File

@@ -0,0 +1,24 @@
using AutoMapper;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Tests.DTOs;
namespace ECMJobRunner.Tests.Mapping
{
/// <summary>
/// AutoMapper profile for test DTOs
/// </summary>
public class TestMappingProfile : Profile
{
/// <summary>
/// Constructor configuring test DTO mappings
/// </summary>
public TestMappingProfile()
{
// CfgProfileDto -> CfgProfile
CreateMap<CfgProfileDto, CfgProfile>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.SqlJobs, opt => opt.Ignore())
.ForMember(dest => dest.ProfileHistories, opt => opt.Ignore());
}
}
}

View File

@@ -0,0 +1,153 @@
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Tests.DTOs;
using ECMJobRunner.Tests.Infrastructure;
using FluentAssertions;
namespace ECMJobRunner.Tests.Repositories;
/// <summary>
/// Tests for CfgProfileRepository
/// </summary>
public class CfgProfileRepositoryTests : IClassFixture<TestFixture>
{
private readonly TestFixture _fixture;
public CfgProfileRepositoryTests(TestFixture fixture)
{
_fixture = fixture;
FakeDataGenerator.ResetIdCounters();
}
[Fact]
public async Task GetByIdAsync_ExistingProfile_ReturnsProfile()
{
// Arrange
await _fixture.ClearDatabaseAsync();
var dto = new CfgProfileDto
{
Active = true,
ProfileName = "Test Profile for GetById",
TypeId = (byte)1,
Schedule = "0 0 * * *",
Comment = "Test",
AddedWho = "TestUser",
AddedWhen = DateTime.Now,
ChangedWho = "TestUser",
ChangedWhen = DateTime.Now
};
var addedProfile = await _fixture.ProfileRepository.AddAsync(dto);
// Act
var result = await _fixture.ProfileRepository.GetByIdAsync(addedProfile.Id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(addedProfile.Id);
result.ProfileName.Should().Be("Test Profile for GetById");
}
[Fact]
public async Task GetAllAsync_ReturnsAllProfiles()
{
// Arrange
await _fixture.ClearDatabaseAsync();
for (int i = 0; i < 5; i++)
{
var dto = new CfgProfileDto
{
Active = true,
ProfileName = $"Profile {i}",
TypeId = (byte)1,
Schedule = "0 0 * * *",
AddedWho = "TestUser",
AddedWhen = DateTime.Now,
ChangedWho = "TestUser",
ChangedWhen = DateTime.Now
};
await _fixture.ProfileRepository.AddAsync(dto);
}
// Act
var result = await _fixture.ProfileRepository.GetAllAsync();
// Assert
result.Should().HaveCount(5);
}
[Fact]
public async Task AddAsync_NewProfile_AddsToDatabase()
{
// Arrange
await _fixture.ClearDatabaseAsync();
var dto = new CfgProfileDto
{
Active = true,
ProfileName = "Test Profile",
TypeId = (byte)1,
Schedule = "0 0 * * *",
Comment = "Test comment",
AddedWho = "TestUser",
AddedWhen = DateTime.Now,
ChangedWho = "TestUser",
ChangedWhen = DateTime.Now
};
// Act
var addedProfile = await _fixture.ProfileRepository.AddAsync(dto);
// Assert
var result = await _fixture.ProfileRepository.GetByIdAsync(addedProfile.Id);
result.Should().NotBeNull();
result!.ProfileName.Should().Be("Test Profile");
}
[Fact]
public async Task FindAsync_WithPredicate_ReturnsMatchingProfiles()
{
// Arrange
await _fixture.ClearDatabaseAsync();
// Add active profiles
for (int i = 0; i < 3; i++)
{
var dto = new CfgProfileDto
{
Active = true,
ProfileName = $"Active Profile {i}",
TypeId = (byte)1,
Schedule = "0 0 * * *",
AddedWho = "TestUser",
AddedWhen = DateTime.Now,
ChangedWho = "TestUser",
ChangedWhen = DateTime.Now
};
await _fixture.ProfileRepository.AddAsync(dto);
}
// Add inactive profiles
for (int i = 0; i < 2; i++)
{
var dto = new CfgProfileDto
{
Active = false,
ProfileName = $"Inactive Profile {i}",
TypeId = (byte)1,
Schedule = "0 0 * * *",
AddedWho = "TestUser",
AddedWhen = DateTime.Now,
ChangedWho = "TestUser",
ChangedWhen = DateTime.Now
};
await _fixture.ProfileRepository.AddAsync(dto);
}
// Act
var result = await _fixture.ProfileRepository.FindAsync(p => p.Active);
// Assert
result.Should().HaveCount(3);
result.Should().OnlyContain(p => p.Active);
}
}

View File

@@ -0,0 +1,22 @@
using Hangfire.Dashboard;
namespace ECMJobRunner.WebCron
{
/// <summary>
/// Authorization filter that allows all users to access Hangfire Dashboard
/// WARNING: This is for development only! Use proper authentication in production.
/// </summary>
public class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter
{
/// <summary>
/// Determines whether the current user is authorized to access the Hangfire Dashboard.
/// </summary>
/// <param name="context">The Hangfire dashboard context containing request information.</param>
/// <returns>Always returns <c>true</c> to allow all users (development only).</returns>
public bool Authorize(DashboardContext context)
{
// Allow all users - FOR DEVELOPMENT ONLY
return true;
}
}
}

View File

@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>ECMJobRunner.WebCron</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>ECMJobRunner.WebCron</Product>
<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>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
<PackageReference Include="Hangfire.Core" Version="1.8.23" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.SqlServer" Version="1.8.23" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.9" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
<PackageReference Include="Serilog.UI" Version="3.2.0" />
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
<ProjectReference Include="..\ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,38 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
namespace ECMJobRunner.WebCron.Extensions;
/// <summary>
/// Extension methods for profile DTOs to support Hangfire job operations.
/// </summary>
public static class DtoExtensions
{
/// <summary>
/// Generates a unique Hangfire job identifier for a profile.
/// </summary>
/// <param name="profile">The profile configuration DTO.</param>
/// <returns>A unique job identifier in the format "profile-{Id}-{normalized-name}".</returns>
/// <example>
/// Example: profile with Id=123 and ProfileName="Import Data"
/// returns "profile-123-import_data"
/// </example>
public static string JobId(this CfgProfileDto profile)
{
return $"profile-{profile.Id}-{profile.ProfileName.Replace(' ', '_').ToLowerInvariant()}";
}
/// <summary>
/// Converts a profile DTO to a DEX job batch command.
/// </summary>
/// <param name="profile">The profile configuration DTO.</param>
/// <returns>A <see cref="TriggeringProfileJobBatchCommand"/> ready to execute the profile job.</returns>
public static TriggeringProfileJobBatchCommand ToJob(this CfgProfileDto profile)
{
return new TriggeringProfileJobBatchCommand
{
ProfileId = profile.Id,
};
}
}

View File

@@ -0,0 +1,337 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text;
namespace ECMJobRunner.WebCron.HealthCheck;
/// <summary>
/// Generates HTML representation of ASP.NET Core health check reports.
/// Produces a Bootstrap 5-based UI with auto-refresh, status indicators, and detailed metrics.
/// </summary>
public static class HealthCheckHtmlGenerator
{
private static readonly string CacheId = Guid.NewGuid().ToString();
/// <summary>
/// Generates a complete HTML page for displaying health check results.
/// </summary>
/// <param name="report">The health check report containing service status information.</param>
/// <returns>A complete HTML document string ready to be sent as HTTP response.</returns>
/// <remarks>
/// The generated UI includes:
/// <list type="bullet">
/// <item><description>Navigation links to Hangfire, Serilog.UI, and health endpoints</description></item>
/// <item><description>Overall health status with color-coded badge</description></item>
/// <item><description>Summary card with total duration and check count</description></item>
/// <item><description>Individual check cards with detailed metrics and exception info</description></item>
/// <item><description>Auto-refresh countdown (10 seconds)</description></item>
/// </list>
/// External dependencies:
/// <list type="bullet">
/// <item><description>Bootstrap 5.3.0 (CDN)</description></item>
/// <item><description>/css/health-ui.css (custom styles)</description></item>
/// <item><description>/js/health-ui.js (auto-refresh logic)</description></item>
/// </list>
/// </remarks>
public static string Generate(HealthReport report)
{
var sb = new StringBuilder();
sb.AppendLine(@$"
<!DOCTYPE html>
<html>
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Health Check - ECMJobRunner</title>
<link rel=""stylesheet"" href=""https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"" />
<link rel=""stylesheet"" href=""/css/health-ui.css?cache={CacheId}"" />
<script src=""/js/health-ui.js?cache={CacheId}""></script>
</head>
<body>");
// Navigation
AppendNavigation(sb);
// Overall Status Header
AppendOverallStatus(sb, report);
// Summary Card
AppendSummaryCard(sb, report);
// Individual Checks
AppendIndividualChecks(sb, report);
// Refresh Info
AppendRefreshInfo(sb);
sb.AppendLine(@"
</body>
</html>");
return sb.ToString();
}
/// <summary>
/// Appends the navigation menu with links to application dashboards.
/// </summary>
/// <param name="sb">The StringBuilder to append HTML to.</param>
private static void AppendNavigation(StringBuilder sb)
{
sb.Append(@"
<div class=""container-fluid"">
<div class=""nav-links"">");
// Append emojis separately (not in verbatim string)
sb.AppendLine();
sb.Append(" <a href=\"/\">🏠 Home</a>");
sb.AppendLine();
sb.Append(" <a href=\"/hangfire\">🔧 Hangfire Dashboard</a>");
sb.AppendLine();
sb.Append(" <a href=\"/serilog-ui\">📋 Logs (Serilog.UI)</a>");
sb.AppendLine();
sb.Append(" <a href=\"/health\">📊 Health API</a>");
sb.AppendLine();
sb.AppendLine(" </div>");
}
/// <summary>
/// Appends the overall status header showing aggregate health and timestamp.
/// </summary>
/// <param name="sb">The StringBuilder to append HTML to.</param>
/// <param name="report">The health check report.</param>
private static void AppendOverallStatus(StringBuilder sb, HealthReport report)
{
var statusClass = GetStatusClass(report.Status);
var badgeClass = GetBadgeClass(report.Status);
var icon = GetStatusIcon(report.Status);
sb.Append($@"
<div class=""header-card"">
<div class=""d-flex justify-content-between align-items-center"">
<div>
<h1 class=""mb-2"">");
sb.Append(icon);
sb.Append($@" Health Check Status</h1>
<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>
</div>
</div>
</div>
<div class=""row"">
<div class=""col-md-4"">");
}
/// <summary>
/// Appends the summary card displaying aggregate metrics (status, duration, check count).
/// </summary>
/// <param name="sb">The StringBuilder to append HTML to.</param>
/// <param name="report">The health check report.</param>
private static void AppendSummaryCard(StringBuilder sb, HealthReport report)
{
var statusClass = GetStatusClass(report.Status);
sb.Append(@"
<div class=""card"">
<div class=""card-header bg-primary text-white"">");
sb.AppendLine();
sb.Append(" 📊 Summary");
sb.AppendLine();
sb.Append($@" </div>
<div class=""card-body"">
<div class=""metric"">
<span class=""metric-label"">Overall Status:</span>
<strong class=""{statusClass}"">{report.Status}</strong>
</div>
<div class=""metric"">
<span class=""metric-label"">Total Duration:</span>
<span class=""metric-value"">{report.TotalDuration.TotalMilliseconds:F0} ms</span>
</div>
<div class=""metric"">
<span class=""metric-label"">Checks:</span>
<span class=""metric-value"">{report.Entries.Count}</span>
</div>
</div>
</div>
</div>
<div class=""col-md-8"">");
}
/// <summary>
/// Appends individual health check result cards with detailed metrics.
/// </summary>
/// <param name="sb">The StringBuilder to append HTML to.</param>
/// <param name="report">The health check report containing check entries.</param>
private static void AppendIndividualChecks(StringBuilder sb, HealthReport report)
{
foreach (var entry in report.Entries)
{
var checkStatusClass = GetStatusClass(entry.Value.Status);
var checkBadgeClass = GetBadgeClass(entry.Value.Status);
var checkIcon = GetStatusIcon(entry.Value.Status);
sb.Append($@"
<div class=""card"">
<div class=""card-header bg-light"">
<div class=""d-flex justify-content-between align-items-center"">
<span>");
sb.Append(checkIcon);
sb.Append($@" <strong>{entry.Key}</strong></span>
<span class=""badge {checkBadgeClass}"">{entry.Value.Status}</span>
</div>
</div>
<div class=""card-body"">
<div class=""metric"">
<span class=""metric-label"">Description:</span>
<span class=""metric-value"">{entry.Value.Description ?? "N/A"}</span>
</div>
<div class=""metric"">
<span class=""metric-label"">Duration:</span>
<span class=""metric-value"">{entry.Value.Duration.TotalMilliseconds:F0} ms</span>
</div>");
if (entry.Value.Exception != null)
{
sb.AppendLine($@"
<div class=""metric"">
<span class=""metric-label"">Exception:</span>
<span class=""metric-value text-danger"" style=""word-break: break-all;"">{System.Net.WebUtility.HtmlEncode(entry.Value.Exception.Message)}</span>
</div>");
}
if (entry.Value.Data.Any())
{
AppendCheckDetails(sb, entry.Value.Data);
}
sb.AppendLine(@"
</div>
</div>");
}
sb.AppendLine(@"
</div>
</div>
</div>");
}
/// <summary>
/// Appends a detail table for additional health check data (e.g., timestamps, counters).
/// </summary>
/// <param name="sb">The StringBuilder to append HTML to.</param>
/// <param name="data">Dictionary of custom data provided by the health check.</param>
private static void AppendCheckDetails(StringBuilder sb, IReadOnlyDictionary<string, object> data)
{
sb.AppendLine(@"
<div class=""mt-3"">
<strong class=""metric-label"">Details:</strong>
<table class=""table table-sm table-borderless mt-2"">");
foreach (var item in data)
{
var value = FormatDataValue(item.Value);
sb.AppendLine($@"
<tr>
<td class=""text-muted"" style=""width: 40%;"">{item.Key}</td>
<td><strong>{value}</strong></td>
</tr>");
}
sb.AppendLine(@"
</table>
</div>");
}
/// <summary>
/// Appends the auto-refresh indicator widget with countdown timer.
/// </summary>
/// <param name="sb">The StringBuilder to append HTML to.</param>
private static void AppendRefreshInfo(StringBuilder sb)
{
sb.AppendLine(@"
<div class=""refresh-info"">");
sb.AppendLine();
sb.Append(" <span class=\"spinner\"></span> Auto-refresh in <strong><span id=\"countdown\">3</span>s</strong>");
sb.AppendLine();
sb.AppendLine(@" </div>");
}
/// <summary>
/// Maps a health status to the corresponding CSS class name for styling.
/// </summary>
/// <param name="status">The health status.</param>
/// <returns>CSS class name (e.g., "status-healthy", "status-degraded").</returns>
private static string GetStatusClass(HealthStatus status)
{
return status switch
{
HealthStatus.Healthy => "status-healthy",
HealthStatus.Degraded => "status-degraded",
HealthStatus.Unhealthy => "status-unhealthy",
_ => ""
};
}
/// <summary>
/// Maps a health status to the corresponding Bootstrap badge CSS class.
/// </summary>
/// <param name="status">The health status.</param>
/// <returns>Badge CSS class name (e.g., "badge-healthy", "badge-degraded").</returns>
private static string GetBadgeClass(HealthStatus status)
{
return status switch
{
HealthStatus.Healthy => "badge-healthy",
HealthStatus.Degraded => "badge-degraded",
HealthStatus.Unhealthy => "badge-unhealthy",
_ => "badge-secondary"
};
}
/// <summary>
/// Maps a health status to the corresponding emoji icon.
/// </summary>
/// <param name="status">The health status.</param>
/// <returns>Unicode emoji character (✅ for healthy, ⚠️ for degraded, ❌ for unhealthy).</returns>
private static string GetStatusIcon(HealthStatus status)
{
return status switch
{
HealthStatus.Healthy => "✅",
HealthStatus.Degraded => "⚠️",
HealthStatus.Unhealthy => "❌",
_ => "❓"
};
}
/// <summary>
/// Formats health check data values for display.
/// Applies special formatting for common types (DateTime, TimeSpan).
/// </summary>
/// <param name="value">The data value to format.</param>
/// <returns>
/// Formatted string representation:
/// <list type="bullet">
/// <item><description><see cref="DateTime"/> → "yyyy-MM-dd HH:mm:ss"</description></item>
/// <item><description><see cref="TimeSpan"/> → "{seconds}s"</description></item>
/// <item><description>Other types → ToString() or "N/A" if null</description></item>
/// </list>
/// </returns>
private static string FormatDataValue(object? value)
{
if (value == null)
return "N/A";
if (value is DateTime dt)
return dt.ToString("yyyy-MM-dd HH:mm:ss");
if (value is TimeSpan ts)
return $"{ts.TotalSeconds:F1}s";
return value.ToString() ?? "N/A";
}
}

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

@@ -0,0 +1,81 @@
using Microsoft.Extensions.Options;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Extension methods for configuring ProfileWorker services in the DI container.
/// </summary>
public static class DependencyInjection
{
/// <summary>
/// Registers ProfileWorker background service and related dependencies.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="configuration">Application configuration containing ProfileWorker settings.</param>
/// <returns>The configured service collection for method chaining.</returns>
/// <remarks>
/// Registers the following services:
/// <list type="bullet">
/// <item><description><see cref="ProfileWorkerOptions"/> - Configuration options from appsettings.json</description></item>
/// <item><description><see cref="ProfileWorker"/> - Singleton background service (also registered as IHostedService)</description></item>
/// <item><description><see cref="ProfileCache"/> - Singleton cache for profile state</description></item>
/// <item><description><see cref="ProfileWork"/> - Scoped service for profile synchronization logic</description></item>
/// </list>
/// </remarks>
public static IServiceCollection AddProfileWorker(this IServiceCollection services, IConfiguration configuration)
{
// Configure ProfileWorker options from appsettings.json
services.Configure<ProfileWorkerOptions>(
configuration.GetSection(ProfileWorkerOptions.SectionName));
// Validate options at startup
services.AddSingleton<IValidateOptions<ProfileWorkerOptions>, ProfileWorkerOptionsValidator>();
// Register ProfileWorker as both HostedService and singleton (for health check access)
services.AddSingleton<ProfileWorker>();
services.AddHostedService(sp => sp.GetRequiredService<ProfileWorker>());
services.AddSingleton<ProfileCache>();
services.AddScoped<ProfileWork>();
return services;
}
}
/// <summary>
/// Validates <see cref="ProfileWorkerOptions"/> configuration at application startup.
/// Ensures IntervalMS is within acceptable bounds to prevent misconfiguration.
/// </summary>
internal class ProfileWorkerOptionsValidator : IValidateOptions<ProfileWorkerOptions>
{
/// <summary>
/// Validates the ProfileWorker options.
/// </summary>
/// <param name="name">The name of the options instance (not used).</param>
/// <param name="options">The options to validate.</param>
/// <returns>
/// <see cref="ValidateOptionsResult.Success"/> if valid,
/// or <see cref="ValidateOptionsResult.Fail(string)"/> with error message if invalid.
/// </returns>
/// <remarks>
/// Validation rules:
/// <list type="bullet">
/// <item><description>IntervalMS must be greater than 0</description></item>
/// <item><description>IntervalMS should be at least 100ms to avoid excessive CPU usage</description></item>
/// </list>
/// </remarks>
public ValidateOptionsResult Validate(string? name, ProfileWorkerOptions options)
{
if (options.IntervalMS <= 0)
{
return ValidateOptionsResult.Fail("ProfileWorker:IntervalMs must be greater than 0");
}
if (options.IntervalMS < 100)
{
return ValidateOptionsResult.Fail("ProfileWorker:IntervalMs should be at least 100ms to avoid excessive CPU usage");
}
return ValidateOptionsResult.Success;
}
}

View File

@@ -0,0 +1,50 @@
using ECMJobRunner.Application.Common.Dtos;
using System.Collections.Concurrent;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Thread-safe cache for storing active profile configurations.
/// Uses <see cref="ConcurrentDictionary{TKey, TValue}"/> to track profile state
/// and detect changes in schedule or removal.
/// </summary>
public class ProfileCache
{
private readonly ConcurrentDictionary<string, CfgProfileDto> _cache = new();
/// <summary>
/// Retrieves a profile from the cache by job identifier.
/// </summary>
/// <param name="jobId">The unique job identifier.</param>
/// <returns>The cached profile, or <c>null</c> if not found.</returns>
public CfgProfileDto? Get(string jobId) => _cache.TryGetValue(jobId, out var profile) ? profile : null;
/// <summary>
/// Adds a new profile or updates an existing profile in the cache.
/// </summary>
/// <param name="jobId">The unique job identifier.</param>
/// <param name="profile">The profile configuration to cache.</param>
public void AddOrUpdate(string jobId, CfgProfileDto profile) => _cache[jobId] = profile;
/// <summary>
/// Attempts to remove a profile from the cache.
/// </summary>
/// <param name="jobId">The unique job identifier.</param>
/// <param name="profile">The removed profile, or <c>null</c> if not found.</param>
/// <returns><c>true</c> if the profile was removed; otherwise, <c>false</c>.</returns>
public bool TryRemove(string jobId, out CfgProfileDto? profile) => _cache.TryRemove(jobId, out profile);
/// <summary>
/// Gets all job identifiers currently stored in the cache.
/// </summary>
/// <returns>A collection of job identifiers.</returns>
public IEnumerable<string> GetAllJobIds() => _cache.Keys;
/// <summary>
/// Attempts to retrieve a profile from the cache.
/// </summary>
/// <param name="jobId">The unique job identifier.</param>
/// <param name="profile">The cached profile, or <c>null</c> if not found.</param>
/// <returns><c>true</c> if the profile was found; otherwise, <c>false</c>.</returns>
public bool TryGetValue(string jobId, out CfgProfileDto? profile) => _cache.TryGetValue(jobId, out profile);
}

View File

@@ -0,0 +1,82 @@
using ECMJobRunner.Application.Profiles.Queries;
using ECMJobRunner.WebCron.Extensions;
using Hangfire;
using MediatR;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Handles the core business logic for synchronizing profiles between the database and Hangfire.
/// Responsible for adding, updating, and removing recurring jobs based on profile configuration.
/// </summary>
/// <param name="Logger">Logger for diagnostic output.</param>
/// <param name="JobManager">Hangfire recurring job manager.</param>
/// <param name="Mediator">MediatR mediator for executing commands and queries.</param>
/// <param name="ProfileCache">Thread-safe cache for tracking profile state.</param>
public class ProfileWork(ILogger<ProfileWork> Logger, IRecurringJobManager JobManager, IMediator Mediator, ProfileCache ProfileCache)
{
/// <summary>
/// Synchronizes active profiles from the database with Hangfire recurring jobs.
/// </summary>
/// <param name="stoppingToken">Cancellation token for graceful shutdown.</param>
/// <remarks>
/// Execution flow:
/// <list type="number">
/// <item><description>Fetches active profiles from database via MediatR query</description></item>
/// <item><description>Removes jobs from Hangfire that no longer exist in the database</description></item>
/// <item><description>Adds or updates jobs with changed schedules</description></item>
/// <item><description>Updates local cache to reflect current state</description></item>
/// </list>
/// Uses HashSet for O(1) job existence checks to optimize performance.
/// </remarks>
/// <returns>A task representing the asynchronous synchronization operation.</returns>
public async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Fetch active profiles from database
var profiles = await Mediator.Send(new GetProfileQuery()
{
Active = true,
IncludeSqlJobs = true
}, stoppingToken);
// Create HashSet for O(1) lookup performance
var profileJobIds = profiles.Select(p => p.JobId()).ToHashSet();
// Remove jobs from Hangfire that no longer exist in the database
foreach (var cachedJobId in ProfileCache.GetAllJobIds())
{
if (!profileJobIds.Contains(cachedJobId))
{
// Remove job from Hangfire if it no longer exists in the database
JobManager.RemoveIfExists(cachedJobId);
ProfileCache.TryRemove(cachedJobId, out _);
Logger.LogInformation("Job {JobId} removed", cachedJobId);
}
}
// Add or update jobs in Hangfire based on the database profiles
foreach (var profile in profiles)
{
if (ProfileCache.TryGetValue(profile.JobId(), out var currentProfile)
&& currentProfile!.Schedule == profile.Schedule)
continue;
// Add or update recurring job using MediatR command
JobManager.AddOrUpdate<IMediator>(
profile.JobId(),
mediator => mediator.Send(profile.ToJob(), stoppingToken),
profile.Schedule,
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.Local
}
);
// Store/update in local cache
ProfileCache.AddOrUpdate(profile.JobId(), profile);
Logger.LogInformation("Job {JobId} registered with schedule: {Schedule}",
profile.JobId(), profile.Schedule);
}
}
}

View File

@@ -0,0 +1,211 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Background service that continuously synchronizes active profiles with Hangfire recurring jobs.
/// Implements health check monitoring to track service status and failure conditions.
/// </summary>
/// <param name="Logger">Logger for diagnostic output.</param>
/// <param name="ScopeFactory">Factory for creating service scopes (required for scoped service resolution).</param>
/// <param name="Options">Configuration options for interval timing.</param>
/// <remarks>
/// This service runs on a configurable interval (default 1 second) and performs the following:
/// <list type="bullet">
/// <item><description>Fetches active profiles from the database</description></item>
/// <item><description>Synchronizes profiles with Hangfire recurring jobs</description></item>
/// <item><description>Tracks health status based on success/failure patterns</description></item>
/// <item><description>Gracefully handles cancellation during application shutdown</description></item>
/// </list>
/// Health states (with adaptive thresholds):
/// <list type="bullet">
/// <item><description><b>Degraded (Initializing)</b>: Service starting up, waiting for first successful run</description></item>
/// <item><description><b>Healthy</b>: Recent successful run with no failures</description></item>
/// <item><description><b>Degraded</b>: 1+ consecutive failures but within time threshold</description></item>
/// <item><description><b>Unhealthy</b>: No success within adaptive threshold (3x interval for fast intervals &lt;10s, 1.5x for slower intervals)</description></item>
/// </list>
/// The adaptive multiplier ensures fast problem detection when using longer intervals (e.g., 60s interval = 90s timeout)
/// while maintaining tolerance for network jitter on short intervals (e.g., 1s interval = 3s timeout).
/// </remarks>
public class ProfileWorker(
ILogger<ProfileWorker> Logger,
IServiceScopeFactory ScopeFactory,
IOptions<ProfileWorkerOptions> Options) : BackgroundService, IHealthCheck
{
private readonly ProfileWorkerOptions _options = Options.Value;
// Health check state
private DateTime? _lastSuccessfulRun = null; // null = not run yet
private int _consecutiveFailures = 0;
private Exception? _lastException = null;
/// <summary>
/// Background service execution loop that synchronizes profiles on a configurable interval.
/// </summary>
/// <param name="stoppingToken">Cancellation token for graceful shutdown.</param>
/// <returns>A task representing the background execution.</returns>
/// <remarks>
/// The loop continues until:
/// <list type="bullet">
/// <item><description>Application shutdown is requested (via stoppingToken)</description></item>
/// <item><description>An unhandled exception causes service failure</description></item>
/// </list>
/// Uses scoped services for each iteration to ensure proper lifetime management.
/// </remarks>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Logger.LogInformation("ProfileWorker started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
using var scope = ScopeFactory.CreateScope();
var work = scope.ServiceProvider.GetRequiredService<ProfileWork>();
await work.ExecuteAsync(stoppingToken);
// Success - update health state
_lastSuccessfulRun = DateTime.Now;
_consecutiveFailures = 0;
_lastException = null;
if (Logger.IsEnabled(LogLevel.Debug))
{
Logger.LogDebug("ProfileWorker sync completed successfully at {time}", _lastSuccessfulRun);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Graceful shutdown - app is stopping
Logger.LogInformation("ProfileWorker stopping due to cancellation request");
break;
}
catch (Exception ex)
{
// Unexpected error - track for health check
_consecutiveFailures++;
_lastException = ex;
Logger.LogError(ex,
"An unexpected error occurred in ProfileWorker (consecutive failures: {failures})",
_consecutiveFailures);
}
await Task.Delay(_options.IntervalMS, stoppingToken);
}
Logger.LogInformation("ProfileWorker stopped");
}
/// <summary>
/// Performs a health check by evaluating recent success/failure patterns.
/// </summary>
/// <param name="context">Health check context (unused).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A <see cref="HealthCheckResult"/> indicating the current health status:
/// <list type="bullet">
/// <item><description><see cref="HealthStatus.Healthy"/> - Service running normally</description></item>
/// <item><description><see cref="HealthStatus.Degraded"/> - Recent failures but still operational, or service initializing</description></item>
/// <item><description><see cref="HealthStatus.Unhealthy"/> - No successful run for extended period</description></item>
/// </list>
/// </returns>
/// <remarks>
/// Health determination logic:
/// <list type="number">
/// <item><description><b>Degraded (Initializing)</b>: Service has not completed its first successful run yet</description></item>
/// <item><description><b>Unhealthy</b>: Time since last success exceeds adaptive threshold (3x interval for fast intervals &lt;10s, 1.5x for slower intervals)</description></item>
/// <item><description><b>Degraded</b>: 1+ consecutive failures within time threshold</description></item>
/// <item><description><b>Healthy</b>: Recent successful run with no failures</description></item>
/// </list>
/// Adaptive multiplier ensures fast problem detection for longer intervals while maintaining tolerance for short intervals.
/// </remarks>
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
// Service hasn't completed its first successful run yet
if (_lastSuccessfulRun == null)
{
var data = new Dictionary<string, object>
{
["Status"] = "Initializing",
["ConsecutiveFailures"] = _consecutiveFailures,
["IntervalMS"] = _options.IntervalMS
};
if (_lastException != null)
{
data["LastException"] = _lastException.Message;
}
// Degraded during initialization (not unhealthy, service is starting up)
return Task.FromResult(HealthCheckResult.Degraded(
_consecutiveFailures > 0
? $"ProfileWorker initializing with {_consecutiveFailures} failure(s)"
: "ProfileWorker is initializing, waiting for first successful run",
_lastException,
data
));
}
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)
var multiplier = _options.IntervalMS < 10000 ? 3.0 : 1.5;
var maxAllowedDelay = TimeSpan.FromMilliseconds(_options.IntervalMS * multiplier);
// Unhealthy: No successful run within adaptive threshold
if (timeSinceLastSuccess > maxAllowedDelay)
{
var data = new Dictionary<string, object>
{
["LastSuccessfulRun"] = _lastSuccessfulRun.Value,
["TimeSinceLastSuccess"] = timeSinceLastSuccess,
["ConsecutiveFailures"] = _consecutiveFailures,
["IntervalMS"] = _options.IntervalMS,
["Multiplier"] = multiplier,
["MaxAllowedDelay"] = maxAllowedDelay
};
if (_lastException != null)
{
data["LastException"] = _lastException.Message;
}
return Task.FromResult(HealthCheckResult.Unhealthy(
$"ProfileWorker has not completed successfully for {timeSinceLastSuccess.TotalSeconds:F0} seconds ({_consecutiveFailures} consecutive failures)",
_lastException,
data
));
}
// Degraded: 1+ consecutive failures but within time limit
if (_consecutiveFailures > 0)
{
var data = new Dictionary<string, object>
{
["LastSuccessfulRun"] = _lastSuccessfulRun.Value,
["ConsecutiveFailures"] = _consecutiveFailures
};
return Task.FromResult(HealthCheckResult.Degraded(
$"ProfileWorker has {_consecutiveFailures} consecutive failure(s) but still operational",
null,
data
));
}
// Healthy
return Task.FromResult(HealthCheckResult.Healthy(
"ProfileWorker is running normally",
new Dictionary<string, object>
{
["LastSuccessfulRun"] = _lastSuccessfulRun.Value
}
));
}
}

View File

@@ -0,0 +1,23 @@
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Configuration options for ProfileWorker background service.
/// Binds to "ProfileWorker" section in appsettings.json.
/// </summary>
public class ProfileWorkerOptions
{
/// <summary>
/// Configuration section name for binding options.
/// </summary>
public const string SectionName = "ProfileWorker";
/// <summary>
/// Interval in milliseconds between profile synchronization checks.
/// Default: 1000ms (1 second).
/// </summary>
/// <remarks>
/// Lower values increase responsiveness but consume more resources.
/// Recommended range: 1000-5000ms.
/// </remarks>
public int IntervalMS { get; set; } = 1000;
}

View File

@@ -0,0 +1,209 @@
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;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
using Serilog.Ui.Web.Extensions;
// Enable Serilog self-diagnostics
Serilog.Debugging.SelfLog.Enable(msg => Console.WriteLine($"[SERILOG] {msg}"));
// Build temporary configuration to read log directory
var tempConfig = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
// Get log directory from configuration
var logDirectory = tempConfig.GetValue<string>("Application:LogDirectory")
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
Console.WriteLine($"[INFO] SQLite Log Database Path: {sqliteDbPath}");
// Configure Serilog with SQLite sink
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Hangfire", Serilog.Events.LogEventLevel.Information)
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true)
.Enrich.FromLogContext()
.CreateLogger();
try
{
Log.Information("Starting ECMJobRunner.WebCron application");
var builder = WebApplication.CreateBuilder(args);
// Use Serilog for logging
builder.Host.UseSerilog();
// Configure Windows Service hosting if enabled
if (builder.Configuration.GetValue<bool>("HostingOptions:UseWindowsService"))
{
builder.Host.UseWindowsService();
}
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddProfileWorker(builder.Configuration);
// Register services
var cnnStr = builder.Configuration.GetConnectionString("SDD-VMP04-SQL17")
?? throw new InvalidOperationException("Connection string 'SDD-VMP04-SQL17' not found.");
builder.Services.AddJobRunnerInfrastructure(cnnStr);
var recClientApiUrl = builder.Configuration.GetValue<string>("ReC:ApiUrl")
?? throw new InvalidOperationException("ReC:ApiUrl not found.");
builder.Services.AddJobRunnerServices(recClientApiUrl, builder.Configuration);
// Get Hangfire storage configuration
var useInMemory = builder.Configuration.GetValue<bool>("Hangfire:InMemory");
// Add Hangfire services with configurable storage
builder.Services.AddHangfire(configuration =>
{
configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings();
// Configure storage based on appsettings
if (useInMemory)
{
configuration.UseInMemoryStorage();
}
else // Use SQL Server storage
{
configuration.UseSqlServerStorage(cnnStr, new SqlServerStorageOptions
{
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
QueuePollInterval = TimeSpan.Zero,
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true,
SqlClientFactory = SqlClientFactory.Instance
});
}
});
// Add Hangfire server
builder.Services.AddHangfireServer();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add health checks
builder.Services.AddHealthChecks()
.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")
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
var serilogUiDbPath = Path.Combine(serilogUiLogDirectory, "logs.db");
builder.Services.AddSerilogUi(logUIOpt =>
{
logUIOpt.UseSqliteServer(dbOpt =>
{
dbOpt.WithConnectionString($"Data Source={serilogUiDbPath}");
dbOpt.WithTable("Logs");
});
});
var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Enable static files for wwwroot
app.UseStaticFiles();
app.UseAuthorization();
// Add Hangfire Dashboard with no authentication (for development)
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = [new AllowAllDashboardAuthorizationFilter()]
});
// Add Health Check UI route (accessible outside Hangfire dashboard)
app.MapGet("/health-ui", async (HealthCheckService healthCheckService, HttpContext context) =>
{
var report = await healthCheckService.CheckHealthAsync(context.RequestAborted);
var html = HealthCheckHtmlGenerator.Generate(report);
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(html);
return Results.Empty;
});
// Add Serilog.UI Dashboard
app.UseSerilogUi();
app.MapControllers();
// Map health check endpoints
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = _ => true,
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = System.Text.Json.JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
timestamp = DateTime.Now,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
exception = e.Value.Exception?.Message,
data = e.Value.Data
})
});
await context.Response.WriteAsync(result);
}
});
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
// Redirect root path to Health UI
app.MapGet("/", () => Results.Redirect("/health-ui"));
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

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

@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:17567",
"sslPort": 44300
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "",
"applicationUrl": "http://localhost:5271",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "",
"applicationUrl": "https://localhost:7027;http://localhost:5271",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Hangfire": "Information"
}
}
}

View File

@@ -0,0 +1,50 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"SDD-VMP04-SQL17": "Server=SDD-VHP04-SQL19\\DD_TESTING01;Database=DD_ECM;User Id=sa;Password=123456789dD!;Encrypt=false;TrustServerCertificate=True;"
},
"AllowedHosts": "*",
"HostingOptions": {
"UseWindowsService": false
},
"Hangfire": {
"InMemory": true
},
"ReC": {
"ApiUrl": "http://172.24.12.39:90"
},
"Application": {
"LogDirectory": "E:\\LogFiles\\Digital Data\\ECMJobRunner.WebCron"
},
"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"
}
}
}
}

View File

@@ -0,0 +1,96 @@
body {
padding: 40px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container-fluid { max-width: 1400px; }
.status-healthy { color: #198754; }
.status-degraded { color: #ffc107; }
.status-unhealthy { color: #dc3545; }
.badge-healthy { background-color: #198754; }
.badge-degraded { background-color: #ffc107; color: #000; }
.badge-unhealthy { background-color: #dc3545; }
.card {
margin-bottom: 20px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
border: none;
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.card-header {
font-weight: 600;
border-radius: 12px 12px 0 0 !important;
padding: 1rem 1.5rem;
}
.metric {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.metric:last-child { border-bottom: none; }
.metric-label { font-weight: 500; color: #666; }
.metric-value { color: #333; font-weight: 600; }
.refresh-info {
position: fixed;
bottom: 20px;
right: 20px;
background: white;
padding: 12px 20px;
border-radius: 50px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
font-size: 14px;
font-weight: 500;
}
.spinner {
animation: spin 2s linear infinite;
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.header-card {
background: white;
border-radius: 16px;
padding: 2rem;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.nav-links {
background: white;
border-radius: 12px;
padding: 1rem;
margin-bottom: 20px;
}
.nav-links a {
margin-right: 15px;
text-decoration: none;
font-weight: 500;
}

View File

@@ -0,0 +1,15 @@
let countdown = 3;
function updateCountdown() {
document.getElementById('countdown').innerText = countdown;
countdown--;
if (countdown < 0) {
location.reload();
}
}
function autoRefresh() {
setInterval(updateCountdown, 1000);
}
window.onload = autoRefresh;