diff --git a/EnvelopeGenerator.Service/EnvelopeGenerator.Service.csproj b/EnvelopeGenerator.Service/EnvelopeGenerator.Service.csproj index b0b45081..e5f42874 100644 --- a/EnvelopeGenerator.Service/EnvelopeGenerator.Service.csproj +++ b/EnvelopeGenerator.Service/EnvelopeGenerator.Service.csproj @@ -5,10 +5,37 @@ enable enable dotnet-EnvelopeGenerator.Service-816256a4-c636-4313-92c0-b62b04040c2b - latest + latest + + + + + + + + ..\packages\DigitalData.Modules.Database.2.3.5.4\lib\net462\DigitalData.Modules.Database.dll + + + ..\packages\DigitalData.Modules.Encryption.1.3.1\lib\net462\DigitalData.Modules.Encryption.dll + + + ..\packages\DigitalData.Modules.Logging.2.6.5\lib\net462\DigitalData.Modules.Logging.dll + + + + ..\packages\Quartz.3.8.0\lib\net462\Quartz.dll + + + + + + + + + diff --git a/EnvelopeGenerator.Service/LogConfigFactory.cs b/EnvelopeGenerator.Service/LogConfigFactory.cs new file mode 100644 index 00000000..4bbcdc3f --- /dev/null +++ b/EnvelopeGenerator.Service/LogConfigFactory.cs @@ -0,0 +1,28 @@ +using DigitalData.Modules.Logging; + +namespace EnvelopeGenerator.Service; + +/// +/// Builds a from . +/// Required because CommonServices jobs have a hard dependency on +/// in their Quartz JobDataMap. +/// Log path mirrors the legacy behavior: <BaseDirectory>\Log. +/// +internal static class LogConfigFactory +{ + public static LogConfig Create(ServiceConfig config) + { + var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log"); + + var logConfig = new LogConfig( + LogConfig.PathType.CustomPath, + logPath, + null, + "Digital Data", + "EnvelopeGenerator.Service"); + + logConfig.Debug = config.Debug; + + return logConfig; + } +} diff --git a/EnvelopeGenerator.Service/MIGRATION_PLAN.md b/EnvelopeGenerator.Service/MIGRATION_PLAN.md new file mode 100644 index 00000000..2cd4c380 --- /dev/null +++ b/EnvelopeGenerator.Service/MIGRATION_PLAN.md @@ -0,0 +1,315 @@ +# Migration Plan: EnvelopeGenerator.Service_legacy ? EnvelopeGenerator.Service +**Revision v3** — Full deep analysis complete + +## Overview + +Migrate the legacy VB.NET Windows Service (`EnvelopeGenerator.Service_legacy`) to a modern +C# Worker Service (`EnvelopeGenerator.Service`) targeting **.NET Framework 4.6.2**, using +`Microsoft.Extensions.Hosting`, `IConfiguration`, `ILogger` and **NLog**. + +--- + +## Deep Analysis Findings (vs. v1/v2) + +### Finding 1 — Connection String: Plain Text, No Encryption Required +`FinalizeDocumentJob` and `APIEnvelopeJob` call `MSSQLServer.DecryptConnectionString()` on the +`JobDataMap[Value.DATABASE]` value. However, `MSSQLServer.DecryptConnectionString()` is a pass-through +for plain-text strings — no wrapping/encryption step is needed. + +**Resolution**: The connection string is read from `IConfiguration` via the standard +`ConnectionStrings:Default` key (same key as `appsettings.Database.json` in `EnvelopeGenerator.Web`). +It is passed **as-is** to both the Worker-level `MSSQLServer` and the `JobDataMap`. No +`EncryptConnectionString()` call is made anywhere in the new service. + +### Finding 2 — Dual TempFiles Architecture +There are **two independent** `TempFiles` classes: +- `EnvelopeGenerator.CommonServices.TempFiles` — used **inside each job** via `New TempFiles(LogConfig)`. + This is called on every job execution and manages the job's own temp lifecycle. It expects `LogConfig`. + **We do not touch this.** +- `EnvelopeGenerator.Service_legacy.TempFiles` — used at the **service/host level** (startup cleanup, + shutdown cleanup). This is the one we rewrite in C# in the Service project using `ILogger`. + +### Finding 3 — LogConfig is a Hard Dependency of All Jobs +`FinalizeDocumentJob.Execute()` and `APIEnvelopeJob.Execute()` both do: +```vb +LogConfig = pContext.MergedJobDataMap.Item(Value.LOGCONFIG) +Logger = LogConfig.GetLogger() +myTempFiles = New TempFiles(LogConfig) ' ? CommonServices.TempFiles +``` +`LogConfig` is **not optional**. The schedulers must construct a valid `LogConfig` and place it in the +`JobDataMap`. It must carry the correct log path, debug flag, and application identity. + +**Resolution**: A `LogConfigFactory` static helper class will be created in the Service project to +build a `LogConfig` from `ServiceConfig` values: +- `LogPath` = `Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log")` (mirrors legacy `My.Application.Info.DirectoryPath + "\Log"`) +- `Debug` = `ServiceConfig.Debug` +- Application name = `"EnvelopeGenerator.Service"` + +### Finding 4 — ProjectInstaller is Obsolete +`ProjectInstaller.vb` was the old `installutil.exe` mechanism for Windows Service registration. +With `AddWindowsService()` + `sc create` / PowerShell `New-Service`, this is **completely replaced**. +No equivalent is needed in the new project. + +### Finding 5 — GDPicture License SQL +The legacy service queries: `SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' and ACTIVE = 1` +This must be replicated exactly in the Worker startup sequence. If the result is null/empty, startup fails +with a descriptive exception (matching legacy behavior). + +### Finding 6 — Quartz Version Mismatch +`CommonServices` uses `Quartz 3.8.0`. `Service_legacy` uses `Quartz 3.15.0`. +The new `EnvelopeGenerator.Service` must use **Quartz 3.8.0** to match `CommonServices` (same AppDomain, +same version must be loaded). The legacy version bump in `Service_legacy` was inconsistent. + +--- + +## 1. Project File (`EnvelopeGenerator.Service.csproj`) + +- Add **NuGet packages**: + - `NLog` (5.x, matching CommonServices' `NLog.5.0.5`) + - `NLog.Extensions.Logging` (latest net462-compatible) + - `Quartz` (**3.8.0** — must match CommonServices) + - `Microsoft.Extensions.Hosting.WindowsServices` (for `AddWindowsService()`) +- Add **project reference** to `EnvelopeGenerator.CommonServices` (vbproj) +- Add **project reference** to `EnvelopeGenerator.Domain` (csproj) + +--- + +## 2. Configuration (`ServiceConfig.cs`) + +```csharp +public class ServiceConfig +{ + public string ConnectionString { get; set; } = string.Empty; + public bool Debug { get; set; } = false; + public int IntervalInMin { get; set; } = 1; + public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams(); +} +``` + +Connection string is read via the standard `ConnectionStrings:Default` key — consistent with +`appsettings.Database.json` used by `EnvelopeGenerator.Web`: + +`appsettings.json`: +```json +{ + "ConnectionStrings": { + "Default": "" + }, + "ServiceConfig": { + "Debug": false, + "IntervalInMin": 1, + "PDFBurnerParams": { + "IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ], + "TopMargin": 0.1, + "YOffset": -0.3, + "FontName": "Arial", + "FontSize": 8, + "FontStyle": 2, + "TruncationSuffix": "..", + "TextMaxWidths": { + "position": 1.41, + "city": 1.41, + "date": 2.0 + } + } + } +} +``` + +> `FontStyle` integer: Regular=0, Bold=1, **Italic=2**, Underline=4, Strikeout=8. +> All values mirror `PDFBurnerParams.vb` defaults exactly. + +--- + +## 3. NLog Setup + +**`nlog.config`**: File target ? `${basedir}/Log/service-${shortdate}.log`, async wrapper, +layout with timestamp + level + logger + message + exception. + +**`Program.cs`**: Wire via `NLog.Extensions.Logging`: +```csharp +builder.Logging.ClearProviders(); +builder.Logging.AddNLog("nlog.config"); +``` + +--- + +## 4. LogConfigFactory (`LogConfigFactory.cs`) + +New static helper — bridges `ServiceConfig` ? `LogConfig` (required by CommonServices jobs): + +```csharp +internal static class LogConfigFactory +{ + public static LogConfig Create(ServiceConfig config) + { + var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log"); + var logConfig = new LogConfig( + LogConfig.PathType.CustomPath, logPath, null, + "Digital Data", "EnvelopeGenerator.Service"); + logConfig.Debug = config.Debug; + return logConfig; + } +} +``` + +This `LogConfig` instance is what gets placed into every `JobDataMap[Value.LOGCONFIG]`. + +--- + +## 5. Quartz LogProvider (`QuartzLogProvider.cs`) + +C# rewrite of `LogProvider.vb` — bridges Quartz `ILogProvider` ? `ILogger`: + +```csharp +internal class QuartzLogProvider : ILogProvider +{ + private readonly ILogger _logger; + public QuartzLogProvider(ILogger logger) => _logger = logger; + public Logger GetLogger(string name) => (level, func, ex, parameters) => { ... }; + // OpenNestedContext / OpenMappedContext ? NotImplementedException (same as legacy) +} +``` + +--- + +## 6. TempFiles (`TempFiles.cs`) — Service-level only + +Service-level rewrite of `Service_legacy/TempFiles.vb` using `ILogger`: +- `Create()` ? create `%TEMP%\EnvelopeGenerator`, or clean existing files +- `CleanUp()` ? delete the directory on service stop + +> **Does NOT replace** `CommonServices.TempFiles` — that one continues to be used +> inside each job via `LogConfig`. + +--- + +## 7. Schedulers + +### `Schedulers/SchedulerFinishEnvelope.cs` + +```csharp +public class SchedulerFinishEnvelope +{ + // Constructor: ILogger, IOptions + // Start(licenseKey, logConfig): + // - Build JobDataMap: + // [GDPICTURE] = licenseKey + // [LOGCONFIG] = logConfig ? LogConfigFactory output + // [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString) + // [PDF_BURNER_PARAMS] = config.PDFBurnerParams + // - Schedule FinalizeDocumentJob with Quartz (interval from config) + // - Register QuartzLogProvider + // Stop(): Scheduler.Shutdown() +} +``` + +### `Schedulers/SchedulerEnvelopeTaskApi.cs` + +```csharp +public class SchedulerEnvelopeTaskApi +{ + // Constructor: ILogger, IOptions + // Start(logConfig): + // - Build JobDataMap: + // [LOGCONFIG] = logConfig + // [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString) + // - Schedule APIEnvelopeJob with Quartz (interval from config) + // - Register QuartzLogProvider + // Stop(): Scheduler.Shutdown() +} +``` + +--- + +## 8. Worker (`Worker.cs`) + +Full lifecycle replacing the placeholder: + +``` +ExecuteAsync: + 1. Read ServiceConfig from IOptions + 2. Validate ConnectionString ? throw if empty + 3. Build LogConfig via LogConfigFactory + 4. Connect to DB (plain connection string — Worker-level only) + 5. Query: SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME='GDPICTURE' AND ACTIVE=1 + ? throw if null/empty (matches legacy behavior) + 6. TempFiles.Create() + 7. await SchedulerFinishEnvelope.Start(licenseKey, logConfig) + 8. await Task.Delay(2500, stoppingToken) ? preserved from legacy + 9. await SchedulerEnvelopeTaskApi.Start(logConfig) + 10. await Task.Delay(Timeout.Infinite, stoppingToken) ? wait for cancellation + +StopAsync override: + 11. await SchedulerFinishEnvelope.Stop() + 12. await SchedulerEnvelopeTaskApi.Stop() + 13. TempFiles.CleanUp() +``` + +--- + +## 9. Program.cs + +```csharp +var builder = Host.CreateApplicationBuilder(args); + +builder.Logging.ClearProviders(); +builder.Logging.AddNLog("nlog.config"); + +builder.Services.Configure( + builder.Configuration.GetSection("ServiceConfig")); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddWindowsService(o => o.ServiceName = "EnvelopeGenerator.Service"); + +var host = builder.Build(); +host.Run(); +``` + +--- + +## 10. Files to Create / Modify + +| File | Action | Notes | +|------|--------|-------| +| `EnvelopeGenerator.Service.csproj` | **Modify** | Add packages, project refs | +| `Program.cs` | **Modify** | Full DI/NLog/WindowsService setup | +| `Worker.cs` | **Modify** | Full service lifecycle | +| `ServiceConfig.cs` | **Create** | | +| `LogConfigFactory.cs` | **Create** | Critical bridge: ServiceConfig ? LogConfig | +| `TempFiles.cs` | **Create** | Service-level only; CommonServices.TempFiles untouched | +| `QuartzLogProvider.cs` | **Create** | | +| `Schedulers/SchedulerFinishEnvelope.cs` | **Create** | | +| `Schedulers/SchedulerEnvelopeTaskApi.cs` | **Create** | | +| `appsettings.json` | **Create** | All PDFBurnerParams explicit | +| `nlog.config` | **Create** | | + +**Not created** (obsolete in new architecture): +- ~~`ProjectInstaller`~~ ? replaced by `sc create` / `New-Service` +- ~~`Service.Designer.vb`~~ ? BackgroundService handles this +- ~~`Service.resx`~~ ? not applicable + +--- + +## 11. Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| **Quartz 3.8.0** (not 3.15.0) | Must match CommonServices; same AppDomain, one version | +| **Connection string plain-text, no encryption** | `MSSQLServer.DecryptConnectionString()` is a pass-through for plain strings; `ConnectionStrings:Default` key used (same convention as Web project) | +| **Connection string in `ConnectionStrings:Default`** | Consistent with rest of the solution (`appsettings.Database.json`); `ServiceConfig` holds only service-specific settings | +| **LogConfigFactory** | Jobs have hard dependency on `LogConfig` in JobDataMap; this is the clean bridge without leaking LogConfig into the DI container | +| **2500ms delay preserved** | Legacy behavior; ensures Scheduler1 is fully initialized before Scheduler2 starts | +| **`AddWindowsService()`** | Native SCM integration; replaces ProjectInstaller/installutil entirely | +| **NLog log path = BaseDirectory/Log** | Mirrors `My.Application.Info.DirectoryPath + "\Log"` from legacy | +| **CommonServices.TempFiles untouched** | Jobs create their own TempFiles internally via LogConfig; service-level TempFiles is separate concern | + +--- + +## Approval Required + +> Please review this plan and confirm before implementation begins. + diff --git a/EnvelopeGenerator.Service/Program.cs b/EnvelopeGenerator.Service/Program.cs index a08adc48..b9f1d366 100644 --- a/EnvelopeGenerator.Service/Program.cs +++ b/EnvelopeGenerator.Service/Program.cs @@ -1,7 +1,41 @@ using EnvelopeGenerator.Service; +using EnvelopeGenerator.Service.Schedulers; +using Microsoft.Extensions.Options; +using NLog.Extensions.Logging; var builder = Host.CreateApplicationBuilder(args); + +// NLog +builder.Logging.ClearProviders(); +builder.Logging.AddNLog("nlog.config"); + +// Configuration +builder.Services.Configure( + builder.Configuration.GetSection("ServiceConfig")); + +// Resolve connection string once for singleton registrations +var connectionString = builder.Configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("ConnectionStrings:Default is not configured."); + +// Services +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(sp => new SchedulerFinishEnvelope( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + connectionString)); + +builder.Services.AddSingleton(sp => new SchedulerEnvelopeTaskApi( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + connectionString)); + builder.Services.AddHostedService(); +// Windows Service (SCM) support — replaces legacy ProjectInstaller/installutil +builder.Services.AddWindowsService(options => + options.ServiceName = "EnvelopeGenerator.Service"); + var host = builder.Build(); host.Run(); + diff --git a/EnvelopeGenerator.Service/QuartzLogProvider.cs b/EnvelopeGenerator.Service/QuartzLogProvider.cs new file mode 100644 index 00000000..d59a9333 --- /dev/null +++ b/EnvelopeGenerator.Service/QuartzLogProvider.cs @@ -0,0 +1,60 @@ +using Quartz.Logging; +using QuartzLogLevel = Quartz.Logging.LogLevel; + +namespace EnvelopeGenerator.Service; + +/// +/// Bridges Quartz's to . +/// Mirrors the legacy LogProvider.vb behavior. +/// OpenNestedContext and OpenMappedContext are not implemented (same as legacy). +/// +internal sealed class QuartzLogProvider : ILogProvider +{ + private readonly ILogger _logger; + + public QuartzLogProvider(ILogger logger) + { + _logger = logger; + } + + public Logger GetLogger(string name) + { + return (level, func, exception, parameters) => + { + if (exception != null) + { + _logger.LogError(exception, exception.Message); + } + else if (func != null) + { + switch (level) + { + case QuartzLogLevel.Trace: + case QuartzLogLevel.Debug: + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + _logger.LogDebug(func(), parameters); + break; + case QuartzLogLevel.Info: + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information)) + _logger.LogInformation(func(), parameters); + break; + case QuartzLogLevel.Warn: + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Warning)) + _logger.LogWarning(func(), parameters); + break; + case QuartzLogLevel.Error: + case QuartzLogLevel.Fatal: + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Error)) + _logger.LogError(func(), parameters); + break; + } + } + + return true; + }; + } + + public IDisposable OpenNestedContext(string message) => throw new NotImplementedException(); + + public IDisposable OpenMappedContext(string key, object value, bool destructure = false) => throw new NotImplementedException(); +} diff --git a/EnvelopeGenerator.Service/Schedulers/SchedulerEnvelopeTaskApi.cs b/EnvelopeGenerator.Service/Schedulers/SchedulerEnvelopeTaskApi.cs new file mode 100644 index 00000000..de340acf --- /dev/null +++ b/EnvelopeGenerator.Service/Schedulers/SchedulerEnvelopeTaskApi.cs @@ -0,0 +1,94 @@ +using System.Collections.Specialized; +using DigitalData.Modules.Logging; +using EnvelopeGenerator.CommonServices.Jobs; +using EnvelopeGenerator.Domain.Constants; +using Microsoft.Extensions.Options; +using Quartz; +using Quartz.Logging; + +namespace EnvelopeGenerator.Service.Schedulers; + +/// +/// Schedules via Quartz. +/// C# rewrite of the legacy Scheduler_Envelopetask_API.vb. +/// +public sealed class SchedulerEnvelopeTaskApi +{ + private readonly ILogger _logger; + private readonly ServiceConfig _config; + private readonly string _connectionString; + + private IScheduler? _scheduler; + + private const string JobName = "SendInvitationMailsAPI"; + + public SchedulerEnvelopeTaskApi( + ILogger logger, + IOptions config, + string connectionString) + { + _logger = logger; + _config = config.Value; + _connectionString = connectionString; + } + + public async Task Start(LogConfig logConfig) + { + try + { + _logger.LogDebug("Starting SchedulerEnvelopeTaskApi..."); + + LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger)); + + _scheduler = await SchedulerBuilder + .Create(new NameValueCollection()) + .UseDefaultThreadPool(x => x.MaxConcurrency = 5) + .BuildScheduler(); + + var jobKey = new JobKey(JobName); + + var jobData = new JobDataMap + { + { Value.LOGCONFIG, logConfig }, + { Value.DATABASE, _connectionString } + }; + + _logger.LogDebug("Initialized Job [{JobName}]", JobName); + + var job = JobBuilder.Create() + .UsingJobData(jobData) + .WithIdentity(jobKey) + .Build(); + + var trigger = TriggerBuilder.Create() + .ForJob(jobKey) + .WithIdentity($"{JobName}-trigger") + .WithSimpleSchedule(s => s + .RepeatForever() + .WithIntervalInMinutes(_config.IntervalInMin)) + .StartNow() + .Build(); + + _logger.LogDebug("{JobName}-trigger initialized", JobName); + + await _scheduler.ScheduleJob(job, trigger); + _logger.LogDebug("{JobName} scheduled", JobName); + + await _scheduler.Start(); + _logger.LogInformation("{JobName} started", JobName); + } + catch (Exception ex) + { + _logger.LogError(ex, "SchedulerEnvelopeTaskApi failed to start"); + throw; + } + } + + public async Task Stop() + { + if (_scheduler is null) return; + _logger.LogInformation("Stopping SchedulerEnvelopeTaskApi..."); + await _scheduler.Shutdown(); + _logger.LogInformation("SchedulerEnvelopeTaskApi stopped!"); + } +} diff --git a/EnvelopeGenerator.Service/Schedulers/SchedulerFinishEnvelope.cs b/EnvelopeGenerator.Service/Schedulers/SchedulerFinishEnvelope.cs new file mode 100644 index 00000000..f6811cdb --- /dev/null +++ b/EnvelopeGenerator.Service/Schedulers/SchedulerFinishEnvelope.cs @@ -0,0 +1,97 @@ +using System.Collections.Specialized; +using DigitalData.Modules.Logging; +using EnvelopeGenerator.CommonServices.Jobs; +using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument; +using EnvelopeGenerator.Domain.Constants; +using Microsoft.Extensions.Options; +using Quartz; +using Quartz.Logging; + +namespace EnvelopeGenerator.Service.Schedulers; + +/// +/// Schedules via Quartz. +/// C# rewrite of the legacy Scheduler_FinishEnvelope.vb. +/// +public sealed class SchedulerFinishEnvelope +{ + private readonly ILogger _logger; + private readonly ServiceConfig _config; + private readonly string _connectionString; + + private IScheduler? _scheduler; + + private const string JobName = "CertificateDocumentJob"; + + public SchedulerFinishEnvelope( + ILogger logger, + IOptions config, + string connectionString) + { + _logger = logger; + _config = config.Value; + _connectionString = connectionString; + } + + public async Task Start(string licenseKey, LogConfig logConfig) + { + try + { + _logger.LogDebug("Starting SchedulerFinishEnvelope..."); + + LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger)); + + _scheduler = await SchedulerBuilder + .Create(new NameValueCollection()) + .UseDefaultThreadPool(x => x.MaxConcurrency = 5) + .BuildScheduler(); + + var jobKey = new JobKey(JobName); + + var jobData = new JobDataMap + { + { Value.GDPICTURE, licenseKey }, + { Value.LOGCONFIG, logConfig }, + { Value.DATABASE, _connectionString }, + { Value.PDF_BURNER_PARAMS, _config.PDFBurnerParams } + }; + + _logger.LogDebug("Initialized Job [{JobName}]", JobName); + + var job = JobBuilder.Create() + .UsingJobData(jobData) + .WithIdentity(jobKey) + .Build(); + + var trigger = TriggerBuilder.Create() + .ForJob(jobKey) + .WithIdentity($"{JobName}-trigger") + .WithSimpleSchedule(s => s + .RepeatForever() + .WithIntervalInMinutes(_config.IntervalInMin)) + .StartNow() + .Build(); + + _logger.LogDebug("Initialized Trigger"); + + await _scheduler.ScheduleJob(job, trigger); + _logger.LogDebug("Job scheduled."); + + await _scheduler.Start(); + _logger.LogDebug("Scheduler started!"); + } + catch (Exception ex) + { + _logger.LogError(ex, "SchedulerFinishEnvelope failed to start"); + throw; + } + } + + public async Task Stop() + { + if (_scheduler is null) return; + _logger.LogInformation("Stopping SchedulerFinishEnvelope..."); + await _scheduler.Shutdown(); + _logger.LogInformation("SchedulerFinishEnvelope stopped!"); + } +} diff --git a/EnvelopeGenerator.Service/ServiceConfig.cs b/EnvelopeGenerator.Service/ServiceConfig.cs new file mode 100644 index 00000000..2d27b95d --- /dev/null +++ b/EnvelopeGenerator.Service/ServiceConfig.cs @@ -0,0 +1,14 @@ +using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument; + +namespace EnvelopeGenerator.Service; + +/// +/// Service-level configuration bound from the "ServiceConfig" section of appsettings.json. +/// Connection string is read separately via the standard "ConnectionStrings:Default" key. +/// +public class ServiceConfig +{ + public bool Debug { get; set; } = false; + public int IntervalInMin { get; set; } = 1; + public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams(); +} diff --git a/EnvelopeGenerator.Service/TempFiles.cs b/EnvelopeGenerator.Service/TempFiles.cs new file mode 100644 index 00000000..0db3b275 --- /dev/null +++ b/EnvelopeGenerator.Service/TempFiles.cs @@ -0,0 +1,75 @@ +namespace EnvelopeGenerator.Service; + +/// +/// Service-level temp file management. Mirrors TempFiles.vb from the legacy project. +/// Manages %TEMP%\EnvelopeGenerator at host startup and shutdown. +/// +/// NOTE: This is independent from EnvelopeGenerator.CommonServices.TempFiles, which is +/// instantiated inside each Quartz job via . +/// Do not confuse the two. +/// +public sealed class TempFiles +{ + private readonly ILogger _logger; + + public string TempPath { get; } + + public TempFiles(ILogger logger) + { + _logger = logger; + TempPath = Path.Combine(Path.GetTempPath(), "EnvelopeGenerator"); + } + + public bool Create() + { + try + { + if (!Directory.Exists(TempPath)) + { + Directory.CreateDirectory(TempPath); + } + else + { + CleanUpFiles(); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create temp directory: {TempPath}", TempPath); + return false; + } + } + + private void CleanUpFiles() + { + try + { + foreach (var file in Directory.GetFiles(TempPath)) + { + _logger.LogDebug("Deleting temp file: {File}", file); + File.Delete(file); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to clean up temp files in: {TempPath}", TempPath); + } + } + + public bool CleanUp() + { + try + { + _logger.LogDebug("Deleting temp directory: {TempPath}", TempPath); + Directory.Delete(TempPath, recursive: true); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete temp directory: {TempPath}", TempPath); + return false; + } + } +} diff --git a/EnvelopeGenerator.Service/Worker.cs b/EnvelopeGenerator.Service/Worker.cs index bf0a660b..32137e49 100644 --- a/EnvelopeGenerator.Service/Worker.cs +++ b/EnvelopeGenerator.Service/Worker.cs @@ -1,24 +1,111 @@ -namespace EnvelopeGenerator.Service +using DigitalData.Modules.Database; +using EnvelopeGenerator.Service.Schedulers; +using Microsoft.Extensions.Options; + +namespace EnvelopeGenerator.Service; + +public sealed class Worker : BackgroundService { - public class Worker : BackgroundService + private readonly ILogger _logger; + private readonly ServiceConfig _config; + private readonly string _connectionString; + private readonly TempFiles _tempFiles; + private readonly SchedulerFinishEnvelope _schedulerFinishEnvelope; + private readonly SchedulerEnvelopeTaskApi _schedulerEnvelopeTaskApi; + + public Worker( + ILogger logger, + IOptions config, + IConfiguration configuration, + TempFiles tempFiles, + SchedulerFinishEnvelope schedulerFinishEnvelope, + SchedulerEnvelopeTaskApi schedulerEnvelopeTaskApi) { - private readonly ILogger _logger; + _logger = logger; + _config = config.Value; + _connectionString = configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("ConnectionStrings:Default is not configured."); + _tempFiles = tempFiles; + _schedulerFinishEnvelope = schedulerFinishEnvelope; + _schedulerEnvelopeTaskApi = schedulerEnvelopeTaskApi; + } - public Worker(ILogger logger) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { - _logger = logger; + _logger.LogInformation("EnvelopeGenerator.Service starting..."); + + // 1. Validate connection string + if (string.IsNullOrWhiteSpace(_connectionString)) + throw new InvalidOperationException("Connection string is empty!"); + + // 2. Build LogConfig (required by CommonServices jobs via JobDataMap) + var logConfig = LogConfigFactory.Create(_config); + _logger.LogInformation("DEBUG = {Debug}", _config.Debug); + + // 3. Connect to DB and load GDPicture license + _logger.LogDebug("Initializing Database..."); + var database = new MSSQLServer(logConfig, _connectionString); + + if (!database.DBInitialized) + throw new InvalidOperationException("Database connection could not be established!"); + + _logger.LogDebug("Database initialized."); + + var licenseKey = database.GetScalarValue( + "SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' AND ACTIVE = 1") as string; + + if (string.IsNullOrWhiteSpace(licenseKey)) + throw new InvalidOperationException("GDPicture license could not be loaded!"); + + _logger.LogDebug("GDPicture license loaded."); + + // 4. Create service-level temp directory + _tempFiles.Create(); + + // 5. Start FinalizeDocument scheduler + _logger.LogDebug("Initializing schedulers..."); + await _schedulerFinishEnvelope.Start(licenseKey!, logConfig); + + // 6. Delay between schedulers (legacy behavior preserved) + await Task.Delay(2500, stoppingToken); + + // 7. Start API envelope scheduler + await _schedulerEnvelopeTaskApi.Start(logConfig); + + _logger.LogInformation("EnvelopeGenerator.Service started!"); + + // 8. Wait for cancellation (service runs until stopped) + await Task.Delay(Timeout.Infinite, stoppingToken); } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + catch (OperationCanceledException) { - while (!stoppingToken.IsCancellationRequested) - { - if (_logger.IsEnabled(LogLevel.Information)) - { - _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); - } - await Task.Delay(1000, stoppingToken); - } + // Normal shutdown � not an error + } + catch (Exception ex) + { + _logger.LogError(ex, "EnvelopeGenerator.Service encountered a fatal error during startup."); + throw; } } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("EnvelopeGenerator.Service stopping..."); + + try + { + await _schedulerFinishEnvelope.Stop(); + await _schedulerEnvelopeTaskApi.Stop(); + _tempFiles.CleanUp(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during service shutdown."); + } + + await base.StopAsync(cancellationToken); + _logger.LogInformation("EnvelopeGenerator.Service stopped!"); + } } diff --git a/EnvelopeGenerator.Service/appsettings.json b/EnvelopeGenerator.Service/appsettings.json index b2dcdb67..62ff4548 100644 --- a/EnvelopeGenerator.Service/appsettings.json +++ b/EnvelopeGenerator.Service/appsettings.json @@ -1,8 +1,24 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" + "ConnectionStrings": { + "Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;" + }, + "ServiceConfig": { + "Debug": false, + "IntervalInMin": 1, + "PDFBurnerParams": { + "IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ], + "TopMargin": 0.1, + "YOffset": -0.3, + "FontName": "Arial", + "FontSize": 8, + "FontStyle": 2, + "TruncationSuffix": "..", + "TextMaxWidths": { + "position": 1.41, + "city": 1.41, + "date": 2.0 + } } } } + diff --git a/EnvelopeGenerator.Service/nlog.config b/EnvelopeGenerator.Service/nlog.config new file mode 100644 index 00000000..c054c6d8 --- /dev/null +++ b/EnvelopeGenerator.Service/nlog.config @@ -0,0 +1,20 @@ + + + + + + + + + + + + +